diff --git a/.env.appStore.example b/.env.appStore.example index 94943a2fa68cd4..cec38afb9a4017 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -1,9 +1,11 @@ # ********** INDEX ********** # # - APP STORE +# - BASECAMP # - DAILY.CO VIDEO # - GOOGLE CALENDAR/MEET/LOGIN # - HUBSPOT +# - HUDDLE01 # - OFFICE 365 # - SLACK # - STRIPE @@ -20,6 +22,14 @@ # - APP STORE ********************************************************************************************** # ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️ + +# - BASECAMP +# Used to enable Basecamp integration with Cal.com +# @see https://github.com/calcom/cal.com#obtaining-basecamp-client-id-and-secret +BASECAMP3_CLIENT_ID= +BASECAMP3_CLIENT_SECRET= +BASECAMP3_USER_AGENT= + # - DAILY.CO VIDEO # Enables Cal Video. to get your key # 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information @@ -28,6 +38,8 @@ DAILY_API_KEY= DAILY_SCALE_PLAN='' +DAILY_WEBHOOK_SECRET='' +DAILY_MEETING_ENDED_WEBHOOK_SECRET='' # - GOOGLE CALENDAR/MEET/LOGIN # Needed to enable Google Calendar integration and Login with Google @@ -116,4 +128,17 @@ SALESFORCE_CONSUMER_SECRET="" ZOHOCRM_CLIENT_ID="" ZOHOCRM_CLIENT_SECRET="" + +# - REVERT +# Used for the Pipedrive integration (via/ Revert (https://revert.dev)) +# @see https://github.com/calcom/cal.com/#obtaining-revert-api-keys +REVERT_API_KEY= +REVERT_PUBLIC_TOKEN= + +# NOTE: If you're self hosting Revert, update this URL to point to your own instance. +REVERT_API_URL=https://api.revert.dev/ # ********************************************************************************************************* + +# - Huddle01 +# Used for the huddle01 integration +HUDDLE01_API_TOKEN= diff --git a/.env.example b/.env.example index d1023ca7c0f423..ce124f9661857b 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # - SHARED # - NEXTAUTH # - E-MAIL SETTINGS +# - ORGANIZATIONS # - LICENSE (DEPRECATED) ************************************************************************************ # https://github.com/calcom/cal.com/blob/main/LICENSE @@ -14,14 +15,22 @@ # - You can not repackage or sell the codebase # - Acquire a commercial license to remove these terms by visiting: cal.com/sales # -# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow -# instructions. This environment variable is deprecated although still supported for backward compatibility. -# @see https://console.cal.com + +# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions. CALCOM_LICENSE_KEY= +# Signature token for the Cal.com License API (used for self-hosted integrations) +# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key +CAL_SIGNATURE_TOKEN= +# The route to the Cal.com License API +CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com" # *********************************************************************************************************** # - DATABASE ************************************************************************************************ DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" +# Needed to run migrations while using a connection pooler like PgBouncer +# Use the same one as DATABASE_URL if you're not using a connection pooler +DATABASE_DIRECT_URL="postgresql://postgres:@localhost:5450/calendso" +INSIGHTS_DATABASE_URL= # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy # Cold boots will be faster and you'll be able to scale your DB independently of your app. @@ -32,6 +41,8 @@ PRISMA_GENERATE_DATAPROXY= # *********************************************************************************************************** # - SHARED ************************************************************************************************** +# Set this to http://app.cal.local:3000 if you want to enable organizations, and +# check variable ORGANIZATIONS_ENABLED at the bottom of this file NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000' # Change to 'http://localhost:3001' if running the website simultaneously NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000' @@ -54,6 +65,11 @@ SAML_CLIENT_SECRET_VERIFIER= # PGSSLMODE='no-verify' PGSSLMODE= +# Define which hostnames are expected for the app to work on +ALLOWED_HOSTNAMES='"cal.com","cal.dev","cal-staging.com","cal.community","cal.local:3000","localhost:3000"' +# Reserved orgs subdomains for our own usage +RESERVED_SUBDOMAINS='"app","auth","docs","design","console","go","status","api","saml","www","matrix","developer","cal","my","team","support","security","blog","learn","admin"' + # - NEXTAUTH # @see: https://github.com/calendso/calendso/issues/263 # @see: https://next-auth.js.org/configuration/options#nextauth_url @@ -71,9 +87,13 @@ CALCOM_TELEMETRY_DISABLED= # ApiKey for cronjobs CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' +# Whether to automatically keep app metadata in the database in sync with the metadata/config files. When disabled, the +# sync runs in a reporting-only dry-run mode. +CRON_ENABLE_APP_SYNC=false + # Application Key for symmetric encryption and decryption # must be 32 bytes for AES256 encryption algorithm -# You can use: `openssl rand -base64 24` to generate one +# You can use: `openssl rand -base64 32` to generate one CALENDSO_ENCRYPTION_KEY= # Intercom Config @@ -82,6 +102,20 @@ NEXT_PUBLIC_INTERCOM_APP_ID= # Secret to enable Intercom Identity Verification INTERCOM_SECRET= +# Posthog Config +NEXT_PUBLIC_POSTHOG_KEY= + +NEXT_PUBLIC_POSTHOG_HOST= + +# plain.com config + +PLAIN_API_KEY= +PLAIN_API_URL=https://api.plain.com/v1 +PLAIN_HMAC_SECRET_KEY= +NEXT_PUBLIC_PLAIN_CHAT_ID= +PLAIN_CHAT_HMAC_SECRET_KEY= +NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS= + # Zendesk Config NEXT_PUBLIC_ZENDESK_KEY= @@ -92,27 +126,63 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# Google OAuth credentials +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` below +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance +# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications +GOOGLE_LOGIN_ENABLED=false + +# - GOOGLE CALENDAR/MEET/LOGIN +# Needed to enable Google Calendar integration and Login with Google +# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS= +# Token to verify incoming webhooks from Google Calendar +GOOGLE_WEBHOOK_TOKEN= +# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. +GOOGLE_WEBHOOK_URL= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= -# Sengrid +# Sendgrid # Used for email reminders in workflows and internal sync services SENDGRID_API_KEY= SENDGRID_EMAIL= NEXT_PUBLIC_SENDGRID_SENDER_NAME= +# Sentry +# Used for capturing exceptions and logging messages +NEXT_PUBLIC_SENTRY_DSN= +SENTRY_DEBUG= +SENTRY_DISABLE_CLIENT_SOURCE_MAPS= +SENTRY_DISABLE_SERVER_SOURCE_MAPS= +SENTRY_MAX_SPANS= +SENTRY_TRACES_SAMPLE_RATE= + +# Formbricks Experience Management Integration +NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com +NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID= +FORMBRICKS_FEEDBACK_SURVEY_ID= + +# AvatarAPI +# Used to pre-fill avatar during signup +AVATARAPI_USERNAME= +AVATARAPI_PASSWORD= + # Twilio # Used to send SMS reminders in workflows TWILIO_SID= TWILIO_TOKEN= TWILIO_MESSAGING_SID= TWILIO_PHONE_NUMBER= +TWILIO_WHATSAPP_PHONE_NUMBER= # For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters) NEXT_PUBLIC_SENDER_ID= TWILIO_VERIFY_SID= -# This is used so we can bypass emails in auth flows for E2E testing -# Set it to "1" if you need to run E2E tests locally +# Set it to "1" if you need to run E2E tests locally. NEXT_PUBLIC_IS_E2E= # Used for internal billing system @@ -121,11 +191,12 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0 NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE= STRIPE_TEAM_MONTHLY_PRICE_ID= +STRIPE_TEAM_PRODUCT_ID= +STRIPE_ORG_MONTHLY_PRICE_ID= STRIPE_WEBHOOK_SECRET= +STRIPE_WEBHOOK_SECRET_APPS= STRIPE_PRIVATE_KEY= STRIPE_CLIENT_ID= -PAYMENT_FEE_FIXED= -PAYMENT_FEE_PERCENTAGE= # Use for internal Public API Keys and optional API_KEY_PREFIX=cal_ @@ -136,6 +207,7 @@ API_KEY_PREFIX=cal_ # allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/ # Configures the global From: header whilst sending emails. EMAIL_FROM='notifications@yourselfhostedcal.com' +EMAIL_FROM_NAME='Cal.com' # Configure SMTP settings (@see https://nodemailer.com/smtp/). # Configuration to receive emails locally (mailhog) @@ -156,18 +228,33 @@ EMAIL_SERVER_PORT=1025 ## You will need to provision an App Password. ## @see https://support.google.com/accounts/answer/185833 # EMAIL_SERVER_PASSWORD='' + +# Used for E2E for email testing +# Set it to "1" if you need to email checks in E2E tests locally +# Make sure to run mailhog container manually or with `yarn dx` +E2E_TEST_MAILHOG_ENABLED= + +# Resend +# Send transactional email using resend +# RESEND_API_KEY= + # ********************************************************************************************************** +# Cloudflare Turnstile +NEXT_PUBLIC_CLOUDFLARE_SITEKEY= +NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER= +CLOUDFLARE_TURNSTILE_SECRET= + # Set the following value to true if you wish to enable Team Impersonation NEXT_PUBLIC_TEAM_IMPERSONATION=false # Close.com internal CRM -CLOSECOM_API_KEY= +CLOSECOM_CLIENT_ID= +CLOSECOM_CLIENT_SECRET= # Sendgrid internal sync service SENDGRID_SYNC_API_KEY= - # Change your Brand NEXT_PUBLIC_APP_NAME="Cal.com" NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com" @@ -176,10 +263,146 @@ NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc." # NEXT_PUBLIC_DISABLE_SIGNUP=true NEXT_PUBLIC_DISABLE_SIGNUP= +# Set this to 'non-strict' to enable CSP for support pages. 'strict' isn't supported yet. Also, check the README for details. # Content Security Policy CSP_POLICY= # Vercel Edge Config EDGE_CONFIG= -NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes +NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes +NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD=0 # Override the booker to only load X number of days worth of data + +# Control time intervals on a user's Schedule availability +NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL= + +# - ORGANIZATIONS ******************************************************************************************* +# Enable Organizations non-prod domain setup, works in combination with organizations feature flag +# This is mainly needed locally, because for orgs to work a full domain name needs to point +# to the app, i.e. app.cal.local instead of using localhost, which is very disruptive +# +# This variable should only be set to 1 or true if you are in a non-prod environment and you want to +# use organizations +ORGANIZATIONS_ENABLED= +NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30 +NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat + +# This variable should only be set to 1 or true if you want to autolink external provider sign-ups with +# existing organizations based on email domain address +ORGANIZATIONS_AUTOLINK= + +# Vercel Config to create subdomains for organizations +# Get it from https://vercel.com///settings +PROJECT_ID_VERCEL= +# Get it from: https://vercel.com/teams//settings +TEAM_ID_VERCEL= +# Get it from: https://vercel.com/account/tokens +AUTH_BEARER_TOKEN_VERCEL= +# Add the main domain that you want to use for testing vercel domain management for organizations. This is necessary because WEBAPP_URL of local isn't a valid public domain +# Would create org1.example.com for an org with slug org1 +# LOCAL_TESTING_DOMAIN_VERCEL="example.com" + +## Set it to 1 if you use cloudflare to manage your DNS and would like us to manage the DNS for you for organizations +# CLOUDFLARE_DNS=1 +## Get it from: https://dash.cloudflare.com/profile/api-tokens. Select Edit Zone template and choose a zone(your domain) +# AUTH_BEARER_TOKEN_CLOUDFLARE= +## Zone ID can be found in the Overview tab of your domain in Cloudflare +# CLOUDFLARE_ZONE_ID= +## It should usually work with the default value. This is the DNS CNAME record content to point to Vercel domain +# CLOUDFLARE_VERCEL_CNAME=cname.vercel-dns.com + +# - APPLE CALENDAR +# Used for E2E tests on Apple Calendar +E2E_TEST_APPLE_CALENDAR_EMAIL="" +E2E_TEST_APPLE_CALENDAR_PASSWORD="" + +# - CALCOM QA ACCOUNT +# Used for E2E tests on Cal.com that require 3rd party integrations +E2E_TEST_CALCOM_QA_EMAIL="qa@example.com" +# Replace with your own password +E2E_TEST_CALCOM_QA_PASSWORD="password" +E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS= +E2E_TEST_CALCOM_GCAL_KEYS= + +# - APP CREDENTIAL SYNC *********************************************************************************** +# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations +# Under settings/admin/apps ensure that all app secrets are set the same as the parent application +# You can use: `openssl rand -base64 32` to generate one +CALCOM_CREDENTIAL_SYNC_SECRET="" +# This is the header name that will be used to verify the webhook secret. Should be in lowercase +CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret" +# This the endpoint from which the token is fetched +CALCOM_CREDENTIAL_SYNC_ENDPOINT="" +# Key should match on Cal.com and your application +# must be 24 bytes for AES256 encryption algorithm +# You can use: `openssl rand -base64 24` to generate one +CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY="" + +# - OIDC E2E TEST ******************************************************************************************* + +# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list +E2E_TEST_SAML_ADMIN_EMAIL= +E2E_TEST_SAML_ADMIN_PASSWORD= + +E2E_TEST_OIDC_CLIENT_ID= +E2E_TEST_OIDC_CLIENT_SECRET= +E2E_TEST_OIDC_PROVIDER_DOMAIN= + +E2E_TEST_OIDC_USER_EMAIL= +E2E_TEST_OIDC_USER_PASSWORD= + +# *********************************************************************************************************** + +# provide a value between 0 and 100 to ensure the percentage of traffic +# redirected from the legacy to the future pages +AB_TEST_BUCKET_PROBABILITY=50 +APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0 +APP_ROUTER_APPS_ENABLED=0 +APP_ROUTER_TEAM_ENABLED=0 + +# disable setry server source maps +SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1 + +# api v2 +NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2" + +# Tasker features +TASKER_ENABLE_WEBHOOKS=0 +TASKER_ENABLE_EMAILS=0 + +# Ratelimiting via unkey +UNKEY_ROOT_KEY= + +# Used for Cal.ai Enterprise Voice AI Agents +# https://retellai.com +RETELL_AI_KEY= + +# Used for the huddle01 integration +HUDDLE01_API_TOKEN= + +# Used to disallow emails as being added as guests on bookings +BLACKLISTED_GUEST_EMAILS= + +# Used to allow browser push notifications +# You can use: 'npx web-push generate-vapid-keys' to generate these keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + +# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs) +NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL= +NEXT_PUBLIC_WEBSITE_TERMS_URL= + +# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs. +# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal] +NEXT_PUBLIC_LOGGER_LEVEL= + +# Used to use Replexica SDK, a tool for real-time AI-powered localization +REPLEXICA_API_KEY= + +# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server. +DIRECTORY_IDS_TO_LOG= + + +# Set this when Cal.com is used to serve only one organization's booking pages +# Read more about it in the README.md +NEXT_PUBLIC_SINGLE_ORG_SLUG= diff --git a/.eslintignore b/.eslintignore index 5889b2527da1e4..069222c27c0cb1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,9 @@ node_modules +apps/api/v2/dist +packages/platform/**/dist/* **/**/node_modules **/**/.next **/**/public packages/prisma/zod apps/web/public/embed +packages/ui/components/icon/dynamicIconImports.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000000..8e9a64e52c7907 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,20 @@ +/apps/api/**/* @calcom/Platform +/deploy/**/* @calcom/Foundation +/infra/**/* @calcom/Foundation +/packages/app-store/applecalendar/**/* @calcom/Foundation +/packages/app-store/caldavcalendar/**/* @calcom/Foundation +/packages/app-store/exchange2013calendar/**/* @calcom/Foundation +/packages/app-store/exchange2016calendar/**/* @calcom/Foundation +/packages/app-store/exchangecalendar/**/* @calcom/Foundation +/packages/app-store/feishucalendar/**/* @calcom/Foundation +/packages/app-store/googlecalendar/**/* @calcom/Foundation +/packages/app-store/ics-feedcalendar/**/* @calcom/Foundation +/packages/app-store/larkcalendar/**/* @calcom/Foundation +/packages/app-store/office365calendar/**/* @calcom/Foundation +/packages/app-store/zohocalendar/**/* @calcom/Foundation +/packages/core/getUserAvailability.ts @calcom/Foundation +/packages/features/bookings/lib/handleNewBooking/**/* @calcom/Foundation +/packages/lib/slots.ts @calcom/Foundation +/packages/platform/**/* @calcom/Platform +/packages/prisma/**/* @calcom/Foundation +/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 91df8f2eb15613..4a8f01b650cef5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Report any issues with the platform title: "" -labels: bug +labels: ["🐛 bug"] assignees: "" --- @@ -20,8 +20,20 @@ A summary of the issue. This needs to be a clear detailed-rich summary. Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead? +### Actual Results + +- What's happening right now that is different from what is expected + +### Expected Results + +- This is an ideal result that the system should get after the tests are performed + ### Technical details - Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/). - Node.js version - Anything else that you think could be an issue. + +### Evidence + +- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f4ce8de8ff4a93..674deef6205dd5 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions - url: https://cal.com/slack - about: Ask a general question about the project on our Slack workspace + url: https://github.com/calcom/cal.com/discussions + about: Need help selfhosting or ask a general question about the project? Open a discussion diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 87d2eb23d43c15..a071344ccd1e59 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest a feature or idea title: "" -labels: feature +labels: ["✨ feature", "🚨 needs approval"] assignees: "" --- @@ -39,3 +39,20 @@ assignees: "" --> (Write your answer here.) + +### Requirement/Document + + + +(Share it here.) + +--- + +##### House rules + +- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding. + - For clarity: Non-core member issues automatically get the `🚨 needs approval` label. + - Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction. + - Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3a92229cc7e7c5..36a604cad22211 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,39 +2,33 @@ -Fixes # (issue) +- Fixes #XXXX (GitHub issue number) +- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description) -## Type of change +## Mandatory Tasks (DO NOT REMOVE) - - -- 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) -- This change requires a documentation update +- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected). +- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox. +- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? - - -- [ ] Test A -- [ ] Test B + -## Mandatory Tasks -- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected. +- Are there environment variables that should be set? +- What are the minimal test data to have? +- What is expected (happy path) to have (input and output)? +- Any other important info that could help to test that PR ## Checklist - + + - 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 diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index 1ef35a7831b88d..72baa43cfc56cd 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -8,22 +8,28 @@ runs: using: "composite" steps: - name: Cache production build - uses: buildjet/cache@v3 + uses: buildjet/cache@v4 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 }} + key-5: ${{ github.event.pull_request.head.sha }} with: path: | ${{ github.workspace }}/apps/web/.next ${{ github.workspace }}/apps/web/public/embed **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} - - run: yarn build + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}-${{ env.key-5 }} + - run: | + export NODE_OPTIONS="--max_old_space_size=8192" + yarn build if: steps.cache-build.outputs.cache-hit != 'true' shell: bash diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml index f69fd9513a636c..f1b519f23f1104 100644 --- a/.github/actions/cache-db/action.yml +++ b/.github/actions/cache-db/action.yml @@ -3,7 +3,7 @@ description: "Cache or restore if necessary" inputs: DATABASE_URL: required: false - default: "postgresql://postgres:@localhost:5432/calendso" + default: "postgresql://postgres:postgres@localhost:5432/calendso" path: required: false default: "backups/backup.sql" @@ -12,15 +12,21 @@ runs: steps: - name: Cache database id: cache-db - uses: buildjet/cache@v3 + uses: buildjet/cache@v4 env: cache-name: cache-db key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }} key-2: ${{ github.event.pull_request.number || github.ref }} + key-3: ${{ github.event.pull_request.head.sha }} + DATABASE_URL: ${{ inputs.DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }} + E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }} + E2E_TEST_CALCOM_QA_PASSWORD: ${{ inputs.E2E_TEST_CALCOM_QA_PASSWORD }} + E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} with: path: ${{ inputs.path }} - key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }} - - run: yarn db-seed + key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} + - run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed if: steps.cache-db.outputs.cache-hit != 'true' shell: bash - name: Postgres Dump Backup diff --git a/.github/actions/dangerous-git-checkout/action.yml b/.github/actions/dangerous-git-checkout/action.yml index 48dca84cbebb92..c2dccc4e290e27 100644 --- a/.github/actions/dangerous-git-checkout/action.yml +++ b/.github/actions/dangerous-git-checkout/action.yml @@ -4,7 +4,7 @@ runs: using: "composite" steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 2 diff --git a/.github/actions/env-read-file/action.yml b/.github/actions/env-read-file/action.yml deleted file mode 100644 index 7caf7328fff6eb..00000000000000 --- a/.github/actions/env-read-file/action.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: "Set environment variables" -description: "Configures environment variables for a workflow" -runs: - using: "composite" - steps: - - name: Create env file - uses: buildjet/cache@v3 - id: env-cache - with: - path: gh.env - key: env-cache-${{ hashFiles('gh.env') }} - restore-keys: env-cache- - - name: Set Environment Variables - uses: tw3lveparsecs/github-actions-setvars@latest - with: - envFilePath: gh.env diff --git a/.github/actions/yarn-install/action.yml b/.github/actions/yarn-install/action.yml index 216cdce6de61d1..19782851982ca3 100644 --- a/.github/actions/yarn-install/action.yml +++ b/.github/actions/yarn-install/action.yml @@ -20,7 +20,7 @@ runs: using: "composite" steps: - name: Use Node ${{ inputs.node_version }} - uses: buildjet/setup-node@v3 + uses: buildjet/setup-node@v4 with: node-version: ${{ inputs.node_version }} - name: Expose yarn config as "$GITHUB_OUTPUT" @@ -32,18 +32,24 @@ runs: # Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325 # Yarn cache is also reusable between arch and os. - name: Restore yarn cache - uses: buildjet/cache@v3 + uses: buildjet/cache@v4 id: yarn-download-cache with: path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }} key: yarn-download-cache-${{ hashFiles('yarn.lock') }} - restore-keys: | - yarn-download-cache- + + # Invalidated on yarn.lock changes + - name: Restore node_modules + id: yarn-nm-cache + uses: buildjet/cache@v4 + with: + path: "**/node_modules/" + key: ${{ runner.os }}-yarn-nm-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} # Invalidated on yarn.lock changes - name: Restore yarn install state id: yarn-install-state-cache - uses: buildjet/cache@v3 + uses: buildjet/cache@v4 with: path: .yarn/ci-cache/ key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} @@ -58,5 +64,6 @@ runs: YARN_ENABLE_IMMUTABLE_INSTALLS: "false" # So it doesn't try to remove our private submodule deps YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change + YARN_NM_MODE: "hardlinks-local" # Reduce node_modules size # Other environment variables HUSKY: "0" # By default do not run HUSKY install diff --git a/.github/actions/yarn-playwright-install/action.yml b/.github/actions/yarn-playwright-install/action.yml index 1e118e3099b8d6..a42611aa332b3c 100644 --- a/.github/actions/yarn-playwright-install/action.yml +++ b/.github/actions/yarn-playwright-install/action.yml @@ -3,17 +3,19 @@ description: "Install playwright, cache and restore if necessary" runs: using: "composite" steps: + - name: Get installed Playwright version + shell: bash + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV - name: Cache playwright binaries id: playwright-cache - uses: buildjet/cache@v2 + uses: buildjet/cache@v4 with: path: | ~/Library/Caches/ms-playwright ~/.cache/ms-playwright ${{ github.workspace }}/node_modules/playwright - key: cache-playwright-${{ hashFiles('**/yarn.lock') }} - restore-keys: cache-playwright- + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} - name: Yarn playwright install shell: bash - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: yarn playwright install + run: yarn playwright install --with-deps diff --git a/.github/labeler.yml b/.github/labeler.yml index 208260cad2b1df..6e1a2b605cecf5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,6 +1,10 @@ "❗️ migrations": - - packages/prisma/migrations/**/migration.sql +- changed-files: + - any-glob-to-any-file: + - packages/prisma/migrations/**/migration.sql "❗️ .env changes": - - .env.example - - .env.appStore.example +- changed-files: + - any-glob-to-any-file: + - .env.example + - .env.appStore.example diff --git a/.github/workflows/all-checks.yml b/.github/workflows/all-checks.yml new file mode 100644 index 00000000000000..8d033f754c6949 --- /dev/null +++ b/.github/workflows/all-checks.yml @@ -0,0 +1,84 @@ +name: All checks + +on: + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + type-check: + name: Type check + uses: ./.github/workflows/check-types.yml + secrets: inherit + + lint: + name: Linters + uses: ./.github/workflows/lint.yml + secrets: inherit + + unit-test: + name: Tests + uses: ./.github/workflows/unit-tests.yml + secrets: inherit + + build-api-v1: + name: Production builds + uses: ./.github/workflows/api-v1-production-build.yml + secrets: inherit + + build-api-v2: + name: Production builds + uses: ./.github/workflows/api-v2-production-build.yml + secrets: inherit + + build-atoms: + name: Production builds + uses: ./.github/workflows/atoms-production-build.yml + secrets: inherit + + build: + name: Production builds + uses: ./.github/workflows/production-build-without-database.yml + secrets: inherit + + integration-test: + name: Tests + needs: [lint, build, build-api-v1, build-api-v2] + uses: ./.github/workflows/integration-tests.yml + secrets: inherit + + e2e: + name: Tests + needs: [lint, build, build-api-v1, build-api-v2] + uses: ./.github/workflows/e2e.yml + secrets: inherit + + e2e-app-store: + name: Tests + needs: [lint, build, build-api-v1, build-api-v2] + uses: ./.github/workflows/e2e-app-store.yml + secrets: inherit + + e2e-embed: + name: Tests + needs: [lint, build, build-api-v1, build-api-v2] + uses: ./.github/workflows/e2e-embed.yml + secrets: inherit + + e2e-embed-react: + name: Tests + needs: [lint, build, build-api-v1, build-api-v2] + uses: ./.github/workflows/e2e-embed-react.yml + secrets: inherit + + required: + needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store] + if: always() + 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') + run: exit 1 diff --git a/.github/workflows/api-v1-production-build.yml b/.github/workflows/api-v1-production-build.yml new file mode 100644 index 00000000000000..00b96bbf696f79 --- /dev/null +++ b/.github/workflows/api-v1-production-build.yml @@ -0,0 +1,94 @@ +name: Production Builds + +on: + workflow_call: + +permissions: + contents: read + +env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} + CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_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_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: Build API v1 + 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@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/cache-db + - name: Cache API v1 production build + uses: buildjet/cache@v4 + id: cache-api-v1-build + env: + cache-name: api-v1-build + key-1: ${{ hashFiles('yarn.lock') }} + key-2: ${{ hashFiles('apps/api/v1/**.[jt]s', 'apps/api/v1/**.[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: | + ${{ github.workspace }}/apps/api/v1/.next + **/.turbo/** + **/dist/** + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + - name: Log Cache Hit + if: steps.cache-api-v1-build.outputs.cache-hit == 'true' + run: echo "Cache hit for API v1 build. Skipping build." + - name: Run build + if: steps.cache-api-v1-build.outputs.cache-hit != 'true' + run: | + export NODE_OPTIONS="--max_old_space_size=8192" + yarn turbo run build --filter=@calcom/api... + shell: bash diff --git a/.github/workflows/api-v2-production-build.yml b/.github/workflows/api-v2-production-build.yml new file mode 100644 index 00000000000000..4d95fd935de31b --- /dev/null +++ b/.github/workflows/api-v2-production-build.yml @@ -0,0 +1,63 @@ +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 + permissions: + contents: read + 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@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - name: Cache API v2 production build + uses: buildjet/cache@v4 + id: cache-api-v2-build + env: + cache-name: api-v2-build + key-1: ${{ hashFiles('yarn.lock') }} + key-2: ${{ hashFiles('apps/api/v2/**.[jt]s', 'apps/api/v2/**.[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: | + **/dist/** + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + - name: Log Cache Hit + if: steps.cache-api-v2-build.outputs.cache-hit == 'true' + run: echo "Cache hit for API v2 build. Skipping build." + - name: Run build + if: steps.cache-api-v2-build.outputs.cache-hit != 'true' + 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 diff --git a/.github/workflows/apply-issue-labels-to-pr.yml b/.github/workflows/apply-issue-labels-to-pr.yml deleted file mode 100644 index c68e332a3cecc2..00000000000000 --- a/.github/workflows/apply-issue-labels-to-pr.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: "Apply issue labels to PR" - -on: - pull_request_target: - types: - - opened - - -jobs: - label_on_pr: - runs-on: ubuntu-latest - - permissions: - contents: none - issues: read - pull-requests: write - - steps: - - name: Apply labels from linked issue to PR - uses: actions/github-script@v5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - async function getLinkedIssues(owner, repo, prNumber) { - const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - closingIssuesReferences(first: 10) { - nodes { - number - labels(first: 10) { - nodes { - name - } - } - } - } - } - } - }`; - - const variables = { - owner: owner, - repo: repo, - prNumber: prNumber, - }; - - const result = await github.graphql(query, variables); - return result.repository.pullRequest.closingIssuesReferences.nodes; - } - - const pr = context.payload.pull_request; - const linkedIssues = await getLinkedIssues( - context.repo.owner, - context.repo.repo, - pr.number - ); - - const labelsToAdd = new Set(); - for (const issue of linkedIssues) { - if (issue.labels && issue.labels.nodes) { - for (const label of issue.labels.nodes) { - labelsToAdd.add(label.name); - } - } - } - - if (labelsToAdd.size) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: Array.from(labelsToAdd), - }); - } diff --git a/.github/workflows/atoms-production-build.yml b/.github/workflows/atoms-production-build.yml new file mode 100644 index 00000000000000..cbdf1393b7937c --- /dev/null +++ b/.github/workflows/atoms-production-build.yml @@ -0,0 +1,41 @@ +name: Atoms production Build + +on: + workflow_call: + +jobs: + build: + name: Build Atoms + permissions: + contents: read + runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - name: Cache atoms production build + uses: buildjet/cache@v4 + id: cache-atoms-build + env: + cache-name: atoms-build + key-1: ${{ hashFiles('yarn.lock') }} + key-2: ${{ hashFiles('packages/platform/atoms/**.[jt]s', 'packages/platform/atoms/**.[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: | + **/dist/** + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + - name: Log Cache Hit + if: steps.cache-atoms-build.outputs.cache-hit == 'true' + run: echo "Cache hit for Atoms build. Skipping build." + - name: Run build + if: steps.cache-atoms-build.outputs.cache-hit != 'true' + run: | + export NODE_OPTIONS="--max_old_space_size=8192" + rm -rf packages/platform/atoms/node_modules + yarn install + yarn workspace @calcom/atoms run build + shell: bash diff --git a/.github/workflows/cache-clean.yml b/.github/workflows/cache-clean.yml index dd0be43ec50b41..3b0f51ed1fbfa8 100644 --- a/.github/workflows/cache-clean.yml +++ b/.github/workflows/cache-clean.yml @@ -6,10 +6,10 @@ on: jobs: cleanup: - runs-on: buildjet-4vcpu-ubuntu-2204 + runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Cleanup run: | @@ -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 diff --git a/.github/workflows/check-if-ui-has-changed.yml b/.github/workflows/check-if-ui-has-changed.yml deleted file mode 100644 index d86ee998cd0190..00000000000000 --- a/.github/workflows/check-if-ui-has-changed.yml +++ /dev/null @@ -1,39 +0,0 @@ -# .github/workflows/chromatic.yml - -# Workflow name -name: 'Chromatic' - -# Event for the workflow -on: - pull_request_target: # So we can test on forks - branches: - - main - paths: - - apps/storybook/** - - packages/ui/** - workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -# List of jobs -jobs: - chromatic-deployment: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks - fetch-depth: 0 - - name: Install dependencies - run: yarn - # 👇 Adds Chromatic as a step in the workflow - - name: Publish to Chromatic - uses: chromaui/action@v1 - # Options required to the GitHub Chromatic Action - with: - # 👇 Chromatic projectToken, refer to the manage page to obtain it. - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - workingDir: "apps/storybook" - buildScriptName: "build" diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml index 644ca2e9d60170..0a4eb817fc4284 100644 --- a/.github/workflows/check-types.yml +++ b/.github/workflows/check-types.yml @@ -2,12 +2,14 @@ name: Check types on: workflow_call: env: - NODE_OPTIONS: "--max-old-space-size=8192" + NODE_OPTIONS: --max-old-space-size=4096 +permissions: + contents: read jobs: check-types: runs-on: buildjet-4vcpu-ubuntu-2204 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install - name: Show info diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 00000000000000..4f9135bee5b94f --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,65 @@ +name: Delete + +on: + delete: + branches-ignore: [main, gh-pages] + +permissions: + contents: write + issues: write + pull-requests: write + +# ensures that currently running Playwright workflow of deleted branch gets cancelled +concurrency: + group: ${{ github.event.ref }} + cancel-in-progress: true + +jobs: + delete_reports: + name: Delete Reports + runs-on: ubuntu-latest + env: + # Contains all reports for deleted branch + BRANCH_REPORTS_DIR: reports/${{ github.event.ref }} + steps: + - name: Checkout GitHub Pages Branch + uses: actions/checkout@v2 + with: + repository: calcom/test-results + ref: gh-pages + token: ${{ secrets.GH_ACCESS_TOKEN }} + - name: Set Git User + # see: https://github.com/actions/checkout/issues/13#issuecomment-724415212 + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Check for workflow reports + run: | + if [ -z "$(ls -A $BRANCH_REPORTS_DIR)" ]; then + echo "BRANCH_REPORTS_EXIST="false"" >> $GITHUB_ENV + else + echo "BRANCH_REPORTS_EXIST="true"" >> $GITHUB_ENV + fi + - name: Delete reports from repo for branch + if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }} + timeout-minutes: 3 + run: | + cd $BRANCH_REPORTS_DIR/.. + + rm -rf ${{ github.event.ref }} + git add . + git commit -m "workflow: remove all reports for branch ${{ github.event.ref }}" + + while true; do + git pull --rebase + if [ $? -ne 0 ]; then + echo "Failed to rebase. Please review manually." + exit 1 + fi + + git push + if [ $? -eq 0 ]; then + echo "Successfully pushed HTML reports to repo." + exit 0 + fi + done diff --git a/.github/workflows/cron-bookingReminder.yml b/.github/workflows/cron-bookingReminder.yml index 7f7d461819fdb0..ff6384bc59cfaa 100644 --- a/.github/workflows/cron-bookingReminder.yml +++ b/.github/workflows/cron-bookingReminder.yml @@ -4,8 +4,8 @@ on: # "Scheduled workflows run on the latest commit on the default or base branch." # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule schedule: - # Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru) - - cron: "0,15,30,45 * * * *" + # Runs "At every 15th minute." (see https://crontab.guru) + - cron: "*/15 * * * *" jobs: cron-bookingReminder: env: @@ -20,4 +20,4 @@ jobs: -X POST \ -H 'content-type: application/json' \ -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ - --fail + -sSf diff --git a/.github/workflows/cron-changeTimeZone.yml b/.github/workflows/cron-changeTimeZone.yml new file mode 100644 index 00000000000000..5a988dc9f4f806 --- /dev/null +++ b/.github/workflows/cron-changeTimeZone.yml @@ -0,0 +1,24 @@ +name: Cron - changeTimeZone + +on: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs "At every full hour." (see https://crontab.guru) + - cron: "0 * * * *" + +jobs: + cron-scheduleEmailReminders: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/changeTimeZone \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + -sSf diff --git a/.github/workflows/cron-downgradeUsers.yml b/.github/workflows/cron-downgradeUsers.yml index c4a782f0d57839..d5c2984b36d5c5 100644 --- a/.github/workflows/cron-downgradeUsers.yml +++ b/.github/workflows/cron-downgradeUsers.yml @@ -5,7 +5,7 @@ on: # "Scheduled workflows run on the latest commit on the default or base branch." # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule schedule: - # Runs “Every month at 1st (see https://crontab.guru) + # Runs "At 00:00 on day-of-month 1." (see https://crontab.guru) - cron: "0 0 1 * *" jobs: cron-downgradeUsers: @@ -21,4 +21,4 @@ jobs: -X POST \ -H 'content-type: application/json' \ -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ - --fail + -sSf diff --git a/.github/workflows/cron-monthlyDigestEmail.yml b/.github/workflows/cron-monthlyDigestEmail.yml new file mode 100644 index 00000000000000..d8ff0716069aca --- /dev/null +++ b/.github/workflows/cron-monthlyDigestEmail.yml @@ -0,0 +1,33 @@ +name: Cron - monthlyDigestEmail + +on: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru) + - cron: "59 23 28-31 * *" +jobs: + cron-monthlyDigestEmail: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: Check if today is the last day of the month + id: check-last-day + run: | + LAST_DAY=$(date -d tomorrow +%d) + if [ "$LAST_DAY" == "01" ]; then + echo "is_last_day=true" >> "$GITHUB_OUTPUT" + else + echo "is_last_day=false" >> "$GITHUB_OUTPUT" + fi + + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + --fail diff --git a/.github/workflows/cron-scheduleEmailReminders.yml b/.github/workflows/cron-scheduleEmailReminders.yml index dc77c2d5dd297d..8dacefe3d1cf2f 100644 --- a/.github/workflows/cron-scheduleEmailReminders.yml +++ b/.github/workflows/cron-scheduleEmailReminders.yml @@ -4,8 +4,8 @@ on: # "Scheduled workflows run on the latest commit on the default or base branch." # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule schedule: - # Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru) - - cron: "0,15,30,45 * * * *" + # Runs "At every 15th minute." (see https://crontab.guru) + - cron: "*/15 * * * *" jobs: cron-scheduleEmailReminders: env: @@ -20,4 +20,4 @@ jobs: -X POST \ -H 'content-type: application/json' \ -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ - --fail + -sSf diff --git a/.github/workflows/cron-scheduleSMSReminders.yml b/.github/workflows/cron-scheduleSMSReminders.yml index 392a9cf5ecc405..bf435cf3e82159 100644 --- a/.github/workflows/cron-scheduleSMSReminders.yml +++ b/.github/workflows/cron-scheduleSMSReminders.yml @@ -4,8 +4,8 @@ on: # "Scheduled workflows run on the latest commit on the default or base branch." # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule schedule: - # Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru) - - cron: "0,15,30,45 * * * *" + # Runs "At every 15th minute." (see https://crontab.guru) + - cron: "*/15 * * * *" jobs: cron-scheduleSMSReminders: env: @@ -20,4 +20,4 @@ jobs: -X POST \ -H 'content-type: application/json' \ -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ - --fail + -sSf diff --git a/.github/workflows/cron-scheduleWhatsappReminders.yml b/.github/workflows/cron-scheduleWhatsappReminders.yml new file mode 100644 index 00000000000000..7df945abed21ba --- /dev/null +++ b/.github/workflows/cron-scheduleWhatsappReminders.yml @@ -0,0 +1,23 @@ +name: Cron - scheduleWhatsappReminders + +on: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs "At every 15th minute." (see https://crontab.guru) + - cron: "*/15 * * * *" +jobs: + cron-scheduleWhatsappReminders: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/workflows/scheduleWhatsappReminders \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + -sSf diff --git a/.github/workflows/cron-stale-issue.yml b/.github/workflows/cron-stale-issue.yml index b8ee2246547deb..7f66fd0d69532a 100644 --- a/.github/workflows/cron-stale-issue.yml +++ b/.github/workflows/cron-stale-issue.yml @@ -8,17 +8,20 @@ on: # "Scheduled workflows run on the latest commit on the default or base branch." # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule schedule: - # Runs every day (see https://crontab.guru) + # Runs "At 00:00." every day (see https://crontab.guru) - cron: "0 0 * * *" + workflow_dispatch: jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v7 with: + days-before-close: -1 days-before-issue-stale: 60 days-before-issue-close: -1 days-before-pr-stale: 14 - days-before-pr-close: 7 + days-before-pr-close: -1 stale-pr-message: "This PR is being marked as stale due to inactivity." close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued." + operations-per-run: 100 diff --git a/.github/workflows/cron-syncAppMeta.yml b/.github/workflows/cron-syncAppMeta.yml new file mode 100644 index 00000000000000..38ea311415fe78 --- /dev/null +++ b/.github/workflows/cron-syncAppMeta.yml @@ -0,0 +1,24 @@ +name: Cron - syncAppMeta + +on: + workflow_dispatch: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs "At 00:00 on day-of-month 1." (see https://crontab.guru) + - cron: "0 0 1 * *" +jobs: + cron-syncAppMeta: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/syncAppMeta \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + -sSf diff --git a/.github/workflows/cron-webhooks-triggers.yml b/.github/workflows/cron-webhooks-triggers.yml new file mode 100644 index 00000000000000..d0901e92426d21 --- /dev/null +++ b/.github/workflows/cron-webhooks-triggers.yml @@ -0,0 +1,23 @@ +name: Cron - webhookTriggers + +on: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs “every minute” (see https://crontab.guru) + - cron: "* * * * *" +jobs: + cron-webhookTriggers: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + --fail diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml deleted file mode 100644 index 59edd22ab15dbc..00000000000000 --- a/.github/workflows/crowdin.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Crowdin Action - -on: - push: - branches: - - main -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - synchronize-with-crowdin: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - token: ${{ secrets.GH_ACCESS_TOKEN }} - - - name: crowdin action - uses: crowdin/github-action@1.5.1 - with: - upload_sources: true - download_translations: true - push_translations: true - commit_message: "New Crowdin translations by Github Action" - localization_branch_name: main - create_pull_request: false - env: - GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/delete-buildjet-cache.yml b/.github/workflows/delete-buildjet-cache.yml new file mode 100644 index 00000000000000..d63487afcbb697 --- /dev/null +++ b/.github/workflows/delete-buildjet-cache.yml @@ -0,0 +1,17 @@ +name: Manually Delete BuildJet Cache +on: + workflow_dispatch: + inputs: + cache_key: + description: "BuildJet Cache Key to Delete" + required: true + type: string +jobs: + manually-delete-buildjet-cache: + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: buildjet/cache-delete@v1 + with: + cache_key: ${{ inputs.cache_key }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml new file mode 100644 index 00000000000000..043eb2ba2e2468 --- /dev/null +++ b/.github/workflows/docs-build.yml @@ -0,0 +1,42 @@ +# This is just to test this file +name: Build + +on: + workflow_call: + +jobs: + build: + name: Build Docs + permissions: + contents: read + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - name: Cache Docs build + uses: buildjet/cache@v4 + id: cache-docs-build + env: + cache-name: docs-build + key-1: ${{ hashFiles('yarn.lock') }} + key-2: ${{ hashFiles('docs/**.*', '!**/node_modules') }} + key-3: ${{ github.event.pull_request.number || github.ref }} + key-4: ${{ github.sha }} + with: + path: | + **/docs/** + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + # Log cache hit + - name: Log Cache Hit + if: steps.cache-docs-build.outputs.cache-hit == 'true' + run: echo "Cache hit for Docs build. Skipping build." + - name: Run build + if: steps.cache-docs-build.outputs.cache-hit != 'true' + working-directory: docs + run: | + export NODE_OPTIONS="--max_old_space_size=8192" + npm install -g mintlify + mintlify dev & + sleep 5 # Let it run for 5 seconds + kill $! + shell: bash diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 00000000000000..09520a0676aa4b --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,50 @@ +name: Draft release +run-name: Draft release ${{ inputs.next_version }} + +on: + workflow_dispatch: + inputs: + next_version: + required: true + type: string + description: 'Version name' + +permissions: + contents: write + +jobs: + draft_release: + runs-on: ubuntu-latest + + steps: + + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: 'main' + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Configure git + run: | + git config --local user.email "github-actions@github.com" + git config --local user.name "GitHub Actions" + + - uses: ./.github/actions/yarn-install + + - name: Bump version + run: | + cd apps/web + yarn version ${{ inputs.next_version }} + + - name: Commit changes + run: | + git add . + git commit -m "chore: release v${{ inputs.next_version }}" + git push + + - name: Draft release + run: gh release create v$VERSION --generate-notes --draft + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.next_version }} diff --git a/.github/workflows/e2e-api-v2.yml b/.github/workflows/e2e-api-v2.yml new file mode 100644 index 00000000000000..8a379b4e497e53 --- /dev/null +++ b/.github/workflows/e2e-api-v2.yml @@ -0,0 +1,82 @@ +name: E2E +on: + workflow_call: +env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} + API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }} + API_PORT: ${{ vars.CI_API_V2_PORT }} + CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }} + DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }} + DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} + DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }} + DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }} + GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} + IS_E2E: true + NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }} + NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }} + NODE_OPTIONS: --max-old-space-size=29000 + REDIS_URL: "redis://localhost:6379" + REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} +jobs: + e2e: + timeout-minutes: 20 + name: E2E API v2 + runs-on: buildjet-8vcpu-ubuntu-2204 + 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 + redis: + image: redis:latest + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + fail-fast: false + steps: + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/cache-db + - name: Run Tests + working-directory: apps/api/v2 + run: | + yarn test:e2e + EXIT_CODE=$? + echo "yarn test:e2e command exit code: $EXIT_CODE" + exit $EXIT_CODE + - name: Upload Test Results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: test-results-api-v2 + path: test-results diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index dd1b98953ad421..a74e61b595cc7f 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -1,37 +1,102 @@ -name: E2E App-Store Apps +name: E2E App Store Tests on: workflow_call: - +permissions: + actions: write + contents: read +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 - name: E2E App-Store Apps + name: E2E App Store runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + 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 + mailhog: + image: mailhog/mailhog:v1.0.1 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 8025:8025 + - 1025:1025 + strategy: + fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - - uses: ./.github/actions/env-read-file - uses: ./.github/actions/cache-db + env: + DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }} + 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 }} - uses: ./.github/actions/cache-build - name: Run Tests - run: yarn test-e2e:app-store - env: - DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} + run: yarn e2e:app-store - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-results - path: test-results + name: blob-report-app-store + path: blob-report diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index c49f8fd0edb805..f0a9c5ab298020 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -1,38 +1,89 @@ -name: E2E Embed tests and booking flow(for non-embed as well) +name: E2E Embed React tests and booking flow (for non-embed as well) on: workflow_call: - +permissions: + actions: write + contents: read +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 + name: E2E Embed React runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + 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 + strategy: + fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - - uses: ./.github/actions/env-read-file - uses: ./.github/actions/cache-db - uses: ./.github/actions/cache-build - name: Run Tests run: | - yarn test-e2e:embed-react + yarn e2e:embed-react yarn workspace @calcom/embed-react packaged:tests - env: - DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-results - path: test-results + name: blob-report-embed-react + path: blob-report diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 335471cbe607ee..57b922e6b8716b 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -1,36 +1,95 @@ -name: E2E Embed tests and booking flow(for non-embed as well) +name: E2E Embed Core tests and booking flow (for non-embed as well) on: workflow_call: - +permissions: + actions: write + contents: read +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 + name: E2E Embed Core runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + 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 + mailhog: + image: mailhog/mailhog:v1.0.1 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 8025:8025 + - 1025:1025 + strategy: + fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - - uses: ./.github/actions/env-read-file - uses: ./.github/actions/cache-db - uses: ./.github/actions/cache-build - name: Run Tests - run: yarn test-e2e:embed - env: - DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} + run: yarn e2e:embed - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-results - path: test-results + name: blob-report-embed-core + path: blob-report diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a8c8dfcc9e1941..d85f6d99feb80b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,39 +1,97 @@ -name: E2E test +name: E2E on: workflow_call: +permissions: + actions: write + contents: read +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 + name: E2E (${{ matrix.shard }}/${{ strategy.job-total }}) + runs-on: buildjet-8vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + 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 + mailhog: + image: mailhog/mailhog:v1.0.1 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 8025:8025 + - 1025:1025 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5] + shard: [1, 2, 3, 4] steps: - - uses: actions/checkout@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - - uses: ./.github/actions/env-read-file - uses: ./.github/actions/cache-db - uses: ./.github/actions/cache-build - name: Run Tests run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }} - env: - DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} - name: Upload Test Results - if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 + if: always() with: - name: test-results-${{ matrix.shard }}_${{ strategy.job-total }} - path: test-results + name: blob-report-${{ matrix.shard }} + path: blob-report + retention-days: 30 diff --git a/.github/workflows/env-create-file.yml b/.github/workflows/env-create-file.yml deleted file mode 100644 index 00b229c6dad513..00000000000000 --- a/.github/workflows/env-create-file.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Create .env file - -on: - workflow_call: - -env: - INPUT_ENV_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso - INPUT_ENV_NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000 - INPUT_ENV_NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000 - INPUT_ENV_NEXTAUTH_SECRET: secret - INPUT_ENV_GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} - INPUT_ENV_GOOGLE_LOGIN_ENABLED: true - # INPUT_ENV_CRON_API_KEY: xxx - INPUT_ENV_CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} - INPUT_ENV_NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} - INPUT_ENV_STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} - INPUT_ENV_STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} - INPUT_ENV_STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} - INPUT_ENV_PAYMENT_FEE_PERCENTAGE: 0.005 - INPUT_ENV_PAYMENT_FEE_FIXED: 10 - INPUT_ENV_SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso - INPUT_ENV_SAML_ADMINS: pro@example.com - INPUT_ENV_NEXTAUTH_URL: http://localhost:3000 - INPUT_ENV_NEXT_PUBLIC_IS_E2E: 1 - # INPUT_ENV_EMAIL_FROM: e2e@cal.com - # INPUT_ENV_EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }} - # INPUT_ENV_EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }} - # INPUT_ENV_EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }} - # INPUT_ENV_EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }} - # INPUT_ENV_MS_GRAPH_CLIENT_ID: xxx - # INPUT_ENV_MS_GRAPH_CLIENT_SECRET: xxx - # INPUT_ENV_ZOOM_CLIENT_ID: xxx - # INPUT_ENV_ZOOM_CLIENT_SECRET: xxx - INPUT_ENV_TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - -jobs: - create_env_file: - runs-on: buildjet-4vcpu-ubuntu-2204 - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/dangerous-git-checkout - - uses: ozaytsev86/create-env-file@v1 - with: - file-name: ${{ github.workspace }}/gh.env - - uses: buildjet/cache@v3 - id: env-cache - with: - path: gh.env - key: env-cache-${{ hashFiles('gh.env') }} diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml new file mode 100644 index 00000000000000..c9e2dc323efd77 --- /dev/null +++ b/.github/workflows/i18n.yml @@ -0,0 +1,25 @@ +name: Run i18n AI automation +on: + push: + branches: + - main +concurrency: + group: ${{ github.workflow }}-main + cancel-in-progress: false + +jobs: + i18n: + name: Run i18n + runs-on: buildjet-2vcpu-ubuntu-2204 + permissions: + actions: write + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: lingodotdev/lingo.dev@main + env: + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + with: + api-key: ${{ secrets.CI_REPLEXICA_API_KEY }} + pull-request: true diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000000000..6ccff66d9f9fe1 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,92 @@ +name: Integration +on: + workflow_call: +permissions: + contents: read +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: + integration: + timeout-minutes: 20 + name: Integration + runs-on: buildjet-8vcpu-ubuntu-2204 + 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 + mailhog: + image: mailhog/mailhog:v1.0.1 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 8025:8025 + - 1025:1025 + strategy: + fail-fast: false + steps: + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/cache-db + - name: Run Tests + run: yarn test -- --integrationTestsOnly + # TODO: Generate test results so we can upload them + # - name: Upload Test Results + # if: ${{ always() }} + # uses: actions/upload-artifact@v4 + # with: + # name: test-results + # path: test-results diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8c5f9c18295c47..9b39ab67ef71dd 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,6 +1,7 @@ name: "Pull Request Labeler" on: - - pull_request_target + pull_request_target: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -11,8 +12,84 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - # https://github.com/actions/labeler/issues/442#issuecomment-1297359481 - sync-labels: "" + team-labels: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: equitybee/team-label-action@main + with: + repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }} + organization-name: calcom + ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" + apply-labels-from-issue: + runs-on: ubuntu-latest + + permissions: + contents: none + issues: read + pull-requests: write + + steps: + - name: Apply labels from linked issue to PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + async function getLinkedIssues(owner, repo, prNumber) { + const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 10) { + nodes { + number + labels(first: 10) { + nodes { + name + } + } + } + } + } + } + }`; + + const variables = { + owner: owner, + repo: repo, + prNumber: prNumber, + }; + + const result = await github.graphql(query, variables); + return result.repository.pullRequest.closingIssuesReferences.nodes; + } + + const pr = context.payload.pull_request; + const linkedIssues = await getLinkedIssues( + context.repo.owner, + context.repo.repo, + pr.number + ); + + const labelsToAdd = new Set(); + for (const issue of linkedIssues) { + if (issue.labels && issue.labels.nodes) { + for (const label of issue.labels.nodes) { + labelsToAdd.add(label.name); + } + } + } + + if (labelsToAdd.size) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: Array.from(labelsToAdd), + }); + } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b566168d0a7bc8..1ddaccfc2f1caa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,31 +1,17 @@ name: Lint on: workflow_call: +permissions: + actions: write + contents: read jobs: lint: runs-on: buildjet-4vcpu-ubuntu-2204 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install - - name: Save Code Linting Reports - run: yarn lint:report + - name: Run Lint + run: yarn lint continue-on-error: true - - - name: Merge lint reports - run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json - - - name: Annotate Code Linting Results - uses: ataylorme/eslint-annotate-action@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - report-json: "lint-results/eslint_report.json" - only-pr-files: false - - - name: Upload ESLint report - if: ${{ always() }} - uses: actions/upload-artifact@v2 - with: - name: lint-results - path: lint-results diff --git a/.github/workflows/merge-reports.yml b/.github/workflows/merge-reports.yml new file mode 100644 index 00000000000000..7eab9cd5470090 --- /dev/null +++ b/.github/workflows/merge-reports.yml @@ -0,0 +1,25 @@ +# https://playwright.dev/docs/test-sharding#merging-reports-from-multiple-shards +on: + workflow_call: +jobs: + merge-reports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/yarn-playwright-install + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + - name: Merge into HTML Report + run: yarn playwright merge-reports --reporter html ./all-blob-reports + - name: Upload HTML report + uses: actions/upload-artifact@v4 + with: + name: html-report--attempt-${{ github.run_number }}-${{ github.run_attempt }} + path: playwright-report + retention-days: 14 diff --git a/.github/workflows/nextjs-bundle-analysis-annotation.yml b/.github/workflows/nextjs-bundle-analysis-annotation.yml new file mode 100644 index 00000000000000..ad0374b13d5055 --- /dev/null +++ b/.github/workflows/nextjs-bundle-analysis-annotation.yml @@ -0,0 +1,81 @@ +name: "Next.js Bundle Analysis Annotation" + +on: + workflow_call: + workflow_dispatch: + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + annotate: + if: always() + runs-on: buildjet-2vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/cache-build + - name: Download base branch bundle stats + uses: dawidd6/action-download-artifact@v2 + if: success() + with: + workflow: nextjs-bundle-analysis.yml + branch: ${{ github.event.pull_request.base.ref }} + path: apps/web/.next/analyze/base + + # And here's the second place - this runs after we have both the current and + # base branch bundle stats, and will compare them to determine what changed. + # There are two configurable arguments that come from package.json: + # + # - budget: optional, set a budget (bytes) against which size changes are measured + # it's set to 350kb here by default, as informed by the following piece: + # https://infrequently.org/2021/03/the-performance-inequality-gap/ + # + # - red-status-percentage: sets the percent size increase where you get a red + # status indicator, defaults to 20% + # + # Either of these arguments can be changed or removed by editing the `nextBundleAnalysis` + # entry in your package.json file. + + - name: Compare with base branch bundle + if: success() + run: | + cd apps/web + ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare + + - name: Get comment body + id: get-comment-body + if: success() && github.event.number + run: | + cd apps/web + body=$(cat .next/analyze/__bundle_analysis_comment.txt) + body="${body//'%'/'%25'}" + body="${body//$'\n'/'%0A'}" + body="${body//$'\r'/'%0D'}" + echo ::set-output name=body::$body + + - name: Find Comment + uses: peter-evans/find-comment@v2 + if: success() && github.event.number + id: fc + with: + issue-number: ${{ github.event.number }} + body-includes: "" + + - name: Create Comment + uses: peter-evans/create-or-update-comment@v3 + if: success() && github.event.number && steps.fc.outputs.comment-id == 0 + with: + issue-number: ${{ github.event.number }} + body: ${{ steps.get-comment-body.outputs.body }} + + - name: Update Comment + uses: peter-evans/create-or-update-comment@v3 + if: success() && github.event.number && steps.fc.outputs.comment-id != 0 + with: + issue-number: ${{ github.event.number }} + body: ${{ steps.get-comment-body.outputs.body }} + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace diff --git a/.github/workflows/nextjs-bundle-analysis.yml b/.github/workflows/nextjs-bundle-analysis.yml index 972e8af2253edb..57267210f345f4 100644 --- a/.github/workflows/nextjs-bundle-analysis.yml +++ b/.github/workflows/nextjs-bundle-analysis.yml @@ -2,94 +2,69 @@ name: "Next.js Bundle Analysis" on: workflow_call: + workflow_dispatch: push: branches: - main +permissions: + actions: write + contents: read + +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_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 - if: ${{ github.event_name == 'push' }} - uses: ./.github/workflows/production-build.yml - secrets: inherit analyze: - needs: build if: always() runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 30 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install - uses: ./.github/actions/cache-build - name: Analyze bundle run: | cd apps/web + export NODE_OPTIONS="--max_old_space_size=8192" npx -p nextjs-bundle-analysis@0.5.0 report - name: Upload bundle - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: bundle path: apps/web/.next/analyze/__bundle_analysis.json - - - name: Download base branch bundle stats - uses: dawidd6/action-download-artifact@v2 - if: success() && github.event.number - with: - workflow: nextjs-bundle-analysis.yml - branch: ${{ github.event.pull_request.base.ref }} - path: apps/web/.next/analyze/base - - # And here's the second place - this runs after we have both the current and - # base branch bundle stats, and will compare them to determine what changed. - # There are two configurable arguments that come from package.json: - # - # - budget: optional, set a budget (bytes) against which size changes are measured - # it's set to 350kb here by default, as informed by the following piece: - # https://infrequently.org/2021/03/the-performance-inequality-gap/ - # - # - red-status-percentage: sets the percent size increase where you get a red - # status indicator, defaults to 20% - # - # Either of these arguments can be changed or removed by editing the `nextBundleAnalysis` - # entry in your package.json file. - - name: Compare with base branch bundle - if: success() && github.event.number - run: | - cd apps/web - ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare - - - name: Get comment body - id: get-comment-body - if: success() && github.event.number - run: | - cd apps/web - body=$(cat .next/analyze/__bundle_analysis_comment.txt) - body="${body//'%'/'%25'}" - body="${body//$'\n'/'%0A'}" - body="${body//$'\r'/'%0D'}" - echo ::set-output name=body::$body - - - name: Find Comment - uses: peter-evans/find-comment@v1 - if: success() && github.event.number - id: fc - with: - issue-number: ${{ github.event.number }} - body-includes: "" - - - name: Create Comment - uses: peter-evans/create-or-update-comment@v1.4.4 - if: success() && github.event.number && steps.fc.outputs.comment-id == 0 - with: - issue-number: ${{ github.event.number }} - body: ${{ steps.get-comment-body.outputs.body }} - - - name: Update Comment - uses: peter-evans/create-or-update-comment@v1.4.4 - if: success() && github.event.number && steps.fc.outputs.comment-id != 0 - with: - issue-number: ${{ github.event.number }} - body: ${{ steps.get-comment-body.outputs.body }} - comment-id: ${{ steps.fc.outputs.comment-id }} - edit-mode: replace diff --git a/.github/workflows/on-changes-requested.yml b/.github/workflows/on-changes-requested.yml new file mode 100644 index 00000000000000..64bbf19cad90fc --- /dev/null +++ b/.github/workflows/on-changes-requested.yml @@ -0,0 +1,21 @@ +name: Changes Requested +on: + pull_request_review: + types: [submitted] + +jobs: + changes-requested: + if: github.event.review.state == 'changes_requested' + runs-on: ubuntu-latest + steps: + - name: Explanation + run: echo "This workflow triggers a workflow_run; it's necessary because otherwise the repo secrets aren't available for 'pull_request_review' events from externally forked pull requests" + - name: Save PR number to context.json + run: | + printf '{ + "pr_number": ${{ github.event.pull_request.number }} + }' >> context.json + - uses: actions/upload-artifact@v4 + with: + name: context.json + path: ./ diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml new file mode 100644 index 00000000000000..252df829c31c3b --- /dev/null +++ b/.github/workflows/post-release.yml @@ -0,0 +1,25 @@ +name: Post release +on: + workflow_dispatch: + push: + # Pattern matched against refs/tags + tags: + - "*" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: 'main' + token: ${{ secrets.GH_ACCESS_TOKEN }} + + - name: Configure git + run: | + git config --local user.email "github-actions@github.com" + git config --local user.name "GitHub Actions" + + - run: git push origin +main:production diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e42cc70e91d86f..d1b72c53d172c5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,113 +2,246 @@ name: PR Update on: pull_request_target: + types: [opened, synchronize, reopened, labeled] branches: - main - paths-ignore: - - "**.md" - - ".github/CODEOWNERS" - merge_group: + - gh-actions-test-branch workflow_dispatch: +permissions: + actions: write + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: changes: - runs-on: buildjet-4vcpu-ubuntu-2204 + name: Detect changes + runs-on: buildjet-2vcpu-ubuntu-2204 permissions: pull-requests: read outputs: - app-store: ${{ steps.filter.outputs.app-store }} - embed: ${{ steps.filter.outputs.embed }} - embed-react: ${{ steps.filter.outputs.embed-react }} + has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }} + commit-sha: ${{ steps.get_sha.outputs.commit-sha }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - - uses: dorny/paths-filter@v2 + - uses: dorny/paths-filter@v3 id: filter with: filters: | - app-store: - - 'apps/web/**' - - 'packages/app-store/**' - embed: - - 'apps/web/**' - - 'packages/embeds/**' - embed-react: - - 'apps/web/**' - - 'packages/embeds/**' + has-files-requiring-all-checks: + - "!(**.md|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json)" + - name: Get Latest Commit SHA + id: get_sha + run: | + echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - env: - name: Create env file - uses: ./.github/workflows/env-create-file.yml - secrets: inherit + check-label: + needs: [changes] + runs-on: buildjet-2vcpu-ubuntu-2204 + name: Check for E2E label + permissions: + pull-requests: read + outputs: + run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }} + steps: + - name: Check if PR exists with ready-for-e2e label for this SHA + id: check-if-pr-has-label + uses: actions/github-script@v7 + with: + script: | + let labels = []; + + if (context.payload.pull_request) { + labels = context.payload.pull_request.labels; + } else { + try { + const sha = '${{ needs.changes.outputs.commit-sha }}'; + console.log('sha', sha); + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: sha + }); + + if (prs.length === 0) { + core.setOutput('run-e2e', false); + console.log(`No pull requests found for commit SHA ${sha}`); + return; + } + + const pr = prs[0]; + console.log(`PR number: ${pr.number}`); + console.log(`PR title: ${pr.title}`); + console.log(`PR state: ${pr.state}`); + console.log(`PR URL: ${pr.html_url}`); + + labels = pr.labels; + } + catch (e) { + core.setOutput('run-e2e', false); + console.log(e); + } + } + + const labelFound = labels.map(l => l.name).includes('ready-for-e2e'); + console.log('Found the label?', labelFound); + core.setOutput('run-e2e', labelFound); + + deps: + name: Install dependencies + needs: [changes, check-label] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/yarn-install.yml type-check: name: Type check + needs: [changes, check-label, deps] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/check-types.yml secrets: inherit - test: - name: Unit tests - uses: ./.github/workflows/test.yml - secrets: inherit - lint: name: Linters + needs: [changes, check-label, deps] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/lint.yml secrets: inherit - build: - name: Production build - needs: env - uses: ./.github/workflows/production-build.yml + unit-test: + name: Tests + needs: [changes, check-label, deps] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/unit-tests.yml + secrets: inherit + + build-api-v1: + name: Production builds + needs: [changes, check-label, deps] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/api-v1-production-build.yml + secrets: inherit + + build-api-v2: + name: Production builds + needs: [changes, check-label, deps] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/api-v2-production-build.yml + secrets: inherit + + build-atoms: + name: Production builds + needs: [changes, check-label, deps] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/atoms-production-build.yml secrets: inherit - build-without-database: - name: Production build (without database) - needs: env + build-docs: + name: Production builds + needs: [changes, check-label, deps] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/docs-build.yml + secrets: inherit + + build: + name: Production builds + needs: [changes, check-label, deps] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/production-build-without-database.yml secrets: inherit + integration-test: + name: Tests + needs: [changes, check-label, build, build-api-v1, build-api-v2] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/integration-tests.yml + secrets: inherit + e2e: - name: E2E tests - needs: [changes, lint, build] + name: Tests + needs: [changes, check-label, build, build-api-v1, build-api-v2] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/e2e.yml secrets: inherit + e2e-api-v2: + name: Tests + needs: [changes, check-label, build, build-api-v1, build-api-v2] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/e2e-api-v2.yml + secrets: inherit + e2e-app-store: - name: E2E App Store tests - if: ${{ needs.changes.outputs.app-store == 'true' }} - needs: [changes, lint, build] + name: Tests + needs: [changes, check-label, build, build-api-v1, build-api-v2] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/e2e-app-store.yml secrets: inherit e2e-embed: - name: E2E embeds tests - if: ${{ needs.changes.outputs.embed == 'true' }} - needs: [changes, lint, build] + name: Tests + needs: [changes, check-label, build, build-api-v1, build-api-v2] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && 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 - if: ${{ needs.changes.outputs.embed-react == 'true' }} - needs: [changes, lint, build] + name: Tests + needs: [changes, check-label, build, build-api-v1, build-api-v2] + if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/e2e-embed-react.yml secrets: inherit analyze: - needs: build + name: Analyze Build + needs: [build] uses: ./.github/workflows/nextjs-bundle-analysis.yml secrets: inherit + merge-reports: + name: Merge reports + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store] + uses: ./.github/workflows/merge-reports.yml + secrets: inherit + + publish-report: + name: Publish HTML report + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + permissions: + contents: write + issues: write + pull-requests: write + needs: [changes, check-label, merge-reports] + uses: ./.github/workflows/publish-report.yml + secrets: inherit + required: - needs: [lint, type-check, test, build, e2e] + needs: + [ + changes, + lint, + type-check, + unit-test, + integration-test, + check-label, + build, + build-api-v1, + build-api-v2, + build-atoms, + build-docs, + e2e, + e2e-api-v2, + 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: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled') + if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) run: exit 1 diff --git a/.github/workflows/production-build-without-database.yml b/.github/workflows/production-build-without-database.yml index a55f44e18076a4..6d787eeefdca12 100644 --- a/.github/workflows/production-build-without-database.yml +++ b/.github/workflows/production-build-without-database.yml @@ -1,16 +1,49 @@ -name: Production Build (without database) - +name: Production Builds on: workflow_call: +permissions: + contents: read + +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 }} + 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: ubuntu-latest + name: Build Web App + runs-on: buildjet-4vcpu-ubuntu-2204 timeout-minutes: 30 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install - - uses: ./.github/actions/env-read-file - uses: ./.github/actions/cache-build diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml deleted file mode 100644 index 4bcc8ef71fcd9f..00000000000000 --- a/.github/workflows/production-build.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Production Build - -on: - workflow_call: - -jobs: - build: - name: Build - runs-on: buildjet-4vcpu-ubuntu-2204 - timeout-minutes: 30 - services: - postgres: - image: postgres:12.1 - env: - POSTGRES_USER: postgres - POSTGRES_DB: calendso - ports: - - 5432:5432 - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/dangerous-git-checkout - - uses: ./.github/actions/yarn-install - - uses: ./.github/actions/env-read-file - - uses: ./.github/actions/cache-db - - uses: ./.github/actions/cache-build diff --git a/.github/workflows/publish-report.yml b/.github/workflows/publish-report.yml new file mode 100644 index 00000000000000..4fd70503cd942d --- /dev/null +++ b/.github/workflows/publish-report.yml @@ -0,0 +1,98 @@ +on: + workflow_call: +permissions: + contents: write + issues: write + pull-requests: write +jobs: + publish-report: + runs-on: ubuntu-latest + continue-on-error: true + env: + # Unique URL path for each workflow run attempt + HTML_REPORT_URL_PATH: reports/${{ github.head_ref }}/${{ github.run_id }}/${{ github.run_attempt }} + BRANCH_REPORTS_DIR: reports/${{ github.head_ref }} + steps: + - name: Checkout GitHub Pages Branch + uses: actions/checkout@v4 + with: + repository: calcom/test-results + ref: gh-pages + token: ${{ secrets.GH_ACCESS_TOKEN }} + - name: Set Git User + # see: https://github.com/actions/checkout/issues/13#issuecomment-724415212 + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Check for workflow reports + run: | + if [ -z "$(ls -A $BRANCH_REPORTS_DIR)" ]; then + echo "BRANCH_REPORTS_EXIST="false"" >> $GITHUB_ENV + else + echo "BRANCH_REPORTS_EXIST="true"" >> $GITHUB_ENV + fi + - name: Cleanup old HTML reports + if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }} + timeout-minutes: 3 + run: | + rm -rf $BRANCH_REPORTS_DIR + git add . + git commit -m "workflow: remove all reports for branch ${{ github.head_ref }}" + - name: Download zipped HTML report + uses: actions/download-artifact@v4 + with: + name: html-report--attempt-${{ github.run_number }}-${{ github.run_attempt }} + path: ${{ env.HTML_REPORT_URL_PATH }} + - name: Push HTML Report + timeout-minutes: 3 + # commit report, then try push-rebase-loop until it's able to merge the HTML report to the gh-pages branch + # this is necessary when this job running at least twice at the same time (e.g. through two pushes at the same time) + run: | + git add . + git commit -m "workflow: add HTML report for run-id ${{ github.run_id }} (attempt: ${{ github.run_attempt }})" + + while true; do + git pull --rebase + if [ $? -ne 0 ]; then + echo "Failed to rebase. Please review manually." + exit 1 + fi + + git push + if [ $? -eq 0 ]; then + echo "Successfully pushed HTML report to repo." + exit 0 + fi + done + - name: Output Report URL as Workflow Annotation + id: url + run: | + FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH + echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL" + echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: + - name: Create comment + if: steps.fc.outputs.comment-id == '' + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + + ## E2E results are ready! + - [Workflow #${{ github.run_number }}.${{ github.run_attempt }} latest results](${{ steps.url.outputs.link }}) + - name: Update comment + if: steps.fc.outputs.comment-id != '' + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace + body: | + + ## E2E results are ready! + - [Workflow #${{ github.run_number }}.${{ github.run_attempt }} latest results](${{ steps.url.outputs.link }}) diff --git a/.github/workflows/re-draft.yml b/.github/workflows/re-draft.yml new file mode 100644 index 00000000000000..631bf23423c6c4 --- /dev/null +++ b/.github/workflows/re-draft.yml @@ -0,0 +1,104 @@ +name: Re-draft +on: + workflow_run: + workflows: + - Changes Requested + types: + - completed + +permissions: + contents: write + issues: write + pull-requests: write + statuses: write + +jobs: + download-context: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: 'Download artifact' + uses: actions/github-script@v7 + with: + script: | + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name == "context.json" + })[0]; + + let download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + + let fs = require('fs'); + fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/context.zip`, Buffer.from(download.data)); + + - name: 'Unzip artifact' + run: unzip context.zip + + - name: 'Return Parsed JSON' + uses: actions/github-script@v7 + id: return-parsed-json + with: + script: | + let fs = require('fs'); + let data = fs.readFileSync('./context.json'); + return JSON.parse(data); + + outputs: + pr_number: ${{fromJSON(steps.return-parsed-json.outputs.result).pr_number}} + + re-draft: + needs: + - download-context + permissions: + contents: write + issues: write + pull-requests: write + statuses: write + runs-on: ubuntu-latest + steps: + - run: echo "This PR was rejected" + - name: Convert PR to draft when changes are requested + uses: actions/github-script@v7 + with: + script: | + async function getPullRequestId() { + const pull_number = ${{ needs.download-context.outputs.pr_number }}; + const pullRequest = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number, + }); + if (!pullRequest.data.node_id) throw new Error(`pullRequestId no found for '${pull_number}'`); + return pullRequest.data.node_id; + } + const query = ` + mutation($id: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $id }) { + pullRequest { + id + number + isDraft + } + } + } + `; + const pullRequestId = await getPullRequestId(); + const variables = { + id: pullRequestId, + } + const response = await github.graphql(query, variables) + if (!response.convertPullRequestToDraft) { + throw new Error("Failed to convert pull request to draft"); + } + console.info("Pull request successfully converted to draft."); + console.info(`Draft conversion response: ${JSON.stringify(response, null, 2)}`); diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml new file mode 100644 index 00000000000000..b9aa6c9961e13f --- /dev/null +++ b/.github/workflows/release-docker.yaml @@ -0,0 +1,47 @@ +name: "Release Docker" + +on: # yamllint disable-line rule:truthy + release: + types: + - created + # in case manual trigger is needed + workflow_dispatch: + inputs: + RELEASE_TAG: + description: "v{Major}.{Minor}.{Patch}" + +jobs: + release: + name: "Remote Release" + + runs-on: "ubuntu-latest" + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: "Determine tag" + run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV' + + - name: "Run remote release workflow" + uses: "actions/github-script@v6" + with: + # Requires a personal access token with Actions Read and write permissions on calcom/docker. + github-token: "${{ secrets.DOCKER_REPO_ACCESS_TOKEN }}" + script: | + try { + const response = await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: 'docker', + workflow_id: 'create-release.yaml', + ref: 'main', + inputs: { + "RELEASE_TAG": process.env.RELEASE_TAG + }, + }); + + console.log(response); + } catch (error) { + console.error(error); + core.setFailed(error.message); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 547a5e99216403..00000000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,17 +0,0 @@ -on: - workflow_dispatch: - push: - # Pattern matched against refs/tags - tags: - - "*" # Push events to every tag not containing / - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - ref: main # Always checkout main even for tagged releases - fetch-depth: 0 - token: ${{ secrets.GH_ACCESS_TOKEN }} - - run: git push origin +main:production diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 5585523eecd1ac..b9a80a0ec97804 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -9,7 +9,7 @@ on: - synchronize permissions: - pull-requests: read + pull-requests: write jobs: validate-pr: @@ -17,5 +17,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v5 + id: lint_pr_title env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: marocchino/sticky-pull-request-comment@v2 + # When the previous steps fails, the workflow would stop. By adding this + # condition you can continue the execution with the populated error message. + if: always() && (steps.lint_pr_title.outputs.error_message != null) + with: + header: pr-title-lint-error + message: | + Hey there and thank you for opening this pull request! 👋🏼 + + We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. + + Details: + + ``` + ${{ steps.lint_pr_title.outputs.error_message }} + ``` diff --git a/.github/workflows/submodule-sync.yml b/.github/workflows/submodule-sync.yml index 442ea4bde14306..9a92204974e3e9 100644 --- a/.github/workflows/submodule-sync.yml +++ b/.github/workflows/submodule-sync.yml @@ -1,6 +1,7 @@ name: Submodule Sync on: schedule: + # Runs "At minute 15 past every 4th hour." (see https://crontab.guru) - cron: "15 */4 * * *" workflow_dispatch: ~ jobs: @@ -9,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: recursive token: ${{ secrets.GH_ACCESS_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index fcc7c6ed1f4491..00000000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Unit tests -on: - workflow_call: - workflow_run: - workflows: [Crowdin Action] - types: [completed] -jobs: - test: - timeout-minutes: 20 - runs-on: buildjet-4vcpu-ubuntu-2204 - steps: - - uses: actions/checkout@v3 - - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV - - uses: ./.github/actions/yarn-install - # Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners - - run: yarn test diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000000000..edd29d60f7bef1 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,24 @@ +name: Unit +on: + workflow_call: +env: + REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }} +permissions: + contents: read +jobs: + test: + name: Unit + timeout-minutes: 20 + runs-on: buildjet-4vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - run: yarn test -- --no-isolate + # We could add different timezones here that we need to run our tests in + - run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly --no-isolate + - name: Run API v2 tests + working-directory: apps/api/v2 + run: | + export NODE_OPTIONS="--max_old_space_size=8192" + yarn test diff --git a/.github/workflows/yarn-install.yml b/.github/workflows/yarn-install.yml index 5fbb5c0f1b3411..6909eb95f232f0 100644 --- a/.github/workflows/yarn-install.yml +++ b/.github/workflows/yarn-install.yml @@ -3,12 +3,16 @@ name: Yarn install on: workflow_call: +permissions: + contents: read + jobs: setup: name: Yarn install & cache runs-on: buildjet-4vcpu-ubuntu-2204 timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/yarn-playwright-install diff --git a/.gitpod.yml b/.gitpod.yml index 027cfc279806d1..dee16358b3d7d3 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -5,7 +5,9 @@ tasks: next_auth_secret=$(openssl rand -base64 32) && calendso_encryption_key=$(openssl rand -base64 24) && sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \ - -e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env + -e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" \ + -e "s|http://localhost:3000|https://localhost:3000|" \ + -e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env command: yarn dx ports: @@ -40,4 +42,4 @@ vscode: - bradlc.vscode-tailwindcss - ban.spellright - stripe.vscode-stripe - - Prisma.prisma \ No newline at end of file + - Prisma.prisma diff --git a/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec1389994..00000000000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/.prettierignore b/.prettierignore index 013d82b3a29a2e..76b9ebc554d5b3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,7 +7,6 @@ public *.lock *.log -*.test.ts .gitignore .npmignore @@ -17,3 +16,5 @@ public packages/prisma/zod packages/prisma/enums apps/web/public/embed +apps/api/v2/swagger/documentation.json +packages/ui/components/icon/dynamicIconImports.tsx diff --git a/.snaplet/transform.ts b/.snaplet/transform.ts index b229acabcbdf43..a49e9dc6086e9c 100644 --- a/.snaplet/transform.ts +++ b/.snaplet/transform.ts @@ -1,9 +1,10 @@ -// This transform config was generated by Snaplet. -// Snaplet found fields that may contain personally identifiable information (PII) -// and used that to populate this file. +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +// This config was generated by Snaplet make sure to check it over before using it. import { copycat as c } from "@snaplet/copycat"; +import { defineConfig } from "snaplet"; -import type { Transform } from "./structure"; +// c.setHashKey(REPLACE_ME_WITH_YOUR_HASH_KEY); function hasStringProp(x: unknown, key: T): x is { [key in T]: string } { return !!x && typeof x === "object" && key in x; @@ -41,57 +42,226 @@ function replaceSensitiveKeys(record: object) { const generateUsername = (x: string) => `${c.firstName(x)}-${c.lastName(x)}${c.int(x, { min: 2, max: 99 })}`; -const config: Transform = () => ({ - public: { - ApiKey: ({ row }) => ({ - hashedKey: c.uuid(row.hashedKey), - }), - App: ({ row }) => ({ - keys: replaceSensitiveKeys(row.keys), - }), - Attendee: ({ row }) => ({ - email: c.email(row.email), - name: c.fullName(row.name), - timeZone: c.timezone(row.timeZone), - }), - Credential: ({ row }) => ({ - key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key), - }), - EventType: ({ row }) => ({ - slug: generateSlug(row.slug), - timeZone: c.timezone(row.timeZone), - eventName: c.words(row.eventName, { max: 3 }), - }), - ResetPasswordRequest: ({ row }) => ({ - email: c.email(row.email), - }), - Schedule: ({ row }) => ({ - timeZone: c.timezone(row.timeZone), - }), - Team: ({ row }) => ({ - bio: c.sentence(row.bio), - name: c.words(row.name, { max: 2 }), - slug: generateSlug(row.slug), - }), - users: ({ row }) => - row.role !== "ADMIN" - ? { - bio: c.sentence(row.bio), - email: c.email(row.email), - name: c.fullName(row.name), - password: c.password(row.password), - timeZone: c.timezone(row.timeZone), - username: generateUsername(row.username), - } - : row, - VerificationToken: ({ row }) => ({ - token: c.uuid(row.token), - }), - Account: ({ row }) => ({ - access_token: c.uuid(row.access_token), - refresh_token: c.uuid(row.refresh_token), - }), +export default defineConfig({ + transform: { + $mode: "unsafe", + public: { + Account({ row }) { + return { + refresh_token: c.uuid(row.refresh_token), + access_token: c.uuid(row.access_token), + expires_at: c.int(row.expires_at, { + min: 0, + max: Math.pow(4, 8) - 1, + }), + token_type: c.uuid(row.token_type), + id_token: c.uuid(row.id_token), + }; + }, + ApiKey: ({ row }) => ({ + hashedKey: c.uuid(row.hashedKey), + note: c.fullName(row.note), + createdAt: c.dateString(row.createdAt, { + minYear: 2020, + }), + }), + App: ({ row }) => ({ + keys: replaceSensitiveKeys(row.keys), + }), + App_RoutingForms_Form({ row }) { + return { + name: c.fullName(row.name), + }; + }, + Attendee: ({ row }) => ({ + email: c.email(row.email), + name: c.fullName(row.name), + timeZone: c.timezone(row.timeZone), + locale: c.fullName(row.locale), + }), + Availability({ row }) { + return { + startTime: c + .dateString(row.startTime, { + minYear: 2020, + }) + .slice(11, 19), + endTime: c + .dateString(row.endTime, { + minYear: 2020, + }) + .slice(11, 19), + }; + }, + Booking({ row }) { + return { + title: c.fullName(row.title), + startTime: c.dateString(row.startTime, { + minYear: 2020, + }), + endTime: c.dateString(row.endTime, { + minYear: 2020, + }), + location: c.sentence(row.location), + metadata: { + [c.word(row.metadata)]: c.words(row.metadata), + }, + }; + }, + Credential: ({ row }) => ({ + key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key), + }), + EventType: ({ row }) => ({ + slug: generateSlug(row.slug), + timeZone: c.timezone(row.timeZone), + eventName: c.words(row.eventName, { max: 3 }), + currency: c.sentence(row.currency), + }), + EventTypeCustomInput({ row }) { + return { + label: c.fullName(row.label), + }; + }, + Feature({ row }) { + return { + slug: c.uuid(row.slug), + }; + }, + InstantMeetingToken({ row }) { + return { + token: c.uuid(row.token), + }; + }, + OAuthClient({ row }) { + return { + name: c.fullName(row.name), + }; + }, + OutOfOfficeEntry({ row }) { + return { + start: c.dateString(row.start, { + minYear: 2020, + }), + }; + }, + Payment({ row }) { + return { + amount: c.int(row.amount, { + min: 0, + max: Math.pow(4, 8) - 1, + }), + currency: c.sentence(row.currency), + data: { + [c.word(row.data)]: c.words(row.data), + }, + }; + }, + ResetPasswordRequest: ({ row }) => ({ + email: c.email(row.email), + }), + Schedule: ({ row }) => ({ + name: c.fullName(row.name), + timeZone: c.timezone(row.timeZone), + }), + Session({ row }) { + return { + sessionToken: c.uuid(row.sessionToken), + }; + }, + Team: ({ row }) => ({ + bio: c.sentence(row.bio), + name: c.words(row.name, { max: 2 }), + slug: generateSlug(row.slug), + timeZone: c.timezone(row.timeZone), + logoUrl: c.username(row.logoUrl), + }), + TempOrgRedirect({ row }) { + return { + from: c.dateString(row.from, { + maxYear: 1999, + }), + toUrl: c.city(row.toUrl), + }; + }, + VerificationToken({ row }) { + return { + id: c + .int(row.id, { + min: 1, + max: Math.pow(4, 8) - 1, + }) + .toString(), + identifier: c.uuid(row.identifier), + token: c.uuid(row.token), + expires: c.dateString(row.expires, { + minYear: 2020, + }), + }; + }, + VerifiedNumber({ row }) { + return { + phoneNumber: c.phoneNumber(row.phoneNumber), + }; + }, + Webhook({ row }) { + return { + subscriberUrl: c.url(row.subscriberUrl), + secret: c.streetAddress(row.secret), + }; + }, + WebhookScheduledTriggers({ row }) { + return { + jobName: c.fullName(row.jobName), + payload: c.password(row.payload), + }; + }, + WorkflowStep({ row }) { + return { + sendTo: c.oneOf(row.sendTo, [ + "Man", + "Woman", + "Transgender", + "Non-binary/non-conforming", + "Not specified", + ]), + sender: c.oneOf(row.sender, [ + "Man", + "Woman", + "Transgender", + "Non-binary/non-conforming", + "Not specified", + ]), + }; + }, + users: ({ row }) => + row.role !== "ADMIN" + ? { + bio: c.sentence(row.bio), + email: c.email(row.email), + name: c.fullName(row.name), + password: c.password(row.password), + timeZone: c.timezone(row.timeZone), + username: generateUsername(row.username), + metadata: { + [c.word(row.metadata)]: c.words(row.metadata), + }, + } + : row, + }, + }, + subset: { + enabled: true, + version: "3", + targets: [ + { + table: "public.users", + rowLimit: 100, + }, + ], + keepDisconnectedTables: false, + followNullableRelations: true, + maxCyclesLoop: 0, + eager: false, + taskSortAlgorithm: "children", }, }); - -export default config; diff --git a/.vscode/extensions.json b/.vscode/extensions.json index cae98d6257f342..74cae7dd162423 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -8,6 +8,7 @@ "ban.spellright", // Spell check for docs "stripe.vscode-stripe", // stripe VSCode extension "Prisma.prisma", // syntax|format|completion for prisma - "rebornix.project-snippets" // Share useful snippets between collaborators + "rebornix.project-snippets", // Share useful snippets between collaborators + "inlang.vs-code-extension" // improved i18n DX ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c07cd934a635f..c344645fab61f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "typescript.tsdk": "node_modules/typescript/lib", "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "typescript.preferences.importModuleSpecifier": "non-relative", "spellright.language": ["en"], diff --git a/.well-known/security.txt b/.well-known/security.txt new file mode 100644 index 00000000000000..6db124150ebb7f --- /dev/null +++ b/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: security@cal.com +Preferred-Languages: en +Canonical: https://cal.com/.well-known/security.txt +Policy: https://github.com/calcom/cal.com/security/policy diff --git a/.yarn/patches/@prisma-client-npm-5.4.2-fca489b2dc.patch b/.yarn/patches/@prisma-client-npm-5.4.2-fca489b2dc.patch new file mode 100644 index 00000000000000..93d2ea935d6591 --- /dev/null +++ b/.yarn/patches/@prisma-client-npm-5.4.2-fca489b2dc.patch @@ -0,0 +1,26 @@ +diff --git a/runtime/binary.js b/runtime/binary.js +index c81267d752644043e97b35d26369a5ce266abfd0..034c11262143d1acbf9902745a441d3b0cdfc0fd 100644 +--- a/runtime/binary.js ++++ b/runtime/binary.js +@@ -185,7 +185,7 @@ It should have this form: { url: "CONNECTION_STRING" }`);if(r&&typeof r=="object + It should have this form: { url: "CONNECTION_STRING" }`);if(typeof i!="string")throw new Me(`Invalid value ${JSON.stringify(i)} for datasource "${t}" provided to PrismaClient constructor. + It should have this form: { url: "CONNECTION_STRING" }`)}}}},adapter:(e,A)=>{if(e===null)return;if(e===void 0)throw new Me('"adapter" property must not be undefined, use null to conditionally disable driver adapters.');if(!cg(A).includes("driverAdapters"))throw new Me('"adapter" property can only be provided to PrismaClient constructor when "driverAdapters" preview feature is enabled.')},datasourceUrl:e=>{if(typeof e<"u"&&typeof e!="string")throw new Me(`Invalid value ${JSON.stringify(e)} for "datasourceUrl" provided to PrismaClient constructor. + Expected string or undefined.`)},errorFormat:e=>{if(e){if(typeof e!="string")throw new Me(`Invalid value ${JSON.stringify(e)} for "errorFormat" provided to PrismaClient constructor.`);if(!PR.includes(e)){let A=Ni(e,PR);throw new Me(`Invalid errorFormat ${e} provided to PrismaClient constructor.${A}`)}}},log:e=>{if(!e)return;if(!Array.isArray(e))throw new Me(`Invalid value ${JSON.stringify(e)} for "log" provided to PrismaClient constructor.`);function A(t){if(typeof t=="string"&&!GR.includes(t)){let r=Ni(t,GR);throw new Me(`Invalid log level "${t}" provided to PrismaClient constructor.${r}`)}}for(let t of e){A(t);let r={level:A,emit:n=>{let i=["stdout","event"];if(!i.includes(n)){let s=Ni(n,i);throw new Me(`Invalid value ${JSON.stringify(n)} for "emit" in logLevel provided to PrismaClient constructor.${s}`)}}};if(t&&typeof t=="object")for(let[n,i]of Object.entries(t))if(r[n])r[n](i);else throw new Me(`Invalid property ${n} for "log" provided to PrismaClient constructor`)}},__internal:e=>{if(!e)return;let A=["debug","hooks","engine","measurePerformance"];if(typeof e!="object")throw new Me(`Invalid value ${JSON.stringify(e)} for "__internal" to PrismaClient constructor`);for(let[t]of Object.entries(e))if(!A.includes(t)){let r=Ni(t,A);throw new Me(`Invalid property ${JSON.stringify(t)} for "__internal" provided to PrismaClient constructor.${r}`)}}};function YR(e,A){for(let[t,r]of Object.entries(e)){if(!vR.includes(t)){let n=Ni(t,vR);throw new Me(`Unknown property ${t} provided to PrismaClient constructor.${n}`)}VJ[t](r,A)}if(e.datasourceUrl&&e.datasources)throw new Me('Can not use "datasourceUrl" and "datasources" options at the same time. Pick one of them')}function Ni(e,A){if(A.length===0||typeof e!="string")return"";let t=qJ(e,A);return t?` Did you mean "${t}"?`:""}function qJ(e,A){if(A.length===0)return null;let t=A.map(n=>({value:n,distance:(0,JR.default)(e,n)}));t.sort((n,i)=>n.distance{let r=new Array(e.length),n=null,i=!1,s=0,o=()=>{i||(s++,s===e.length&&(i=!0,n?t(n):A(r)))},a=c=>{i||(i=!0,t(c))};for(let c=0;c{r[c]=g,o()},g=>{if(!Cg(g)){a(g);return}g.batchRequestIdx===c?a(g):(n||(n=g),o())})})}var pr=ce("prisma:client");typeof globalThis=="object"&&(globalThis.NODE_CLIENT=!0);var OJ={requestArgsToMiddlewareArgs:e=>e,middlewareArgsToRequestArgs:e=>e},HJ=Symbol.for("prisma.client.transaction.id"),WJ={id:0,nextId(){return++this.id}};function _R(e){class A{constructor(r){this._middlewares=new dg;this._createPrismaPromise=ed();this.$extends=Xf;EI(e),r&&YR(r,e);let n=r?.adapter?cf(r.adapter):void 0,i=new HR.EventEmitter().on("error",()=>{});this._extensions=wa.empty(),this._previewFeatures=cg(e),this._clientVersion=e.clientVersion??TR,this._activeProvider=e.activeProvider,this._tracingHelper=DR(this._previewFeatures);let s={rootEnvPath:e.relativeEnvPaths.rootEnvPath&&Qo.default.resolve(e.dirname,e.relativeEnvPaths.rootEnvPath),schemaEnvPath:e.relativeEnvPaths.schemaEnvPath&&Qo.default.resolve(e.dirname,e.relativeEnvPaths.schemaEnvPath)},o=!n&&qi(s,{conflictCheck:"none"})||e.injectableEdgeEnv?.();try{let a=r??{},c=a.__internal??{},g=c.debug===!0;g&&ce.enable("prisma:client");let l=Qo.default.resolve(e.dirname,e.relativePath);WR.default.existsSync(l)||(l=e.dirname),pr("dirname",e.dirname),pr("relativePath",e.relativePath),pr("cwd",l);let u=c.engine||{};if(a.errorFormat?this._errorFormat=a.errorFormat:process.env.NODE_ENV==="production"?this._errorFormat="minimal":process.env.NO_COLOR?this._errorFormat="colorless":this._errorFormat="colorless",this._runtimeDataModel=e.runtimeDataModel,this._engineConfig={cwd:l,dirname:e.dirname,enableDebugLogs:g,allowTriggerPanic:u.allowTriggerPanic,datamodelPath:Qo.default.join(e.dirname,e.filename??"schema.prisma"),prismaPath:u.binaryPath??void 0,engineEndpoint:u.endpoint,generator:e.generator,showColors:this._errorFormat==="pretty",logLevel:a.log&&kR(a.log),logQueries:a.log&&!!(typeof a.log=="string"?a.log==="query":a.log.find(E=>typeof E=="string"?E==="query":E.level==="query")),env:o?.parsed??{},flags:[],clientVersion:e.clientVersion,engineVersion:e.engineVersion,previewFeatures:this._previewFeatures,activeProvider:e.activeProvider,inlineSchema:e.inlineSchema,overrideDatasources:hI(a,e.datasourceNames),inlineDatasources:e.inlineDatasources,inlineSchemaHash:e.inlineSchemaHash,tracingHelper:this._tracingHelper,logEmitter:i,isBundled:e.isBundled,adapter:n},pr("clientVersion",e.clientVersion),this._engine=tR(e,this._engineConfig),this._requestHandler=new Ig(this,i),a.log)for(let E of a.log){let h=typeof E=="string"?E:E.emit==="stdout"?E.level:null;h&&this.$on(h,d=>{Wi.log(`${Wi.tags[h]??""}`,d.message||d.query)})}this._metrics=new yn(this._engine)}catch(a){throw a.clientVersion=this._clientVersion,a}return this._appliedParent=is(this)}get[Symbol.toStringTag](){return"PrismaClient"}$use(r){this._middlewares.use(r)}$on(r,n){r==="beforeExit"?this._engine.on("beforeExit",n):this._engine.on(r,i=>{let s=i.fields;return n(r==="query"?{timestamp:i.timestamp,query:s?.query??i.query,params:s?.params??i.params,duration:s?.duration_ms??i.duration,target:i.target}:{timestamp:i.timestamp,message:s?.message??i.message,target:i.target})})}$connect(){try{return this._engine.start()}catch(r){throw r.clientVersion=this._clientVersion,r}}async $disconnect(){try{await this._engine.stop()}catch(r){throw r.clientVersion=this._clientVersion,r}finally{Rd()}}$executeRawInternal(r,n,i,s){let o=this._activeProvider,a=this._engineConfig.adapter?.flavour;return this._request({action:"executeRaw",args:i,transaction:r,clientMethod:n,argsMapper:$h({clientMethod:n,activeProvider:o,activeProviderFlavour:a}),callsite:nr(this._errorFormat),dataPath:[],middlewareArgsMapper:s})}$executeRaw(r,...n){return this._createPrismaPromise(i=>{if(r.raw!==void 0||r.sql!==void 0){let[s,o]=qR(r,n);return zh(this._activeProvider,s.text,s.values,Array.isArray(r)?"prisma.$executeRaw``":"prisma.$executeRaw(sql``)"),this.$executeRawInternal(i,"$executeRaw",s,o)}throw new AA("`$executeRaw` is a tag function, please use it like the following:\n```\nconst result = await prisma.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`\n```\n\nOr read our docs at https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#executeraw\n",{clientVersion:this._clientVersion})})}$executeRawUnsafe(r,...n){return this._createPrismaPromise(i=>(zh(this._activeProvider,r,n,"prisma.$executeRawUnsafe(, [...values])"),this.$executeRawInternal(i,"$executeRawUnsafe",[r,...n])))}$runCommandRaw(r){if(e.activeProvider!=="mongodb")throw new AA(`The ${e.activeProvider} provider does not support $runCommandRaw. Use the mongodb provider.`,{clientVersion:this._clientVersion});return this._createPrismaPromise(n=>this._request({args:r,clientMethod:"$runCommandRaw",dataPath:[],action:"runCommandRaw",argsMapper:CR,callsite:nr(this._errorFormat),transaction:n}))}async $queryRawInternal(r,n,i,s){let o=this._activeProvider,a=this._engineConfig.adapter?.flavour;return this._request({action:"queryRaw",args:i,transaction:r,clientMethod:n,argsMapper:$h({clientMethod:n,activeProvider:o,activeProviderFlavour:a}),callsite:nr(this._errorFormat),dataPath:[],middlewareArgsMapper:s}).then(LR)}$queryRaw(r,...n){return this._createPrismaPromise(i=>{if(r.raw!==void 0||r.sql!==void 0)return this.$queryRawInternal(i,"$queryRaw",...qR(r,n));throw new AA("`$queryRaw` is a tag function, please use it like the following:\n```\nconst result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`\n```\n\nOr read our docs at https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw\n",{clientVersion:this._clientVersion})})}$queryRawUnsafe(r,...n){return this._createPrismaPromise(i=>this.$queryRawInternal(i,"$queryRawUnsafe",[r,...n]))}_transactionWithArray({promises:r,options:n}){let i=WJ.nextId(),s=bR(r.length),o=r.map((a,c)=>{if(a?.[Symbol.toStringTag]!=="PrismaPromise")throw new Error("All elements of the array need to be Prisma Client promises. Hint: Please make sure you are not awaiting the Prisma client calls you intended to pass in the $transaction function.");let g=n?.isolationLevel,l={kind:"batch",id:i,index:c,isolationLevel:g,lock:s};return a.requestTransaction?.(l)??a});return VR(o)}async _transactionWithCallback({callback:r,options:n}){let i={traceparent:this._tracingHelper.getTraceParent()},s=await this._engine.transaction("start",i,n),o;try{let a={kind:"itx",...s};o=await r(this._createItxClient(a)),await this._engine.transaction("commit",i,s)}catch(a){throw await this._engine.transaction("rollback",i,s).catch(()=>{}),a}return o}_createItxClient(r){return is(Et(pa(this),[aA("_appliedParent",()=>this._appliedParent._createItxClient(r)),aA("_createPrismaPromise",()=>ed(r)),aA(HJ,()=>r.id),As(td)]))}$transaction(r,n){let i;typeof r=="function"?i=()=>this._transactionWithCallback({callback:r,options:n}):i=()=>this._transactionWithArray({promises:r,options:n});let s={name:"transaction",attributes:{method:"$transaction"}};return this._tracingHelper.runInChildSpan(s,i)}_request(r){r.otelParentCtx=this._tracingHelper.getActiveContext();let n=r.middlewareArgsMapper??OJ,i={args:n.requestArgsToMiddlewareArgs(r.args),dataPath:r.dataPath,runInTransaction:!!r.transaction,action:r.action,model:r.model},s={middleware:{name:"middleware",middleware:!0,attributes:{method:"$use"},active:!1},operation:{name:"operation",attributes:{method:i.action,model:i.model,name:i.model?`${i.model}.${i.action}`:i.action}}},o=-1,a=async c=>{let g=this._middlewares.get(++o);if(g)return this._tracingHelper.runInChildSpan(s.middleware,Q=>g(c,I=>(Q?.end(),a(I))));let{runInTransaction:l,args:u,...E}=c,h={...r,...E};u&&(h.args=n.middlewareArgsToRequestArgs(u)),r.transaction!==void 0&&l===!1&&delete h.transaction;let d=await nI(this,h);return h.model?eI({result:d,modelName:h.model,args:h.args,extensions:this._extensions,runtimeDataModel:this._runtimeDataModel}):d};return this._tracingHelper.runInChildSpan(s.operation,()=>new OR.AsyncResource("prisma-client-request").runInAsyncScope(()=>a(i)))}async _executeRequest({args:r,clientMethod:n,dataPath:i,callsite:s,action:o,model:a,argsMapper:c,transaction:g,unpacker:l,otelParentCtx:u,customDataProxyFetch:E}){try{r=c?c(r):r;let h={name:"serialize"},d=this._tracingHelper.runInChildSpan(h,()=>ER({modelName:a,runtimeDataModel:this._runtimeDataModel,action:o,args:r,clientMethod:n,callsite:s,extensions:this._extensions,errorFormat:this._errorFormat,clientVersion:this._clientVersion}));return ce.enabled("prisma:client")&&(pr("Prisma Client call:"),pr(`prisma.${n}(${Mf(r)})`),pr("Generated request:"),pr(JSON.stringify(d,null,2)+` +-`)),g?.kind==="batch"&&await g.lock,this._requestHandler.request({protocolQuery:d,modelName:a,action:o,clientMethod:n,dataPath:i,callsite:s,args:r,extensions:this._extensions,transaction:g,unpacker:l,otelParentCtx:u,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:E})}catch(h){throw h.clientVersion=this._clientVersion,h}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new AA("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(r){return!!this._engineConfig.previewFeatures?.includes(r)}}return A}function qR(e,A){return _J(e)?[new QA(e,A),mR]:[e,yR]}function _J(e){return Array.isArray(e)&&Array.isArray(e.raw)}var KJ=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function KR(e){return new Proxy(e,{get(A,t){if(t in A)return A[t];if(!KJ.has(t))throw new TypeError(`Invalid enum value: ${String(t)}`)}})}function jR(e){qi(e,{conflictCheck:"warn"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce}); ++`)),g?.kind==="batch"&&await g.lock,this._requestHandler.request({protocolQuery:d,modelName:a,action:o,clientMethod:n,dataPath:i,callsite:s,args:r,extensions:this._extensions,transaction:g,unpacker:l,otelParentCtx:u,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:E})}catch(h){throw h.clientVersion=this._clientVersion,h}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new AA("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(r){return!!this._engineConfig.previewFeatures?.includes(r)}}return A}function qR(e,A){return _J(e)?[new QA(e,A),mR]:[e,yR]}function _J(e){return Array.isArray(e)&&Array.isArray(e.raw)}var KJ=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function KR(e){return new Proxy(e,{get(A,t){if(t in A)return A[t];if(!KJ.has(t))throw new TypeError(`Invalid enum value: ${String(t)}`)}})}function jR(e){qi(e,{conflictCheck:"none"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce}); + /*! Bundled license information: + + undici/lib/fetch/body.js: +diff --git a/runtime/library.js b/runtime/library.js +index 65b30894c697f97a54924dcf7acc4b7e45002f4d..b2d26124f34759bb222a08e76bcc0f6d52b3c573 100644 +--- a/runtime/library.js ++++ b/runtime/library.js +@@ -126,7 +126,7 @@ It should have this form: { url: "CONNECTION_STRING" }`);if(n&&typeof n=="object + It should have this form: { url: "CONNECTION_STRING" }`);if(typeof o!="string")throw new q(`Invalid value ${JSON.stringify(o)} for datasource "${r}" provided to PrismaClient constructor. + It should have this form: { url: "CONNECTION_STRING" }`)}}}},adapter:(e,t)=>{if(e===null)return;if(e===void 0)throw new q('"adapter" property must not be undefined, use null to conditionally disable driver adapters.');if(!gn(t).includes("driverAdapters"))throw new q('"adapter" property can only be provided to PrismaClient constructor when "driverAdapters" preview feature is enabled.')},datasourceUrl:e=>{if(typeof e<"u"&&typeof e!="string")throw new q(`Invalid value ${JSON.stringify(e)} for "datasourceUrl" provided to PrismaClient constructor. + Expected string or undefined.`)},errorFormat:e=>{if(e){if(typeof e!="string")throw new q(`Invalid value ${JSON.stringify(e)} for "errorFormat" provided to PrismaClient constructor.`);if(!Cl.includes(e)){let t=Rt(e,Cl);throw new q(`Invalid errorFormat ${e} provided to PrismaClient constructor.${t}`)}}},log:e=>{if(!e)return;if(!Array.isArray(e))throw new q(`Invalid value ${JSON.stringify(e)} for "log" provided to PrismaClient constructor.`);function t(r){if(typeof r=="string"&&!Al.includes(r)){let n=Rt(r,Al);throw new q(`Invalid log level "${r}" provided to PrismaClient constructor.${n}`)}}for(let r of e){t(r);let n={level:t,emit:i=>{let o=["stdout","event"];if(!o.includes(i)){let s=Rt(i,o);throw new q(`Invalid value ${JSON.stringify(i)} for "emit" in logLevel provided to PrismaClient constructor.${s}`)}}};if(r&&typeof r=="object")for(let[i,o]of Object.entries(r))if(n[i])n[i](o);else throw new q(`Invalid property ${i} for "log" provided to PrismaClient constructor`)}},__internal:e=>{if(!e)return;let t=["debug","hooks","engine","measurePerformance"];if(typeof e!="object")throw new q(`Invalid value ${JSON.stringify(e)} for "__internal" to PrismaClient constructor`);for(let[r]of Object.entries(e))if(!t.includes(r)){let n=Rt(r,t);throw new q(`Invalid property ${JSON.stringify(r)} for "__internal" provided to PrismaClient constructor.${n}`)}}};function Ml(e,t){for(let[r,n]of Object.entries(e)){if(!Tl.includes(r)){let i=Rt(r,Tl);throw new q(`Unknown property ${r} provided to PrismaClient constructor.${i}`)}Xd[r](n,t)}if(e.datasourceUrl&&e.datasources)throw new q('Can not use "datasourceUrl" and "datasources" options at the same time. Pick one of them')}function Rt(e,t){if(t.length===0||typeof e!="string")return"";let r=em(e,t);return r?` Did you mean "${r}"?`:""}function em(e,t){if(t.length===0)return null;let r=t.map(i=>({value:i,distance:(0,Rl.default)(e,i)}));r.sort((i,o)=>i.distance{let n=new Array(e.length),i=null,o=!1,s=0,a=()=>{o||(s++,s===e.length&&(o=!0,i?r(i):t(n)))},l=u=>{o||(o=!0,r(u))};for(let u=0;u{n[u]=c,a()},c=>{if(!Pn(c)){l(c);return}c.batchRequestIdx===u?l(c):(i||(i=c),a())})})}var Ue=D("prisma:client");typeof globalThis=="object"&&(globalThis.NODE_CLIENT=!0);var tm={requestArgsToMiddlewareArgs:e=>e,middlewareArgsToRequestArgs:e=>e},rm=Symbol.for("prisma.client.transaction.id"),nm={id:0,nextId(){return++this.id}};function Dl(e){class t{constructor(n){this._middlewares=new wn;this._createPrismaPromise=Hi();this.$extends=ra;xa(e),n&&Ml(n,e);let i=n?.adapter?fs(n.adapter):void 0,o=new Fl.EventEmitter().on("error",()=>{});this._extensions=ln.empty(),this._previewFeatures=gn(e),this._clientVersion=e.clientVersion??wl,this._activeProvider=e.activeProvider,this._tracingHelper=ml(this._previewFeatures);let s={rootEnvPath:e.relativeEnvPaths.rootEnvPath&&yr.default.resolve(e.dirname,e.relativeEnvPaths.rootEnvPath),schemaEnvPath:e.relativeEnvPaths.schemaEnvPath&&yr.default.resolve(e.dirname,e.relativeEnvPaths.schemaEnvPath)},a=!i&&_t(s,{conflictCheck:"none"})||e.injectableEdgeEnv?.();try{let l=n??{},u=l.__internal??{},c=u.debug===!0;c&&D.enable("prisma:client");let p=yr.default.resolve(e.dirname,e.relativePath);Ol.default.existsSync(p)||(p=e.dirname),Ue("dirname",e.dirname),Ue("relativePath",e.relativePath),Ue("cwd",p);let d=u.engine||{};if(l.errorFormat?this._errorFormat=l.errorFormat:process.env.NODE_ENV==="production"?this._errorFormat="minimal":process.env.NO_COLOR?this._errorFormat="colorless":this._errorFormat="colorless",this._runtimeDataModel=e.runtimeDataModel,this._engineConfig={cwd:p,dirname:e.dirname,enableDebugLogs:c,allowTriggerPanic:d.allowTriggerPanic,datamodelPath:yr.default.join(e.dirname,e.filename??"schema.prisma"),prismaPath:d.binaryPath??void 0,engineEndpoint:d.endpoint,generator:e.generator,showColors:this._errorFormat==="pretty",logLevel:l.log&&gl(l.log),logQueries:l.log&&!!(typeof l.log=="string"?l.log==="query":l.log.find(f=>typeof f=="string"?f==="query":f.level==="query")),env:a?.parsed??{},flags:[],clientVersion:e.clientVersion,engineVersion:e.engineVersion,previewFeatures:this._previewFeatures,activeProvider:e.activeProvider,inlineSchema:e.inlineSchema,overrideDatasources:ba(l,e.datasourceNames),inlineDatasources:e.inlineDatasources,inlineSchemaHash:e.inlineSchemaHash,tracingHelper:this._tracingHelper,logEmitter:o,isBundled:e.isBundled,adapter:i},Ue("clientVersion",e.clientVersion),this._engine=Ua(e,this._engineConfig),this._requestHandler=new Cn(this,o),l.log)for(let f of l.log){let y=typeof f=="string"?f:f.emit==="stdout"?f.level:null;y&&this.$on(y,g=>{$t.log(`${$t.tags[y]??""}`,g.message||g.query)})}this._metrics=new dt(this._engine)}catch(l){throw l.clientVersion=this._clientVersion,l}return this._appliedParent=zt(this)}get[Symbol.toStringTag](){return"PrismaClient"}$use(n){this._middlewares.use(n)}$on(n,i){n==="beforeExit"?this._engine.on("beforeExit",i):this._engine.on(n,o=>{let s=o.fields;return i(n==="query"?{timestamp:o.timestamp,query:s?.query??o.query,params:s?.params??o.params,duration:s?.duration_ms??o.duration,target:o.target}:{timestamp:o.timestamp,message:s?.message??o.message,target:o.target})})}$connect(){try{return this._engine.start()}catch(n){throw n.clientVersion=this._clientVersion,n}}async $disconnect(){try{await this._engine.stop()}catch(n){throw n.clientVersion=this._clientVersion,n}finally{Eo()}}$executeRawInternal(n,i,o,s){let a=this._activeProvider,l=this._engineConfig.adapter?.flavour;return this._request({action:"executeRaw",args:o,transaction:n,clientMethod:i,argsMapper:Ji({clientMethod:i,activeProvider:a,activeProviderFlavour:l}),callsite:Ve(this._errorFormat),dataPath:[],middlewareArgsMapper:s})}$executeRaw(n,...i){return this._createPrismaPromise(o=>{if(n.raw!==void 0||n.sql!==void 0){let[s,a]=Il(n,i);return Gi(this._activeProvider,s.text,s.values,Array.isArray(n)?"prisma.$executeRaw``":"prisma.$executeRaw(sql``)"),this.$executeRawInternal(o,"$executeRaw",s,a)}throw new X("`$executeRaw` is a tag function, please use it like the following:\n```\nconst result = await prisma.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`\n```\n\nOr read our docs at https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#executeraw\n",{clientVersion:this._clientVersion})})}$executeRawUnsafe(n,...i){return this._createPrismaPromise(o=>(Gi(this._activeProvider,n,i,"prisma.$executeRawUnsafe(, [...values])"),this.$executeRawInternal(o,"$executeRawUnsafe",[n,...i])))}$runCommandRaw(n){if(e.activeProvider!=="mongodb")throw new X(`The ${e.activeProvider} provider does not support $runCommandRaw. Use the mongodb provider.`,{clientVersion:this._clientVersion});return this._createPrismaPromise(i=>this._request({args:n,clientMethod:"$runCommandRaw",dataPath:[],action:"runCommandRaw",argsMapper:nl,callsite:Ve(this._errorFormat),transaction:i}))}async $queryRawInternal(n,i,o,s){let a=this._activeProvider,l=this._engineConfig.adapter?.flavour;return this._request({action:"queryRaw",args:o,transaction:n,clientMethod:i,argsMapper:Ji({clientMethod:i,activeProvider:a,activeProviderFlavour:l}),callsite:Ve(this._errorFormat),dataPath:[],middlewareArgsMapper:s}).then(Pl)}$queryRaw(n,...i){return this._createPrismaPromise(o=>{if(n.raw!==void 0||n.sql!==void 0)return this.$queryRawInternal(o,"$queryRaw",...Il(n,i));throw new X("`$queryRaw` is a tag function, please use it like the following:\n```\nconst result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`\n```\n\nOr read our docs at https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw\n",{clientVersion:this._clientVersion})})}$queryRawUnsafe(n,...i){return this._createPrismaPromise(o=>this.$queryRawInternal(o,"$queryRawUnsafe",[n,...i]))}_transactionWithArray({promises:n,options:i}){let o=nm.nextId(),s=fl(n.length),a=n.map((l,u)=>{if(l?.[Symbol.toStringTag]!=="PrismaPromise")throw new Error("All elements of the array need to be Prisma Client promises. Hint: Please make sure you are not awaiting the Prisma client calls you intended to pass in the $transaction function.");let c=i?.isolationLevel,p={kind:"batch",id:o,index:u,isolationLevel:c,lock:s};return l.requestTransaction?.(p)??l});return Sl(a)}async _transactionWithCallback({callback:n,options:i}){let o={traceparent:this._tracingHelper.getTraceParent()},s=await this._engine.transaction("start",o,i),a;try{let l={kind:"itx",...s};a=await n(this._createItxClient(l)),await this._engine.transaction("commit",o,s)}catch(l){throw await this._engine.transaction("rollback",o,s).catch(()=>{}),l}return a}_createItxClient(n){return zt(Ee(on(this),[re("_appliedParent",()=>this._appliedParent._createItxClient(n)),re("_createPrismaPromise",()=>Hi(n)),re(rm,()=>n.id),Gt(zi)]))}$transaction(n,i){let o;typeof n=="function"?o=()=>this._transactionWithCallback({callback:n,options:i}):o=()=>this._transactionWithArray({promises:n,options:i});let s={name:"transaction",attributes:{method:"$transaction"}};return this._tracingHelper.runInChildSpan(s,o)}_request(n){n.otelParentCtx=this._tracingHelper.getActiveContext();let i=n.middlewareArgsMapper??tm,o={args:i.requestArgsToMiddlewareArgs(n.args),dataPath:n.dataPath,runInTransaction:!!n.transaction,action:n.action,model:n.model},s={middleware:{name:"middleware",middleware:!0,attributes:{method:"$use"},active:!1},operation:{name:"operation",attributes:{method:o.action,model:o.model,name:o.model?`${o.model}.${o.action}`:o.action}}},a=-1,l=async u=>{let c=this._middlewares.get(++a);if(c)return this._tracingHelper.runInChildSpan(s.middleware,P=>c(u,T=>(P?.end(),l(T))));let{runInTransaction:p,args:d,...f}=u,y={...n,...f};d&&(y.args=i.middlewareArgsToRequestArgs(d)),n.transaction!==void 0&&p===!1&&delete y.transaction;let g=await ua(this,y);return y.model?oa({result:g,modelName:y.model,args:y.args,extensions:this._extensions,runtimeDataModel:this._runtimeDataModel}):g};return this._tracingHelper.runInChildSpan(s.operation,()=>new kl.AsyncResource("prisma-client-request").runInAsyncScope(()=>l(o)))}async _executeRequest({args:n,clientMethod:i,dataPath:o,callsite:s,action:a,model:l,argsMapper:u,transaction:c,unpacker:p,otelParentCtx:d,customDataProxyFetch:f}){try{n=u?u(n):n;let y={name:"serialize"},g=this._tracingHelper.runInChildSpan(y,()=>el({modelName:l,runtimeDataModel:this._runtimeDataModel,action:a,args:n,clientMethod:i,callsite:s,extensions:this._extensions,errorFormat:this._errorFormat,clientVersion:this._clientVersion}));return D.enabled("prisma:client")&&(Ue("Prisma Client call:"),Ue(`prisma.${i}(${$s(n)})`),Ue("Generated request:"),Ue(JSON.stringify(g,null,2)+` +-`)),c?.kind==="batch"&&await c.lock,this._requestHandler.request({protocolQuery:g,modelName:l,action:a,clientMethod:i,dataPath:o,callsite:s,args:n,extensions:this._extensions,transaction:c,unpacker:p,otelParentCtx:d,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:f})}catch(y){throw y.clientVersion=this._clientVersion,y}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new X("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(n){return!!this._engineConfig.previewFeatures?.includes(n)}}return t}function Il(e,t){return im(e)?[new oe(e,t),ul]:[e,cl]}function im(e){return Array.isArray(e)&&Array.isArray(e.raw)}var om=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function _l(e){return new Proxy(e,{get(t,r){if(r in t)return t[r];if(!om.has(r))throw new TypeError(`Invalid enum value: ${String(r)}`)}})}function Nl(e){_t(e,{conflictCheck:"warn"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce}); ++`)),c?.kind==="batch"&&await c.lock,this._requestHandler.request({protocolQuery:g,modelName:l,action:a,clientMethod:i,dataPath:o,callsite:s,args:n,extensions:this._extensions,transaction:c,unpacker:p,otelParentCtx:d,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:f})}catch(y){throw y.clientVersion=this._clientVersion,y}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new X("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(n){return!!this._engineConfig.previewFeatures?.includes(n)}}return t}function Il(e,t){return im(e)?[new oe(e,t),ul]:[e,cl]}function im(e){return Array.isArray(e)&&Array.isArray(e.raw)}var om=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function _l(e){return new Proxy(e,{get(t,r){if(r in t)return t[r];if(!om.has(r))throw new TypeError(`Invalid enum value: ${String(r)}`)}})}function Nl(e){_t(e,{conflictCheck:"none"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce}); + /*! Bundled license information: + + decimal.js/decimal.mjs: diff --git a/.yarn/patches/dayjs-npm-1.11.2-644b12fe04.patch b/.yarn/patches/dayjs-npm-1.11.2-644b12fe04.patch new file mode 100644 index 00000000000000..570ce93dba44e0 --- /dev/null +++ b/.yarn/patches/dayjs-npm-1.11.2-644b12fe04.patch @@ -0,0 +1,8 @@ +diff --git a/plugin/timezone.js b/plugin/timezone.js +index fb6112a96f03f53ba78162ac323dce17f635161d..991e4e5410a32f5a69cb6e0e9476a9c529657aae 100644 +--- a/plugin/timezone.js ++++ b/plugin/timezone.js +@@ -1 +1 @@ +-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";var t={year:0,month:1,day:2,hour:3,minute:4,second:5},e={};return function(n,i,o){var r,a=function(t,n,i){void 0===i&&(i={});var o=new Date(t),r=function(t,n){void 0===n&&(n={});var i=n.timeZoneName||"short",o=t+"|"+i,r=e[o];return r||(r=new Intl.DateTimeFormat("en-US",{hour12:!1,timeZone:t,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:i}),e[o]=r),r}(n,i);return r.formatToParts(o)},u=function(e,n){for(var i=a(e,n),r=[],u=0;u=0&&(r[c]=parseInt(m,10))}var d=r[3],l=24===d?0:d,v=r[0]+"-"+r[1]+"-"+r[2]+" "+l+":"+r[4]+":"+r[5]+":000",h=+e;return(o.utc(v).valueOf()-(h-=h%1e3))/6e4},f=i.prototype;f.tz=function(t,e){void 0===t&&(t=r);var n=this.utcOffset(),i=this.toDate(),a=i.toLocaleString("en-US",{timeZone:t}),u=Math.round((i-new Date(a))/1e3/60),f=o(a).$set("millisecond",this.$ms).utcOffset(15*-Math.round(i.getTimezoneOffset()/15)-u,!0);if(e){var s=f.utcOffset();f=f.add(n-s,"minute")}return f.$x.$timezone=t,f},f.offsetName=function(t){var e=this.$x.$timezone||o.tz.guess(),n=a(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var s=f.startOf;f.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return s.call(this,t,e);var n=o(this.format("YYYY-MM-DD HH:mm:ss:SSS"));return s.call(n,t,e).tz(this.$x.$timezone,!0)},o.tz=function(t,e,n){var i=n&&e,a=n||e||r,f=u(+o(),a);if("string"!=typeof t)return o(t).tz(a);var s=function(t,e,n){var i=t-60*e*1e3,o=u(i,n);if(e===o)return[i,e];var r=u(i-=60*(o-e)*1e3,n);return o===r?[i,o]:[t-60*Math.min(o,r)*1e3,Math.max(o,r)]}(o.utc(t,i).valueOf(),f,a),m=s[0],c=s[1],d=o(m).utcOffset(c);return d.$x.$timezone=a,d},o.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},o.tz.setDefault=function(t){r=t}}})); +\ No newline at end of file ++!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";function t(){return t=Object.assign?Object.assign.bind():function(t){for(var e=1;e=0&&(r[c]=parseInt(m,10))}var l=r[3],d=24===l?0:l,h=r[0]+"-"+r[1]+"-"+r[2]+" "+d+":"+r[4]+":"+r[5]+":000",v=+t;return(u.utc(h).valueOf()-(v-=v%1e3))/6e4},c=a.prototype;c.tz=function(e,i){void 0===e&&(e=f);var o=this.utcOffset(),a=this.toDate(),s=function(e){var i=r[e];return i||(i=new Intl.DateTimeFormat("en-US",t({},n,{timeZone:e})),r[e]=i),i}(e).format(a),m=Math.round((a-new Date(s))/1e3/60),c=u(s,{locale:this.$L}).$set("millisecond",this.$ms).utcOffset(15*-Math.round(a.getTimezoneOffset()/15)-m,!0);if(i){var l=c.utcOffset();c=c.add(o-l,"minute")}return c.$x.$timezone=e,c},c.offsetName=function(t){var e=this.$x.$timezone||u.tz.guess(),n=s(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var l=c.startOf;c.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return l.call(this,t,e);var n=u(this.format("YYYY-MM-DD HH:mm:ss:SSS"),{locale:this.$L});return l.call(n,t,e).tz(this.$x.$timezone,!0)},u.tz=function(t,e,n){var i=n&&e,r=n||e||f,o=m(+u(),r);if("string"!=typeof t)return u(t).tz(r);var a=function(t,e,n){var i=t-60*e*1e3,r=m(i,n);if(e===r)return[i,e];var o=m(i-=60*(r-e)*1e3,n);return r===o?[i,r]:[t-60*Math.min(r,o)*1e3,Math.max(r,o)]}(u.utc(t,i).valueOf(),o,r),s=a[0],c=a[1],l=u(s).utcOffset(c);return l.$x.$timezone=r,l},u.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},u.tz.setDefault=function(t){f=t}}})); diff --git a/.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch b/.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch new file mode 100644 index 00000000000000..570ce93dba44e0 --- /dev/null +++ b/.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch @@ -0,0 +1,8 @@ +diff --git a/plugin/timezone.js b/plugin/timezone.js +index fb6112a96f03f53ba78162ac323dce17f635161d..991e4e5410a32f5a69cb6e0e9476a9c529657aae 100644 +--- a/plugin/timezone.js ++++ b/plugin/timezone.js +@@ -1 +1 @@ +-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";var t={year:0,month:1,day:2,hour:3,minute:4,second:5},e={};return function(n,i,o){var r,a=function(t,n,i){void 0===i&&(i={});var o=new Date(t),r=function(t,n){void 0===n&&(n={});var i=n.timeZoneName||"short",o=t+"|"+i,r=e[o];return r||(r=new Intl.DateTimeFormat("en-US",{hour12:!1,timeZone:t,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:i}),e[o]=r),r}(n,i);return r.formatToParts(o)},u=function(e,n){for(var i=a(e,n),r=[],u=0;u=0&&(r[c]=parseInt(m,10))}var d=r[3],l=24===d?0:d,v=r[0]+"-"+r[1]+"-"+r[2]+" "+l+":"+r[4]+":"+r[5]+":000",h=+e;return(o.utc(v).valueOf()-(h-=h%1e3))/6e4},f=i.prototype;f.tz=function(t,e){void 0===t&&(t=r);var n=this.utcOffset(),i=this.toDate(),a=i.toLocaleString("en-US",{timeZone:t}),u=Math.round((i-new Date(a))/1e3/60),f=o(a).$set("millisecond",this.$ms).utcOffset(15*-Math.round(i.getTimezoneOffset()/15)-u,!0);if(e){var s=f.utcOffset();f=f.add(n-s,"minute")}return f.$x.$timezone=t,f},f.offsetName=function(t){var e=this.$x.$timezone||o.tz.guess(),n=a(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var s=f.startOf;f.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return s.call(this,t,e);var n=o(this.format("YYYY-MM-DD HH:mm:ss:SSS"));return s.call(n,t,e).tz(this.$x.$timezone,!0)},o.tz=function(t,e,n){var i=n&&e,a=n||e||r,f=u(+o(),a);if("string"!=typeof t)return o(t).tz(a);var s=function(t,e,n){var i=t-60*e*1e3,o=u(i,n);if(e===o)return[i,e];var r=u(i-=60*(o-e)*1e3,n);return o===r?[i,o]:[t-60*Math.min(o,r)*1e3,Math.max(o,r)]}(o.utc(t,i).valueOf(),f,a),m=s[0],c=s[1],d=o(m).utcOffset(c);return d.$x.$timezone=a,d},o.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},o.tz.setDefault=function(t){r=t}}})); +\ No newline at end of file ++!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){"use strict";function t(){return t=Object.assign?Object.assign.bind():function(t){for(var e=1;e=0&&(r[c]=parseInt(m,10))}var l=r[3],d=24===l?0:l,h=r[0]+"-"+r[1]+"-"+r[2]+" "+d+":"+r[4]+":"+r[5]+":000",v=+t;return(u.utc(h).valueOf()-(v-=v%1e3))/6e4},c=a.prototype;c.tz=function(e,i){void 0===e&&(e=f);var o=this.utcOffset(),a=this.toDate(),s=function(e){var i=r[e];return i||(i=new Intl.DateTimeFormat("en-US",t({},n,{timeZone:e})),r[e]=i),i}(e).format(a),m=Math.round((a-new Date(s))/1e3/60),c=u(s,{locale:this.$L}).$set("millisecond",this.$ms).utcOffset(15*-Math.round(a.getTimezoneOffset()/15)-m,!0);if(i){var l=c.utcOffset();c=c.add(o-l,"minute")}return c.$x.$timezone=e,c},c.offsetName=function(t){var e=this.$x.$timezone||u.tz.guess(),n=s(this.valueOf(),e,{timeZoneName:t}).find((function(t){return"timezonename"===t.type.toLowerCase()}));return n&&n.value};var l=c.startOf;c.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return l.call(this,t,e);var n=u(this.format("YYYY-MM-DD HH:mm:ss:SSS"),{locale:this.$L});return l.call(n,t,e).tz(this.$x.$timezone,!0)},u.tz=function(t,e,n){var i=n&&e,r=n||e||f,o=m(+u(),r);if("string"!=typeof t)return u(t).tz(r);var a=function(t,e,n){var i=t-60*e*1e3,r=m(i,n);if(e===r)return[i,e];var o=m(i-=60*(r-e)*1e3,n);return r===o?[i,r]:[t-60*Math.min(r,o)*1e3,Math.max(r,o)]}(u.utc(t,i).valueOf(),o,r),s=a[0],c=a[1],l=u(s).utcOffset(c);return l.$x.$timezone=r,l},u.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},u.tz.setDefault=function(t){f=t}}})); diff --git a/.yarn/patches/libphonenumber-js+1.11.18.patch b/.yarn/patches/libphonenumber-js+1.11.18.patch new file mode 100644 index 00000000000000..049f65a115e021 --- /dev/null +++ b/.yarn/patches/libphonenumber-js+1.11.18.patch @@ -0,0 +1,15 @@ +diff --git a/index.cjs b/index.cjs +index c83f700..da6fc7e 100644 +--- a/index.cjs ++++ b/index.cjs +@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) { + // https://github.com/babel/babel/issues/2212#issuecomment-131827986 + // An alternative approach: + // https://www.npmjs.com/package/babel-plugin-add-module-exports +-exports = module.exports = min.parsePhoneNumberFromString +-exports['default'] = min.parsePhoneNumberFromString ++// exports = module.exports = min.parsePhoneNumberFromString ++// exports['default'] = min.parsePhoneNumberFromString + + // `parsePhoneNumberFromString()` named export is now considered legacy: + // it has been promoted to a default export due to being too verbose. \ No newline at end of file diff --git a/.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch b/.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch new file mode 100644 index 00000000000000..43667e866815bd --- /dev/null +++ b/.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch @@ -0,0 +1,26 @@ +diff --git a/dist/commonjs/serverSideTranslations.js b/dist/commonjs/serverSideTranslations.js +index bcad3d02fbdfab8dacb1d85efd79e98623a0c257..fff668f598154a13c4030d1b4a90d5d9c18214ad 100644 +--- a/dist/commonjs/serverSideTranslations.js ++++ b/dist/commonjs/serverSideTranslations.js +@@ -36,7 +36,6 @@ var _fs = _interopRequireDefault(require("fs")); + var _path = _interopRequireDefault(require("path")); + var _createConfig = require("./config/createConfig"); + var _node = _interopRequireDefault(require("./createClient/node")); +-var _appWithTranslation = require("./appWithTranslation"); + var _utils = require("./utils"); + function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } + function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } +@@ -110,12 +109,8 @@ var serverSideTranslations = /*#__PURE__*/function () { + lng: initialLocale + })); + localeExtension = config.localeExtension, localePath = config.localePath, fallbackLng = config.fallbackLng, reloadOnPrerender = config.reloadOnPrerender; +- if (!reloadOnPrerender) { +- _context.next = 18; +- break; +- } + _context.next = 18; +- return _appWithTranslation.globalI18n === null || _appWithTranslation.globalI18n === void 0 ? void 0 : _appWithTranslation.globalI18n.reloadResources(); ++ return void 0; + case 18: + _createClient = (0, _node["default"])(_objectSpread(_objectSpread({}, config), {}, { + lng: initialLocale diff --git a/.yarn/plugins/@yarnpkg/plugin-version.cjs b/.yarn/plugins/@yarnpkg/plugin-version.cjs new file mode 100644 index 00000000000000..87de4f440a7784 --- /dev/null +++ b/.yarn/plugins/@yarnpkg/plugin-version.cjs @@ -0,0 +1,550 @@ +/* eslint-disable */ +//prettier-ignore +module.exports = { +name: "@yarnpkg/plugin-version", +factory: function (require) { +var plugin=(()=>{var ZB=Object.create,zy=Object.defineProperty,$B=Object.defineProperties,eU=Object.getOwnPropertyDescriptor,tU=Object.getOwnPropertyDescriptors,nU=Object.getOwnPropertyNames,uS=Object.getOwnPropertySymbols,rU=Object.getPrototypeOf,oS=Object.prototype.hasOwnProperty,iU=Object.prototype.propertyIsEnumerable;var lS=(i,o,f)=>o in i?zy(i,o,{enumerable:!0,configurable:!0,writable:!0,value:f}):i[o]=f,E0=(i,o)=>{for(var f in o||(o={}))oS.call(o,f)&&lS(i,f,o[f]);if(uS)for(var f of uS(o))iU.call(o,f)&&lS(i,f,o[f]);return i},Gf=(i,o)=>$B(i,tU(o)),uU=i=>zy(i,"__esModule",{value:!0});var ce=(i,o)=>()=>(o||i((o={exports:{}}).exports,o),o.exports),sS=(i,o)=>{for(var f in o)zy(i,f,{get:o[f],enumerable:!0})},oU=(i,o,f)=>{if(o&&typeof o=="object"||typeof o=="function")for(let p of nU(o))!oS.call(i,p)&&p!=="default"&&zy(i,p,{get:()=>o[p],enumerable:!(f=eU(o,p))||f.enumerable});return i},Mi=i=>oU(uU(zy(i!=null?ZB(rU(i)):{},"default",i&&i.__esModule&&"default"in i?{get:()=>i.default,enumerable:!0}:{value:i,enumerable:!0})),i);var eD=ce((F$,aS)=>{function lU(i,o){for(var f=-1,p=i==null?0:i.length,E=Array(p);++f{function sU(){this.__data__=[],this.size=0}fS.exports=sU});var tD=ce((P$,dS)=>{function aU(i,o){return i===o||i!==i&&o!==o}dS.exports=aU});var qy=ce((I$,pS)=>{var fU=tD();function cU(i,o){for(var f=i.length;f--;)if(fU(i[f][0],o))return f;return-1}pS.exports=cU});var vS=ce((B$,hS)=>{var dU=qy(),pU=Array.prototype,hU=pU.splice;function vU(i){var o=this.__data__,f=dU(o,i);if(f<0)return!1;var p=o.length-1;return f==p?o.pop():hU.call(o,f,1),--this.size,!0}hS.exports=vU});var yS=ce((U$,mS)=>{var mU=qy();function yU(i){var o=this.__data__,f=mU(o,i);return f<0?void 0:o[f][1]}mS.exports=yU});var _S=ce((j$,gS)=>{var gU=qy();function _U(i){return gU(this.__data__,i)>-1}gS.exports=_U});var DS=ce((z$,ES)=>{var EU=qy();function DU(i,o){var f=this.__data__,p=EU(f,i);return p<0?(++this.size,f.push([i,o])):f[p][1]=o,this}ES.exports=DU});var Hy=ce((q$,wS)=>{var wU=cS(),SU=vS(),TU=yS(),CU=_S(),xU=DS();function jv(i){var o=-1,f=i==null?0:i.length;for(this.clear();++o{var AU=Hy();function RU(){this.__data__=new AU,this.size=0}SS.exports=RU});var xS=ce((W$,CS)=>{function OU(i){var o=this.__data__,f=o.delete(i);return this.size=o.size,f}CS.exports=OU});var RS=ce((V$,AS)=>{function kU(i){return this.__data__.get(i)}AS.exports=kU});var kS=ce((G$,OS)=>{function MU(i){return this.__data__.has(i)}OS.exports=MU});var nD=ce((Y$,MS)=>{var NU=typeof global=="object"&&global&&global.Object===Object&&global;MS.exports=NU});var Yf=ce((K$,NS)=>{var LU=nD(),FU=typeof self=="object"&&self&&self.Object===Object&&self,bU=LU||FU||Function("return this")();NS.exports=bU});var zv=ce((X$,LS)=>{var PU=Yf(),IU=PU.Symbol;LS.exports=IU});var BS=ce((Q$,bS)=>{var PS=zv(),IS=Object.prototype,BU=IS.hasOwnProperty,UU=IS.toString,Wy=PS?PS.toStringTag:void 0;function jU(i){var o=BU.call(i,Wy),f=i[Wy];try{i[Wy]=void 0;var p=!0}catch(t){}var E=UU.call(i);return p&&(o?i[Wy]=f:delete i[Wy]),E}bS.exports=jU});var jS=ce((J$,US)=>{var zU=Object.prototype,qU=zU.toString;function HU(i){return qU.call(i)}US.exports=HU});var Qp=ce((Z$,zS)=>{var qS=zv(),WU=BS(),VU=jS(),GU="[object Null]",YU="[object Undefined]",HS=qS?qS.toStringTag:void 0;function KU(i){return i==null?i===void 0?YU:GU:HS&&HS in Object(i)?WU(i):VU(i)}zS.exports=KU});var qv=ce(($$,WS)=>{function XU(i){var o=typeof i;return i!=null&&(o=="object"||o=="function")}WS.exports=XU});var rD=ce((eee,VS)=>{var QU=Qp(),JU=qv(),ZU="[object AsyncFunction]",$U="[object Function]",ej="[object GeneratorFunction]",tj="[object Proxy]";function nj(i){if(!JU(i))return!1;var o=QU(i);return o==$U||o==ej||o==ZU||o==tj}VS.exports=nj});var YS=ce((tee,GS)=>{var rj=Yf(),ij=rj["__core-js_shared__"];GS.exports=ij});var QS=ce((nee,KS)=>{var iD=YS(),XS=function(){var i=/[^.]+$/.exec(iD&&iD.keys&&iD.keys.IE_PROTO||"");return i?"Symbol(src)_1."+i:""}();function uj(i){return!!XS&&XS in i}KS.exports=uj});var uD=ce((ree,JS)=>{var oj=Function.prototype,lj=oj.toString;function sj(i){if(i!=null){try{return lj.call(i)}catch(o){}try{return i+""}catch(o){}}return""}JS.exports=sj});var $S=ce((iee,ZS)=>{var aj=rD(),fj=QS(),cj=qv(),dj=uD(),pj=/[\\^$.*+?()[\]{}|]/g,hj=/^\[object .+?Constructor\]$/,vj=Function.prototype,mj=Object.prototype,yj=vj.toString,gj=mj.hasOwnProperty,_j=RegExp("^"+yj.call(gj).replace(pj,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");function Ej(i){if(!cj(i)||fj(i))return!1;var o=aj(i)?_j:hj;return o.test(dj(i))}ZS.exports=Ej});var tT=ce((uee,eT)=>{function Dj(i,o){return i==null?void 0:i[o]}eT.exports=Dj});var sd=ce((oee,nT)=>{var wj=$S(),Sj=tT();function Tj(i,o){var f=Sj(i,o);return wj(f)?f:void 0}nT.exports=Tj});var L_=ce((lee,rT)=>{var Cj=sd(),xj=Yf(),Aj=Cj(xj,"Map");rT.exports=Aj});var Vy=ce((see,iT)=>{var Rj=sd(),Oj=Rj(Object,"create");iT.exports=Oj});var lT=ce((aee,uT)=>{var oT=Vy();function kj(){this.__data__=oT?oT(null):{},this.size=0}uT.exports=kj});var aT=ce((fee,sT)=>{function Mj(i){var o=this.has(i)&&delete this.__data__[i];return this.size-=o?1:0,o}sT.exports=Mj});var cT=ce((cee,fT)=>{var Nj=Vy(),Lj="__lodash_hash_undefined__",Fj=Object.prototype,bj=Fj.hasOwnProperty;function Pj(i){var o=this.__data__;if(Nj){var f=o[i];return f===Lj?void 0:f}return bj.call(o,i)?o[i]:void 0}fT.exports=Pj});var pT=ce((dee,dT)=>{var Ij=Vy(),Bj=Object.prototype,Uj=Bj.hasOwnProperty;function jj(i){var o=this.__data__;return Ij?o[i]!==void 0:Uj.call(o,i)}dT.exports=jj});var vT=ce((pee,hT)=>{var zj=Vy(),qj="__lodash_hash_undefined__";function Hj(i,o){var f=this.__data__;return this.size+=this.has(i)?0:1,f[i]=zj&&o===void 0?qj:o,this}hT.exports=Hj});var yT=ce((hee,mT)=>{var Wj=lT(),Vj=aT(),Gj=cT(),Yj=pT(),Kj=vT();function Hv(i){var o=-1,f=i==null?0:i.length;for(this.clear();++o{var _T=yT(),Xj=Hy(),Qj=L_();function Jj(){this.size=0,this.__data__={hash:new _T,map:new(Qj||Xj),string:new _T}}gT.exports=Jj});var wT=ce((mee,DT)=>{function Zj(i){var o=typeof i;return o=="string"||o=="number"||o=="symbol"||o=="boolean"?i!=="__proto__":i===null}DT.exports=Zj});var Gy=ce((yee,ST)=>{var $j=wT();function ez(i,o){var f=i.__data__;return $j(o)?f[typeof o=="string"?"string":"hash"]:f.map}ST.exports=ez});var CT=ce((gee,TT)=>{var tz=Gy();function nz(i){var o=tz(this,i).delete(i);return this.size-=o?1:0,o}TT.exports=nz});var AT=ce((_ee,xT)=>{var rz=Gy();function iz(i){return rz(this,i).get(i)}xT.exports=iz});var OT=ce((Eee,RT)=>{var uz=Gy();function oz(i){return uz(this,i).has(i)}RT.exports=oz});var MT=ce((Dee,kT)=>{var lz=Gy();function sz(i,o){var f=lz(this,i),p=f.size;return f.set(i,o),this.size+=f.size==p?0:1,this}kT.exports=sz});var oD=ce((wee,NT)=>{var az=ET(),fz=CT(),cz=AT(),dz=OT(),pz=MT();function Wv(i){var o=-1,f=i==null?0:i.length;for(this.clear();++o{var hz=Hy(),vz=L_(),mz=oD(),yz=200;function gz(i,o){var f=this.__data__;if(f instanceof hz){var p=f.__data__;if(!vz||p.length{var _z=Hy(),Ez=TS(),Dz=xS(),wz=RS(),Sz=kS(),Tz=FT();function Vv(i){var o=this.__data__=new _z(i);this.size=o.size}Vv.prototype.clear=Ez;Vv.prototype.delete=Dz;Vv.prototype.get=wz;Vv.prototype.has=Sz;Vv.prototype.set=Tz;bT.exports=Vv});var BT=ce((Cee,IT)=>{function Cz(i,o){for(var f=-1,p=i==null?0:i.length;++f{var xz=sd(),Az=function(){try{var i=xz(Object,"defineProperty");return i({},"",{}),i}catch(o){}}();UT.exports=Az});var sD=ce((Aee,jT)=>{var zT=lD();function Rz(i,o,f){o=="__proto__"&&zT?zT(i,o,{configurable:!0,enumerable:!0,value:f,writable:!0}):i[o]=f}jT.exports=Rz});var aD=ce((Ree,qT)=>{var Oz=sD(),kz=tD(),Mz=Object.prototype,Nz=Mz.hasOwnProperty;function Lz(i,o,f){var p=i[o];(!(Nz.call(i,o)&&kz(p,f))||f===void 0&&!(o in i))&&Oz(i,o,f)}qT.exports=Lz});var Gv=ce((Oee,HT)=>{var Fz=aD(),bz=sD();function Pz(i,o,f,p){var E=!f;f||(f={});for(var t=-1,k=o.length;++t{function Iz(i,o){for(var f=-1,p=Array(i);++f{function Bz(i){return i!=null&&typeof i=="object"}GT.exports=Bz});var KT=ce((Nee,YT)=>{var Uz=Qp(),jz=ad(),zz="[object Arguments]";function qz(i){return jz(i)&&Uz(i)==zz}YT.exports=qz});var fD=ce((Lee,XT)=>{var QT=KT(),Hz=ad(),JT=Object.prototype,Wz=JT.hasOwnProperty,Vz=JT.propertyIsEnumerable,Gz=QT(function(){return arguments}())?QT:function(i){return Hz(i)&&Wz.call(i,"callee")&&!Vz.call(i,"callee")};XT.exports=Gz});var fd=ce((Fee,ZT)=>{var Yz=Array.isArray;ZT.exports=Yz});var eC=ce((bee,$T)=>{function Kz(){return!1}$T.exports=Kz});var cD=ce((Yy,Yv)=>{var Xz=Yf(),Qz=eC(),tC=typeof Yy=="object"&&Yy&&!Yy.nodeType&&Yy,nC=tC&&typeof Yv=="object"&&Yv&&!Yv.nodeType&&Yv,Jz=nC&&nC.exports===tC,rC=Jz?Xz.Buffer:void 0,Zz=rC?rC.isBuffer:void 0,$z=Zz||Qz;Yv.exports=$z});var uC=ce((Pee,iC)=>{var eq=9007199254740991,tq=/^(?:0|[1-9]\d*)$/;function nq(i,o){var f=typeof i;return o=o==null?eq:o,!!o&&(f=="number"||f!="symbol"&&tq.test(i))&&i>-1&&i%1==0&&i{var rq=9007199254740991;function iq(i){return typeof i=="number"&&i>-1&&i%1==0&&i<=rq}oC.exports=iq});var sC=ce((Bee,lC)=>{var uq=Qp(),oq=dD(),lq=ad(),sq="[object Arguments]",aq="[object Array]",fq="[object Boolean]",cq="[object Date]",dq="[object Error]",pq="[object Function]",hq="[object Map]",vq="[object Number]",mq="[object Object]",yq="[object RegExp]",gq="[object Set]",_q="[object String]",Eq="[object WeakMap]",Dq="[object ArrayBuffer]",wq="[object DataView]",Sq="[object Float32Array]",Tq="[object Float64Array]",Cq="[object Int8Array]",xq="[object Int16Array]",Aq="[object Int32Array]",Rq="[object Uint8Array]",Oq="[object Uint8ClampedArray]",kq="[object Uint16Array]",Mq="[object Uint32Array]",o0={};o0[Sq]=o0[Tq]=o0[Cq]=o0[xq]=o0[Aq]=o0[Rq]=o0[Oq]=o0[kq]=o0[Mq]=!0;o0[sq]=o0[aq]=o0[Dq]=o0[fq]=o0[wq]=o0[cq]=o0[dq]=o0[pq]=o0[hq]=o0[vq]=o0[mq]=o0[yq]=o0[gq]=o0[_q]=o0[Eq]=!1;function Nq(i){return lq(i)&&oq(i.length)&&!!o0[uq(i)]}lC.exports=Nq});var F_=ce((Uee,aC)=>{function Lq(i){return function(o){return i(o)}}aC.exports=Lq});var b_=ce((Ky,Kv)=>{var Fq=nD(),fC=typeof Ky=="object"&&Ky&&!Ky.nodeType&&Ky,Xy=fC&&typeof Kv=="object"&&Kv&&!Kv.nodeType&&Kv,bq=Xy&&Xy.exports===fC,pD=bq&&Fq.process,Pq=function(){try{var i=Xy&&Xy.require&&Xy.require("util").types;return i||pD&&pD.binding&&pD.binding("util")}catch(o){}}();Kv.exports=Pq});var hC=ce((jee,cC)=>{var Iq=sC(),Bq=F_(),dC=b_(),pC=dC&&dC.isTypedArray,Uq=pC?Bq(pC):Iq;cC.exports=Uq});var hD=ce((zee,vC)=>{var jq=VT(),zq=fD(),qq=fd(),Hq=cD(),Wq=uC(),Vq=hC(),Gq=Object.prototype,Yq=Gq.hasOwnProperty;function Kq(i,o){var f=qq(i),p=!f&&zq(i),E=!f&&!p&&Hq(i),t=!f&&!p&&!E&&Vq(i),k=f||p||E||t,L=k?jq(i.length,String):[],N=L.length;for(var C in i)(o||Yq.call(i,C))&&!(k&&(C=="length"||E&&(C=="offset"||C=="parent")||t&&(C=="buffer"||C=="byteLength"||C=="byteOffset")||Wq(C,N)))&&L.push(C);return L}vC.exports=Kq});var P_=ce((qee,mC)=>{var Xq=Object.prototype;function Qq(i){var o=i&&i.constructor,f=typeof o=="function"&&o.prototype||Xq;return i===f}mC.exports=Qq});var vD=ce((Hee,yC)=>{function Jq(i,o){return function(f){return i(o(f))}}yC.exports=Jq});var _C=ce((Wee,gC)=>{var Zq=vD(),$q=Zq(Object.keys,Object);gC.exports=$q});var DC=ce((Vee,EC)=>{var eH=P_(),tH=_C(),nH=Object.prototype,rH=nH.hasOwnProperty;function iH(i){if(!eH(i))return tH(i);var o=[];for(var f in Object(i))rH.call(i,f)&&f!="constructor"&&o.push(f);return o}EC.exports=iH});var mD=ce((Gee,wC)=>{var uH=rD(),oH=dD();function lH(i){return i!=null&&oH(i.length)&&!uH(i)}wC.exports=lH});var I_=ce((Yee,SC)=>{var sH=hD(),aH=DC(),fH=mD();function cH(i){return fH(i)?sH(i):aH(i)}SC.exports=cH});var CC=ce((Kee,TC)=>{var dH=Gv(),pH=I_();function hH(i,o){return i&&dH(o,pH(o),i)}TC.exports=hH});var AC=ce((Xee,xC)=>{function vH(i){var o=[];if(i!=null)for(var f in Object(i))o.push(f);return o}xC.exports=vH});var OC=ce((Qee,RC)=>{var mH=qv(),yH=P_(),gH=AC(),_H=Object.prototype,EH=_H.hasOwnProperty;function DH(i){if(!mH(i))return gH(i);var o=yH(i),f=[];for(var p in i)p=="constructor"&&(o||!EH.call(i,p))||f.push(p);return f}RC.exports=DH});var B_=ce((Jee,kC)=>{var wH=hD(),SH=OC(),TH=mD();function CH(i){return TH(i)?wH(i,!0):SH(i)}kC.exports=CH});var NC=ce((Zee,MC)=>{var xH=Gv(),AH=B_();function RH(i,o){return i&&xH(o,AH(o),i)}MC.exports=RH});var IC=ce((Qy,Xv)=>{var OH=Yf(),LC=typeof Qy=="object"&&Qy&&!Qy.nodeType&&Qy,FC=LC&&typeof Xv=="object"&&Xv&&!Xv.nodeType&&Xv,kH=FC&&FC.exports===LC,bC=kH?OH.Buffer:void 0,PC=bC?bC.allocUnsafe:void 0;function MH(i,o){if(o)return i.slice();var f=i.length,p=PC?PC(f):new i.constructor(f);return i.copy(p),p}Xv.exports=MH});var UC=ce(($ee,BC)=>{function NH(i,o){var f=-1,p=i.length;for(o||(o=Array(p));++f{function LH(i,o){for(var f=-1,p=i==null?0:i.length,E=0,t=[];++f{function FH(){return[]}qC.exports=FH});var U_=ce((nte,HC)=>{var bH=zC(),PH=yD(),IH=Object.prototype,BH=IH.propertyIsEnumerable,WC=Object.getOwnPropertySymbols,UH=WC?function(i){return i==null?[]:(i=Object(i),bH(WC(i),function(o){return BH.call(i,o)}))}:PH;HC.exports=UH});var GC=ce((rte,VC)=>{var jH=Gv(),zH=U_();function qH(i,o){return jH(i,zH(i),o)}VC.exports=qH});var j_=ce((ite,YC)=>{function HH(i,o){for(var f=-1,p=o.length,E=i.length;++f{var WH=vD(),VH=WH(Object.getPrototypeOf,Object);KC.exports=VH});var gD=ce((ote,XC)=>{var GH=j_(),YH=z_(),KH=U_(),XH=yD(),QH=Object.getOwnPropertySymbols,JH=QH?function(i){for(var o=[];i;)GH(o,KH(i)),i=YH(i);return o}:XH;XC.exports=JH});var JC=ce((lte,QC)=>{var ZH=Gv(),$H=gD();function eW(i,o){return ZH(i,$H(i),o)}QC.exports=eW});var _D=ce((ste,ZC)=>{var tW=j_(),nW=fd();function rW(i,o,f){var p=o(i);return nW(i)?p:tW(p,f(i))}ZC.exports=rW});var e6=ce((ate,$C)=>{var iW=_D(),uW=U_(),oW=I_();function lW(i){return iW(i,oW,uW)}$C.exports=lW});var ED=ce((fte,t6)=>{var sW=_D(),aW=gD(),fW=B_();function cW(i){return sW(i,fW,aW)}t6.exports=cW});var r6=ce((cte,n6)=>{var dW=sd(),pW=Yf(),hW=dW(pW,"DataView");n6.exports=hW});var u6=ce((dte,i6)=>{var vW=sd(),mW=Yf(),yW=vW(mW,"Promise");i6.exports=yW});var l6=ce((pte,o6)=>{var gW=sd(),_W=Yf(),EW=gW(_W,"Set");o6.exports=EW});var a6=ce((hte,s6)=>{var DW=sd(),wW=Yf(),SW=DW(wW,"WeakMap");s6.exports=SW});var q_=ce((vte,f6)=>{var DD=r6(),wD=L_(),SD=u6(),TD=l6(),CD=a6(),c6=Qp(),Qv=uD(),d6="[object Map]",TW="[object Object]",p6="[object Promise]",h6="[object Set]",v6="[object WeakMap]",m6="[object DataView]",CW=Qv(DD),xW=Qv(wD),AW=Qv(SD),RW=Qv(TD),OW=Qv(CD),Jp=c6;(DD&&Jp(new DD(new ArrayBuffer(1)))!=m6||wD&&Jp(new wD)!=d6||SD&&Jp(SD.resolve())!=p6||TD&&Jp(new TD)!=h6||CD&&Jp(new CD)!=v6)&&(Jp=function(i){var o=c6(i),f=o==TW?i.constructor:void 0,p=f?Qv(f):"";if(p)switch(p){case CW:return m6;case xW:return d6;case AW:return p6;case RW:return h6;case OW:return v6}return o});f6.exports=Jp});var g6=ce((mte,y6)=>{var kW=Object.prototype,MW=kW.hasOwnProperty;function NW(i){var o=i.length,f=new i.constructor(o);return o&&typeof i[0]=="string"&&MW.call(i,"index")&&(f.index=i.index,f.input=i.input),f}y6.exports=NW});var E6=ce((yte,_6)=>{var LW=Yf(),FW=LW.Uint8Array;_6.exports=FW});var H_=ce((gte,D6)=>{var w6=E6();function bW(i){var o=new i.constructor(i.byteLength);return new w6(o).set(new w6(i)),o}D6.exports=bW});var T6=ce((_te,S6)=>{var PW=H_();function IW(i,o){var f=o?PW(i.buffer):i.buffer;return new i.constructor(f,i.byteOffset,i.byteLength)}S6.exports=IW});var x6=ce((Ete,C6)=>{var BW=/\w*$/;function UW(i){var o=new i.constructor(i.source,BW.exec(i));return o.lastIndex=i.lastIndex,o}C6.exports=UW});var M6=ce((Dte,A6)=>{var R6=zv(),O6=R6?R6.prototype:void 0,k6=O6?O6.valueOf:void 0;function jW(i){return k6?Object(k6.call(i)):{}}A6.exports=jW});var L6=ce((wte,N6)=>{var zW=H_();function qW(i,o){var f=o?zW(i.buffer):i.buffer;return new i.constructor(f,i.byteOffset,i.length)}N6.exports=qW});var b6=ce((Ste,F6)=>{var HW=H_(),WW=T6(),VW=x6(),GW=M6(),YW=L6(),KW="[object Boolean]",XW="[object Date]",QW="[object Map]",JW="[object Number]",ZW="[object RegExp]",$W="[object Set]",eV="[object String]",tV="[object Symbol]",nV="[object ArrayBuffer]",rV="[object DataView]",iV="[object Float32Array]",uV="[object Float64Array]",oV="[object Int8Array]",lV="[object Int16Array]",sV="[object Int32Array]",aV="[object Uint8Array]",fV="[object Uint8ClampedArray]",cV="[object Uint16Array]",dV="[object Uint32Array]";function pV(i,o,f){var p=i.constructor;switch(o){case nV:return HW(i);case KW:case XW:return new p(+i);case rV:return WW(i,f);case iV:case uV:case oV:case lV:case sV:case aV:case fV:case cV:case dV:return YW(i,f);case QW:return new p;case JW:case eV:return new p(i);case ZW:return VW(i);case $W:return new p;case tV:return GW(i)}}F6.exports=pV});var B6=ce((Tte,P6)=>{var hV=qv(),I6=Object.create,vV=function(){function i(){}return function(o){if(!hV(o))return{};if(I6)return I6(o);i.prototype=o;var f=new i;return i.prototype=void 0,f}}();P6.exports=vV});var j6=ce((Cte,U6)=>{var mV=B6(),yV=z_(),gV=P_();function _V(i){return typeof i.constructor=="function"&&!gV(i)?mV(yV(i)):{}}U6.exports=_V});var q6=ce((xte,z6)=>{var EV=q_(),DV=ad(),wV="[object Map]";function SV(i){return DV(i)&&EV(i)==wV}z6.exports=SV});var G6=ce((Ate,H6)=>{var TV=q6(),CV=F_(),W6=b_(),V6=W6&&W6.isMap,xV=V6?CV(V6):TV;H6.exports=xV});var K6=ce((Rte,Y6)=>{var AV=q_(),RV=ad(),OV="[object Set]";function kV(i){return RV(i)&&AV(i)==OV}Y6.exports=kV});var Z6=ce((Ote,X6)=>{var MV=K6(),NV=F_(),Q6=b_(),J6=Q6&&Q6.isSet,LV=J6?NV(J6):MV;X6.exports=LV});var rx=ce((kte,$6)=>{var FV=PT(),bV=BT(),PV=aD(),IV=CC(),BV=NC(),UV=IC(),jV=UC(),zV=GC(),qV=JC(),HV=e6(),WV=ED(),VV=q_(),GV=g6(),YV=b6(),KV=j6(),XV=fd(),QV=cD(),JV=G6(),ZV=qv(),$V=Z6(),eG=I_(),tG=B_(),nG=1,rG=2,iG=4,ex="[object Arguments]",uG="[object Array]",oG="[object Boolean]",lG="[object Date]",sG="[object Error]",tx="[object Function]",aG="[object GeneratorFunction]",fG="[object Map]",cG="[object Number]",nx="[object Object]",dG="[object RegExp]",pG="[object Set]",hG="[object String]",vG="[object Symbol]",mG="[object WeakMap]",yG="[object ArrayBuffer]",gG="[object DataView]",_G="[object Float32Array]",EG="[object Float64Array]",DG="[object Int8Array]",wG="[object Int16Array]",SG="[object Int32Array]",TG="[object Uint8Array]",CG="[object Uint8ClampedArray]",xG="[object Uint16Array]",AG="[object Uint32Array]",Wu={};Wu[ex]=Wu[uG]=Wu[yG]=Wu[gG]=Wu[oG]=Wu[lG]=Wu[_G]=Wu[EG]=Wu[DG]=Wu[wG]=Wu[SG]=Wu[fG]=Wu[cG]=Wu[nx]=Wu[dG]=Wu[pG]=Wu[hG]=Wu[vG]=Wu[TG]=Wu[CG]=Wu[xG]=Wu[AG]=!0;Wu[sG]=Wu[tx]=Wu[mG]=!1;function W_(i,o,f,p,E,t){var k,L=o&nG,N=o&rG,C=o&iG;if(f&&(k=E?f(i,p,E,t):f(i)),k!==void 0)return k;if(!ZV(i))return i;var U=XV(i);if(U){if(k=GV(i),!L)return jV(i,k)}else{var q=VV(i),W=q==tx||q==aG;if(QV(i))return UV(i,L);if(q==nx||q==ex||W&&!E){if(k=N||W?{}:KV(i),!L)return N?qV(i,BV(k,i)):zV(i,IV(k,i))}else{if(!Wu[q])return E?i:{};k=YV(i,q,L)}}t||(t=new FV);var ne=t.get(i);if(ne)return ne;t.set(i,k),$V(i)?i.forEach(function(Se){k.add(W_(Se,o,f,Se,i,t))}):JV(i)&&i.forEach(function(Se,he){k.set(he,W_(Se,o,f,he,i,t))});var m=C?N?WV:HV:N?tG:eG,we=U?void 0:m(i);return bV(we||i,function(Se,he){we&&(he=Se,Se=i[he]),PV(k,he,W_(Se,o,f,he,i,t))}),k}$6.exports=W_});var V_=ce((Mte,ix)=>{var RG=Qp(),OG=ad(),kG="[object Symbol]";function MG(i){return typeof i=="symbol"||OG(i)&&RG(i)==kG}ix.exports=MG});var ox=ce((Nte,ux)=>{var NG=fd(),LG=V_(),FG=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,bG=/^\w*$/;function PG(i,o){if(NG(i))return!1;var f=typeof i;return f=="number"||f=="symbol"||f=="boolean"||i==null||LG(i)?!0:bG.test(i)||!FG.test(i)||o!=null&&i in Object(o)}ux.exports=PG});var ax=ce((Lte,lx)=>{var sx=oD(),IG="Expected a function";function xD(i,o){if(typeof i!="function"||o!=null&&typeof o!="function")throw new TypeError(IG);var f=function(){var p=arguments,E=o?o.apply(this,p):p[0],t=f.cache;if(t.has(E))return t.get(E);var k=i.apply(this,p);return f.cache=t.set(E,k)||t,k};return f.cache=new(xD.Cache||sx),f}xD.Cache=sx;lx.exports=xD});var cx=ce((Fte,fx)=>{var BG=ax(),UG=500;function jG(i){var o=BG(i,function(p){return f.size===UG&&f.clear(),p}),f=o.cache;return o}fx.exports=jG});var px=ce((bte,dx)=>{var zG=cx(),qG=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,HG=/\\(\\)?/g,WG=zG(function(i){var o=[];return i.charCodeAt(0)===46&&o.push(""),i.replace(qG,function(f,p,E,t){o.push(E?t.replace(HG,"$1"):p||f)}),o});dx.exports=WG});var _x=ce((Pte,hx)=>{var vx=zv(),VG=eD(),GG=fd(),YG=V_(),KG=1/0,mx=vx?vx.prototype:void 0,yx=mx?mx.toString:void 0;function gx(i){if(typeof i=="string")return i;if(GG(i))return VG(i,gx)+"";if(YG(i))return yx?yx.call(i):"";var o=i+"";return o=="0"&&1/i==-KG?"-0":o}hx.exports=gx});var Dx=ce((Ite,Ex)=>{var XG=_x();function QG(i){return i==null?"":XG(i)}Ex.exports=QG});var G_=ce((Bte,wx)=>{var JG=fd(),ZG=ox(),$G=px(),eY=Dx();function tY(i,o){return JG(i)?i:ZG(i,o)?[i]:$G(eY(i))}wx.exports=tY});var Tx=ce((Ute,Sx)=>{function nY(i){var o=i==null?0:i.length;return o?i[o-1]:void 0}Sx.exports=nY});var AD=ce((jte,Cx)=>{var rY=V_(),iY=1/0;function uY(i){if(typeof i=="string"||rY(i))return i;var o=i+"";return o=="0"&&1/i==-iY?"-0":o}Cx.exports=uY});var Ax=ce((zte,xx)=>{var oY=G_(),lY=AD();function sY(i,o){o=oY(o,i);for(var f=0,p=o.length;i!=null&&f{function aY(i,o,f){var p=-1,E=i.length;o<0&&(o=-o>E?0:E+o),f=f>E?E:f,f<0&&(f+=E),E=o>f?0:f-o>>>0,o>>>=0;for(var t=Array(E);++p{var fY=Ax(),cY=Ox();function dY(i,o){return o.length<2?i:fY(i,cY(o,0,-1))}kx.exports=dY});var Lx=ce((Wte,Nx)=>{var pY=G_(),hY=Tx(),vY=Mx(),mY=AD();function yY(i,o){return o=pY(o,i),i=vY(i,o),i==null||delete i[mY(hY(o))]}Nx.exports=yY});var Px=ce((Vte,Fx)=>{var gY=Qp(),_Y=z_(),EY=ad(),DY="[object Object]",wY=Function.prototype,SY=Object.prototype,bx=wY.toString,TY=SY.hasOwnProperty,CY=bx.call(Object);function xY(i){if(!EY(i)||gY(i)!=DY)return!1;var o=_Y(i);if(o===null)return!0;var f=TY.call(o,"constructor")&&o.constructor;return typeof f=="function"&&f instanceof f&&bx.call(f)==CY}Fx.exports=xY});var Bx=ce((Gte,Ix)=>{var AY=Px();function RY(i){return AY(i)?void 0:i}Ix.exports=RY});var qx=ce((Yte,Ux)=>{var jx=zv(),OY=fD(),kY=fd(),zx=jx?jx.isConcatSpreadable:void 0;function MY(i){return kY(i)||OY(i)||!!(zx&&i&&i[zx])}Ux.exports=MY});var Vx=ce((Kte,Hx)=>{var NY=j_(),LY=qx();function Wx(i,o,f,p,E){var t=-1,k=i.length;for(f||(f=LY),E||(E=[]);++t0&&f(L)?o>1?Wx(L,o-1,f,p,E):NY(E,L):p||(E[E.length]=L)}return E}Hx.exports=Wx});var Yx=ce((Xte,Gx)=>{var FY=Vx();function bY(i){var o=i==null?0:i.length;return o?FY(i,1):[]}Gx.exports=bY});var Xx=ce((Qte,Kx)=>{function PY(i,o,f){switch(f.length){case 0:return i.call(o);case 1:return i.call(o,f[0]);case 2:return i.call(o,f[0],f[1]);case 3:return i.call(o,f[0],f[1],f[2])}return i.apply(o,f)}Kx.exports=PY});var Zx=ce((Jte,Qx)=>{var IY=Xx(),Jx=Math.max;function BY(i,o,f){return o=Jx(o===void 0?i.length-1:o,0),function(){for(var p=arguments,E=-1,t=Jx(p.length-o,0),k=Array(t);++E{function UY(i){return function(){return i}}$x.exports=UY});var n5=ce(($te,t5)=>{function jY(i){return i}t5.exports=jY});var u5=ce((ene,r5)=>{var zY=e5(),i5=lD(),qY=n5(),HY=i5?function(i,o){return i5(i,"toString",{configurable:!0,enumerable:!1,value:zY(o),writable:!0})}:qY;r5.exports=HY});var l5=ce((tne,o5)=>{var WY=800,VY=16,GY=Date.now;function YY(i){var o=0,f=0;return function(){var p=GY(),E=VY-(p-f);if(f=p,E>0){if(++o>=WY)return arguments[0]}else o=0;return i.apply(void 0,arguments)}}o5.exports=YY});var a5=ce((nne,s5)=>{var KY=u5(),XY=l5(),QY=XY(KY);s5.exports=QY});var c5=ce((rne,f5)=>{var JY=Yx(),ZY=Zx(),$Y=a5();function eK(i){return $Y(ZY(i,void 0,JY),i+"")}f5.exports=eK});var p5=ce((ine,d5)=>{var tK=eD(),nK=rx(),rK=Lx(),iK=G_(),uK=Gv(),oK=Bx(),lK=c5(),sK=ED(),aK=1,fK=2,cK=4,dK=lK(function(i,o){var f={};if(i==null)return f;var p=!1;o=tK(o,function(t){return t=iK(t,i),p||(p=t.length>1),t}),uK(i,sK(i),f),p&&(f=nK(f,aK|fK|cK,oK));for(var E=o.length;E--;)rK(f,o[E]);return f});d5.exports=dK});var eg=ce((vne,y5)=>{"use strict";var g5=Object.getOwnPropertySymbols,_K=Object.prototype.hasOwnProperty,EK=Object.prototype.propertyIsEnumerable;function DK(i){if(i==null)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(i)}function wK(){try{if(!Object.assign)return!1;var i=new String("abc");if(i[5]="de",Object.getOwnPropertyNames(i)[0]==="5")return!1;for(var o={},f=0;f<10;f++)o["_"+String.fromCharCode(f)]=f;var p=Object.getOwnPropertyNames(o).map(function(t){return o[t]});if(p.join("")!=="0123456789")return!1;var E={};return"abcdefghijklmnopqrst".split("").forEach(function(t){E[t]=t}),Object.keys(Object.assign({},E)).join("")==="abcdefghijklmnopqrst"}catch(t){return!1}}y5.exports=wK()?Object.assign:function(i,o){for(var f,p=DK(i),E,t=1;t{"use strict";var LD=eg(),Kf=typeof Symbol=="function"&&Symbol.for,tg=Kf?Symbol.for("react.element"):60103,SK=Kf?Symbol.for("react.portal"):60106,TK=Kf?Symbol.for("react.fragment"):60107,CK=Kf?Symbol.for("react.strict_mode"):60108,xK=Kf?Symbol.for("react.profiler"):60114,AK=Kf?Symbol.for("react.provider"):60109,RK=Kf?Symbol.for("react.context"):60110,OK=Kf?Symbol.for("react.forward_ref"):60112,kK=Kf?Symbol.for("react.suspense"):60113,MK=Kf?Symbol.for("react.memo"):60115,NK=Kf?Symbol.for("react.lazy"):60116,_5=typeof Symbol=="function"&&Symbol.iterator;function ng(i){for(var o="https://reactjs.org/docs/error-decoder.html?invariant="+i,f=1;fJ_.length&&J_.push(i)}function BD(i,o,f,p){var E=typeof i;(E==="undefined"||E==="boolean")&&(i=null);var t=!1;if(i===null)t=!0;else switch(E){case"string":case"number":t=!0;break;case"object":switch(i.$$typeof){case tg:case SK:t=!0}}if(t)return f(p,i,o===""?"."+UD(i,0):o),1;if(t=0,o=o===""?".":o+":",Array.isArray(i))for(var k=0;k{"use strict";var BK="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED";M5.exports=BK});var HD=ce((gne,L5)=>{"use strict";var qD=function(){};process.env.NODE_ENV!=="production"&&(F5=N5(),Z_={},b5=Function.call.bind(Object.prototype.hasOwnProperty),qD=function(i){var o="Warning: "+i;typeof console!="undefined"&&console.error(o);try{throw new Error(o)}catch(f){}});var F5,Z_,b5;function P5(i,o,f,p,E){if(process.env.NODE_ENV!=="production"){for(var t in i)if(b5(i,t)){var k;try{if(typeof i[t]!="function"){var L=Error((p||"React class")+": "+f+" type `"+t+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof i[t]+"`.");throw L.name="Invariant Violation",L}k=i[t](o,t,p,f,null,F5)}catch(C){k=C}if(k&&!(k instanceof Error)&&qD((p||"React class")+": type specification of "+f+" `"+t+"` is invalid; the type checker function must return `null` or an `Error` but returned a "+typeof k+". You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument)."),k instanceof Error&&!(k.message in Z_)){Z_[k.message]=!0;var N=E?E():"";qD("Failed "+f+" type: "+k.message+(N!=null?N:""))}}}}P5.resetWarningCache=function(){process.env.NODE_ENV!=="production"&&(Z_={})};L5.exports=P5});var I5=ce(pu=>{"use strict";process.env.NODE_ENV!=="production"&&function(){"use strict";var i=eg(),o=HD(),f="16.13.1",p=typeof Symbol=="function"&&Symbol.for,E=p?Symbol.for("react.element"):60103,t=p?Symbol.for("react.portal"):60106,k=p?Symbol.for("react.fragment"):60107,L=p?Symbol.for("react.strict_mode"):60108,N=p?Symbol.for("react.profiler"):60114,C=p?Symbol.for("react.provider"):60109,U=p?Symbol.for("react.context"):60110,q=p?Symbol.for("react.concurrent_mode"):60111,W=p?Symbol.for("react.forward_ref"):60112,ne=p?Symbol.for("react.suspense"):60113,m=p?Symbol.for("react.suspense_list"):60120,we=p?Symbol.for("react.memo"):60115,Se=p?Symbol.for("react.lazy"):60116,he=p?Symbol.for("react.block"):60121,ge=p?Symbol.for("react.fundamental"):60117,ze=p?Symbol.for("react.responder"):60118,pe=p?Symbol.for("react.scope"):60119,Oe=typeof Symbol=="function"&&Symbol.iterator,le="@@iterator";function Ue(X){if(X===null||typeof X!="object")return null;var _e=Oe&&X[Oe]||X[le];return typeof _e=="function"?_e:null}var Ge={current:null},rt={suspense:null},wt={current:null},xt=/^(.*)[\\\/]/;function $e(X,_e,Ne){var Me="";if(_e){var dt=_e.fileName,Hn=dt.replace(xt,"");if(/^index\./.test(Hn)){var Dn=dt.match(xt);if(Dn){var or=Dn[1];if(or){var mi=or.replace(xt,"");Hn=mi+"/"+Hn}}}Me=" (at "+Hn+":"+_e.lineNumber+")"}else Ne&&(Me=" (created by "+Ne+")");return` + in `+(X||"Unknown")+Me}var ft=1;function Ke(X){return X._status===ft?X._result:null}function jt(X,_e,Ne){var Me=_e.displayName||_e.name||"";return X.displayName||(Me!==""?Ne+"("+Me+")":Ne)}function $t(X){if(X==null)return null;if(typeof X.tag=="number"&&ct("Received an unexpected object in getComponentName(). This is likely a bug in React. Please file an issue."),typeof X=="function")return X.displayName||X.name||null;if(typeof X=="string")return X;switch(X){case k:return"Fragment";case t:return"Portal";case N:return"Profiler";case L:return"StrictMode";case ne:return"Suspense";case m:return"SuspenseList"}if(typeof X=="object")switch(X.$$typeof){case U:return"Context.Consumer";case C:return"Context.Provider";case W:return jt(X,X.render,"ForwardRef");case we:return $t(X.type);case he:return $t(X.render);case Se:{var _e=X,Ne=Ke(_e);if(Ne)return $t(Ne);break}}return null}var at={},Q=null;function ae(X){Q=X}at.getCurrentStack=null,at.getStackAddendum=function(){var X="";if(Q){var _e=$t(Q.type),Ne=Q._owner;X+=$e(_e,Q._source,Ne&&$t(Ne.type))}var Me=at.getCurrentStack;return Me&&(X+=Me()||""),X};var Ce={current:!1},ue={ReactCurrentDispatcher:Ge,ReactCurrentBatchConfig:rt,ReactCurrentOwner:wt,IsSomeRendererActing:Ce,assign:i};i(ue,{ReactDebugCurrentFrame:at,ReactComponentTreeHook:{}});function je(X){{for(var _e=arguments.length,Ne=new Array(_e>1?_e-1:0),Me=1;Me<_e;Me++)Ne[Me-1]=arguments[Me];At("warn",X,Ne)}}function ct(X){{for(var _e=arguments.length,Ne=new Array(_e>1?_e-1:0),Me=1;Me<_e;Me++)Ne[Me-1]=arguments[Me];At("error",X,Ne)}}function At(X,_e,Ne){{var Me=Ne.length>0&&typeof Ne[Ne.length-1]=="string"&&Ne[Ne.length-1].indexOf(` + in`)===0;if(!Me){var dt=ue.ReactDebugCurrentFrame,Hn=dt.getStackAddendum();Hn!==""&&(_e+="%s",Ne=Ne.concat([Hn]))}var Dn=Ne.map(function(Su){return""+Su});Dn.unshift("Warning: "+_e),Function.prototype.apply.call(console[X],console,Dn);try{var or=0,mi="Warning: "+_e.replace(/%s/g,function(){return Ne[or++]});throw new Error(mi)}catch(Su){}}}var en={};function ln(X,_e){{var Ne=X.constructor,Me=Ne&&(Ne.displayName||Ne.name)||"ReactClass",dt=Me+"."+_e;if(en[dt])return;ct("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",_e,Me),en[dt]=!0}}var An={isMounted:function(X){return!1},enqueueForceUpdate:function(X,_e,Ne){ln(X,"forceUpdate")},enqueueReplaceState:function(X,_e,Ne,Me){ln(X,"replaceState")},enqueueSetState:function(X,_e,Ne,Me){ln(X,"setState")}},nr={};Object.freeze(nr);function un(X,_e,Ne){this.props=X,this.context=_e,this.refs=nr,this.updater=Ne||An}un.prototype.isReactComponent={},un.prototype.setState=function(X,_e){if(!(typeof X=="object"||typeof X=="function"||X==null))throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,X,_e,"setState")},un.prototype.forceUpdate=function(X){this.updater.enqueueForceUpdate(this,X,"forceUpdate")};{var Wt={isMounted:["isMounted","Instead, make sure to clean up subscriptions and pending requests in componentWillUnmount to prevent memory leaks."],replaceState:["replaceState","Refactor your code to use setState instead (see https://github.com/facebook/react/issues/3236)."]},vr=function(X,_e){Object.defineProperty(un.prototype,X,{get:function(){je("%s(...) is deprecated in plain JavaScript React classes. %s",_e[0],_e[1])}})};for(var w in Wt)Wt.hasOwnProperty(w)&&vr(w,Wt[w])}function Ut(){}Ut.prototype=un.prototype;function Vn(X,_e,Ne){this.props=X,this.context=_e,this.refs=nr,this.updater=Ne||An}var fr=Vn.prototype=new Ut;fr.constructor=Vn,i(fr,un.prototype),fr.isPureReactComponent=!0;function Fr(){var X={current:null};return Object.seal(X),X}var ur=Object.prototype.hasOwnProperty,br={key:!0,ref:!0,__self:!0,__source:!0},Kt,vu,a0;a0={};function So(X){if(ur.call(X,"ref")){var _e=Object.getOwnPropertyDescriptor(X,"ref").get;if(_e&&_e.isReactWarning)return!1}return X.ref!==void 0}function Go(X){if(ur.call(X,"key")){var _e=Object.getOwnPropertyDescriptor(X,"key").get;if(_e&&_e.isReactWarning)return!1}return X.key!==void 0}function Os(X,_e){var Ne=function(){Kt||(Kt=!0,ct("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://fb.me/react-special-props)",_e))};Ne.isReactWarning=!0,Object.defineProperty(X,"key",{get:Ne,configurable:!0})}function Yo(X,_e){var Ne=function(){vu||(vu=!0,ct("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://fb.me/react-special-props)",_e))};Ne.isReactWarning=!0,Object.defineProperty(X,"ref",{get:Ne,configurable:!0})}function Ko(X){if(typeof X.ref=="string"&&wt.current&&X.__self&&wt.current.stateNode!==X.__self){var _e=$t(wt.current.type);a0[_e]||(ct('Component "%s" contains the string ref "%s". Support for string refs will be removed in a future major release. This case cannot be automatically converted to an arrow function. We ask you to manually fix this case by using useRef() or createRef() instead. Learn more about using refs safely here: https://fb.me/react-strict-mode-string-ref',$t(wt.current.type),X.ref),a0[_e]=!0)}}var qt=function(X,_e,Ne,Me,dt,Hn,Dn){var or={$$typeof:E,type:X,key:_e,ref:Ne,props:Dn,_owner:Hn};return or._store={},Object.defineProperty(or._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:!1}),Object.defineProperty(or,"_self",{configurable:!1,enumerable:!1,writable:!1,value:Me}),Object.defineProperty(or,"_source",{configurable:!1,enumerable:!1,writable:!1,value:dt}),Object.freeze&&(Object.freeze(or.props),Object.freeze(or)),or};function _i(X,_e,Ne){var Me,dt={},Hn=null,Dn=null,or=null,mi=null;if(_e!=null){So(_e)&&(Dn=_e.ref,Ko(_e)),Go(_e)&&(Hn=""+_e.key),or=_e.__self===void 0?null:_e.__self,mi=_e.__source===void 0?null:_e.__source;for(Me in _e)ur.call(_e,Me)&&!br.hasOwnProperty(Me)&&(dt[Me]=_e[Me])}var Su=arguments.length-2;if(Su===1)dt.children=Ne;else if(Su>1){for(var bu=Array(Su),Pu=0;Pu1){for(var mu=Array(Pu),yi=0;yi is not supported and will be removed in a future major release. Did you mean to render instead?")),Ne.Provider},set:function(Dn){Ne.Provider=Dn}},_currentValue:{get:function(){return Ne._currentValue},set:function(Dn){Ne._currentValue=Dn}},_currentValue2:{get:function(){return Ne._currentValue2},set:function(Dn){Ne._currentValue2=Dn}},_threadCount:{get:function(){return Ne._threadCount},set:function(Dn){Ne._threadCount=Dn}},Consumer:{get:function(){return Me||(Me=!0,ct("Rendering is not supported and will be removed in a future major release. Did you mean to render instead?")),Ne.Consumer}}}),Ne.Consumer=Hn}return Ne._currentRenderer=null,Ne._currentRenderer2=null,Ne}function Ht(X){var _e={$$typeof:Se,_ctor:X,_status:-1,_result:null};{var Ne,Me;Object.defineProperties(_e,{defaultProps:{configurable:!0,get:function(){return Ne},set:function(dt){ct("React.lazy(...): It is not supported to assign `defaultProps` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),Ne=dt,Object.defineProperty(_e,"defaultProps",{enumerable:!0})}},propTypes:{configurable:!0,get:function(){return Me},set:function(dt){ct("React.lazy(...): It is not supported to assign `propTypes` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),Me=dt,Object.defineProperty(_e,"propTypes",{enumerable:!0})}}})}return _e}function Du(X){return X!=null&&X.$$typeof===we?ct("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof X!="function"?ct("forwardRef requires a render function but was given %s.",X===null?"null":typeof X):X.length!==0&&X.length!==2&&ct("forwardRef render functions accept exactly two parameters: props and ref. %s",X.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),X!=null&&(X.defaultProps!=null||X.propTypes!=null)&&ct("forwardRef render functions do not support propTypes or defaultProps. Did you accidentally pass a React component?"),{$$typeof:W,render:X}}function Yi(X){return typeof X=="string"||typeof X=="function"||X===k||X===q||X===N||X===L||X===ne||X===m||typeof X=="object"&&X!==null&&(X.$$typeof===Se||X.$$typeof===we||X.$$typeof===C||X.$$typeof===U||X.$$typeof===W||X.$$typeof===ge||X.$$typeof===ze||X.$$typeof===pe||X.$$typeof===he)}function Y0(X,_e){return Yi(X)||ct("memo: The first argument must be a component. Instead received: %s",X===null?"null":typeof X),{$$typeof:we,type:X,compare:_e===void 0?null:_e}}function Ui(){var X=Ge.current;if(X===null)throw Error(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: +1. You might have mismatching versions of React and the renderer (such as React DOM) +2. You might be breaking the Rules of Hooks +3. You might have more than one copy of React in the same app +See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.`);return X}function Wl(X,_e){var Ne=Ui();if(_e!==void 0&&ct("useContext() second argument is reserved for future use in React. Passing it is not supported. You passed: %s.%s",_e,typeof _e=="number"&&Array.isArray(arguments[2])?` + +Did you call array.map(useContext)? Calling Hooks inside a loop is not supported. Learn more at https://fb.me/rules-of-hooks`:""),X._context!==void 0){var Me=X._context;Me.Consumer===X?ct("Calling useContext(Context.Consumer) is not supported, may cause bugs, and will be removed in a future major release. Did you mean to call useContext(Context) instead?"):Me.Provider===X&&ct("Calling useContext(Context.Provider) is not supported. Did you mean to call useContext(Context) instead?")}return Ne.useContext(X,_e)}function xo(X){var _e=Ui();return _e.useState(X)}function ni(X,_e,Ne){var Me=Ui();return Me.useReducer(X,_e,Ne)}function oo(X){var _e=Ui();return _e.useRef(X)}function Vl(X,_e){var Ne=Ui();return Ne.useEffect(X,_e)}function Ao(X,_e){var Ne=Ui();return Ne.useLayoutEffect(X,_e)}function Ms(X,_e){var Ne=Ui();return Ne.useCallback(X,_e)}function Xn(X,_e){var Ne=Ui();return Ne.useMemo(X,_e)}function Qo(X,_e,Ne){var Me=Ui();return Me.useImperativeHandle(X,_e,Ne)}function lo(X,_e){{var Ne=Ui();return Ne.useDebugValue(X,_e)}}var b0;b0=!1;function yl(){if(wt.current){var X=$t(wt.current.type);if(X)return` + +Check the render method of \``+X+"`."}return""}function Ro(X){if(X!==void 0){var _e=X.fileName.replace(/^.*[\\\/]/,""),Ne=X.lineNumber;return` + +Check your code at `+_e+":"+Ne+"."}return""}function Et(X){return X!=null?Ro(X.__source):""}var Pt={};function Bn(X){var _e=yl();if(!_e){var Ne=typeof X=="string"?X:X.displayName||X.name;Ne&&(_e=` + +Check the top-level render call using <`+Ne+">.")}return _e}function Ir(X,_e){if(!(!X._store||X._store.validated||X.key!=null)){X._store.validated=!0;var Ne=Bn(_e);if(!Pt[Ne]){Pt[Ne]=!0;var Me="";X&&X._owner&&X._owner!==wt.current&&(Me=" It was passed a child from "+$t(X._owner.type)+"."),ae(X),ct('Each child in a list should have a unique "key" prop.%s%s See https://fb.me/react-warning-keys for more information.',Ne,Me),ae(null)}}}function ji(X,_e){if(typeof X=="object"){if(Array.isArray(X))for(var Ne=0;Ne",dt=" Did you accidentally export a JSX literal instead of a component?"):Dn=typeof X,ct("React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",Dn,dt)}var or=_i.apply(this,arguments);if(or==null)return or;if(Me)for(var mi=2;mi{"use strict";process.env.NODE_ENV==="production"?WD.exports=k5():WD.exports=I5()});var B5=ce((nm,rg)=>{(function(){var i,o="4.17.21",f=200,p="Unsupported core-js use. Try https://npms.io/search?q=ponyfill.",E="Expected a function",t="Invalid `variable` option passed into `_.template`",k="__lodash_hash_undefined__",L=500,N="__lodash_placeholder__",C=1,U=2,q=4,W=1,ne=2,m=1,we=2,Se=4,he=8,ge=16,ze=32,pe=64,Oe=128,le=256,Ue=512,Ge=30,rt="...",wt=800,xt=16,$e=1,ft=2,Ke=3,jt=1/0,$t=9007199254740991,at=17976931348623157e292,Q=0/0,ae=4294967295,Ce=ae-1,ue=ae>>>1,je=[["ary",Oe],["bind",m],["bindKey",we],["curry",he],["curryRight",ge],["flip",Ue],["partial",ze],["partialRight",pe],["rearg",le]],ct="[object Arguments]",At="[object Array]",en="[object AsyncFunction]",ln="[object Boolean]",An="[object Date]",nr="[object DOMException]",un="[object Error]",Wt="[object Function]",vr="[object GeneratorFunction]",w="[object Map]",Ut="[object Number]",Vn="[object Null]",fr="[object Object]",Fr="[object Promise]",ur="[object Proxy]",br="[object RegExp]",Kt="[object Set]",vu="[object String]",a0="[object Symbol]",So="[object Undefined]",Go="[object WeakMap]",Os="[object WeakSet]",Yo="[object ArrayBuffer]",Ko="[object DataView]",qt="[object Float32Array]",_i="[object Float64Array]",eu="[object Int8Array]",ai="[object Int16Array]",mr="[object Int32Array]",Xo="[object Uint8Array]",W0="[object Uint8ClampedArray]",Lu="[object Uint16Array]",V0="[object Uint32Array]",Hr=/\b__p \+= '';/g,To=/\b(__p \+=) '' \+/g,Co=/(__e\(.*?\)|\b__t\)) \+\n'';/g,L0=/&(?:amp|lt|gt|quot|#39);/g,tu=/[&<>"']/g,Si=RegExp(L0.source),ks=RegExp(tu.source),Hl=/<%-([\s\S]+?)%>/g,F0=/<%([\s\S]+?)%>/g,f0=/<%=([\s\S]+?)%>/g,Pr=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,Ei=/^\w*$/,G0=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,fi=/[\\^$.*+?()[\]{}|]/g,Zt=RegExp(fi.source),Ln=/^\s+/,Di=/\s/,ci=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/,Ht=/\{\n\/\* \[wrapped with (.+)\] \*/,Du=/,? & /,Yi=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g,Y0=/[()=,{}\[\]\/\s]/,Ui=/\\(\\)?/g,Wl=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,xo=/\w*$/,ni=/^[-+]0x[0-9a-f]+$/i,oo=/^0b[01]+$/i,Vl=/^\[object .+?Constructor\]$/,Ao=/^0o[0-7]+$/i,Ms=/^(?:0|[1-9]\d*)$/,Xn=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,Qo=/($^)/,lo=/['\n\r\u2028\u2029\\]/g,b0="\\ud800-\\udfff",yl="\\u0300-\\u036f",Ro="\\ufe20-\\ufe2f",Et="\\u20d0-\\u20ff",Pt=yl+Ro+Et,Bn="\\u2700-\\u27bf",Ir="a-z\\xdf-\\xf6\\xf8-\\xff",ji="\\xac\\xb1\\xd7\\xf7",Wr="\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf",wu="\\u2000-\\u206f",c0=" \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",Ti="A-Z\\xc0-\\xd6\\xd8-\\xde",d0="\\ufe0e\\ufe0f",as=ji+Wr+wu+c0,St="['\u2019]",so="["+b0+"]",Jo="["+as+"]",Gl="["+Pt+"]",Fu="\\d+",fs="["+Bn+"]",P0="["+Ir+"]",X="[^"+b0+as+Fu+Bn+Ir+Ti+"]",_e="\\ud83c[\\udffb-\\udfff]",Ne="(?:"+Gl+"|"+_e+")",Me="[^"+b0+"]",dt="(?:\\ud83c[\\udde6-\\uddff]){2}",Hn="[\\ud800-\\udbff][\\udc00-\\udfff]",Dn="["+Ti+"]",or="\\u200d",mi="(?:"+P0+"|"+X+")",Su="(?:"+Dn+"|"+X+")",bu="(?:"+St+"(?:d|ll|m|re|s|t|ve))?",Pu="(?:"+St+"(?:D|LL|M|RE|S|T|VE))?",mu=Ne+"?",yi="["+d0+"]?",Oo="(?:"+or+"(?:"+[Me,dt,Hn].join("|")+")"+yi+mu+")*",Tu="\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",ao="\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])",Iu=yi+mu+Oo,Oa="(?:"+[fs,dt,Hn].join("|")+")"+Iu,p0="(?:"+[Me+Gl+"?",Gl,dt,Hn,so].join("|")+")",Zs=RegExp(St,"g"),K0=RegExp(Gl,"g"),$s=RegExp(_e+"(?="+_e+")|"+p0+Iu,"g"),ka=RegExp([Dn+"?"+P0+"+"+bu+"(?="+[Jo,Dn,"$"].join("|")+")",Su+"+"+Pu+"(?="+[Jo,Dn+mi,"$"].join("|")+")",Dn+"?"+mi+"+"+bu,Dn+"+"+Pu,ao,Tu,Fu,Oa].join("|"),"g"),cs=RegExp("["+or+b0+Pt+d0+"]"),w0=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,Gn=["Array","Buffer","DataView","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Promise","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],ic=-1,ri={};ri[qt]=ri[_i]=ri[eu]=ri[ai]=ri[mr]=ri[Xo]=ri[W0]=ri[Lu]=ri[V0]=!0,ri[ct]=ri[At]=ri[Yo]=ri[ln]=ri[Ko]=ri[An]=ri[un]=ri[Wt]=ri[w]=ri[Ut]=ri[fr]=ri[br]=ri[Kt]=ri[vu]=ri[Go]=!1;var Gr={};Gr[ct]=Gr[At]=Gr[Yo]=Gr[Ko]=Gr[ln]=Gr[An]=Gr[qt]=Gr[_i]=Gr[eu]=Gr[ai]=Gr[mr]=Gr[w]=Gr[Ut]=Gr[fr]=Gr[br]=Gr[Kt]=Gr[vu]=Gr[a0]=Gr[Xo]=Gr[W0]=Gr[Lu]=Gr[V0]=!0,Gr[un]=Gr[Wt]=Gr[Go]=!1;var Yl={\u00C0:"A",\u00C1:"A",\u00C2:"A",\u00C3:"A",\u00C4:"A",\u00C5:"A",\u00E0:"a",\u00E1:"a",\u00E2:"a",\u00E3:"a",\u00E4:"a",\u00E5:"a",\u00C7:"C",\u00E7:"c",\u00D0:"D",\u00F0:"d",\u00C8:"E",\u00C9:"E",\u00CA:"E",\u00CB:"E",\u00E8:"e",\u00E9:"e",\u00EA:"e",\u00EB:"e",\u00CC:"I",\u00CD:"I",\u00CE:"I",\u00CF:"I",\u00EC:"i",\u00ED:"i",\u00EE:"i",\u00EF:"i",\u00D1:"N",\u00F1:"n",\u00D2:"O",\u00D3:"O",\u00D4:"O",\u00D5:"O",\u00D6:"O",\u00D8:"O",\u00F2:"o",\u00F3:"o",\u00F4:"o",\u00F5:"o",\u00F6:"o",\u00F8:"o",\u00D9:"U",\u00DA:"U",\u00DB:"U",\u00DC:"U",\u00F9:"u",\u00FA:"u",\u00FB:"u",\u00FC:"u",\u00DD:"Y",\u00FD:"y",\u00FF:"y",\u00C6:"Ae",\u00E6:"ae",\u00DE:"Th",\u00FE:"th",\u00DF:"ss",\u0100:"A",\u0102:"A",\u0104:"A",\u0101:"a",\u0103:"a",\u0105:"a",\u0106:"C",\u0108:"C",\u010A:"C",\u010C:"C",\u0107:"c",\u0109:"c",\u010B:"c",\u010D:"c",\u010E:"D",\u0110:"D",\u010F:"d",\u0111:"d",\u0112:"E",\u0114:"E",\u0116:"E",\u0118:"E",\u011A:"E",\u0113:"e",\u0115:"e",\u0117:"e",\u0119:"e",\u011B:"e",\u011C:"G",\u011E:"G",\u0120:"G",\u0122:"G",\u011D:"g",\u011F:"g",\u0121:"g",\u0123:"g",\u0124:"H",\u0126:"H",\u0125:"h",\u0127:"h",\u0128:"I",\u012A:"I",\u012C:"I",\u012E:"I",\u0130:"I",\u0129:"i",\u012B:"i",\u012D:"i",\u012F:"i",\u0131:"i",\u0134:"J",\u0135:"j",\u0136:"K",\u0137:"k",\u0138:"k",\u0139:"L",\u013B:"L",\u013D:"L",\u013F:"L",\u0141:"L",\u013A:"l",\u013C:"l",\u013E:"l",\u0140:"l",\u0142:"l",\u0143:"N",\u0145:"N",\u0147:"N",\u014A:"N",\u0144:"n",\u0146:"n",\u0148:"n",\u014B:"n",\u014C:"O",\u014E:"O",\u0150:"O",\u014D:"o",\u014F:"o",\u0151:"o",\u0154:"R",\u0156:"R",\u0158:"R",\u0155:"r",\u0157:"r",\u0159:"r",\u015A:"S",\u015C:"S",\u015E:"S",\u0160:"S",\u015B:"s",\u015D:"s",\u015F:"s",\u0161:"s",\u0162:"T",\u0164:"T",\u0166:"T",\u0163:"t",\u0165:"t",\u0167:"t",\u0168:"U",\u016A:"U",\u016C:"U",\u016E:"U",\u0170:"U",\u0172:"U",\u0169:"u",\u016B:"u",\u016D:"u",\u016F:"u",\u0171:"u",\u0173:"u",\u0174:"W",\u0175:"w",\u0176:"Y",\u0177:"y",\u0178:"Y",\u0179:"Z",\u017B:"Z",\u017D:"Z",\u017A:"z",\u017C:"z",\u017E:"z",\u0132:"IJ",\u0133:"ij",\u0152:"Oe",\u0153:"oe",\u0149:"'n",\u017F:"s"},ea={"&":"&","<":"<",">":">",'"':""","'":"'"},lf={"&":"&","<":"<",">":">",""":'"',"'":"'"},Ns={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},Ma=parseFloat,Ls=parseInt,h0=typeof global=="object"&&global&&global.Object===Object&&global,Fs=typeof self=="object"&&self&&self.Object===Object&&self,Ni=h0||Fs||Function("return this")(),B=typeof nm=="object"&&nm&&!nm.nodeType&&nm,z=B&&typeof rg=="object"&&rg&&!rg.nodeType&&rg,G=z&&z.exports===B,$=G&&h0.process,De=function(){try{var Te=z&&z.require&&z.require("util").types;return Te||$&&$.binding&&$.binding("util")}catch(et){}}(),me=De&&De.isArrayBuffer,xe=De&&De.isDate,Z=De&&De.isMap,ke=De&&De.isRegExp,Xe=De&&De.isSet,ht=De&&De.isTypedArray;function ie(Te,et,Ve){switch(Ve.length){case 0:return Te.call(et);case 1:return Te.call(et,Ve[0]);case 2:return Te.call(et,Ve[0],Ve[1]);case 3:return Te.call(et,Ve[0],Ve[1],Ve[2])}return Te.apply(et,Ve)}function qe(Te,et,Ve,Gt){for(var Yt=-1,sr=Te==null?0:Te.length;++Yt-1}function tn(Te,et,Ve){for(var Gt=-1,Yt=Te==null?0:Te.length;++Gt-1;);return Ve}function gl(Te,et){for(var Ve=Te.length;Ve--&&_t(et,Te[Ve],0)>-1;);return Ve}function af(Te,et){for(var Ve=Te.length,Gt=0;Ve--;)Te[Ve]===et&&++Gt;return Gt}var Mo=Yn(Yl),ds=Yn(ea);function bs(Te){return"\\"+Ns[Te]}function No(Te,et){return Te==null?i:Te[et]}function Lo(Te){return cs.test(Te)}function ps(Te){return w0.test(Te)}function Vu(Te){for(var et,Ve=[];!(et=Te.next()).done;)Ve.push(et.value);return Ve}function yu(Te){var et=-1,Ve=Array(Te.size);return Te.forEach(function(Gt,Yt){Ve[++et]=[Yt,Gt]}),Ve}function pi(Te,et){return function(Ve){return Te(et(Ve))}}function T0(Te,et){for(var Ve=-1,Gt=Te.length,Yt=0,sr=[];++Ve-1}function ia(d,v){var x=this.__data__,b=Ql(x,d);return b<0?(++this.size,x.push([d,v])):x[b][1]=v,this}to.prototype.clear=Na,to.prototype.delete=pf,to.prototype.get=uc,to.prototype.has=ms,to.prototype.set=ia;function B0(d){var v=-1,x=d==null?0:d.length;for(this.clear();++v=v?d:v)),d}function U0(d,v,x,b,H,ee){var de,ye=v&C,be=v&U,gt=v&q;if(x&&(de=H?x(d,b,H,ee):x(d)),de!==i)return de;if(!ku(d))return d;var Dt=Jn(d);if(Dt){if(de=Es(d),!ye)return Ji(d,de)}else{var Rt=Ou(d),rn=Rt==Wt||Rt==vr;if(Gs(d))return fc(d,ye);if(Rt==fr||Rt==ct||rn&&!H){if(de=be||rn?{}:vc(d),!ye)return be?Jl(d,tl(de,d)):t0(d,hf(de,d))}else{if(!Gr[Rt])return H?d:{};de=Dh(d,Rt,ye)}}ee||(ee=new el);var Rn=ee.get(d);if(Rn)return Rn;ee.set(d,de),L2(d)?d.forEach(function(ir){de.add(U0(ir,v,x,ir,d,ee))}):gp(d)&&d.forEach(function(ir,Zr){de.set(Zr,U0(ir,v,x,Zr,d,ee))});var $n=gt?be?rr:$c:be?fn:M0,Nr=Dt?i:$n(d);return tt(Nr||d,function(ir,Zr){Nr&&(Zr=ir,ir=d[Zr]),gs(de,Zr,U0(ir,v,x,Zr,d,ee))}),de}function vf(d){var v=M0(d);return function(x){return jc(x,d,v)}}function jc(d,v,x){var b=x.length;if(d==null)return!b;for(d=wn(d);b--;){var H=x[b],ee=v[H],de=d[H];if(de===i&&!(H in d)||!ee(de))return!1}return!0}function lc(d,v,x){if(typeof d!="function")throw new Kr(E);return Wa(function(){d.apply(i,x)},v)}function Sl(d,v,x,b){var H=-1,ee=on,de=!0,ye=d.length,be=[],gt=v.length;if(!ye)return be;x&&(v=Lt(v,di(x))),b?(ee=tn,de=!1):v.length>=f&&(ee=Zo,de=!1,v=new ho(v));e:for(;++HH?0:H+x),b=b===i||b>H?H:Cr(b),b<0&&(b+=H),b=x>b?0:Ep(b);x0&&x(ye)?v>1?bi(ye,v-1,x,b,H):gn(H,ye):b||(H[H.length]=ye)}return H}var g=dc(),y=dc(!0);function A(d,v){return d&&g(d,v,M0)}function F(d,v){return d&&y(d,v,M0)}function I(d,v){return bt(v,function(x){return Ea(d[x])})}function J(d,v){v=Us(v,d);for(var x=0,b=v.length;d!=null&&xv}function Mt(d,v){return d!=null&&ei.call(d,v)}function Er(d,v){return d!=null&&v in wn(d)}function $u(d,v,x){return d>=Wn(v,x)&&d=120&&Dt.length>=120)?new ho(de&&Dt):i}Dt=d[0];var Rt=-1,rn=ye[0];e:for(;++Rt-1;)ye!==d&&R0.call(ye,be,1),R0.call(d,be,1);return d}function u2(d,v){for(var x=d?v.length:0,b=x-1;x--;){var H=v[x];if(x==b||H!==ee){var ee=H;go(H)?R0.call(d,H,1):Cd(d,H)}}return d}function o2(d,v){return d+vs(y0()*(v-d+1))}function wd(d,v,x,b){for(var H=-1,ee=Xr(Ku((v-d)/(x||1)),0),de=Ve(ee);ee--;)de[b?ee:++H]=d,d+=x;return de}function Hc(d,v){var x="";if(!d||v<1||v>$t)return x;do v%2&&(x+=d),v=vs(v/2),v&&(d+=d);while(v);return x}function Mr(d,v){return r1(Nd(d,v,r0),d+"")}function l2(d){return ba(Ac(d))}function s2(d,v){var x=Ac(d);return yc(x,Zu(v,0,x.length))}function ja(d,v,x,b){if(!ku(d))return d;v=Us(v,d);for(var H=-1,ee=v.length,de=ee-1,ye=d;ye!=null&&++HH?0:H+v),x=x>H?H:x,x<0&&(x+=H),H=v>x?0:x-v>>>0,v>>>=0;for(var ee=Ve(H);++b>>1,de=d[ee];de!==null&&!Nl(de)&&(x?de<=v:de=f){var gt=v?null:mm(d);if(gt)return Q0(gt);de=!1,H=Zo,be=new ho}else be=v?[]:ye;e:for(;++b=b?d:rl(d,v,x)}var Kc=hs||function(d){return Ni.clearTimeout(d)};function fc(d,v){if(v)return d.slice();var x=d.length,b=Fi?Fi(x):new d.constructor(x);return d.copy(b),b}function cc(d){var v=new d.constructor(d.byteLength);return new A0(v).set(new A0(d)),v}function f2(d,v){var x=v?cc(d.buffer):d.buffer;return new d.constructor(x,d.byteOffset,d.byteLength)}function yh(d){var v=new d.constructor(d.source,xo.exec(d));return v.lastIndex=d.lastIndex,v}function gf(d){return Sr?wn(Sr.call(d)):{}}function Xc(d,v){var x=v?cc(d.buffer):d.buffer;return new d.constructor(x,d.byteOffset,d.length)}function gh(d,v){if(d!==v){var x=d!==i,b=d===null,H=d===d,ee=Nl(d),de=v!==i,ye=v===null,be=v===v,gt=Nl(v);if(!ye&&!gt&&!ee&&d>v||ee&&de&&be&&!ye&&!gt||b&&de&&be||!x&&be||!H)return 1;if(!b&&!ee&&!gt&&d=ye)return be;var gt=x[b];return be*(gt=="desc"?-1:1)}}return d.index-v.index}function js(d,v,x,b){for(var H=-1,ee=d.length,de=x.length,ye=-1,be=v.length,gt=Xr(ee-de,0),Dt=Ve(be+gt),Rt=!b;++ye1?x[H-1]:i,de=H>2?x[2]:i;for(ee=d.length>3&&typeof ee=="function"?(H--,ee):i,de&&io(x[0],x[1],de)&&(ee=H<3?i:ee,H=1),v=wn(v);++b-1?H[ee?v[de]:de]:i}}function Jc(d){return ol(function(v){var x=v.length,b=x,H=Ur.prototype.thru;for(d&&v.reverse();b--;){var ee=v[b];if(typeof ee!="function")throw new Kr(E);if(H&&!de&&Bo(ee)=="wrapper")var de=new Ur([],!0)}for(b=de?b:x;++b1&&ui.reverse(),Dt&&beye))return!1;var gt=ee.get(d),Dt=ee.get(v);if(gt&&Dt)return gt==v&&Dt==d;var Rt=-1,rn=!0,Rn=x&ne?new ho:i;for(ee.set(d,v),ee.set(v,d);++Rt1?"& ":"")+v[b],v=v.join(x>2?", ":" "),d.replace(ci,`{ +/* [wrapped with `+v+`] */ +`)}function $l(d){return Jn(d)||sl(d)||!!(co&&d&&d[co])}function go(d,v){var x=typeof d;return v=v==null?$t:v,!!v&&(x=="number"||x!="symbol"&&Ms.test(d))&&d>-1&&d%1==0&&d0){if(++v>=wt)return arguments[0]}else v=0;return d.apply(i,arguments)}}function yc(d,v){var x=-1,b=d.length,H=b-1;for(v=v===i?b:v;++x1?d[v-1]:i;return x=typeof x=="function"?(d.pop(),x):i,E2(d,x)});function Bh(d){var v=Y(d);return v.__chain__=!0,v}function Uh(d,v){return v(d),d}function h1(d,v){return v(d)}var Qd=ol(function(d){var v=d.length,x=v?d[0]:0,b=this.__wrapped__,H=function(ee){return Ia(ee,d)};return v>1||this.__actions__.length||!(b instanceof lt)||!go(x)?this.thru(H):(b=b.slice(x,+x+(v?1:0)),b.__actions__.push({func:h1,args:[H],thisArg:i}),new Ur(b,this.__chain__).thru(function(ee){return v&&!ee.length&&ee.push(i),ee}))});function jh(){return Bh(this)}function Jd(){return new Ur(this.value(),this.__chain__)}function zh(){this.__values__===i&&(this.__values__=lv(this.value()));var d=this.__index__>=this.__values__.length,v=d?i:this.__values__[this.__index__++];return{done:d,value:v}}function Cm(){return this}function xm(d){for(var v,x=this;x instanceof Jr;){var b=Fd(x);b.__index__=0,b.__values__=i,v?H.__wrapped__=b:v=b;var H=b;x=x.__wrapped__}return H.__wrapped__=d,v}function Of(){var d=this.__wrapped__;if(d instanceof lt){var v=d;return this.__actions__.length&&(v=new lt(this)),v=v.reverse(),v.__actions__.push({func:h1,args:[Hd],thisArg:i}),new Ur(v,this.__chain__)}return this.thru(Hd)}function kf(){return mh(this.__wrapped__,this.__actions__)}var D2=za(function(d,v,x){ei.call(d,x)?++d[x]:ju(d,x,1)});function Am(d,v,x){var b=Jn(d)?kt:n2;return x&&io(d,v,x)&&(v=i),b(d,zn(v,3))}function Zd(d,v){var x=Jn(d)?bt:zc;return x(d,zn(v,3))}var w2=xl(Bd),$d=xl(u1);function qh(d,v){return bi(v1(d,v),1)}function ep(d,v){return bi(v1(d,v),jt)}function Hh(d,v,x){return x=x===i?1:Cr(x),bi(v1(d,v),x)}function Wh(d,v){var x=Jn(d)?tt:_s;return x(d,zn(v,3))}function tp(d,v){var x=Jn(d)?Tt:oa;return x(d,zn(v,3))}var Rm=za(function(d,v,x){ei.call(d,x)?d[x].push(v):ju(d,x,[v])});function Om(d,v,x,b){d=al(d)?d:Ac(d),x=x&&!b?Cr(x):0;var H=d.length;return x<0&&(x=Xr(H+x,0)),_1(d)?x<=H&&d.indexOf(v,x)>-1:!!H&&_t(d,v,x)>-1}var km=Mr(function(d,v,x){var b=-1,H=typeof v=="function",ee=al(d)?Ve(d.length):[];return _s(d,function(de){ee[++b]=H?ie(v,de,x):Tl(de,v,x)}),ee}),Vh=za(function(d,v,x){ju(d,x,v)});function v1(d,v){var x=Jn(d)?Lt:Ed;return x(d,zn(v,3))}function Mm(d,v,x,b){return d==null?[]:(Jn(v)||(v=v==null?[]:[v]),x=b?i:x,Jn(x)||(x=x==null?[]:[x]),vo(d,v,x))}var np=za(function(d,v,x){d[x?0:1].push(v)},function(){return[[],[]]});function rp(d,v,x){var b=Jn(d)?lr:yr,H=arguments.length<3;return b(d,zn(v,4),x,H,_s)}function Nm(d,v,x){var b=Jn(d)?Qn:yr,H=arguments.length<3;return b(d,zn(v,4),x,H,oa)}function Lm(d,v){var x=Jn(d)?bt:zc;return x(d,C2(zn(v,3)))}function Gh(d){var v=Jn(d)?ba:l2;return v(d)}function Fm(d,v,x){(x?io(d,v,x):v===i)?v=1:v=Cr(v);var b=Jn(d)?Pa:s2;return b(d,v)}function bm(d){var v=Jn(d)?ua:nl;return v(d)}function ip(d){if(d==null)return 0;if(al(d))return _1(d)?Ki(d):d.length;var v=Ou(d);return v==w||v==Kt?d.size:Ba(d).length}function up(d,v,x){var b=Jn(d)?_r:hh;return x&&io(d,v,x)&&(v=i),b(d,zn(v,3))}var ya=Mr(function(d,v){if(d==null)return[];var x=v.length;return x>1&&io(d,v[0],v[1])?v=[]:x>2&&io(v[0],v[1],v[2])&&(v=[v[0]]),vo(d,bi(v,1),[])}),m1=ra||function(){return Ni.Date.now()};function op(d,v){if(typeof v!="function")throw new Kr(E);return d=Cr(d),function(){if(--d<1)return v.apply(this,arguments)}}function Yh(d,v,x){return v=x?i:v,v=d&&v==null?d.length:v,dn(d,Oe,i,i,i,i,v)}function S2(d,v){var x;if(typeof v!="function")throw new Kr(E);return d=Cr(d),function(){return--d>0&&(x=v.apply(this,arguments)),d<=1&&(v=i),x}}var y1=Mr(function(d,v,x){var b=m;if(x.length){var H=T0(x,dr(y1));b|=ze}return dn(d,b,v,x,H)}),Kh=Mr(function(d,v,x){var b=m|we;if(x.length){var H=T0(x,dr(Kh));b|=ze}return dn(v,b,d,x,H)});function lp(d,v,x){v=x?i:v;var b=dn(d,he,i,i,i,i,i,v);return b.placeholder=lp.placeholder,b}function Xh(d,v,x){v=x?i:v;var b=dn(d,ge,i,i,i,i,i,v);return b.placeholder=Xh.placeholder,b}function sp(d,v,x){var b,H,ee,de,ye,be,gt=0,Dt=!1,Rt=!1,rn=!0;if(typeof d!="function")throw new Kr(E);v=fl(v)||0,ku(x)&&(Dt=!!x.leading,Rt="maxWait"in x,ee=Rt?Xr(fl(x.maxWait)||0,v):ee,rn="trailing"in x?!!x.trailing:rn);function Rn(i0){var Ts=b,wo=H;return b=H=i,gt=i0,de=d.apply(wo,Ts),de}function $n(i0){return gt=i0,ye=Wa(Zr,v),Dt?Rn(i0):de}function Nr(i0){var Ts=i0-be,wo=i0-gt,Rv=v-Ts;return Rt?Wn(Rv,ee-wo):Rv}function ir(i0){var Ts=i0-be,wo=i0-gt;return be===i||Ts>=v||Ts<0||Rt&&wo>=ee}function Zr(){var i0=m1();if(ir(i0))return ui(i0);ye=Wa(Zr,Nr(i0))}function ui(i0){return ye=i,rn&&b?Rn(i0):(b=H=i,de)}function bl(){ye!==i&&Kc(ye),gt=0,b=be=H=ye=i}function Wi(){return ye===i?de:ui(m1())}function uo(){var i0=m1(),Ts=ir(i0);if(b=arguments,H=this,be=i0,Ts){if(ye===i)return $n(be);if(Rt)return Kc(ye),ye=Wa(Zr,v),Rn(be)}return ye===i&&(ye=Wa(Zr,v)),de}return uo.cancel=bl,uo.flush=Wi,uo}var Qh=Mr(function(d,v){return lc(d,1,v)}),Jh=Mr(function(d,v,x){return lc(d,fl(v)||0,x)});function ap(d){return dn(d,Ue)}function T2(d,v){if(typeof d!="function"||v!=null&&typeof v!="function")throw new Kr(E);var x=function(){var b=arguments,H=v?v.apply(this,b):b[0],ee=x.cache;if(ee.has(H))return ee.get(H);var de=d.apply(this,b);return x.cache=ee.set(H,de)||ee,de};return x.cache=new(T2.Cache||B0),x}T2.Cache=B0;function C2(d){if(typeof d!="function")throw new Kr(E);return function(){var v=arguments;switch(v.length){case 0:return!d.call(this);case 1:return!d.call(this,v[0]);case 2:return!d.call(this,v[0],v[1]);case 3:return!d.call(this,v[0],v[1],v[2])}return!d.apply(this,v)}}function z0(d){return S2(2,d)}var x2=Rd(function(d,v){v=v.length==1&&Jn(v[0])?Lt(v[0],di(zn())):Lt(bi(v,1),di(zn()));var x=v.length;return Mr(function(b){for(var H=-1,ee=Wn(b.length,x);++H=v}),sl=e0(function(){return arguments}())?e0:function(d){return zu(d)&&ei.call(d,"callee")&&!I0.call(d,"callee")},Jn=Ve.isArray,Vs=me?di(me):He;function al(d){return d!=null&&M2(d.length)&&!Ea(d)}function n0(d){return zu(d)&&al(d)}function ev(d){return d===!0||d===!1||zu(d)&&mt(d)==ln}var Gs=$0||Ip,hp=xe?di(xe):Be;function jm(d){return zu(d)&&d.nodeType===1&&!Ec(d)}function tv(d){if(d==null)return!0;if(al(d)&&(Jn(d)||typeof d=="string"||typeof d.splice=="function"||Gs(d)||Da(d)||sl(d)))return!d.length;var v=Ou(d);if(v==w||v==Kt)return!d.size;if(xf(d))return!Ba(d).length;for(var x in d)if(ei.call(d,x))return!1;return!0}function vp(d,v){return ut(d,v)}function zm(d,v,x){x=typeof x=="function"?x:i;var b=x?x(d,v):i;return b===i?ut(d,v,i,x):!!b}function mp(d){if(!zu(d))return!1;var v=mt(d);return v==un||v==nr||typeof d.message=="string"&&typeof d.name=="string"&&!Ec(d)}function _c(d){return typeof d=="number"&&Xi(d)}function Ea(d){if(!ku(d))return!1;var v=mt(d);return v==Wt||v==vr||v==en||v==ur}function yp(d){return typeof d=="number"&&d==Cr(d)}function M2(d){return typeof d=="number"&&d>-1&&d%1==0&&d<=$t}function ku(d){var v=typeof d;return d!=null&&(v=="object"||v=="function")}function zu(d){return d!=null&&typeof d=="object"}var gp=Z?di(Z):jn;function _p(d,v){return d===v||ti(d,v,Pn(v))}function nv(d,v,x){return x=typeof x=="function"?x:i,ti(d,v,Pn(v),x)}function qm(d){return rv(d)&&d!=+d}function Hm(d){if(Al(d))throw new Yt(p);return tr(d)}function Wm(d){return d===null}function N2(d){return d==null}function rv(d){return typeof d=="number"||zu(d)&&mt(d)==Ut}function Ec(d){if(!zu(d)||mt(d)!=fr)return!1;var v=$o(d);if(v===null)return!0;var x=ei.call(v,"constructor")&&v.constructor;return typeof x=="function"&&x instanceof x&&Au.call(x)==na}var g1=ke?di(ke):ii;function Vm(d){return yp(d)&&d>=-$t&&d<=$t}var L2=Xe?di(Xe):qi;function _1(d){return typeof d=="string"||!Jn(d)&&zu(d)&&mt(d)==vu}function Nl(d){return typeof d=="symbol"||zu(d)&&mt(d)==a0}var Da=ht?di(ht):jr;function iv(d){return d===i}function Gm(d){return zu(d)&&Ou(d)==Go}function uv(d){return zu(d)&&mt(d)==Os}var ov=p2(r2),Ym=p2(function(d,v){return d<=v});function lv(d){if(!d)return[];if(al(d))return _1(d)?Yr(d):Ji(d);if(Ru&&d[Ru])return Vu(d[Ru]());var v=Ou(d),x=v==w?yu:v==Kt?Q0:Ac;return x(d)}function wa(d){if(!d)return d===0?d:0;if(d=fl(d),d===jt||d===-jt){var v=d<0?-1:1;return v*at}return d===d?d:0}function Cr(d){var v=wa(d),x=v%1;return v===v?x?v-x:v:0}function Ep(d){return d?Zu(Cr(d),0,ae):0}function fl(d){if(typeof d=="number")return d;if(Nl(d))return Q;if(ku(d)){var v=typeof d.valueOf=="function"?d.valueOf():d;d=ku(v)?v+"":v}if(typeof d!="string")return d===0?d:+d;d=xu(d);var x=oo.test(d);return x||Ao.test(d)?Ls(d.slice(2),x?2:8):ni.test(d)?Q:+d}function cu(d){return O0(d,fn(d))}function E1(d){return d?Zu(Cr(d),-$t,$t):d===0?d:0}function ki(d){return d==null?"":il(d)}var Dp=no(function(d,v){if(xf(v)||al(v)){O0(v,M0(v),d);return}for(var x in v)ei.call(v,x)&&gs(d,x,v[x])}),F2=no(function(d,v){O0(v,fn(v),d)}),Do=no(function(d,v,x,b){O0(v,fn(v),d,b)}),Ss=no(function(d,v,x,b){O0(v,M0(v),d,b)}),Mf=ol(Ia);function b2(d,v){var x=Qr(d);return v==null?x:hf(x,v)}var wp=Mr(function(d,v){d=wn(d);var x=-1,b=v.length,H=b>2?v[2]:i;for(H&&io(v[0],v[1],H)&&(b=1);++x1),ee}),O0(d,rr(d),x),b&&(x=U0(x,C|U|q,ym));for(var H=v.length;H--;)Cd(x,v[H]);return x});function T1(d,v){return Ka(d,C2(zn(v)))}var Cp=ol(function(d,v){return d==null?{}:dh(d,v)});function Ka(d,v){if(d==null)return{};var x=Lt(rr(d),function(b){return[b]});return v=zn(v),ph(d,x,function(b,H){return v(b,H[0])})}function Km(d,v,x){v=Us(v,d);var b=-1,H=v.length;for(H||(H=1,d=i);++bv){var b=d;d=v,v=b}if(x||d%1||v%1){var H=y0();return Wn(d+H*(v-d+Ma("1e-"+((H+"").length-1))),v)}return o2(d,v)}var q2=_f(function(d,v,x){return v=v.toLowerCase(),d+(x?Uo(v):v)});function Uo(d){return Rp(ki(d).toLowerCase())}function H2(d){return d=ki(d),d&&d.replace(Xn,Mo).replace(K0,"")}function Qm(d,v,x){d=ki(d),v=il(v);var b=d.length;x=x===i?b:Zu(Cr(x),0,b);var H=x;return x-=v.length,x>=0&&d.slice(x,H)==v}function A1(d){return d=ki(d),d&&ks.test(d)?d.replace(tu,ds):d}function Jm(d){return d=ki(d),d&&Zt.test(d)?d.replace(fi,"\\$&"):d}var Zm=_f(function(d,v,x){return d+(x?"-":"")+v.toLowerCase()}),av=_f(function(d,v,x){return d+(x?" ":"")+v.toLowerCase()}),$m=_h("toLowerCase");function fv(d,v,x){d=ki(d),v=Cr(v);var b=v?Ki(d):0;if(!v||b>=v)return d;var H=(v-b)/2;return da(vs(H),x)+d+da(Ku(H),x)}function ey(d,v,x){d=ki(d),v=Cr(v);var b=v?Ki(d):0;return v&&b>>0,x?(d=ki(d),d&&(typeof v=="string"||v!=null&&!g1(v))&&(v=il(v),!v&&Lo(d))?aa(Yr(d),0,x):d.split(v,x)):[]}var bf=_f(function(d,v,x){return d+(x?" ":"")+Rp(v)});function dv(d,v,x){return d=ki(d),x=x==null?0:Zu(Cr(x),0,d.length),v=il(v),d.slice(x,x+v.length)==v}function pv(d,v,x){var b=Y.templateSettings;x&&io(d,v,x)&&(v=i),d=ki(d),v=Do({},v,b,Df);var H=Do({},v.imports,b.imports,Df),ee=M0(H),de=ko(H,ee),ye,be,gt=0,Dt=v.interpolate||Qo,Rt="__p += '",rn=fu((v.escape||Qo).source+"|"+Dt.source+"|"+(Dt===f0?Wl:Qo).source+"|"+(v.evaluate||Qo).source+"|$","g"),Rn="//# sourceURL="+(ei.call(v,"sourceURL")?(v.sourceURL+"").replace(/\s/g," "):"lodash.templateSources["+ ++ic+"]")+` +`;d.replace(rn,function(ir,Zr,ui,bl,Wi,uo){return ui||(ui=bl),Rt+=d.slice(gt,uo).replace(lo,bs),Zr&&(ye=!0,Rt+=`' + +__e(`+Zr+`) + +'`),Wi&&(be=!0,Rt+=`'; +`+Wi+`; +__p += '`),ui&&(Rt+=`' + +((__t = (`+ui+`)) == null ? '' : __t) + +'`),gt=uo+ir.length,ir}),Rt+=`'; +`;var $n=ei.call(v,"variable")&&v.variable;if(!$n)Rt=`with (obj) { +`+Rt+` +} +`;else if(Y0.test($n))throw new Yt(t);Rt=(be?Rt.replace(Hr,""):Rt).replace(To,"$1").replace(Co,"$1;"),Rt="function("+($n||"obj")+`) { +`+($n?"":`obj || (obj = {}); +`)+"var __t, __p = ''"+(ye?", __e = _.escape":"")+(be?`, __j = Array.prototype.join; +function print() { __p += __j.call(arguments, '') } +`:`; +`)+Rt+`return __p +}`;var Nr=_v(function(){return sr(ee,Rn+"return "+Rt).apply(i,de)});if(Nr.source=Rt,mp(Nr))throw Nr;return Nr}function hv(d){return ki(d).toLowerCase()}function W2(d){return ki(d).toUpperCase()}function V2(d,v,x){if(d=ki(d),d&&(x||v===i))return xu(d);if(!d||!(v=il(v)))return d;var b=Yr(d),H=Yr(v),ee=sf(b,H),de=gl(b,H)+1;return aa(b,ee,de).join("")}function Ap(d,v,x){if(d=ki(d),d&&(x||v===i))return d.slice(0,fo(d)+1);if(!d||!(v=il(v)))return d;var b=Yr(d),H=gl(b,Yr(v))+1;return aa(b,0,H).join("")}function vv(d,v,x){if(d=ki(d),d&&(x||v===i))return d.replace(Ln,"");if(!d||!(v=il(v)))return d;var b=Yr(d),H=sf(b,Yr(v));return aa(b,H).join("")}function G2(d,v){var x=Ge,b=rt;if(ku(v)){var H="separator"in v?v.separator:H;x="length"in v?Cr(v.length):x,b="omission"in v?il(v.omission):b}d=ki(d);var ee=d.length;if(Lo(d)){var de=Yr(d);ee=de.length}if(x>=ee)return d;var ye=x-Ki(b);if(ye<1)return b;var be=de?aa(de,0,ye).join(""):d.slice(0,ye);if(H===i)return be+b;if(de&&(ye+=be.length-ye),g1(H)){if(d.slice(ye).search(H)){var gt,Dt=be;for(H.global||(H=fu(H.source,ki(xo.exec(H))+"g")),H.lastIndex=0;gt=H.exec(Dt);)var Rt=gt.index;be=be.slice(0,Rt===i?ye:Rt)}}else if(d.indexOf(il(H),ye)!=ye){var rn=be.lastIndexOf(H);rn>-1&&(be=be.slice(0,rn))}return be+b}function mv(d){return d=ki(d),d&&Si.test(d)?d.replace(L0,Oi):d}var yv=_f(function(d,v,x){return d+(x?" ":"")+v.toUpperCase()}),Rp=_h("toUpperCase");function gv(d,v,x){return d=ki(d),v=x?i:v,v===i?ps(d)?cf(d):v0(d):d.match(v)||[]}var _v=Mr(function(d,v){try{return ie(d,i,v)}catch(x){return mp(x)?x:new Yt(x)}}),uy=ol(function(d,v){return tt(v,function(x){x=Rl(x),ju(d,x,y1(d[x],d))}),d});function Ev(d){var v=d==null?0:d.length,x=zn();return d=v?Lt(d,function(b){if(typeof b[1]!="function")throw new Kr(E);return[x(b[0]),b[1]]}):[],Mr(function(b){for(var H=-1;++H$t)return[];var x=ae,b=Wn(d,ae);v=zn(v),d-=ae;for(var H=S0(b,v);++x0||v<0)?new lt(x):(d<0?x=x.takeRight(-d):d&&(x=x.drop(d)),v!==i&&(v=Cr(v),x=v<0?x.dropRight(-v):x.take(v-d)),x)},lt.prototype.takeRightWhile=function(d){return this.reverse().takeWhile(d).reverse()},lt.prototype.toArray=function(){return this.take(ae)},A(lt.prototype,function(d,v){var x=/^(?:filter|find|map|reject)|While$/.test(v),b=/^(?:head|last)$/.test(v),H=Y[b?"take"+(v=="last"?"Right":""):v],ee=b||/^find/.test(v);!H||(Y.prototype[v]=function(){var de=this.__wrapped__,ye=b?[1]:arguments,be=de instanceof lt,gt=ye[0],Dt=be||Jn(de),Rt=function(Zr){var ui=H.apply(Y,gn([Zr],ye));return b&&rn?ui[0]:ui};Dt&&x&&typeof gt=="function"&>.length!=1&&(be=Dt=!1);var rn=this.__chain__,Rn=!!this.__actions__.length,$n=ee&&!rn,Nr=be&&!Rn;if(!ee&&Dt){de=Nr?de:new lt(this);var ir=d.apply(de,ye);return ir.__actions__.push({func:h1,args:[Rt],thisArg:i}),new Ur(ir,rn)}return $n&&Nr?d.apply(this,ye):(ir=this.thru(Rt),$n?b?ir.value()[0]:ir.value():ir)})}),tt(["pop","push","shift","sort","splice","unshift"],function(d){var v=Vr[d],x=/^(?:push|sort|unshift)$/.test(d)?"tap":"thru",b=/^(?:pop|shift)$/.test(d);Y.prototype[d]=function(){var H=arguments;if(b&&!this.__chain__){var ee=this.value();return v.apply(Jn(ee)?ee:[],H)}return this[x](function(de){return v.apply(Jn(de)?de:[],H)})}}),A(lt.prototype,function(d,v){var x=Y[v];if(x){var b=x.name+"";ei.call(xn,b)||(xn[b]=[]),xn[b].push({name:v,func:x})}}),xn[ca(i,we).name]=[{name:"wrapper",func:i}],lt.prototype.clone=hi,lt.prototype.reverse=Qi,lt.prototype.value=g0,Y.prototype.at=Qd,Y.prototype.chain=jh,Y.prototype.commit=Jd,Y.prototype.next=zh,Y.prototype.plant=xm,Y.prototype.reverse=Of,Y.prototype.toJSON=Y.prototype.valueOf=Y.prototype.value=kf,Y.prototype.first=Y.prototype.head,Ru&&(Y.prototype[Ru]=Cm),Y},Z0=J0();typeof define=="function"&&typeof define.amd=="object"&&define.amd?(Ni._=Z0,define(function(){return Z0})):z?((z.exports=Z0)._=Z0,B._=Z0):Ni._=Z0}).call(nm)});var GD=ce((Dne,VD)=>{"use strict";var Ai=VD.exports;VD.exports.default=Ai;var hu="[",ig="]",rm="\x07",$_=";",U5=process.env.TERM_PROGRAM==="Apple_Terminal";Ai.cursorTo=(i,o)=>{if(typeof i!="number")throw new TypeError("The `x` argument is required");return typeof o!="number"?hu+(i+1)+"G":hu+(o+1)+";"+(i+1)+"H"};Ai.cursorMove=(i,o)=>{if(typeof i!="number")throw new TypeError("The `x` argument is required");let f="";return i<0?f+=hu+-i+"D":i>0&&(f+=hu+i+"C"),o<0?f+=hu+-o+"A":o>0&&(f+=hu+o+"B"),f};Ai.cursorUp=(i=1)=>hu+i+"A";Ai.cursorDown=(i=1)=>hu+i+"B";Ai.cursorForward=(i=1)=>hu+i+"C";Ai.cursorBackward=(i=1)=>hu+i+"D";Ai.cursorLeft=hu+"G";Ai.cursorSavePosition=U5?"7":hu+"s";Ai.cursorRestorePosition=U5?"8":hu+"u";Ai.cursorGetPosition=hu+"6n";Ai.cursorNextLine=hu+"E";Ai.cursorPrevLine=hu+"F";Ai.cursorHide=hu+"?25l";Ai.cursorShow=hu+"?25h";Ai.eraseLines=i=>{let o="";for(let f=0;f[ig,"8",$_,$_,o,rm,i,ig,"8",$_,$_,rm].join("");Ai.image=(i,o={})=>{let f=`${ig}1337;File=inline=1`;return o.width&&(f+=`;width=${o.width}`),o.height&&(f+=`;height=${o.height}`),o.preserveAspectRatio===!1&&(f+=";preserveAspectRatio=0"),f+":"+i.toString("base64")+rm};Ai.iTerm={setCwd:(i=process.cwd())=>`${ig}50;CurrentDir=${i}${rm}`,annotation:(i,o={})=>{let f=`${ig}1337;`,p=typeof o.x!="undefined",E=typeof o.y!="undefined";if((p||E)&&!(p&&E&&typeof o.length!="undefined"))throw new Error("`x`, `y` and `length` must be defined when `x` or `y` is defined");return i=i.replace(/\|/g,""),f+=o.isHidden?"AddHiddenAnnotation=":"AddAnnotation=",o.length>0?f+=(p?[i,o.length,o.x,o.y]:[o.length,i]).join("|"):f+=i,f+rm}}});var z5=ce((wne,YD)=>{"use strict";var j5=(i,o)=>{for(let f of Reflect.ownKeys(o))Object.defineProperty(i,f,Object.getOwnPropertyDescriptor(o,f));return i};YD.exports=j5;YD.exports.default=j5});var H5=ce((Sne,e4)=>{"use strict";var UK=z5(),t4=new WeakMap,q5=(i,o={})=>{if(typeof i!="function")throw new TypeError("Expected a function");let f,p=0,E=i.displayName||i.name||"",t=function(...k){if(t4.set(t,++p),p===1)f=i.apply(this,k),i=null;else if(o.throw===!0)throw new Error(`Function \`${E}\` can only be called once`);return f};return UK(t,i),t4.set(t,p),t};e4.exports=q5;e4.exports.default=q5;e4.exports.callCount=i=>{if(!t4.has(i))throw new Error(`The given function \`${i.name}\` is not wrapped by the \`onetime\` package`);return t4.get(i)}});var W5=ce((Tne,n4)=>{n4.exports=["SIGABRT","SIGALRM","SIGHUP","SIGINT","SIGTERM"];process.platform!=="win32"&&n4.exports.push("SIGVTALRM","SIGXCPU","SIGXFSZ","SIGUSR2","SIGTRAP","SIGSYS","SIGQUIT","SIGIOT");process.platform==="linux"&&n4.exports.push("SIGIO","SIGPOLL","SIGPWR","SIGSTKFLT","SIGUNUSED")});var JD=ce((Cne,ug)=>{var jK=require("assert"),og=W5(),zK=/^win/i.test(process.platform),r4=require("events");typeof r4!="function"&&(r4=r4.EventEmitter);var zl;process.__signal_exit_emitter__?zl=process.__signal_exit_emitter__:(zl=process.__signal_exit_emitter__=new r4,zl.count=0,zl.emitted={});zl.infinite||(zl.setMaxListeners(Infinity),zl.infinite=!0);ug.exports=function(i,o){jK.equal(typeof i,"function","a callback must be provided for exit handler"),lg===!1&&V5();var f="exit";o&&o.alwaysLast&&(f="afterexit");var p=function(){zl.removeListener(f,i),zl.listeners("exit").length===0&&zl.listeners("afterexit").length===0&&KD()};return zl.on(f,i),p};ug.exports.unload=KD;function KD(){!lg||(lg=!1,og.forEach(function(i){try{process.removeListener(i,XD[i])}catch(o){}}),process.emit=QD,process.reallyExit=G5,zl.count-=1)}function im(i,o,f){zl.emitted[i]||(zl.emitted[i]=!0,zl.emit(i,o,f))}var XD={};og.forEach(function(i){XD[i]=function(){var f=process.listeners(i);f.length===zl.count&&(KD(),im("exit",null,i),im("afterexit",null,i),zK&&i==="SIGHUP"&&(i="SIGINT"),process.kill(process.pid,i))}});ug.exports.signals=function(){return og};ug.exports.load=V5;var lg=!1;function V5(){lg||(lg=!0,zl.count+=1,og=og.filter(function(i){try{return process.on(i,XD[i]),!0}catch(o){return!1}}),process.emit=HK,process.reallyExit=qK)}var G5=process.reallyExit;function qK(i){process.exitCode=i||0,im("exit",process.exitCode,null),im("afterexit",process.exitCode,null),G5.call(process,process.exitCode)}var QD=process.emit;function HK(i,o){if(i==="exit"){o!==void 0&&(process.exitCode=o);var f=QD.apply(this,arguments);return im("exit",process.exitCode,null),im("afterexit",process.exitCode,null),f}else return QD.apply(this,arguments)}});var K5=ce((xne,Y5)=>{"use strict";var WK=H5(),VK=JD();Y5.exports=WK(()=>{VK(()=>{process.stderr.write("[?25h")},{alwaysLast:!0})})});var ZD=ce(um=>{"use strict";var GK=K5(),i4=!1;um.show=(i=process.stderr)=>{!i.isTTY||(i4=!1,i.write("[?25h"))};um.hide=(i=process.stderr)=>{!i.isTTY||(GK(),i4=!0,i.write("[?25l"))};um.toggle=(i,o)=>{i!==void 0&&(i4=i),i4?um.show(o):um.hide(o)}});var Z5=ce(sg=>{"use strict";var X5=sg&&sg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(sg,"__esModule",{value:!0});var Q5=X5(GD()),J5=X5(ZD()),YK=(i,{showCursor:o=!1}={})=>{let f=0,p="",E=!1,t=k=>{!o&&!E&&(J5.default.hide(),E=!0);let L=k+` +`;L!==p&&(p=L,i.write(Q5.default.eraseLines(f)+L),f=L.split(` +`).length)};return t.clear=()=>{i.write(Q5.default.eraseLines(f)),p="",f=0},t.done=()=>{p="",f=0,o||(J5.default.show(),E=!1)},t};sg.default={create:YK}});var e9=ce((One,$5)=>{$5.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY_BUILD_BASE",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}}]});var r9=ce(Ra=>{"use strict";var t9=e9(),bc=process.env;Object.defineProperty(Ra,"_vendors",{value:t9.map(function(i){return i.constant})});Ra.name=null;Ra.isPR=null;t9.forEach(function(i){var o=Array.isArray(i.env)?i.env:[i.env],f=o.every(function(p){return n9(p)});if(Ra[i.constant]=f,f)switch(Ra.name=i.name,typeof i.pr){case"string":Ra.isPR=!!bc[i.pr];break;case"object":"env"in i.pr?Ra.isPR=i.pr.env in bc&&bc[i.pr.env]!==i.pr.ne:"any"in i.pr?Ra.isPR=i.pr.any.some(function(p){return!!bc[p]}):Ra.isPR=n9(i.pr);break;default:Ra.isPR=null}});Ra.isCI=!!(bc.CI||bc.CONTINUOUS_INTEGRATION||bc.BUILD_NUMBER||bc.RUN_ID||Ra.name);function n9(i){return typeof i=="string"?!!bc[i]:Object.keys(i).every(function(o){return bc[o]===i[o]})}});var u9=ce((Mne,i9)=>{"use strict";i9.exports=r9().isCI});var l9=ce((Nne,o9)=>{"use strict";var KK=i=>{let o=new Set;do for(let f of Reflect.ownKeys(i))o.add([i,f]);while((i=Reflect.getPrototypeOf(i))&&i!==Object.prototype);return o};o9.exports=(i,{include:o,exclude:f}={})=>{let p=E=>{let t=k=>typeof k=="string"?E===k:k.test(E);return o?o.some(t):f?!f.some(t):!0};for(let[E,t]of KK(i.constructor.prototype)){if(t==="constructor"||!p(t))continue;let k=Reflect.getOwnPropertyDescriptor(E,t);k&&typeof k.value=="function"&&(i[t]=i[t].bind(i))}return i}});var h9=ce($i=>{"use strict";Object.defineProperty($i,"__esModule",{value:!0});var om,ag,u4,o4,$D;typeof window=="undefined"||typeof MessageChannel!="function"?(lm=null,ew=null,tw=function(){if(lm!==null)try{var i=$i.unstable_now();lm(!0,i),lm=null}catch(o){throw setTimeout(tw,0),o}},s9=Date.now(),$i.unstable_now=function(){return Date.now()-s9},om=function(i){lm!==null?setTimeout(om,0,i):(lm=i,setTimeout(tw,0))},ag=function(i,o){ew=setTimeout(i,o)},u4=function(){clearTimeout(ew)},o4=function(){return!1},$D=$i.unstable_forceFrameRate=function(){}):(l4=window.performance,nw=window.Date,a9=window.setTimeout,f9=window.clearTimeout,typeof console!="undefined"&&(c9=window.cancelAnimationFrame,typeof window.requestAnimationFrame!="function"&&console.error("This browser doesn't support requestAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills"),typeof c9!="function"&&console.error("This browser doesn't support cancelAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills")),typeof l4=="object"&&typeof l4.now=="function"?$i.unstable_now=function(){return l4.now()}:(d9=nw.now(),$i.unstable_now=function(){return nw.now()-d9}),fg=!1,cg=null,s4=-1,rw=5,iw=0,o4=function(){return $i.unstable_now()>=iw},$D=function(){},$i.unstable_forceFrameRate=function(i){0>i||125f4(k,f))N!==void 0&&0>f4(N,k)?(i[p]=N,i[L]=f,p=L):(i[p]=k,i[t]=f,p=t);else if(N!==void 0&&0>f4(N,f))i[p]=N,i[L]=f,p=L;else break e}}return o}return null}function f4(i,o){var f=i.sortIndex-o.sortIndex;return f!==0?f:i.id-o.id}var Xf=[],dd=[],XK=1,Rs=null,ls=3,d4=!1,$p=!1,dg=!1;function p4(i){for(var o=uf(dd);o!==null;){if(o.callback===null)c4(dd);else if(o.startTime<=i)c4(dd),o.sortIndex=o.expirationTime,ow(Xf,o);else break;o=uf(dd)}}function lw(i){if(dg=!1,p4(i),!$p)if(uf(Xf)!==null)$p=!0,om(sw);else{var o=uf(dd);o!==null&&ag(lw,o.startTime-i)}}function sw(i,o){$p=!1,dg&&(dg=!1,u4()),d4=!0;var f=ls;try{for(p4(o),Rs=uf(Xf);Rs!==null&&(!(Rs.expirationTime>o)||i&&!o4());){var p=Rs.callback;if(p!==null){Rs.callback=null,ls=Rs.priorityLevel;var E=p(Rs.expirationTime<=o);o=$i.unstable_now(),typeof E=="function"?Rs.callback=E:Rs===uf(Xf)&&c4(Xf),p4(o)}else c4(Xf);Rs=uf(Xf)}if(Rs!==null)var t=!0;else{var k=uf(dd);k!==null&&ag(lw,k.startTime-o),t=!1}return t}finally{Rs=null,ls=f,d4=!1}}function p9(i){switch(i){case 1:return-1;case 2:return 250;case 5:return 1073741823;case 4:return 1e4;default:return 5e3}}var QK=$D;$i.unstable_ImmediatePriority=1;$i.unstable_UserBlockingPriority=2;$i.unstable_NormalPriority=3;$i.unstable_IdlePriority=5;$i.unstable_LowPriority=4;$i.unstable_runWithPriority=function(i,o){switch(i){case 1:case 2:case 3:case 4:case 5:break;default:i=3}var f=ls;ls=i;try{return o()}finally{ls=f}};$i.unstable_next=function(i){switch(ls){case 1:case 2:case 3:var o=3;break;default:o=ls}var f=ls;ls=o;try{return i()}finally{ls=f}};$i.unstable_scheduleCallback=function(i,o,f){var p=$i.unstable_now();if(typeof f=="object"&&f!==null){var E=f.delay;E=typeof E=="number"&&0p?(i.sortIndex=E,ow(dd,i),uf(Xf)===null&&i===uf(dd)&&(dg?u4():dg=!0,ag(lw,E-p))):(i.sortIndex=f,ow(Xf,i),$p||d4||($p=!0,om(sw))),i};$i.unstable_cancelCallback=function(i){i.callback=null};$i.unstable_wrapCallback=function(i){var o=ls;return function(){var f=ls;ls=o;try{return i.apply(this,arguments)}finally{ls=f}}};$i.unstable_getCurrentPriorityLevel=function(){return ls};$i.unstable_shouldYield=function(){var i=$i.unstable_now();p4(i);var o=uf(Xf);return o!==Rs&&Rs!==null&&o!==null&&o.callback!==null&&o.startTime<=i&&o.expirationTime{"use strict";process.env.NODE_ENV!=="production"&&function(){"use strict";Object.defineProperty(Ri,"__esModule",{value:!0});var i=!1,o=!1,f=!0,p,E,t,k,L;if(typeof window=="undefined"||typeof MessageChannel!="function"){var N=null,C=null,U=function(){if(N!==null)try{var Et=Ri.unstable_now(),Pt=!0;N(Pt,Et),N=null}catch(Bn){throw setTimeout(U,0),Bn}},q=Date.now();Ri.unstable_now=function(){return Date.now()-q},p=function(Et){N!==null?setTimeout(p,0,Et):(N=Et,setTimeout(U,0))},E=function(Et,Pt){C=setTimeout(Et,Pt)},t=function(){clearTimeout(C)},k=function(){return!1},L=Ri.unstable_forceFrameRate=function(){}}else{var W=window.performance,ne=window.Date,m=window.setTimeout,we=window.clearTimeout;if(typeof console!="undefined"){var Se=window.requestAnimationFrame,he=window.cancelAnimationFrame;typeof Se!="function"&&console.error("This browser doesn't support requestAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills"),typeof he!="function"&&console.error("This browser doesn't support cancelAnimationFrame. Make sure that you load a polyfill in older browsers. https://fb.me/react-polyfills")}if(typeof W=="object"&&typeof W.now=="function")Ri.unstable_now=function(){return W.now()};else{var ge=ne.now();Ri.unstable_now=function(){return ne.now()-ge}}var ze=!1,pe=null,Oe=-1,le=5,Ue=0,Ge=300,rt=!1;if(o&&navigator!==void 0&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0){var wt=navigator.scheduling;k=function(){var Et=Ri.unstable_now();return Et>=Ue?rt||wt.isInputPending()?!0:Et>=Ge:!1},L=function(){rt=!0}}else k=function(){return Ri.unstable_now()>=Ue},L=function(){};Ri.unstable_forceFrameRate=function(Et){if(Et<0||Et>125){console.error("forceFrameRate takes a positive int between 0 and 125, forcing framerates higher than 125 fps is not unsupported");return}Et>0?le=Math.floor(1e3/Et):le=5};var xt=function(){if(pe!==null){var Et=Ri.unstable_now();Ue=Et+le;var Pt=!0;try{var Bn=pe(Pt,Et);Bn?ft.postMessage(null):(ze=!1,pe=null)}catch(Ir){throw ft.postMessage(null),Ir}}else ze=!1;rt=!1},$e=new MessageChannel,ft=$e.port2;$e.port1.onmessage=xt,p=function(Et){pe=Et,ze||(ze=!0,ft.postMessage(null))},E=function(Et,Pt){Oe=m(function(){Et(Ri.unstable_now())},Pt)},t=function(){we(Oe),Oe=-1}}function Ke(Et,Pt){var Bn=Et.length;Et.push(Pt),at(Et,Pt,Bn)}function jt(Et){var Pt=Et[0];return Pt===void 0?null:Pt}function $t(Et){var Pt=Et[0];if(Pt!==void 0){var Bn=Et.pop();return Bn!==Pt&&(Et[0]=Bn,Q(Et,Bn,0)),Pt}else return null}function at(Et,Pt,Bn){for(var Ir=Bn;;){var ji=Math.floor((Ir-1)/2),Wr=Et[ji];if(Wr!==void 0&&ae(Wr,Pt)>0)Et[ji]=Pt,Et[Ir]=Wr,Ir=ji;else return}}function Q(Et,Pt,Bn){for(var Ir=Bn,ji=Et.length;Irur){if(ur*=2,ur>Fr){console.error("Scheduler Profiling: Event log exceeded maximum size. Don't forget to call `stopLoggingProfilingEvents()`."),mr();return}var Bn=new Int32Array(ur*4);Bn.set(Kt),br=Bn.buffer,Kt=Bn}Kt.set(Et,Pt)}}function ai(){ur=fr,br=new ArrayBuffer(ur*4),Kt=new Int32Array(br),vu=0}function mr(){var Et=br;return ur=0,br=null,Kt=null,vu=0,Et}function Xo(Et,Pt){f&&(Wt[Vn]++,Kt!==null&&eu([a0,Pt*1e3,Et.id,Et.priorityLevel]))}function W0(Et,Pt){f&&(Wt[vr]=Ce,Wt[w]=0,Wt[Vn]--,Kt!==null&&eu([So,Pt*1e3,Et.id]))}function Lu(Et,Pt){f&&(Wt[Vn]--,Kt!==null&&eu([Os,Pt*1e3,Et.id]))}function V0(Et,Pt){f&&(Wt[vr]=Ce,Wt[w]=0,Wt[Vn]--,Kt!==null&&eu([Go,Pt*1e3,Et.id]))}function Hr(Et,Pt){f&&(ln++,Wt[vr]=Et.priorityLevel,Wt[w]=Et.id,Wt[Ut]=ln,Kt!==null&&eu([Yo,Pt*1e3,Et.id,ln]))}function To(Et,Pt){f&&(Wt[vr]=Ce,Wt[w]=0,Wt[Ut]=0,Kt!==null&&eu([Ko,Pt*1e3,Et.id,ln]))}function Co(Et){f&&(An++,Kt!==null&&eu([qt,Et*1e3,An]))}function L0(Et){f&&Kt!==null&&eu([_i,Et*1e3,An])}var tu=1073741823,Si=-1,ks=250,Hl=5e3,F0=1e4,f0=tu,Pr=[],Ei=[],G0=1,fi=!1,Zt=null,Ln=ct,Di=!1,ci=!1,Ht=!1;function Du(Et){for(var Pt=jt(Ei);Pt!==null;){if(Pt.callback===null)$t(Ei);else if(Pt.startTime<=Et)$t(Ei),Pt.sortIndex=Pt.expirationTime,Ke(Pr,Pt),f&&(Xo(Pt,Et),Pt.isQueued=!0);else return;Pt=jt(Ei)}}function Yi(Et){if(Ht=!1,Du(Et),!ci)if(jt(Pr)!==null)ci=!0,p(Y0);else{var Pt=jt(Ei);Pt!==null&&E(Yi,Pt.startTime-Et)}}function Y0(Et,Pt){f&&L0(Pt),ci=!1,Ht&&(Ht=!1,t()),Di=!0;var Bn=Ln;try{if(f)try{return Ui(Et,Pt)}catch(Wr){if(Zt!==null){var Ir=Ri.unstable_now();V0(Zt,Ir),Zt.isQueued=!1}throw Wr}else return Ui(Et,Pt)}finally{if(Zt=null,Ln=Bn,Di=!1,f){var ji=Ri.unstable_now();Co(ji)}}}function Ui(Et,Pt){var Bn=Pt;for(Du(Bn),Zt=jt(Pr);Zt!==null&&!(i&&fi)&&!(Zt.expirationTime>Bn&&(!Et||k()));){var Ir=Zt.callback;if(Ir!==null){Zt.callback=null,Ln=Zt.priorityLevel;var ji=Zt.expirationTime<=Bn;Hr(Zt,Bn);var Wr=Ir(ji);Bn=Ri.unstable_now(),typeof Wr=="function"?(Zt.callback=Wr,To(Zt,Bn)):(f&&(W0(Zt,Bn),Zt.isQueued=!1),Zt===jt(Pr)&&$t(Pr)),Du(Bn)}else $t(Pr);Zt=jt(Pr)}if(Zt!==null)return!0;var wu=jt(Ei);return wu!==null&&E(Yi,wu.startTime-Bn),!1}function Wl(Et,Pt){switch(Et){case ue:case je:case ct:case At:case en:break;default:Et=ct}var Bn=Ln;Ln=Et;try{return Pt()}finally{Ln=Bn}}function xo(Et){var Pt;switch(Ln){case ue:case je:case ct:Pt=ct;break;default:Pt=Ln;break}var Bn=Ln;Ln=Pt;try{return Et()}finally{Ln=Bn}}function ni(Et){var Pt=Ln;return function(){var Bn=Ln;Ln=Pt;try{return Et.apply(this,arguments)}finally{Ln=Bn}}}function oo(Et){switch(Et){case ue:return Si;case je:return ks;case en:return f0;case At:return F0;case ct:default:return Hl}}function Vl(Et,Pt,Bn){var Ir=Ri.unstable_now(),ji,Wr;if(typeof Bn=="object"&&Bn!==null){var wu=Bn.delay;typeof wu=="number"&&wu>0?ji=Ir+wu:ji=Ir,Wr=typeof Bn.timeout=="number"?Bn.timeout:oo(Et)}else Wr=oo(Et),ji=Ir;var c0=ji+Wr,Ti={id:G0++,callback:Pt,priorityLevel:Et,startTime:ji,expirationTime:c0,sortIndex:-1};return f&&(Ti.isQueued=!1),ji>Ir?(Ti.sortIndex=ji,Ke(Ei,Ti),jt(Pr)===null&&Ti===jt(Ei)&&(Ht?t():Ht=!0,E(Yi,ji-Ir))):(Ti.sortIndex=c0,Ke(Pr,Ti),f&&(Xo(Ti,Ir),Ti.isQueued=!0),!ci&&!Di&&(ci=!0,p(Y0))),Ti}function Ao(){fi=!0}function Ms(){fi=!1,!ci&&!Di&&(ci=!0,p(Y0))}function Xn(){return jt(Pr)}function Qo(Et){if(f&&Et.isQueued){var Pt=Ri.unstable_now();Lu(Et,Pt),Et.isQueued=!1}Et.callback=null}function lo(){return Ln}function b0(){var Et=Ri.unstable_now();Du(Et);var Pt=jt(Pr);return Pt!==Zt&&Zt!==null&&Pt!==null&&Pt.callback!==null&&Pt.startTime<=Et&&Pt.expirationTime{"use strict";process.env.NODE_ENV==="production"?aw.exports=h9():aw.exports=v9()});var m9=ce((Pne,pg)=>{pg.exports=function i(o){"use strict";var f=eg(),p=su(),E=h4();function t(g){for(var y="https://reactjs.org/docs/error-decoder.html?invariant="+g,A=1;AG0||(g.current=Ei[G0],Ei[G0]=null,G0--)}function Zt(g,y){G0++,Ei[G0]=g.current,g.current=y}var Ln={},Di={current:Ln},ci={current:!1},Ht=Ln;function Du(g,y){var A=g.type.contextTypes;if(!A)return Ln;var F=g.stateNode;if(F&&F.__reactInternalMemoizedUnmaskedChildContext===y)return F.__reactInternalMemoizedMaskedChildContext;var I={},J;for(J in A)I[J]=y[J];return F&&(g=g.stateNode,g.__reactInternalMemoizedUnmaskedChildContext=y,g.__reactInternalMemoizedMaskedChildContext=I),I}function Yi(g){return g=g.childContextTypes,g!=null}function Y0(g){fi(ci,g),fi(Di,g)}function Ui(g){fi(ci,g),fi(Di,g)}function Wl(g,y,A){if(Di.current!==Ln)throw Error(t(168));Zt(Di,y,g),Zt(ci,A,g)}function xo(g,y,A){var F=g.stateNode;if(g=y.childContextTypes,typeof F.getChildContext!="function")return A;F=F.getChildContext();for(var I in F)if(!(I in g))throw Error(t(108,Ge(y)||"Unknown",I));return f({},A,{},F)}function ni(g){var y=g.stateNode;return y=y&&y.__reactInternalMemoizedMergedChildContext||Ln,Ht=Di.current,Zt(Di,y,g),Zt(ci,ci.current,g),!0}function oo(g,y,A){var F=g.stateNode;if(!F)throw Error(t(169));A?(y=xo(g,y,Ht),F.__reactInternalMemoizedMergedChildContext=y,fi(ci,g),fi(Di,g),Zt(Di,y,g)):fi(ci,g),Zt(ci,A,g)}var Vl=E.unstable_runWithPriority,Ao=E.unstable_scheduleCallback,Ms=E.unstable_cancelCallback,Xn=E.unstable_shouldYield,Qo=E.unstable_requestPaint,lo=E.unstable_now,b0=E.unstable_getCurrentPriorityLevel,yl=E.unstable_ImmediatePriority,Ro=E.unstable_UserBlockingPriority,Et=E.unstable_NormalPriority,Pt=E.unstable_LowPriority,Bn=E.unstable_IdlePriority,Ir={},ji=Qo!==void 0?Qo:function(){},Wr=null,wu=null,c0=!1,Ti=lo(),d0=1e4>Ti?lo:function(){return lo()-Ti};function as(){switch(b0()){case yl:return 99;case Ro:return 98;case Et:return 97;case Pt:return 96;case Bn:return 95;default:throw Error(t(332))}}function St(g){switch(g){case 99:return yl;case 98:return Ro;case 97:return Et;case 96:return Pt;case 95:return Bn;default:throw Error(t(332))}}function so(g,y){return g=St(g),Vl(g,y)}function Jo(g,y,A){return g=St(g),Ao(g,y,A)}function Gl(g){return Wr===null?(Wr=[g],wu=Ao(yl,fs)):Wr.push(g),Ir}function Fu(){if(wu!==null){var g=wu;wu=null,Ms(g)}fs()}function fs(){if(!c0&&Wr!==null){c0=!0;var g=0;try{var y=Wr;so(99,function(){for(;g=y&&(fo=!0),g.firstContext=null)}function Tu(g,y){if(Su!==g&&y!==!1&&y!==0)if((typeof y!="number"||y===1073741823)&&(Su=g,y=1073741823),y={context:g,observedBits:y,next:null},mi===null){if(or===null)throw Error(t(308));mi=y,or.dependencies={expirationTime:0,firstContext:y,responders:null}}else mi=mi.next=y;return un?g._currentValue:g._currentValue2}var ao=!1;function Iu(g){return{baseState:g,firstUpdate:null,lastUpdate:null,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null}}function Oa(g){return{baseState:g.baseState,firstUpdate:g.firstUpdate,lastUpdate:g.lastUpdate,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null}}function p0(g,y){return{expirationTime:g,suspenseConfig:y,tag:0,payload:null,callback:null,next:null,nextEffect:null}}function Zs(g,y){g.lastUpdate===null?g.firstUpdate=g.lastUpdate=y:(g.lastUpdate.next=y,g.lastUpdate=y)}function K0(g,y){var A=g.alternate;if(A===null){var F=g.updateQueue,I=null;F===null&&(F=g.updateQueue=Iu(g.memoizedState))}else F=g.updateQueue,I=A.updateQueue,F===null?I===null?(F=g.updateQueue=Iu(g.memoizedState),I=A.updateQueue=Iu(A.memoizedState)):F=g.updateQueue=Oa(I):I===null&&(I=A.updateQueue=Oa(F));I===null||F===I?Zs(F,y):F.lastUpdate===null||I.lastUpdate===null?(Zs(F,y),Zs(I,y)):(Zs(F,y),I.lastUpdate=y)}function $s(g,y){var A=g.updateQueue;A=A===null?g.updateQueue=Iu(g.memoizedState):ka(g,A),A.lastCapturedUpdate===null?A.firstCapturedUpdate=A.lastCapturedUpdate=y:(A.lastCapturedUpdate.next=y,A.lastCapturedUpdate=y)}function ka(g,y){var A=g.alternate;return A!==null&&y===A.updateQueue&&(y=g.updateQueue=Oa(y)),y}function cs(g,y,A,F,I,J){switch(A.tag){case 1:return g=A.payload,typeof g=="function"?g.call(J,F,I):g;case 3:g.effectTag=g.effectTag&-4097|64;case 0:if(g=A.payload,I=typeof g=="function"?g.call(J,F,I):g,I==null)break;return f({},F,I);case 2:ao=!0}return F}function w0(g,y,A,F,I){ao=!1,y=ka(g,y);for(var J=y.baseState,fe=null,mt=0,Ct=y.firstUpdate,Mt=J;Ct!==null;){var Er=Ct.expirationTime;Erii?(qi=tr,tr=null):qi=tr.sibling;var jr=iu(He,tr,ut[ii],Jt);if(jr===null){tr===null&&(tr=qi);break}g&&tr&&jr.alternate===null&&y(He,tr),Be=J(jr,Be,ii),ti===null?jn=jr:ti.sibling=jr,ti=jr,tr=qi}if(ii===ut.length)return A(He,tr),jn;if(tr===null){for(;iiii?(qi=tr,tr=null):qi=tr.sibling;var gu=iu(He,tr,jr.value,Jt);if(gu===null){tr===null&&(tr=qi);break}g&&tr&&gu.alternate===null&&y(He,tr),Be=J(gu,Be,ii),ti===null?jn=gu:ti.sibling=gu,ti=gu,tr=qi}if(jr.done)return A(He,tr),jn;if(tr===null){for(;!jr.done;ii++,jr=ut.next())jr=$u(He,jr.value,Jt),jr!==null&&(Be=J(jr,Be,ii),ti===null?jn=jr:ti.sibling=jr,ti=jr);return jn}for(tr=F(He,tr);!jr.done;ii++,jr=ut.next())jr=j0(tr,He,ii,jr.value,Jt),jr!==null&&(g&&jr.alternate!==null&&tr.delete(jr.key===null?ii:jr.key),Be=J(jr,Be,ii),ti===null?jn=jr:ti.sibling=jr,ti=jr);return g&&tr.forEach(function(Ba){return y(He,Ba)}),jn}return function(He,Be,ut,Jt){var jn=typeof ut=="object"&&ut!==null&&ut.type===U&&ut.key===null;jn&&(ut=ut.props.children);var ti=typeof ut=="object"&&ut!==null;if(ti)switch(ut.$$typeof){case N:e:{for(ti=ut.key,jn=Be;jn!==null;){if(jn.key===ti)if(jn.tag===7?ut.type===U:jn.elementType===ut.type){A(He,jn.sibling),Be=I(jn,ut.type===U?ut.props.children:ut.props,Jt),Be.ref=Fs(He,jn,ut),Be.return=He,He=Be;break e}else{A(He,jn);break}else y(He,jn);jn=jn.sibling}ut.type===U?(Be=Zu(ut.props.children,He.mode,Jt,ut.key),Be.return=He,He=Be):(Jt=Ia(ut.type,ut.key,ut.props,null,He.mode,Jt),Jt.ref=Fs(He,Be,ut),Jt.return=He,He=Jt)}return fe(He);case C:e:{for(jn=ut.key;Be!==null;){if(Be.key===jn)if(Be.tag===4&&Be.stateNode.containerInfo===ut.containerInfo&&Be.stateNode.implementation===ut.implementation){A(He,Be.sibling),Be=I(Be,ut.children||[],Jt),Be.return=He,He=Be;break e}else{A(He,Be);break}else y(He,Be);Be=Be.sibling}Be=vf(ut,He.mode,Jt),Be.return=He,He=Be}return fe(He)}if(typeof ut=="string"||typeof ut=="number")return ut=""+ut,Be!==null&&Be.tag===6?(A(He,Be.sibling),Be=I(Be,ut,Jt),Be.return=He,He=Be):(A(He,Be),Be=U0(ut,He.mode,Jt),Be.return=He,He=Be),fe(He);if(h0(ut))return Tl(He,Be,ut,Jt);if(le(ut))return e0(He,Be,ut,Jt);if(ti&&Ni(He,ut),typeof ut=="undefined"&&!jn)switch(He.tag){case 1:case 0:throw He=He.type,Error(t(152,He.displayName||He.name||"Component"))}return A(He,Be)}}var z=B(!0),G=B(!1),$={},De={current:$},me={current:$},xe={current:$};function Z(g){if(g===$)throw Error(t(174));return g}function ke(g,y){Zt(xe,y,g),Zt(me,g,g),Zt(De,$,g),y=jt(y),fi(De,g),Zt(De,y,g)}function Xe(g){fi(De,g),fi(me,g),fi(xe,g)}function ht(g){var y=Z(xe.current),A=Z(De.current);y=$t(A,g.type,y),A!==y&&(Zt(me,g,g),Zt(De,y,g))}function ie(g){me.current===g&&(fi(De,g),fi(me,g))}var qe={current:0};function tt(g){for(var y=g;y!==null;){if(y.tag===13){var A=y.memoizedState;if(A!==null&&(A=A.dehydrated,A===null||Hr(A)||To(A)))return y}else if(y.tag===19&&y.memoizedProps.revealOrder!==void 0){if((y.effectTag&64)!=0)return y}else if(y.child!==null){y.child.return=y,y=y.child;continue}if(y===g)break;for(;y.sibling===null;){if(y.return===null||y.return===g)return null;y=y.return}y.sibling.return=y.return,y=y.sibling}return null}function Tt(g,y){return{responder:g,props:y}}var kt=k.ReactCurrentDispatcher,bt=k.ReactCurrentBatchConfig,on=0,tn=null,Lt=null,gn=null,lr=null,Qn=null,_r=null,Cn=0,Ar=null,v0=0,Rr=!1,nt=null,_t=0;function Ze(){throw Error(t(321))}function Ft(g,y){if(y===null)return!1;for(var A=0;ACn&&(Cn=Er,La(Cn))):(oc(Er,Ct.suspenseConfig),J=Ct.eagerReducer===g?Ct.eagerState:g(J,Ct.action)),fe=Ct,Ct=Ct.next}while(Ct!==null&&Ct!==F);Mt||(mt=fe,I=J),Ne(J,y.memoizedState)||(fo=!0),y.memoizedState=J,y.baseUpdate=mt,y.baseState=I,A.lastRenderedState=J}return[y.memoizedState,A.dispatch]}function S0(g){var y=Yn();return typeof g=="function"&&(g=g()),y.memoizedState=y.baseState=g,g=y.queue={last:null,dispatch:null,lastRenderedReducer:nu,lastRenderedState:g},g=g.dispatch=bs.bind(null,tn,g),[y.memoizedState,g]}function X0(g){return Cu(nu,g)}function xu(g,y,A,F){return g={tag:g,create:y,destroy:A,deps:F,next:null},Ar===null?(Ar={lastEffect:null},Ar.lastEffect=g.next=g):(y=Ar.lastEffect,y===null?Ar.lastEffect=g.next=g:(A=y.next,y.next=g,g.next=A,Ar.lastEffect=g)),g}function di(g,y,A,F){var I=Yn();v0|=g,I.memoizedState=xu(y,A,void 0,F===void 0?null:F)}function ko(g,y,A,F){var I=yr();F=F===void 0?null:F;var J=void 0;if(Lt!==null){var fe=Lt.memoizedState;if(J=fe.destroy,F!==null&&Ft(F,fe.deps)){xu(0,A,J,F);return}}v0|=g,I.memoizedState=xu(y,A,J,F)}function Zo(g,y){return di(516,192,g,y)}function sf(g,y){return ko(516,192,g,y)}function gl(g,y){if(typeof y=="function")return g=g(),y(g),function(){y(null)};if(y!=null)return g=g(),y.current=g,function(){y.current=null}}function af(){}function Mo(g,y){return Yn().memoizedState=[g,y===void 0?null:y],g}function ds(g,y){var A=yr();y=y===void 0?null:y;var F=A.memoizedState;return F!==null&&y!==null&&Ft(y,F[1])?F[0]:(A.memoizedState=[g,y],g)}function bs(g,y,A){if(!(25>_t))throw Error(t(301));var F=g.alternate;if(g===tn||F!==null&&F===tn)if(Rr=!0,g={expirationTime:on,suspenseConfig:null,action:A,eagerReducer:null,eagerState:null,next:null},nt===null&&(nt=new Map),A=nt.get(y),A===void 0)nt.set(y,g);else{for(y=A;y.next!==null;)y=y.next;y.next=g}else{var I=g0(),J=ri.suspense;I=bn(I,g,J),J={expirationTime:I,suspenseConfig:J,action:A,eagerReducer:null,eagerState:null,next:null};var fe=y.last;if(fe===null)J.next=J;else{var mt=fe.next;mt!==null&&(J.next=mt),fe.next=J}if(y.last=J,g.expirationTime===0&&(F===null||F.expirationTime===0)&&(F=y.lastRenderedReducer,F!==null))try{var Ct=y.lastRenderedState,Mt=F(Ct,A);if(J.eagerReducer=F,J.eagerState=Mt,Ne(Mt,Ct))return}catch(Er){}finally{}Qu(g,I)}}var No={readContext:Tu,useCallback:Ze,useContext:Ze,useEffect:Ze,useImperativeHandle:Ze,useLayoutEffect:Ze,useMemo:Ze,useReducer:Ze,useRef:Ze,useState:Ze,useDebugValue:Ze,useResponder:Ze,useDeferredValue:Ze,useTransition:Ze},Lo={readContext:Tu,useCallback:Mo,useContext:Tu,useEffect:Zo,useImperativeHandle:function(g,y,A){return A=A!=null?A.concat([g]):null,di(4,36,gl.bind(null,y,g),A)},useLayoutEffect:function(g,y){return di(4,36,g,y)},useMemo:function(g,y){var A=Yn();return y=y===void 0?null:y,g=g(),A.memoizedState=[g,y],g},useReducer:function(g,y,A){var F=Yn();return y=A!==void 0?A(y):y,F.memoizedState=F.baseState=y,g=F.queue={last:null,dispatch:null,lastRenderedReducer:g,lastRenderedState:y},g=g.dispatch=bs.bind(null,tn,g),[F.memoizedState,g]},useRef:function(g){var y=Yn();return g={current:g},y.memoizedState=g},useState:S0,useDebugValue:af,useResponder:Tt,useDeferredValue:function(g,y){var A=S0(g),F=A[0],I=A[1];return Zo(function(){E.unstable_next(function(){var J=bt.suspense;bt.suspense=y===void 0?null:y;try{I(g)}finally{bt.suspense=J}})},[g,y]),F},useTransition:function(g){var y=S0(!1),A=y[0],F=y[1];return[Mo(function(I){F(!0),E.unstable_next(function(){var J=bt.suspense;bt.suspense=g===void 0?null:g;try{F(!1),I()}finally{bt.suspense=J}})},[g,A]),A]}},ps={readContext:Tu,useCallback:ds,useContext:Tu,useEffect:sf,useImperativeHandle:function(g,y,A){return A=A!=null?A.concat([g]):null,ko(4,36,gl.bind(null,y,g),A)},useLayoutEffect:function(g,y){return ko(4,36,g,y)},useMemo:function(g,y){var A=yr();y=y===void 0?null:y;var F=A.memoizedState;return F!==null&&y!==null&&Ft(y,F[1])?F[0]:(g=g(),A.memoizedState=[g,y],g)},useReducer:Cu,useRef:function(){return yr().memoizedState},useState:X0,useDebugValue:af,useResponder:Tt,useDeferredValue:function(g,y){var A=X0(g),F=A[0],I=A[1];return sf(function(){E.unstable_next(function(){var J=bt.suspense;bt.suspense=y===void 0?null:y;try{I(g)}finally{bt.suspense=J}})},[g,y]),F},useTransition:function(g){var y=X0(!1),A=y[0],F=y[1];return[ds(function(I){F(!0),E.unstable_next(function(){var J=bt.suspense;bt.suspense=g===void 0?null:g;try{F(!1),I()}finally{bt.suspense=J}})},[g,A]),A]}},Vu=null,yu=null,pi=!1;function T0(g,y){var A=Io(5,null,null,0);A.elementType="DELETED",A.type="DELETED",A.stateNode=y,A.return=g,A.effectTag=8,g.lastEffect!==null?(g.lastEffect.nextEffect=A,g.lastEffect=A):g.firstEffect=g.lastEffect=A}function Q0(g,y){switch(g.tag){case 5:return y=Lu(y,g.type,g.pendingProps),y!==null?(g.stateNode=y,!0):!1;case 6:return y=V0(y,g.pendingProps),y!==null?(g.stateNode=y,!0):!1;case 13:return!1;default:return!1}}function Fo(g){if(pi){var y=yu;if(y){var A=y;if(!Q0(g,y)){if(y=Co(A),!y||!Q0(g,y)){g.effectTag=g.effectTag&-1025|2,pi=!1,Vu=g;return}T0(Vu,A)}Vu=g,yu=L0(y)}else g.effectTag=g.effectTag&-1025|2,pi=!1,Vu=g}}function ta(g){for(g=g.return;g!==null&&g.tag!==5&&g.tag!==3&&g.tag!==13;)g=g.return;Vu=g}function Kl(g){if(!w||g!==Vu)return!1;if(!pi)return ta(g),pi=!0,!1;var y=g.type;if(g.tag!==5||y!=="head"&&y!=="body"&&!ct(y,g.memoizedProps))for(y=yu;y;)T0(g,y),y=Co(y);if(ta(g),g.tag===13){if(!w)throw Error(t(316));if(g=g.memoizedState,g=g!==null?g.dehydrated:null,!g)throw Error(t(317));yu=ks(g)}else yu=Vu?Co(g.stateNode):null;return!0}function Ki(){w&&(yu=Vu=null,pi=!1)}var Yr=k.ReactCurrentOwner,fo=!1;function Oi(g,y,A,F){y.child=g===null?G(y,null,A,F):z(y,g.child,A,F)}function gi(g,y,A,F,I){A=A.render;var J=y.ref;return Oo(y,I),F=nn(g,y,A,F,J,I),g!==null&&!fo?(y.updateQueue=g.updateQueue,y.effectTag&=-517,g.expirationTime<=I&&(g.expirationTime=0),fu(g,y,I)):(y.effectTag|=1,Oi(g,y,F,I),y.child)}function ff(g,y,A,F,I,J){if(g===null){var fe=A.type;return typeof fe=="function"&&!hf(fe)&&fe.defaultProps===void 0&&A.compare===null&&A.defaultProps===void 0?(y.tag=15,y.type=fe,cf(g,y,fe,F,I,J)):(g=Ia(A.type,null,F,null,y.mode,J),g.ref=y.ref,g.return=y,y.child=g)}return fe=g.child,Iy)&&Ur.set(g,y)))}}function eo(g,y){g.expirationTimeg?y:g)}function Ju(g){if(g.lastExpiredTime!==0)g.callbackExpirationTime=1073741823,g.callbackPriority=99,g.callbackNode=Gl(to.bind(null,g));else{var y=po(g),A=g.callbackNode;if(y===0)A!==null&&(g.callbackNode=null,g.callbackExpirationTime=0,g.callbackPriority=90);else{var F=g0();if(y===1073741823?F=99:y===1||y===2?F=95:(F=10*(1073741821-y)-10*(1073741821-F),F=0>=F?99:250>=F?98:5250>=F?97:95),A!==null){var I=g.callbackPriority;if(g.callbackExpirationTime===y&&I>=F)return;A!==Ir&&Ms(A)}g.callbackExpirationTime=y,g.callbackPriority=F,y=y===1073741823?Gl(to.bind(null,g)):Jo(F,bo.bind(null,g),{timeout:10*(1073741821-y)-d0()}),g.callbackNode=y}}}function bo(g,y){if(Qi=0,y)return y=g0(),oa(g,y),Ju(g),null;var A=po(g);if(A!==0){if(y=g.callbackNode,(kn&(Xi|ru))!==wr)throw Error(t(327));if(Bs(),g===se&&A===Le||ms(g,A),re!==null){var F=kn;kn|=Xi;var I=B0(g);do try{$1();break}catch(mt){ia(g,mt)}while(1);if(bu(),kn=F,Ku.current=I,Ae===Xr)throw y=ot,ms(g,A),Sl(g,A),Ju(g),y;if(re===null)switch(I=g.finishedWork=g.current.alternate,g.finishedExpirationTime=A,F=Ae,se=null,F){case Ci:case Xr:throw Error(t(345));case Wn:oa(g,2=A){g.lastPingedTime=A,ms(g,A);break}}if(J=po(g),J!==0&&J!==A)break;if(F!==0&&F!==A){g.lastPingedTime=F;break}g.timeoutHandle=ln(Dl.bind(null,g),I);break}Dl(g);break;case m0:if(Sl(g,A),F=g.lastSuspendedTime,A===F&&(g.nextKnownPendingLevel=Uc(I)),yn&&(I=g.lastPingedTime,I===0||I>=A)){g.lastPingedTime=A,ms(g,A);break}if(I=po(g),I!==0&&I!==A)break;if(F!==0&&F!==A){g.lastPingedTime=F;break}if(Xt!==1073741823?F=10*(1073741821-Xt)-d0():vt===1073741823?F=0:(F=10*(1073741821-vt)-5e3,I=d0(),A=10*(1073741821-A)-I,F=I-F,0>F&&(F=0),F=(120>F?120:480>F?480:1080>F?1080:1920>F?1920:3e3>F?3e3:4320>F?4320:1960*df(F/1960))-F,A=F?F=0:(I=fe.busyDelayMs|0,J=d0()-(10*(1073741821-J)-(fe.timeoutMs|0||5e3)),F=J<=I?0:I+F-J),10 component higher in the tree to provide a loading indicator or placeholder to display.`+Pr(I))}Ae!==y0&&(Ae=Wn),J=_l(J,I),Ct=F;do{switch(Ct.tag){case 3:fe=J,Ct.effectTag|=4096,Ct.expirationTime=y;var Be=hs(Ct,fe,y);$s(Ct,Be);break e;case 1:fe=J;var ut=Ct.type,Jt=Ct.stateNode;if((Ct.effectTag&64)==0&&(typeof ut.getDerivedStateFromError=="function"||Jt!==null&&typeof Jt.componentDidCatch=="function"&&(cr===null||!cr.has(Jt)))){Ct.effectTag|=4096,Ct.expirationTime=y;var jn=ra(Ct,fe,y);$s(Ct,jn);break e}}Ct=Ct.return}while(Ct!==null)}re=ho(re)}catch(ti){y=ti;continue}break}while(1)}function B0(){var g=Ku.current;return Ku.current=No,g===null?No:g}function oc(g,y){g_n&&(_n=g)}function gd(){for(;re!==null;)re=e2(re)}function $1(){for(;re!==null&&!Xn();)re=e2(re)}function e2(g){var y=Pa(g.alternate,g,Le);return g.memoizedProps=g.pendingProps,y===null&&(y=ho(g)),vs.current=null,y}function ho(g){re=g;do{var y=re.alternate;if(g=re.return,(re.effectTag&2048)==0){e:{var A=y;y=re;var F=Le,I=y.pendingProps;switch(y.tag){case 2:break;case 16:break;case 15:case 0:break;case 1:Yi(y.type)&&Y0(y);break;case 3:Xe(y),Ui(y),I=y.stateNode,I.pendingContext&&(I.context=I.pendingContext,I.pendingContext=null),(A===null||A.child===null)&&Kl(y)&&Gu(y),Vr(y);break;case 5:ie(y);var J=Z(xe.current);if(F=y.type,A!==null&&y.stateNode!=null)Bu(A,y,F,I,J),A.ref!==y.ref&&(y.effectTag|=128);else if(I){if(A=Z(De.current),Kl(y)){if(I=y,!w)throw Error(t(175));A=tu(I.stateNode,I.type,I.memoizedProps,J,A,I),I.updateQueue=A,A=A!==null,A&&Gu(y)}else{var fe=ae(F,I,J,A,y);Kr(fe,y,!1,!1),y.stateNode=fe,ue(fe,F,I,J,A)&&Gu(y)}y.ref!==null&&(y.effectTag|=128)}else if(y.stateNode===null)throw Error(t(166));break;case 6:if(A&&y.stateNode!=null)Sn(A,y,A.memoizedProps,I);else{if(typeof I!="string"&&y.stateNode===null)throw Error(t(166));if(A=Z(xe.current),J=Z(De.current),Kl(y)){if(A=y,!w)throw Error(t(176));(A=Si(A.stateNode,A.memoizedProps,A))&&Gu(y)}else y.stateNode=en(I,A,J,y)}break;case 11:break;case 13:if(fi(qe,y),I=y.memoizedState,(y.effectTag&64)!=0){y.expirationTime=F;break e}I=I!==null,J=!1,A===null?y.memoizedProps.fallback!==void 0&&Kl(y):(F=A.memoizedState,J=F!==null,I||F===null||(F=A.child.sibling,F!==null&&(fe=y.firstEffect,fe!==null?(y.firstEffect=F,F.nextEffect=fe):(y.firstEffect=y.lastEffect=F,F.nextEffect=null),F.effectTag=8))),I&&!J&&(y.mode&2)!=0&&(A===null&&y.memoizedProps.unstable_avoidThisFallback!==!0||(qe.current&1)!=0?Ae===Ci&&(Ae=Xu):((Ae===Ci||Ae===Xu)&&(Ae=m0),_n!==0&&se!==null&&(Sl(se,Le),_s(se,_n)))),vr&&I&&(y.effectTag|=4),Wt&&(I||J)&&(y.effectTag|=4);break;case 7:break;case 8:break;case 12:break;case 4:Xe(y),Vr(y);break;case 10:mu(y);break;case 9:break;case 14:break;case 17:Yi(y.type)&&Y0(y);break;case 19:if(fi(qe,y),I=y.memoizedState,I===null)break;if(J=(y.effectTag&64)!=0,fe=I.rendering,fe===null){if(J)Au(I,!1);else if(Ae!==Ci||A!==null&&(A.effectTag&64)!=0)for(A=y.child;A!==null;){if(fe=tt(A),fe!==null){for(y.effectTag|=64,Au(I,!1),A=fe.updateQueue,A!==null&&(y.updateQueue=A,y.effectTag|=4),I.lastEffect===null&&(y.firstEffect=null),y.lastEffect=I.lastEffect,A=F,I=y.child;I!==null;)J=I,F=A,J.effectTag&=2,J.nextEffect=null,J.firstEffect=null,J.lastEffect=null,fe=J.alternate,fe===null?(J.childExpirationTime=0,J.expirationTime=F,J.child=null,J.memoizedProps=null,J.memoizedState=null,J.updateQueue=null,J.dependencies=null):(J.childExpirationTime=fe.childExpirationTime,J.expirationTime=fe.expirationTime,J.child=fe.child,J.memoizedProps=fe.memoizedProps,J.memoizedState=fe.memoizedState,J.updateQueue=fe.updateQueue,F=fe.dependencies,J.dependencies=F===null?null:{expirationTime:F.expirationTime,firstContext:F.firstContext,responders:F.responders}),I=I.sibling;Zt(qe,qe.current&1|2,y),y=y.child;break e}A=A.sibling}}else{if(!J)if(A=tt(fe),A!==null){if(y.effectTag|=64,J=!0,A=A.updateQueue,A!==null&&(y.updateQueue=A,y.effectTag|=4),Au(I,!0),I.tail===null&&I.tailMode==="hidden"&&!fe.alternate){y=y.lastEffect=I.lastEffect,y!==null&&(y.nextEffect=null);break}}else d0()>I.tailExpiration&&1I&&(I=F),fe>I&&(I=fe),J=J.sibling;A.childExpirationTime=I}if(y!==null)return y;g!==null&&(g.effectTag&2048)==0&&(g.firstEffect===null&&(g.firstEffect=re.firstEffect),re.lastEffect!==null&&(g.lastEffect!==null&&(g.lastEffect.nextEffect=re.firstEffect),g.lastEffect=re.lastEffect),1g?y:g}function Dl(g){var y=as();return so(99,el.bind(null,g,y)),null}function el(g,y){do Bs();while(Qr!==null);if((kn&(Xi|ru))!==wr)throw Error(t(327));var A=g.finishedWork,F=g.finishedExpirationTime;if(A===null)return null;if(g.finishedWork=null,g.finishedExpirationTime=0,A===g.current)throw Error(t(177));g.callbackNode=null,g.callbackExpirationTime=0,g.callbackPriority=90,g.nextKnownPendingLevel=0;var I=Uc(A);if(g.firstPendingTime=I,F<=g.lastSuspendedTime?g.firstSuspendedTime=g.lastSuspendedTime=g.nextKnownPendingLevel=0:F<=g.firstSuspendedTime&&(g.firstSuspendedTime=F-1),F<=g.lastPingedTime&&(g.lastPingedTime=0),F<=g.lastExpiredTime&&(g.lastExpiredTime=0),g===se&&(re=se=null,Le=0),1=A?Yt(g,y,A):(Zt(qe,qe.current&1,y),y=fu(g,y,A),y!==null?y.sibling:null);Zt(qe,qe.current&1,y);break;case 19:if(F=y.childExpirationTime>=A,(g.effectTag&64)!=0){if(F)return wn(g,y,A);y.effectTag|=64}if(I=y.memoizedState,I!==null&&(I.rendering=null,I.tail=null),Zt(qe,qe.current,y),!F)return null}return fu(g,y,A)}fo=!1}}else fo=!1;switch(y.expirationTime=0,y.tag){case 2:if(F=y.type,g!==null&&(g.alternate=null,y.alternate=null,y.effectTag|=2),g=y.pendingProps,I=Du(y,Di.current),Oo(y,A),I=nn(null,y,F,g,I,A),y.effectTag|=1,typeof I=="object"&&I!==null&&typeof I.render=="function"&&I.$$typeof===void 0){if(y.tag=1,sn(),Yi(F)){var J=!0;ni(y)}else J=!1;y.memoizedState=I.state!==null&&I.state!==void 0?I.state:null;var fe=F.getDerivedStateFromProps;typeof fe=="function"&&Yl(y,F,fe,g),I.updater=ea,y.stateNode=I,I._reactInternalFiber=y,Ls(y,F,g,A),y=et(null,y,F,!0,J,A)}else y.tag=0,Oi(null,y,I,A),y=y.child;return y;case 16:if(I=y.elementType,g!==null&&(g.alternate=null,y.alternate=null,y.effectTag|=2),g=y.pendingProps,Ue(I),I._status!==1)throw I._result;switch(I=I._result,y.type=I,J=y.tag=tl(I),g=Hn(I,g),J){case 0:y=Z0(null,y,I,g,A);break;case 1:y=Te(null,y,I,g,A);break;case 11:y=gi(null,y,I,g,A);break;case 14:y=ff(null,y,I,Hn(I.type,g),F,A);break;default:throw Error(t(306,I,""))}return y;case 0:return F=y.type,I=y.pendingProps,I=y.elementType===F?I:Hn(F,I),Z0(g,y,F,I,A);case 1:return F=y.type,I=y.pendingProps,I=y.elementType===F?I:Hn(F,I),Te(g,y,F,I,A);case 3:if(Ve(y),F=y.updateQueue,F===null)throw Error(t(282));if(I=y.memoizedState,I=I!==null?I.element:null,w0(y,F,y.pendingProps,null,A),F=y.memoizedState.element,F===I)Ki(),y=fu(g,y,A);else{if((I=y.stateNode.hydrate)&&(w?(yu=L0(y.stateNode.containerInfo),Vu=y,I=pi=!0):I=!1),I)for(A=G(y,null,F,A),y.child=A;A;)A.effectTag=A.effectTag&-3|1024,A=A.sibling;else Oi(g,y,F,A),Ki();y=y.child}return y;case 5:return ht(y),g===null&&Fo(y),F=y.type,I=y.pendingProps,J=g!==null?g.memoizedProps:null,fe=I.children,ct(F,I)?fe=null:J!==null&&ct(F,J)&&(y.effectTag|=16),J0(g,y),y.mode&4&&A!==1&&At(F,I)?(y.expirationTime=y.childExpirationTime=1,y=null):(Oi(g,y,fe,A),y=y.child),y;case 6:return g===null&&Fo(y),null;case 13:return Yt(g,y,A);case 4:return ke(y,y.stateNode.containerInfo),F=y.pendingProps,g===null?y.child=z(y,null,F,A):Oi(g,y,F,A),y.child;case 11:return F=y.type,I=y.pendingProps,I=y.elementType===F?I:Hn(F,I),gi(g,y,F,I,A);case 7:return Oi(g,y,y.pendingProps,A),y.child;case 8:return Oi(g,y,y.pendingProps.children,A),y.child;case 12:return Oi(g,y,y.pendingProps.children,A),y.child;case 10:e:{if(F=y.type._context,I=y.pendingProps,fe=y.memoizedProps,J=I.value,Pu(y,J),fe!==null){var mt=fe.value;if(J=Ne(mt,J)?0:(typeof F._calculateChangedBits=="function"?F._calculateChangedBits(mt,J):1073741823)|0,J===0){if(fe.children===I.children&&!ci.current){y=fu(g,y,A);break e}}else for(mt=y.child,mt!==null&&(mt.return=y);mt!==null;){var Ct=mt.dependencies;if(Ct!==null){fe=mt.child;for(var Mt=Ct.firstContext;Mt!==null;){if(Mt.context===F&&(Mt.observedBits&J)!=0){mt.tag===1&&(Mt=p0(A,null),Mt.tag=2,K0(mt,Mt)),mt.expirationTime=y&&g<=y}function Sl(g,y){var A=g.firstSuspendedTime,F=g.lastSuspendedTime;Ay||A===0)&&(g.lastSuspendedTime=y),y<=g.lastPingedTime&&(g.lastPingedTime=0),y<=g.lastExpiredTime&&(g.lastExpiredTime=0)}function _s(g,y){y>g.firstPendingTime&&(g.firstPendingTime=y);var A=g.firstSuspendedTime;A!==0&&(y>=A?g.firstSuspendedTime=g.lastSuspendedTime=g.nextKnownPendingLevel=0:y>=g.lastSuspendedTime&&(g.lastSuspendedTime=y+1),y>g.nextKnownPendingLevel&&(g.nextKnownPendingLevel=y))}function oa(g,y){var A=g.lastExpiredTime;(A===0||A>y)&&(g.lastExpiredTime=y)}function n2(g){var y=g._reactInternalFiber;if(y===void 0)throw typeof g.render=="function"?Error(t(188)):Error(t(268,Object.keys(g)));return g=$e(y),g===null?null:g.stateNode}function la(g,y){g=g.memoizedState,g!==null&&g.dehydrated!==null&&g.retryTime{"use strict";Object.defineProperty(Qf,"__esModule",{value:!0});var JK=0;Qf.__interactionsRef=null;Qf.__subscriberRef=null;Qf.unstable_clear=function(i){return i()};Qf.unstable_getCurrent=function(){return null};Qf.unstable_getThreadID=function(){return++JK};Qf.unstable_trace=function(i,o,f){return f()};Qf.unstable_wrap=function(i){return i};Qf.unstable_subscribe=function(){};Qf.unstable_unsubscribe=function(){}});var g9=ce(au=>{"use strict";process.env.NODE_ENV!=="production"&&function(){"use strict";Object.defineProperty(au,"__esModule",{value:!0});var i=!0,o=0,f=0,p=0;au.__interactionsRef=null,au.__subscriberRef=null,i&&(au.__interactionsRef={current:new Set},au.__subscriberRef={current:null});function E(ge){if(!i)return ge();var ze=au.__interactionsRef.current;au.__interactionsRef.current=new Set;try{return ge()}finally{au.__interactionsRef.current=ze}}function t(){return i?au.__interactionsRef.current:null}function k(){return++p}function L(ge,ze,pe){var Oe=arguments.length>3&&arguments[3]!==void 0?arguments[3]:o;if(!i)return pe();var le={__count:1,id:f++,name:ge,timestamp:ze},Ue=au.__interactionsRef.current,Ge=new Set(Ue);Ge.add(le),au.__interactionsRef.current=Ge;var rt=au.__subscriberRef.current,wt;try{rt!==null&&rt.onInteractionTraced(le)}finally{try{rt!==null&&rt.onWorkStarted(Ge,Oe)}finally{try{wt=pe()}finally{au.__interactionsRef.current=Ue;try{rt!==null&&rt.onWorkStopped(Ge,Oe)}finally{le.__count--,rt!==null&&le.__count===0&&rt.onInteractionScheduledWorkCompleted(le)}}}}return wt}function N(ge){var ze=arguments.length>1&&arguments[1]!==void 0?arguments[1]:o;if(!i)return ge;var pe=au.__interactionsRef.current,Oe=au.__subscriberRef.current;Oe!==null&&Oe.onWorkScheduled(pe,ze),pe.forEach(function(Ge){Ge.__count++});var le=!1;function Ue(){var Ge=au.__interactionsRef.current;au.__interactionsRef.current=pe,Oe=au.__subscriberRef.current;try{var rt;try{Oe!==null&&Oe.onWorkStarted(pe,ze)}finally{try{rt=ge.apply(void 0,arguments)}finally{au.__interactionsRef.current=Ge,Oe!==null&&Oe.onWorkStopped(pe,ze)}}return rt}finally{le||(le=!0,pe.forEach(function(wt){wt.__count--,Oe!==null&&wt.__count===0&&Oe.onInteractionScheduledWorkCompleted(wt)}))}}return Ue.cancel=function(){Oe=au.__subscriberRef.current;try{Oe!==null&&Oe.onWorkCanceled(pe,ze)}finally{pe.forEach(function(rt){rt.__count--,Oe&&rt.__count===0&&Oe.onInteractionScheduledWorkCompleted(rt)})}},Ue}var C=null;i&&(C=new Set);function U(ge){i&&(C.add(ge),C.size===1&&(au.__subscriberRef.current={onInteractionScheduledWorkCompleted:ne,onInteractionTraced:W,onWorkCanceled:he,onWorkScheduled:m,onWorkStarted:we,onWorkStopped:Se}))}function q(ge){i&&(C.delete(ge),C.size===0&&(au.__subscriberRef.current=null))}function W(ge){var ze=!1,pe=null;if(C.forEach(function(Oe){try{Oe.onInteractionTraced(ge)}catch(le){ze||(ze=!0,pe=le)}}),ze)throw pe}function ne(ge){var ze=!1,pe=null;if(C.forEach(function(Oe){try{Oe.onInteractionScheduledWorkCompleted(ge)}catch(le){ze||(ze=!0,pe=le)}}),ze)throw pe}function m(ge,ze){var pe=!1,Oe=null;if(C.forEach(function(le){try{le.onWorkScheduled(ge,ze)}catch(Ue){pe||(pe=!0,Oe=Ue)}}),pe)throw Oe}function we(ge,ze){var pe=!1,Oe=null;if(C.forEach(function(le){try{le.onWorkStarted(ge,ze)}catch(Ue){pe||(pe=!0,Oe=Ue)}}),pe)throw Oe}function Se(ge,ze){var pe=!1,Oe=null;if(C.forEach(function(le){try{le.onWorkStopped(ge,ze)}catch(Ue){pe||(pe=!0,Oe=Ue)}}),pe)throw Oe}function he(ge,ze){var pe=!1,Oe=null;if(C.forEach(function(le){try{le.onWorkCanceled(ge,ze)}catch(Ue){pe||(pe=!0,Oe=Ue)}}),pe)throw Oe}au.unstable_clear=E,au.unstable_getCurrent=t,au.unstable_getThreadID=k,au.unstable_trace=L,au.unstable_wrap=N,au.unstable_subscribe=U,au.unstable_unsubscribe=q}()});var _9=ce((Une,fw)=>{"use strict";process.env.NODE_ENV==="production"?fw.exports=y9():fw.exports=g9()});var E9=ce((jne,hg)=>{"use strict";process.env.NODE_ENV!=="production"&&(hg.exports=function i(o){"use strict";var f=eg(),p=su(),E=HD(),t=h4(),k=_9(),L=0,N=1,C=2,U=3,q=4,W=5,ne=6,m=7,we=8,Se=9,he=10,ge=11,ze=12,pe=13,Oe=14,le=15,Ue=16,Ge=17,rt=18,wt=19,xt=20,$e=21,ft=function(){};ft=function(a,c){for(var _=arguments.length,T=new Array(_>2?_-2:0),R=2;R<_;R++)T[R-2]=arguments[R];if(c===void 0)throw new Error("`warningWithoutStack(condition, format, ...args)` requires a warning message argument");if(T.length>8)throw new Error("warningWithoutStack() currently supports at most 8 arguments.");if(!a){if(typeof console!="undefined"){var j=T.map(function(oe){return""+oe});j.unshift("Warning: "+c),Function.prototype.apply.call(console.error,console,j)}try{var V=0,te="Warning: "+c.replace(/%s/g,function(){return T[V++]});throw new Error(te)}catch(oe){}}};var Ke=ft;function jt(a){return a._reactInternalFiber}function $t(a,c){a._reactInternalFiber=c}var at=p.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;at.hasOwnProperty("ReactCurrentDispatcher")||(at.ReactCurrentDispatcher={current:null}),at.hasOwnProperty("ReactCurrentBatchConfig")||(at.ReactCurrentBatchConfig={suspense:null});var Q=typeof Symbol=="function"&&Symbol.for,ae=Q?Symbol.for("react.element"):60103,Ce=Q?Symbol.for("react.portal"):60106,ue=Q?Symbol.for("react.fragment"):60107,je=Q?Symbol.for("react.strict_mode"):60108,ct=Q?Symbol.for("react.profiler"):60114,At=Q?Symbol.for("react.provider"):60109,en=Q?Symbol.for("react.context"):60110,ln=Q?Symbol.for("react.concurrent_mode"):60111,An=Q?Symbol.for("react.forward_ref"):60112,nr=Q?Symbol.for("react.suspense"):60113,un=Q?Symbol.for("react.suspense_list"):60120,Wt=Q?Symbol.for("react.memo"):60115,vr=Q?Symbol.for("react.lazy"):60116,w=Q?Symbol.for("react.fundamental"):60117,Ut=Q?Symbol.for("react.responder"):60118,Vn=Q?Symbol.for("react.scope"):60119,fr=typeof Symbol=="function"&&Symbol.iterator,Fr="@@iterator";function ur(a){if(a===null||typeof a!="object")return null;var c=fr&&a[fr]||a[Fr];return typeof c=="function"?c:null}var br=Ke;br=function(a,c){if(!a){for(var _=at.ReactDebugCurrentFrame,T=_.getStackAddendum(),R=arguments.length,j=new Array(R>2?R-2:0),V=2;V import('./MyComponent'))`,T),a._status=So,a._result=R}},function(T){a._status===a0&&(a._status=Go,a._result=T)})}}function Ko(a,c,_){var T=c.displayName||c.name||"";return a.displayName||(T!==""?_+"("+T+")":_)}function qt(a){if(a==null)return null;if(typeof a.tag=="number"&&Ke(!1,"Received an unexpected object in getComponentName(). This is likely a bug in React. Please file an issue."),typeof a=="function")return a.displayName||a.name||null;if(typeof a=="string")return a;switch(a){case ue:return"Fragment";case Ce:return"Portal";case ct:return"Profiler";case je:return"StrictMode";case nr:return"Suspense";case un:return"SuspenseList"}if(typeof a=="object")switch(a.$$typeof){case en:return"Context.Consumer";case At:return"Context.Provider";case An:return Ko(a,a.render,"ForwardRef");case Wt:return qt(a.type);case vr:{var c=a,_=Os(c);if(_)return qt(_);break}}return null}var _i=0,eu=1,ai=2,mr=4,Xo=6,W0=8,Lu=16,V0=32,Hr=64,To=128,Co=256,L0=512,tu=1024,Si=1028,ks=932,Hl=2047,F0=2048,f0=4096,Pr=!0,Ei=!0,G0=!0,fi=!0,Zt=!0,Ln=!0,Di=!1,ci=!1,Ht=!1,Du=!1,Yi=!1,Y0=!0,Ui=!1,Wl=!1,xo=!1,ni=!1,oo=!1,Vl=at.ReactCurrentOwner;function Ao(a){var c=a,_=a;if(a.alternate)for(;c.return;)c=c.return;else{var T=c;do c=T,(c.effectTag&(ai|tu))!==_i&&(_=c.return),T=c.return;while(T)}return c.tag===U?_:null}function Ms(a){return Ao(a)===a}function Xn(a){{var c=Vl.current;if(c!==null&&c.tag===N){var _=c,T=_.stateNode;T._warnedAboutRefsInRender||Ke(!1,"%s is accessing isMounted inside its render() function. render() should be a pure function of props and state. It should never access something that requires stale data from the previous render, such as refs. Move this logic to componentDidMount and componentDidUpdate instead.",qt(_.type)||"A component"),T._warnedAboutRefsInRender=!0}}var R=jt(a);return R?Ao(R)===R:!1}function Qo(a){if(Ao(a)!==a)throw Error("Unable to find node on an unmounted component.")}function lo(a){var c=a.alternate;if(!c){var _=Ao(a);if(_===null)throw Error("Unable to find node on an unmounted component.");return _!==a?null:a}for(var T=a,R=c;;){var j=T.return;if(j===null)break;var V=j.alternate;if(V===null){var te=j.return;if(te!==null){T=R=te;continue}break}if(j.child===V.child){for(var oe=j.child;oe;){if(oe===T)return Qo(j),a;if(oe===R)return Qo(j),c;oe=oe.sibling}throw Error("Unable to find node on an unmounted component.")}if(T.return!==R.return)T=j,R=V;else{for(var Ie=!1,Ye=j.child;Ye;){if(Ye===T){Ie=!0,T=j,R=V;break}if(Ye===R){Ie=!0,R=j,T=V;break}Ye=Ye.sibling}if(!Ie){for(Ye=V.child;Ye;){if(Ye===T){Ie=!0,T=V,R=j;break}if(Ye===R){Ie=!0,R=V,T=j;break}Ye=Ye.sibling}if(!Ie)throw Error("Child was not found in either parent set. This indicates a bug in React related to the return pointer. Please file an issue.")}}if(T.alternate!==R)throw Error("Return fibers should always be each others' alternates. This error is likely caused by a bug in React. Please file an issue.")}if(T.tag!==U)throw Error("Unable to find node on an unmounted component.");return T.stateNode.current===T?a:c}function b0(a){var c=lo(a);if(!c)return null;for(var _=c;;){if(_.tag===W||_.tag===ne)return _;if(_.child){_.child.return=_,_=_.child;continue}if(_===c)return null;for(;!_.sibling;){if(!_.return||_.return===c)return null;_=_.return}_.sibling.return=_.return,_=_.sibling}return null}function yl(a){var c=lo(a);if(!c)return null;for(var _=c;;){if(_.tag===W||_.tag===ne||Ht&&_.tag===xt)return _;if(_.child&&_.tag!==q){_.child.return=_,_=_.child;continue}if(_===c)return null;for(;!_.sibling;){if(!_.return||_.return===c)return null;_=_.return}_.sibling.return=_.return,_=_.sibling}return null}var Ro=o.getPublicInstance,Et=o.getRootHostContext,Pt=o.getChildHostContext,Bn=o.prepareForCommit,Ir=o.resetAfterCommit,ji=o.createInstance,Wr=o.appendInitialChild,wu=o.finalizeInitialChildren,c0=o.prepareUpdate,Ti=o.shouldSetTextContent,d0=o.shouldDeprioritizeSubtree,as=o.createTextInstance,St=o.setTimeout,so=o.clearTimeout,Jo=o.noTimeout,Gl=o.now,Fu=o.isPrimaryRenderer,fs=o.warnsIfNotActing,P0=o.supportsMutation,X=o.supportsPersistence,_e=o.supportsHydration,Ne=o.mountResponderInstance,Me=o.unmountResponderInstance,dt=o.getFundamentalComponentInstance,Hn=o.mountFundamentalComponent,Dn=o.shouldUpdateFundamentalComponent,or=o.getInstanceFromNode,mi=o.appendChild,Su=o.appendChildToContainer,bu=o.commitTextUpdate,Pu=o.commitMount,mu=o.commitUpdate,yi=o.insertBefore,Oo=o.insertInContainerBefore,Tu=o.removeChild,ao=o.removeChildFromContainer,Iu=o.resetTextContent,Oa=o.hideInstance,p0=o.hideTextInstance,Zs=o.unhideInstance,K0=o.unhideTextInstance,$s=o.updateFundamentalComponent,ka=o.unmountFundamentalComponent,cs=o.cloneInstance,w0=o.createContainerChildSet,Gn=o.appendChildToContainerChildSet,ic=o.finalizeContainerChildren,ri=o.replaceContainerChildren,Gr=o.cloneHiddenInstance,Yl=o.cloneHiddenTextInstance,ea=o.cloneInstance,lf=o.canHydrateInstance,Ns=o.canHydrateTextInstance,Ma=o.canHydrateSuspenseInstance,Ls=o.isSuspenseInstancePending,h0=o.isSuspenseInstanceFallback,Fs=o.registerSuspenseInstanceRetry,Ni=o.getNextHydratableSibling,B=o.getFirstHydratableChild,z=o.hydrateInstance,G=o.hydrateTextInstance,$=o.hydrateSuspenseInstance,De=o.getNextHydratableInstanceAfterSuspenseInstance,me=o.commitHydratedContainer,xe=o.commitHydratedSuspenseInstance,Z=o.clearSuspenseBoundary,ke=o.clearSuspenseBoundaryFromContainer,Xe=o.didNotMatchHydratedContainerTextInstance,ht=o.didNotMatchHydratedTextInstance,ie=o.didNotHydrateContainerInstance,qe=o.didNotHydrateInstance,tt=o.didNotFindHydratableContainerInstance,Tt=o.didNotFindHydratableContainerTextInstance,kt=o.didNotFindHydratableContainerSuspenseInstance,bt=o.didNotFindHydratableInstance,on=o.didNotFindHydratableTextInstance,tn=o.didNotFindHydratableSuspenseInstance,Lt=/^(.*)[\\\/]/,gn=function(a,c,_){var T="";if(c){var R=c.fileName,j=R.replace(Lt,"");if(/^index\./.test(j)){var V=R.match(Lt);if(V){var te=V[1];if(te){var oe=te.replace(Lt,"");j=oe+"/"+j}}}T=" (at "+j+":"+c.lineNumber+")"}else _&&(T=" (created by "+_+")");return` + in `+(a||"Unknown")+T},lr=at.ReactDebugCurrentFrame;function Qn(a){switch(a.tag){case U:case q:case ne:case m:case he:case Se:return"";default:var c=a._debugOwner,_=a._debugSource,T=qt(a.type),R=null;return c&&(R=qt(c.type)),gn(T,_,R)}}function _r(a){var c="",_=a;do c+=Qn(_),_=_.return;while(_);return c}var Cn=null,Ar=null;function v0(){{if(Cn===null)return null;var a=Cn._debugOwner;if(a!==null&&typeof a!="undefined")return qt(a.type)}return null}function Rr(){return Cn===null?"":_r(Cn)}function nt(){lr.getCurrentStack=null,Cn=null,Ar=null}function _t(a){lr.getCurrentStack=Rr,Cn=a,Ar=null}function Ze(a){Ar=a}var Ft="\u269B",nn="\u26D4",sn=typeof performance!="undefined"&&typeof performance.mark=="function"&&typeof performance.clearMarks=="function"&&typeof performance.measure=="function"&&typeof performance.clearMeasures=="function",Yn=null,yr=null,nu=null,Cu=!1,S0=!1,X0=!1,xu=0,di=0,ko=new Set,Zo=function(a){return Ft+" "+a},sf=function(a,c){var _=c?nn+" ":Ft+" ",T=c?" Warning: "+c:"";return""+_+a+T},gl=function(a){performance.mark(Zo(a))},af=function(a){performance.clearMarks(Zo(a))},Mo=function(a,c,_){var T=Zo(c),R=sf(a,_);try{performance.measure(R,T)}catch(j){}performance.clearMarks(T),performance.clearMeasures(R)},ds=function(a,c){return a+" (#"+c+")"},bs=function(a,c,_){return _===null?a+" ["+(c?"update":"mount")+"]":a+"."+_},No=function(a,c){var _=qt(a.type)||"Unknown",T=a._debugID,R=a.alternate!==null,j=bs(_,R,c);if(Cu&&ko.has(j))return!1;ko.add(j);var V=ds(j,T);return gl(V),!0},Lo=function(a,c){var _=qt(a.type)||"Unknown",T=a._debugID,R=a.alternate!==null,j=bs(_,R,c),V=ds(j,T);af(V)},ps=function(a,c,_){var T=qt(a.type)||"Unknown",R=a._debugID,j=a.alternate!==null,V=bs(T,j,c),te=ds(V,R);Mo(V,te,_)},Vu=function(a){switch(a.tag){case U:case W:case ne:case q:case m:case he:case Se:case we:return!0;default:return!1}},yu=function(){yr!==null&&nu!==null&&Lo(nu,yr),nu=null,yr=null,X0=!1},pi=function(){for(var a=Yn;a;)a._debugIsCurrentlyTiming&&ps(a,null,null),a=a.return},T0=function(a){a.return!==null&&T0(a.return),a._debugIsCurrentlyTiming&&No(a,null)},Q0=function(){Yn!==null&&T0(Yn)};function Fo(){Pr&&di++}function ta(){Pr&&(Cu&&(S0=!0),yr!==null&&yr!=="componentWillMount"&&yr!=="componentWillReceiveProps"&&(X0=!0))}function Kl(a){if(Pr){if(!sn||Vu(a)||(Yn=a,!No(a,null)))return;a._debugIsCurrentlyTiming=!0}}function Ki(a){if(Pr){if(!sn||Vu(a))return;a._debugIsCurrentlyTiming=!1,Lo(a,null)}}function Yr(a){if(Pr){if(!sn||Vu(a)||(Yn=a.return,!a._debugIsCurrentlyTiming))return;a._debugIsCurrentlyTiming=!1,ps(a,null,null)}}function fo(a){if(Pr){if(!sn||Vu(a)||(Yn=a.return,!a._debugIsCurrentlyTiming))return;a._debugIsCurrentlyTiming=!1;var c=a.tag===pe?"Rendering was suspended":"An error was thrown inside this error boundary";ps(a,null,c)}}function Oi(a,c){if(Pr){if(!sn||(yu(),!No(a,c)))return;nu=a,yr=c}}function gi(){if(Pr){if(!sn)return;if(yr!==null&&nu!==null){var a=X0?"Scheduled a cascading update":null;ps(nu,yr,a)}yr=null,nu=null}}function ff(a){if(Pr){if(Yn=a,!sn)return;xu=0,gl("(React Tree Reconciliation)"),Q0()}}function cf(a,c){if(Pr){if(!sn)return;var _=null;if(a!==null)if(a.tag===U)_="A top-level update interrupted the previous render";else{var T=qt(a.type)||"Unknown";_="An update to "+T+" interrupted the previous render"}else xu>1&&(_="There were cascading updates");xu=0;var R=c?"(React Tree Reconciliation: Completed Root)":"(React Tree Reconciliation: Yielded)";pi(),Mo(R,"(React Tree Reconciliation)",_)}}function J0(){if(Pr){if(!sn)return;Cu=!0,S0=!1,ko.clear(),gl("(Committing Changes)")}}function Z0(){if(Pr){if(!sn)return;var a=null;S0?a="Lifecycle hook scheduled a cascading update":xu>0&&(a="Caused by a cascading update in earlier commit"),S0=!1,xu++,Cu=!1,ko.clear(),Mo("(Committing Changes)","(Committing Changes)",a)}}function Te(){if(Pr){if(!sn)return;di=0,gl("(Committing Snapshot Effects)")}}function et(){if(Pr){if(!sn)return;var a=di;di=0,Mo("(Committing Snapshot Effects: "+a+" Total)","(Committing Snapshot Effects)",null)}}function Ve(){if(Pr){if(!sn)return;di=0,gl("(Committing Host Effects)")}}function Gt(){if(Pr){if(!sn)return;var a=di;di=0,Mo("(Committing Host Effects: "+a+" Total)","(Committing Host Effects)",null)}}function Yt(){if(Pr){if(!sn)return;di=0,gl("(Calling Lifecycle Methods)")}}function sr(){if(Pr){if(!sn)return;var a=di;di=0,Mo("(Calling Lifecycle Methods: "+a+" Total)","(Calling Lifecycle Methods)",null)}}var Br=[],wn;wn=[];var fu=-1;function Gu(a){return{current:a}}function Kr(a,c){if(fu<0){Ke(!1,"Unexpected pop.");return}c!==wn[fu]&&Ke(!1,"Unexpected Fiber popped."),a.current=Br[fu],Br[fu]=null,wn[fu]=null,fu--}function Vr(a,c,_){fu++,Br[fu]=a.current,wn[fu]=_,a.current=c}var Bu;Bu={};var Sn={};Object.freeze(Sn);var C0=Gu(Sn),Au=Gu(!1),ei=Sn;function _l(a,c,_){return ni?Sn:_&&zi(c)?ei:C0.current}function Ps(a,c,_){if(!ni){var T=a.stateNode;T.__reactInternalMemoizedUnmaskedChildContext=c,T.__reactInternalMemoizedMaskedChildContext=_}}function Uu(a,c){if(ni)return Sn;var _=a.type,T=_.contextTypes;if(!T)return Sn;var R=a.stateNode;if(R&&R.__reactInternalMemoizedUnmaskedChildContext===c)return R.__reactInternalMemoizedMaskedChildContext;var j={};for(var V in T)j[V]=c[V];{var te=qt(_)||"Unknown";E(T,j,"context",te,Rr)}return R&&Ps(a,c,j),j}function na(){return ni?!1:Au.current}function zi(a){if(ni)return!1;var c=a.childContextTypes;return c!=null}function Is(a){ni||(Kr(Au,a),Kr(C0,a))}function x0(a){ni||(Kr(Au,a),Kr(C0,a))}function Li(a,c,_){if(!ni){if(C0.current!==Sn)throw Error("Unexpected context found on stack. This error is likely caused by a bug in React. Please file an issue.");Vr(C0,c,a),Vr(Au,_,a)}}function A0(a,c,_){if(ni)return _;var T=a.stateNode,R=c.childContextTypes;if(typeof T.getChildContext!="function"){{var j=qt(c)||"Unknown";Bu[j]||(Bu[j]=!0,Ke(!1,"%s.childContextTypes is specified but there is no getChildContext() method on the instance. You can either define getChildContext() on %s or remove childContextTypes from it.",j,j))}return _}var V;Ze("getChildContext"),Oi(a,"getChildContext"),V=T.getChildContext(),gi(),Ze(null);for(var te in V)if(!(te in R))throw Error((qt(c)||"Unknown")+'.getChildContext(): key "'+te+'" is not defined in childContextTypes.');{var oe=qt(c)||"Unknown";E(R,V,"child context",oe,Rr)}return f({},_,{},V)}function Fi(a){if(ni)return!1;var c=a.stateNode,_=c&&c.__reactInternalMemoizedMergedChildContext||Sn;return ei=C0.current,Vr(C0,_,a),Vr(Au,Au.current,a),!0}function $o(a,c,_){if(!ni){var T=a.stateNode;if(!T)throw Error("Expected to have an instance by this point. This error is likely caused by a bug in React. Please file an issue.");if(_){var R=A0(a,c,ei);T.__reactInternalMemoizedMergedChildContext=R,Kr(Au,a),Kr(C0,a),Vr(C0,R,a),Vr(Au,_,a)}else Kr(Au,a),Vr(Au,_,a)}}function El(a){if(ni)return Sn;if(!(Ms(a)&&a.tag===N))throw Error("Expected subtree parent to be a mounted class component. This error is likely caused by a bug in React. Please file an issue.");var c=a;do{switch(c.tag){case U:return c.stateNode.context;case N:{var _=c.type;if(zi(_))return c.stateNode.__reactInternalMemoizedMergedChildContext;break}}c=c.return}while(c!==null);throw Error("Found unexpected detached subtree parent. This error is likely caused by a bug in React. Please file an issue.")}var I0=1,R0=2,co=t.unstable_runWithPriority,Ru=t.unstable_scheduleCallback,Yu=t.unstable_cancelCallback,Xl=t.unstable_shouldYield,hs=t.unstable_requestPaint,ra=t.unstable_now,df=t.unstable_getCurrentPriorityLevel,Ku=t.unstable_ImmediatePriority,vs=t.unstable_UserBlockingPriority,wr=t.unstable_NormalPriority,$0=t.unstable_LowPriority,Xi=t.unstable_IdlePriority;if(Ln&&!(k.__interactionsRef!=null&&k.__interactionsRef.current!=null))throw Error("It is not supported to run the profiling version of a renderer (for example, `react-dom/profiling`) without also replacing the `scheduler/tracing` module with `scheduler/tracing-profiling`. Your bundler might have a setting for aliasing both modules. Learn more at http://fb.me/react-profiling");var ru={},Ci=99,Xr=98,Wn=97,Xu=96,m0=95,y0=90,kn=Xl,se=hs!==void 0?hs:function(){},re=null,Le=null,Ae=!1,ot=ra(),vt=ot<1e4?ra:function(){return ra()-ot};function Xt(){switch(df()){case Ku:return Ci;case vs:return Xr;case wr:return Wn;case $0:return Xu;case Xi:return m0;default:throw Error("Unknown priority level.")}}function xn(a){switch(a){case Ci:return Ku;case Xr:return vs;case Wn:return wr;case Xu:return $0;case m0:return Xi;default:throw Error("Unknown priority level.")}}function _n(a,c){var _=xn(a);return co(_,c)}function yn(a,c,_){var T=xn(a);return Ru(T,c,_)}function En(a){return re===null?(re=[a],Le=Ru(Ku,xi)):re.push(a),ru}function er(a){a!==ru&&Yu(a)}function It(){if(Le!==null){var a=Le;Le=null,Yu(a)}xi()}function xi(){if(!Ae&&re!==null){Ae=!0;var a=0;try{var c=!0,_=re;_n(Ci,function(){for(;a<_.length;a++){var T=_[a];do T=T(c);while(T!==null)}}),re=null}catch(T){throw re!==null&&(re=re.slice(a+1)),Ru(Ku,It),T}finally{Ae=!1}}}var Sr=0,cr=1,Y=2,Qr=4,Jr=8,Ur=1073741823,lt=0,hi=1,Qi=2,g0=3,bn=Ur,Qu=bn-1,eo=10,po=Qu-1;function Ju(a){return po-(a/eo|0)}function bo(a){return(po-a)*eo}function to(a,c){return((a/c|0)+1)*c}function Na(a,c,_){return po-to(po-a+c/eo,_/eo)}var pf=5e3,uc=250;function ms(a){return Na(a,pf,uc)}function ia(a,c){return Na(a,c,uc)}var B0=500,oc=100;function La(a){return Na(a,B0,oc)}function gd(a){return g0++}function $1(a,c){if(c===bn)return Ci;if(c===hi||c===Qi)return m0;var _=bo(c)-bo(a);return _<=0?Ci:_<=B0+oc?Xr:_<=pf+uc?Wn:m0}function e2(a,c){return a===c&&(a!==0||1/a==1/c)||a!==a&&c!==c}var ho=typeof Object.is=="function"?Object.is:e2,Uc=Object.prototype.hasOwnProperty;function Dl(a,c){if(ho(a,c))return!0;if(typeof a!="object"||a===null||typeof c!="object"||c===null)return!1;var _=Object.keys(a),T=Object.keys(c);if(_.length!==T.length)return!1;for(var R=0;R<_.length;R++)if(!Uc.call(c,_[R])||!ho(a[_[R]],c[_[R]]))return!1;return!0}var el=function(){};{var _d=function(a){for(var c=arguments.length,_=new Array(c>1?c-1:0),T=1;T2?_-2:0),R=2;R<_;R++)T[R-2]=arguments[R];_d.apply(void 0,[c].concat(T))}}}var Bs=el,wl={recordUnsafeLifecycleWarnings:function(a,c){},flushPendingUnsafeLifecycleWarnings:function(){},recordLegacyContextWarning:function(a,c){},flushLegacyContextWarning:function(){},discardPendingWarnings:function(){}};{var t2=function(a){for(var c=null,_=a;_!==null;)_.mode&cr&&(c=_),_=_.return;return c},Po=function(a){var c=[];return a.forEach(function(_){c.push(_)}),c.sort().join(", ")},Fa=[],ba=[],Pa=[],ua=[],ys=[],gs=[],Ql=new Set;wl.recordUnsafeLifecycleWarnings=function(a,c){Ql.has(a.type)||(typeof c.componentWillMount=="function"&&c.componentWillMount.__suppressDeprecationWarning!==!0&&Fa.push(a),a.mode&cr&&typeof c.UNSAFE_componentWillMount=="function"&&ba.push(a),typeof c.componentWillReceiveProps=="function"&&c.componentWillReceiveProps.__suppressDeprecationWarning!==!0&&Pa.push(a),a.mode&cr&&typeof c.UNSAFE_componentWillReceiveProps=="function"&&ua.push(a),typeof c.componentWillUpdate=="function"&&c.componentWillUpdate.__suppressDeprecationWarning!==!0&&ys.push(a),a.mode&cr&&typeof c.UNSAFE_componentWillUpdate=="function"&&gs.push(a))},wl.flushPendingUnsafeLifecycleWarnings=function(){var a=new Set;Fa.length>0&&(Fa.forEach(function(Nt){a.add(qt(Nt.type)||"Component"),Ql.add(Nt.type)}),Fa=[]);var c=new Set;ba.length>0&&(ba.forEach(function(Nt){c.add(qt(Nt.type)||"Component"),Ql.add(Nt.type)}),ba=[]);var _=new Set;Pa.length>0&&(Pa.forEach(function(Nt){_.add(qt(Nt.type)||"Component"),Ql.add(Nt.type)}),Pa=[]);var T=new Set;ua.length>0&&(ua.forEach(function(Nt){T.add(qt(Nt.type)||"Component"),Ql.add(Nt.type)}),ua=[]);var R=new Set;ys.length>0&&(ys.forEach(function(Nt){R.add(qt(Nt.type)||"Component"),Ql.add(Nt.type)}),ys=[]);var j=new Set;if(gs.length>0&&(gs.forEach(function(Nt){j.add(qt(Nt.type)||"Component"),Ql.add(Nt.type)}),gs=[]),c.size>0){var V=Po(c);Ke(!1,`Using UNSAFE_componentWillMount in strict mode is not recommended and may indicate bugs in your code. See https://fb.me/react-unsafe-component-lifecycles for details. + +* Move code with side effects to componentDidMount, and set initial state in the constructor. + +Please update the following components: %s`,V)}if(T.size>0){var te=Po(T);Ke(!1,`Using UNSAFE_componentWillReceiveProps in strict mode is not recommended and may indicate bugs in your code. See https://fb.me/react-unsafe-component-lifecycles for details. + +* Move data fetching code or side effects to componentDidUpdate. +* If you're updating state whenever props change, refactor your code to use memoization techniques or move it to static getDerivedStateFromProps. Learn more at: https://fb.me/react-derived-state + +Please update the following components: %s`,te)}if(j.size>0){var oe=Po(j);Ke(!1,`Using UNSAFE_componentWillUpdate in strict mode is not recommended and may indicate bugs in your code. See https://fb.me/react-unsafe-component-lifecycles for details. + +* Move data fetching code or side effects to componentDidUpdate. + +Please update the following components: %s`,oe)}if(a.size>0){var Ie=Po(a);Bs(!1,`componentWillMount has been renamed, and is not recommended for use. See https://fb.me/react-unsafe-component-lifecycles for details. + +* Move code with side effects to componentDidMount, and set initial state in the constructor. +* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run \`npx react-codemod rename-unsafe-lifecycles\` in your project source folder. + +Please update the following components: %s`,Ie)}if(_.size>0){var Ye=Po(_);Bs(!1,`componentWillReceiveProps has been renamed, and is not recommended for use. See https://fb.me/react-unsafe-component-lifecycles for details. + +* Move data fetching code or side effects to componentDidUpdate. +* If you're updating state whenever props change, refactor your code to use memoization techniques or move it to static getDerivedStateFromProps. Learn more at: https://fb.me/react-derived-state +* Rename componentWillReceiveProps to UNSAFE_componentWillReceiveProps to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run \`npx react-codemod rename-unsafe-lifecycles\` in your project source folder. + +Please update the following components: %s`,Ye)}if(R.size>0){var pt=Po(R);Bs(!1,`componentWillUpdate has been renamed, and is not recommended for use. See https://fb.me/react-unsafe-component-lifecycles for details. + +* Move data fetching code or side effects to componentDidUpdate. +* Rename componentWillUpdate to UNSAFE_componentWillUpdate to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run \`npx react-codemod rename-unsafe-lifecycles\` in your project source folder. + +Please update the following components: %s`,pt)}};var Io=new Map,hf=new Set;wl.recordLegacyContextWarning=function(a,c){var _=t2(a);if(_===null){Ke(!1,"Expected to find a StrictMode component in a strict mode tree. This error is likely caused by a bug in React. Please file an issue.");return}if(!hf.has(a.type)){var T=Io.get(_);(a.type.contextTypes!=null||a.type.childContextTypes!=null||c!==null&&typeof c.getChildContext=="function")&&(T===void 0&&(T=[],Io.set(_,T)),T.push(a))}},wl.flushLegacyContextWarning=function(){Io.forEach(function(a,c){var _=new Set;a.forEach(function(j){_.add(qt(j.type)||"Component"),hf.add(j.type)});var T=Po(_),R=_r(c);Ke(!1,`Legacy context API has been detected within a strict-mode tree. + +The old API will be supported in all 16.x releases, but applications using it should migrate to the new version. + +Please update the following components: %s + +Learn more about this warning here: https://fb.me/react-legacy-context%s`,T,R)})},wl.discardPendingWarnings=function(){Fa=[],ba=[],Pa=[],ua=[],ys=[],gs=[],Io=new Map}}var tl=null,ju=null,Ia=function(a){tl=a};function Zu(a){{if(tl===null)return a;var c=tl(a);return c===void 0?a:c.current}}function U0(a){return Zu(a)}function vf(a){{if(tl===null)return a;var c=tl(a);if(c===void 0){if(a!=null&&typeof a.render=="function"){var _=Zu(a.render);if(a.render!==_){var T={$$typeof:An,render:_};return a.displayName!==void 0&&(T.displayName=a.displayName),T}}return a}return c.current}}function jc(a,c){{if(tl===null)return!1;var _=a.elementType,T=c.type,R=!1,j=typeof T=="object"&&T!==null?T.$$typeof:null;switch(a.tag){case N:{typeof T=="function"&&(R=!0);break}case L:{(typeof T=="function"||j===vr)&&(R=!0);break}case ge:{(j===An||j===vr)&&(R=!0);break}case Oe:case le:{(j===Wt||j===vr)&&(R=!0);break}default:return!1}if(R){var V=tl(_);if(V!==void 0&&V===tl(T))return!0}return!1}}function lc(a){{if(tl===null||typeof WeakSet!="function")return;ju===null&&(ju=new WeakSet),ju.add(a)}}var Sl=function(a,c){{if(tl===null)return;var _=c.staleFamilies,T=c.updatedFamilies;Xa(),xp(function(){oa(a.current,T,_)})}},_s=function(a,c){{if(a.context!==Sn)return;Xa(),fv(function(){l_(c,a,null,null)})}};function oa(a,c,_){{var T=a.alternate,R=a.child,j=a.sibling,V=a.tag,te=a.type,oe=null;switch(V){case L:case le:case N:oe=te;break;case ge:oe=te.render;break;default:break}if(tl===null)throw new Error("Expected resolveFamily to be set during hot reload.");var Ie=!1,Ye=!1;if(oe!==null){var pt=tl(oe);pt!==void 0&&(_.has(pt)?Ye=!0:c.has(pt)&&(V===N?Ye=!0:Ie=!0))}ju!==null&&(ju.has(a)||T!==null&&ju.has(T))&&(Ye=!0),Ye&&(a._debugNeedsRemount=!0),(Ye||Ie)&&dl(a,bn),R!==null&&!Ye&&oa(R,c,_),j!==null&&oa(j,c,_)}}var n2=function(a,c){{var _=new Set,T=new Set(c.map(function(R){return R.current}));return la(a.current,T,_),_}};function la(a,c,_){{var T=a.child,R=a.sibling,j=a.tag,V=a.type,te=null;switch(j){case L:case le:case N:te=V;break;case ge:te=V.render;break;default:break}var oe=!1;te!==null&&c.has(te)&&(oe=!0),oe?sc(a,_):T!==null&&la(T,c,_),R!==null&&la(R,c,_)}}function sc(a,c){{var _=zc(a,c);if(_)return;for(var T=a;;){switch(T.tag){case W:c.add(T.stateNode);return;case q:c.add(T.stateNode.containerInfo);return;case U:c.add(T.stateNode.containerInfo);return}if(T.return===null)throw new Error("Expected to reach root first.");T=T.return}}}function zc(a,c){for(var _=a,T=!1;;){if(_.tag===W)T=!0,c.add(_.stateNode);else if(_.child!==null){_.child.return=_,_=_.child;continue}if(_===a)return T;for(;_.sibling===null;){if(_.return===null||_.return===a)return T;_=_.return}_.sibling.return=_.return,_=_.sibling}return!1}function bi(a,c){if(a&&a.defaultProps){var _=f({},c),T=a.defaultProps;for(var R in T)_[R]===void 0&&(_[R]=T[R]);return _}return c}function g(a){if(Yo(a),a._status!==So)throw a._result;return a._result}var y=Gu(null),A;A={};var F=null,I=null,J=null,fe=!1;function mt(){F=null,I=null,J=null,fe=!1}function Ct(){fe=!0}function Mt(){fe=!1}function Er(a,c){var _=a.type._context;Fu?(Vr(y,_._currentValue,a),_._currentValue=c,_._currentRenderer===void 0||_._currentRenderer===null||_._currentRenderer===A||Ke(!1,"Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported."),_._currentRenderer=A):(Vr(y,_._currentValue2,a),_._currentValue2=c,_._currentRenderer2===void 0||_._currentRenderer2===null||_._currentRenderer2===A||Ke(!1,"Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported."),_._currentRenderer2=A)}function $u(a){var c=y.current;Kr(y,a);var _=a.type._context;Fu?_._currentValue=c:_._currentValue2=c}function iu(a,c,_){if(ho(_,c))return 0;var T=typeof a._calculateChangedBits=="function"?a._calculateChangedBits(_,c):Ur;return(T&Ur)!==T&&Kt(!1,"calculateChangedBits: Expected the return value to be a 31-bit integer. Instead received: %s",T),T|0}function j0(a,c){for(var _=a;_!==null;){var T=_.alternate;if(_.childExpirationTime=c&&up(),_.firstContext=null)}}function He(a,c){if(fe&&Kt(!1,"Context can only be read while React is rendering. In classes, you can read it in the render method or getDerivedStateFromProps. In function components, you can read it directly in the function body, but not inside Hooks like useReducer() or useMemo()."),J!==a){if(!(c===!1||c===0)){var _;typeof c!="number"||c===Ur?(J=a,_=Ur):_=c;var T={context:a,observedBits:_,next:null};if(I===null){if(F===null)throw Error("Context can only be read while React is rendering. In classes, you can read it in the render method or getDerivedStateFromProps. In function components, you can read it directly in the function body, but not inside Hooks like useReducer() or useMemo().");I=T,F.dependencies={expirationTime:lt,firstContext:T,responders:null}}else I=I.next=T}}return Fu?a._currentValue:a._currentValue2}var Be=0,ut=1,Jt=2,jn=3,ti=!1,tr,ii;tr=!1,ii=null;function qi(a){var c={baseState:a,firstUpdate:null,lastUpdate:null,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null};return c}function jr(a){var c={baseState:a.baseState,firstUpdate:a.firstUpdate,lastUpdate:a.lastUpdate,firstCapturedUpdate:null,lastCapturedUpdate:null,firstEffect:null,lastEffect:null,firstCapturedEffect:null,lastCapturedEffect:null};return c}function gu(a,c){var _={expirationTime:a,suspenseConfig:c,tag:Be,payload:null,callback:null,next:null,nextEffect:null};return _.priority=Xt(),_}function Ba(a,c){a.lastUpdate===null?a.firstUpdate=a.lastUpdate=c:(a.lastUpdate.next=c,a.lastUpdate=c)}function Ua(a,c){var _=a.alternate,T,R;_===null?(T=a.updateQueue,R=null,T===null&&(T=a.updateQueue=qi(a.memoizedState))):(T=a.updateQueue,R=_.updateQueue,T===null?R===null?(T=a.updateQueue=qi(a.memoizedState),R=_.updateQueue=qi(_.memoizedState)):T=a.updateQueue=jr(R):R===null&&(R=_.updateQueue=jr(T))),R===null||T===R?Ba(T,c):T.lastUpdate===null||R.lastUpdate===null?(Ba(T,c),Ba(R,c)):(Ba(T,c),R.lastUpdate=c),a.tag===N&&(ii===T||R!==null&&ii===R)&&!tr&&(Ke(!1,"An update (setState, replaceState, or forceUpdate) was scheduled from inside an update function. Update functions should be pure, with zero side-effects. Consider using componentDidUpdate or a callback."),tr=!0)}function r2(a,c){var _=a.updateQueue;_===null?_=a.updateQueue=qi(a.memoizedState):_=Ed(a,_),_.lastCapturedUpdate===null?_.firstCapturedUpdate=_.lastCapturedUpdate=c:(_.lastCapturedUpdate.next=c,_.lastCapturedUpdate=c)}function Ed(a,c){var _=a.alternate;return _!==null&&c===_.updateQueue&&(c=a.updateQueue=jr(c)),c}function Dd(a,c,_,T,R,j){switch(_.tag){case ut:{var V=_.payload;if(typeof V=="function"){Ct(),Ei&&a.mode&cr&&V.call(j,T,R);var te=V.call(j,T,R);return Mt(),te}return V}case jn:a.effectTag=a.effectTag&~f0|Hr;case Be:{var oe=_.payload,Ie;return typeof oe=="function"?(Ct(),Ei&&a.mode&cr&&oe.call(j,T,R),Ie=oe.call(j,T,R),Mt()):Ie=oe,Ie==null?T:f({},T,Ie)}case Jt:return ti=!0,T}return T}function mf(a,c,_,T,R){ti=!1,c=Ed(a,c),ii=c;for(var j=c.baseState,V=null,te=lt,oe=c.firstUpdate,Ie=j;oe!==null;){var Ye=oe.expirationTime;if(Ye from render. Or maybe you meant to call this function rather than return it."))}function yh(a){function c(it,Ot){if(!!a){var Je=it.lastEffect;Je!==null?(Je.nextEffect=Ot,it.lastEffect=Ot):it.firstEffect=it.lastEffect=Ot,Ot.nextEffect=null,Ot.effectTag=W0}}function _(it,Ot){if(!a)return null;for(var Je=Ot;Je!==null;)c(it,Je),Je=Je.sibling;return null}function T(it,Ot){for(var Je=new Map,Bt=Ot;Bt!==null;)Bt.key!==null?Je.set(Bt.key,Bt):Je.set(Bt.index,Bt),Bt=Bt.sibling;return Je}function R(it,Ot,Je){var Bt=wo(it,Ot,Je);return Bt.index=0,Bt.sibling=null,Bt}function j(it,Ot,Je){if(it.index=Je,!a)return Ot;var Bt=it.alternate;if(Bt!==null){var Mn=Bt.index;return Mnqr?(_u=ar,ar=null):_u=ar.sibling;var _0=Nt(it,ar,Je[qr],Bt);if(_0===null){ar===null&&(ar=_u);break}a&&ar&&_0.alternate===null&&c(it,ar),ou=j(_0,ou,qr),qu===null?oi=_0:qu.sibling=_0,qu=_0,ar=_u}if(qr===Je.length)return _(it,ar),oi;if(ar===null){for(;qrH0?(Cs=_u,_u=null):Cs=_u.sibling;var pl=Nt(it,_u,Hu.value,Bt);if(pl===null){_u===null&&(_u=Cs);break}a&&_u&&pl.alternate===null&&c(it,_u),_0=j(pl,_0,H0),qr===null?ou=pl:qr.sibling=pl,qr=pl,_u=Cs}if(Hu.done)return _(it,_u),ou;if(_u===null){for(;!Hu.done;H0++,Hu=ar.next()){var Ja=pt(it,Hu.value,Bt);Ja!==null&&(_0=j(Ja,_0,H0),qr===null?ou=Ja:qr.sibling=Ja,qr=Ja)}return ou}for(var jo=T(it,_u);!Hu.done;H0++,Hu=ar.next()){var xs=Vt(jo,it,H0,Hu.value,Bt);xs!==null&&(a&&xs.alternate!==null&&jo.delete(xs.key===null?H0:xs.key),_0=j(xs,_0,H0),qr===null?ou=xs:qr.sibling=xs,qr=xs)}return a&&jo.forEach(function(X2){return c(it,X2)}),ou}function $r(it,Ot,Je,Bt){if(Ot!==null&&Ot.tag===ne){_(it,Ot.sibling);var Mn=R(Ot,Je,Bt);return Mn.return=it,Mn}_(it,Ot);var pn=Cy(Je,it.mode,Bt);return pn.return=it,pn}function wi(it,Ot,Je,Bt){for(var Mn=Je.key,pn=Ot;pn!==null;){if(pn.key===Mn)if(pn.tag===m?Je.type===ue:pn.elementType===Je.type||jc(pn,Je)){_(it,pn.sibling);var Pi=R(pn,Je.type===ue?Je.props.children:Je.props,Bt);return Pi.ref=fc(it,pn,Je),Pi.return=it,Pi._debugSource=Je._source,Pi._debugOwner=Je._owner,Pi}else{_(it,pn);break}else c(it,pn);pn=pn.sibling}if(Je.type===ue){var oi=Qa(Je.props.children,it.mode,Bt,Je.key);return oi.return=it,oi}else{var qu=Ty(Je,it.mode,Bt);return qu.ref=fc(it,Ot,Je),qu.return=it,qu}}function N0(it,Ot,Je,Bt){for(var Mn=Je.key,pn=Ot;pn!==null;){if(pn.key===Mn)if(pn.tag===q&&pn.stateNode.containerInfo===Je.containerInfo&&pn.stateNode.implementation===Je.implementation){_(it,pn.sibling);var Pi=R(pn,Je.children||[],Bt);return Pi.return=it,Pi}else{_(it,pn);break}else c(it,pn);pn=pn.sibling}var oi=xy(Je,it.mode,Bt);return oi.return=it,oi}function Vi(it,Ot,Je,Bt){var Mn=typeof Je=="object"&&Je!==null&&Je.type===ue&&Je.key===null;Mn&&(Je=Je.props.children);var pn=typeof Je=="object"&&Je!==null;if(pn)switch(Je.$$typeof){case ae:return V(wi(it,Ot,Je,Bt));case Ce:return V(N0(it,Ot,Je,Bt))}if(typeof Je=="string"||typeof Je=="number")return V($r(it,Ot,""+Je,Bt));if(Kc(Je))return vn(it,Ot,Je,Bt);if(ur(Je))return xr(it,Ot,Je,Bt);if(pn&&cc(it,Je),typeof Je=="function"&&f2(),typeof Je=="undefined"&&!Mn)switch(it.tag){case N:{var Pi=it.stateNode;if(Pi.render._isMockFunction)break}case L:{var oi=it.type;throw Error((oi.displayName||oi.name||"Component")+"(...): Nothing was returned from render. This usually means a return statement is missing. Or, to render nothing, return null.")}}return _(it,Ot)}return Vi}var gf=yh(!0),Xc=yh(!1);function gh(a,c){if(!(a===null||c.child===a.child))throw Error("Resuming work not yet implemented.");if(c.child!==null){var _=c.child,T=wo(_,_.pendingProps,_.expirationTime);for(c.child=T,T.return=c;_.sibling!==null;)_=_.sibling,T=T.sibling=wo(_,_.pendingProps,_.expirationTime),T.return=c;T.sibling=null}}function vm(a,c){for(var _=a.child;_!==null;)Rv(_,c),_=_.sibling}var js={},fa=Gu(js),Ji=Gu(js),O0=Gu(js);function t0(a){if(a===js)throw Error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue.");return a}function Jl(){var a=t0(O0.current);return a}function za(a,c){Vr(O0,c,a),Vr(Ji,a,a),Vr(fa,js,a);var _=Et(c);Kr(fa,a),Vr(fa,_,a)}function no(a){Kr(fa,a),Kr(Ji,a),Kr(O0,a)}function ul(){var a=t0(fa.current);return a}function dc(a){var c=t0(O0.current),_=t0(fa.current),T=Pt(_,a.type,c);_!==T&&(Vr(Ji,a,a),Vr(fa,T,a))}function Od(a){Ji.current===a&&(Kr(fa,a),Kr(Ji,a))}var _h=0,_f=1,Ef=1,Qc=2,xl=Gu(_h);function Jc(a,c){return(a&c)!=0}function ca(a){return a&_f}function c2(a,c){return a&_f|c}function d2(a,c){return a|c}function Or(a,c){Vr(xl,c,a)}function da(a){Kr(xl,a)}function kd(a,c){var _=a.memoizedState;if(_!==null)return _.dehydrated!==null;var T=a.memoizedProps;return T.fallback===void 0?!1:T.unstable_avoidThisFallback!==!0?!0:!c}function Zc(a){for(var c=a;c!==null;){if(c.tag===pe){var _=c.memoizedState;if(_!==null){var T=_.dehydrated;if(T===null||Ls(T)||h0(T))return c}}else if(c.tag===wt&&c.memoizedProps.revealOrder!==void 0){var R=(c.effectTag&Hr)!==_i;if(R)return c}else if(c.child!==null){c.child.return=c,c=c.child;continue}if(c===a)return null;for(;c.sibling===null;){if(c.return===null||c.return===a)return null;c=c.return}c.sibling.return=c.return,c=c.sibling}return null}var p2={},vi=Array.isArray;function Md(a,c,_,T){return{fiber:T,props:c,responder:a,rootEventTypes:null,state:_}}function mm(a,c,_,T,R){var j=p2,V=a.getInitialState;V!==null&&(j=V(c));var te=Md(a,c,j,_);if(!R)for(var oe=_;oe!==null;){var Ie=oe.tag;if(Ie===W){R=oe.stateNode;break}else if(Ie===U){R=oe.stateNode.containerInfo;break}oe=oe.return}Ne(a,te,c,j,R),T.set(a,te)}function h2(a,c,_,T,R){var j,V;if(a&&(j=a.responder,V=a.props),!(j&&j.$$typeof===Ut))throw Error("An invalid value was used as an event listener. Expect one or many event listeners created via React.unstable_useResponder().");var te=V;if(_.has(j)){Kt(!1,'Duplicate event responder "%s" found in event listeners. Event listeners passed to elements cannot use the same event responder more than once.',j.displayName);return}_.add(j);var oe=T.get(j);oe===void 0?mm(j,te,c,T,R):(oe.props=te,oe.fiber=c)}function dn(a,c,_){var T=new Set,R=c.dependencies;if(a!=null){R===null&&(R=c.dependencies={expirationTime:lt,firstContext:null,responders:new Map});var j=R.responders;if(j===null&&(j=new Map),vi(a))for(var V=0,te=a.length;V0){var j=R.dispatch;if(Es!==null){var V=Es.get(R);if(V!==void 0){Es.delete(R);var te=T.memoizedState,oe=V;do{var Ie=oe.action;te=a(te,Ie),oe=oe.next}while(oe!==null);return ho(te,T.memoizedState)||up(),T.memoizedState=te,T.baseUpdate===R.last&&(T.baseState=te),R.lastRenderedState=te,[te,j]}}return[T.memoizedState,j]}var Ye=R.last,pt=T.baseUpdate,Nt=T.baseState,Vt;if(pt!==null?(Ye!==null&&(Ye.next=null),Vt=pt.next):Vt=Ye!==null?Ye.next:null,Vt!==null){var zt=Nt,vn=null,xr=null,$r=pt,wi=Vt,N0=!1;do{var Vi=wi.expirationTime;if(ViOu&&(Ou=Vi,G2(Ou));else if(vv(Vi,wi.suspenseConfig),wi.eagerReducer===a)zt=wi.eagerState;else{var it=wi.action;zt=a(zt,it)}$r=wi,wi=wi.next}while(wi!==null&&wi!==Vt);N0||(xr=$r,vn=zt),ho(zt,T.memoizedState)||up(),T.memoizedState=zt,T.baseUpdate=xr,T.baseState=vn,R.lastRenderedState=zt}var Ot=R.dispatch;return[T.memoizedState,Ot]}function Rf(a){var c=mc();typeof a=="function"&&(a=a()),c.memoizedState=c.baseState=a;var _=c.queue={last:null,dispatch:null,lastRenderedReducer:Nd,lastRenderedState:a},T=_.dispatch=u1.bind(null,ll,_);return[c.memoizedState,T]}function n1(a){return t1(Nd,a)}function Wa(a,c,_,T){var R={tag:a,create:c,destroy:_,deps:T,next:null};if(Zl===null)Zl=Ha(),Zl.lastEffect=R.next=R;else{var j=Zl.lastEffect;if(j===null)Zl.lastEffect=R.next=R;else{var V=j.next;j.next=R,R.next=V,Zl.lastEffect=R}}return R}function r1(a){var c=mc(),_={current:a};return Object.seal(_),c.memoizedState=_,_}function Ld(a){var c=e1();return c.memoizedState}function g2(a,c,_,T){var R=mc(),j=T===void 0?null:T;Tf|=a,R.memoizedState=Wa(c,_,void 0,j)}function yc(a,c,_,T){var R=e1(),j=T===void 0?null:T,V=void 0;if(Pn!==null){var te=Pn.memoizedState;if(V=te.destroy,j!==null){var oe=te.deps;if(xf(j,oe)){Wa(wf,_,V,j);return}}}Tf|=a,R.memoizedState=Wa(c,_,V,j)}function i1(a,c){return typeof jest!="undefined"&&Av(ll),g2(mr|L0,rr|$c,a,c)}function Rl(a,c){return typeof jest!="undefined"&&Av(ll),yc(mr|L0,rr|$c,a,c)}function pa(a,c){return g2(mr,Sf|ol,a,c)}function wh(a,c){return yc(mr,Sf|ol,a,c)}function Fd(a,c){if(typeof c=="function"){var _=c,T=a();return _(T),function(){_(null)}}else if(c!=null){var R=c;R.hasOwnProperty("current")||Kt(!1,"Expected useImperativeHandle() first argument to either be a ref callback or React.createRef() object. Instead received: %s.","an object with keys {"+Object.keys(R).join(", ")+"}");var j=a();return R.current=j,function(){R.current=null}}}function bd(a,c,_){typeof c!="function"&&Kt(!1,"Expected useImperativeHandle() second argument to be a function that creates a handle. Instead received: %s.",c!==null?typeof c:"null");var T=_!=null?_.concat([a]):null;return g2(mr,Sf|ol,Fd.bind(null,c,a),T)}function Sh(a,c,_){typeof c!="function"&&Kt(!1,"Expected useImperativeHandle() second argument to be a function that creates a handle. Instead received: %s.",c!==null?typeof c:"null");var T=_!=null?_.concat([a]):null;return yc(mr,Sf|ol,Fd.bind(null,c,a),T)}function _2(a,c){}var Th=_2;function Ol(a,c){var _=mc(),T=c===void 0?null:c;return _.memoizedState=[a,T],a}function es(a,c){var _=e1(),T=c===void 0?null:c,R=_.memoizedState;if(R!==null&&T!==null){var j=R[1];if(xf(T,j))return R[0]}return _.memoizedState=[a,T],a}function Ds(a,c){var _=mc(),T=c===void 0?null:c,R=a();return _.memoizedState=[R,T],R}function zs(a,c){var _=e1(),T=c===void 0?null:c,R=_.memoizedState;if(R!==null&&T!==null){var j=R[1];if(xf(T,j))return R[0]}var V=a();return _.memoizedState=[V,T],V}function Pd(a,c){var _=Rf(a),T=_[0],R=_[1];return i1(function(){t.unstable_next(function(){var j=Bo.suspense;Bo.suspense=c===void 0?null:c;try{R(a)}finally{Bo.suspense=j}})},[a,c]),T}function Ch(a,c){var _=n1(a),T=_[0],R=_[1];return Rl(function(){t.unstable_next(function(){var j=Bo.suspense;Bo.suspense=c===void 0?null:c;try{R(a)}finally{Bo.suspense=j}})},[a,c]),T}function Id(a){var c=Rf(!1),_=c[0],T=c[1],R=Ol(function(j){T(!0),t.unstable_next(function(){var V=Bo.suspense;Bo.suspense=a===void 0?null:a;try{T(!1),j()}finally{Bo.suspense=V}})},[a,_]);return[R,_]}function Bd(a){var c=n1(!1),_=c[0],T=c[1],R=es(function(j){T(!0),t.unstable_next(function(){var V=Bo.suspense;Bo.suspense=a===void 0?null:a;try{T(!1),j()}finally{Bo.suspense=V}})},[a,_]);return[R,_]}function u1(a,c,_){if(!(vc=0){var _=l1()-s1;a.actualDuration+=_,c&&(a.selfBaseDuration=_),s1=-1}}var Ml=null,Ga=null,ha=!1;function qd(){ha&&Kt(!1,"We should not be hydrating here. This is a bug in React. Please file a bug.")}function Hd(a){if(!_e)return!1;var c=a.stateNode.containerInfo;return Ga=B(c),Ml=a,ha=!0,!0}function Em(a,c){return _e?(Ga=Ni(c),Gd(a),ha=!0,!0):!1}function Wd(a,c){switch(a.tag){case U:ie(a.stateNode.containerInfo,c);break;case W:qe(a.type,a.memoizedProps,a.stateNode,c);break}var _=eE();_.stateNode=c,_.return=a,_.effectTag=W0,a.lastEffect!==null?(a.lastEffect.nextEffect=_,a.lastEffect=_):a.firstEffect=a.lastEffect=_}function Mh(a,c){switch(c.effectTag=c.effectTag&~tu|ai,a.tag){case U:{var _=a.stateNode.containerInfo;switch(c.tag){case W:var T=c.type,R=c.pendingProps;tt(_,T,R);break;case ne:var j=c.pendingProps;Tt(_,j);break;case pe:kt(_);break}break}case W:{var V=a.type,te=a.memoizedProps,oe=a.stateNode;switch(c.tag){case W:var Ie=c.type,Ye=c.pendingProps;bt(V,te,oe,Ie,Ye);break;case ne:var pt=c.pendingProps;on(V,te,oe,pt);break;case pe:tn(V,te,oe);break}break}default:return}}function Nh(a,c){switch(a.tag){case W:{var _=a.type,T=a.pendingProps,R=lf(c,_,T);return R!==null?(a.stateNode=R,!0):!1}case ne:{var j=a.pendingProps,V=Ns(c,j);return V!==null?(a.stateNode=V,!0):!1}case pe:{if(Di){var te=Ma(c);if(te!==null){var oe={dehydrated:te,retryTime:hi};a.memoizedState=oe;var Ie=tE(te);return Ie.return=a,a.child=Ie,!0}}return!1}default:return!1}}function Vd(a){if(!!ha){var c=Ga;if(!c){Mh(Ml,a),ha=!1,Ml=a;return}var _=c;if(!Nh(a,c)){if(c=Ni(_),!c||!Nh(a,c)){Mh(Ml,a),ha=!1,Ml=a;return}Wd(Ml,_)}Ml=a,Ga=B(c)}}function Dm(a,c,_){if(!_e)throw Error("Expected prepareToHydrateHostInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.");var T=a.stateNode,R=z(T,a.type,a.memoizedProps,c,_,a);return a.updateQueue=R,R!==null}function wm(a){if(!_e)throw Error("Expected prepareToHydrateHostTextInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.");var c=a.stateNode,_=a.memoizedProps,T=G(c,_,a);if(T){var R=Ml;if(R!==null)switch(R.tag){case U:{var j=R.stateNode.containerInfo;Xe(j,c,_);break}case W:{var V=R.type,te=R.memoizedProps,oe=R.stateNode;ht(V,te,oe,c,_);break}}}return T}function Lh(a){if(!_e)throw Error("Expected prepareToHydrateHostSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.");var c=a.memoizedState,_=c!==null?c.dehydrated:null;if(!_)throw Error("Expected to have a hydrated suspense instance. This error is likely caused by a bug in React. Please file an issue.");$(_,a)}function Sm(a){if(!_e)throw Error("Expected skipPastDehydratedSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.");var c=a.memoizedState,_=c!==null?c.dehydrated:null;if(!_)throw Error("Expected to have a hydrated suspense instance. This error is likely caused by a bug in React. Please file an issue.");return De(_)}function Gd(a){for(var c=a.return;c!==null&&c.tag!==W&&c.tag!==U&&c.tag!==pe;)c=c.return;Ml=c}function f1(a){if(!_e||a!==Ml)return!1;if(!ha)return Gd(a),ha=!0,!1;var c=a.type;if(a.tag!==W||c!=="head"&&c!=="body"&&!Ti(c,a.memoizedProps))for(var _=Ga;_;)Wd(a,_),_=Ni(_);return Gd(a),a.tag===pe?Ga=Sm(a):Ga=Ml?Ni(a.stateNode):null,!0}function c1(){!_e||(Ml=null,Ga=null,ha=!1)}var d1=at.ReactCurrentOwner,va=!1,Yd,qs,Hs,Ws,Kd,ma,p1,E2,gc,Xd;Yd={},qs={},Hs={},Ws={},Kd={},ma=!1,p1=!1,E2={},gc={},Xd={};function _o(a,c,_,T){a===null?c.child=Xc(c,null,_,T):c.child=gf(c,a.child,_,T)}function Fh(a,c,_,T){c.child=gf(c,a.child,null,T),c.child=gf(c,null,_,T)}function bh(a,c,_,T,R){if(c.type!==c.elementType){var j=_.propTypes;j&&E(j,T,"prop",qt(_),Rr)}var V=_.render,te=c.ref,oe;return e0(c,R),d1.current=c,Ze("render"),oe=Af(a,c,V,T,te,R),Ei&&c.mode&cr&&c.memoizedState!==null&&(oe=Af(a,c,V,T,te,R)),Ze(null),a!==null&&!va?(v2(a,c,R),ya(a,c,R)):(c.effectTag|=eu,_o(a,c,oe,R),c.child)}function Ph(a,c,_,T,R,j){if(a===null){var V=_.type;if(i0(V)&&_.compare===null&&_.defaultProps===void 0){var te=V;return te=Zu(V),c.tag=le,c.type=te,Zd(c,V),Ih(a,c,te,T,R,j)}{var oe=V.propTypes;oe&&E(oe,T,"prop",qt(V),Rr)}var Ie=Sy(_.type,null,T,null,c.mode,j);return Ie.ref=c.ref,Ie.return=c,c.child=Ie,Ie}{var Ye=_.type,pt=Ye.propTypes;pt&&E(pt,T,"prop",qt(Ye),Rr)}var Nt=a.child;if(R component appears to have a render method, but doesn't extend React.Component. This is likely to cause errors. Change %s to extend React.Component instead.",oe,oe),Yd[oe]=!0)}c.mode&cr&&wl.recordLegacyContextWarning(c,null),d1.current=c,te=Af(null,c,_,R,j,T)}if(c.effectTag|=eu,typeof te=="object"&&te!==null&&typeof te.render=="function"&&te.$$typeof===void 0){{var Ie=qt(_)||"Unknown";qs[Ie]||(Ke(!1,"The <%s /> component appears to be a function component that returns a class instance. Change %s to a class that extends React.Component instead. If you can't use a class try assigning the prototype on the function as a workaround. `%s.prototype = React.Component.prototype`. Don't use an arrow function since it cannot be called with `new` by React.",Ie,Ie,Ie),qs[Ie]=!0)}c.tag=N,m2();var Ye=!1;zi(_)?(Ye=!0,Fi(c)):Ye=!1,c.memoizedState=te.state!==null&&te.state!==void 0?te.state:null;var pt=_.getDerivedStateFromProps;return typeof pt=="function"&&yf(c,_,pt,R),il(c,te),ac(c,_,R,T),Jd(null,c,_,!0,Ye,T)}else return c.tag=L,ni&&_.contextTypes&&Ke(!1,"%s uses the legacy contextTypes API which is no longer supported. Use React.createContext() with React.useContext() instead.",qt(_)||"Unknown"),Ei&&c.mode&cr&&c.memoizedState!==null&&(te=Af(null,c,_,R,j,T)),_o(null,c,te,T),Zd(c,_),c.child}function Zd(a,c){if(c&&c.childContextTypes&&Ke(!1,"%s(...): childContextTypes cannot be defined on a function component.",c.displayName||c.name||"Component"),a.ref!==null){var _="",T=v0();T&&(_+=` + +Check the render method of \``+T+"`.");var R=T||a._debugID||"",j=a._debugSource;j&&(R=j.fileName+":"+j.lineNumber),Kd[R]||(Kd[R]=!0,Kt(!1,"Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?%s",_))}if(Wl&&c.defaultProps!==void 0){var V=qt(c)||"Unknown";Xd[V]||(Ke(!1,"%s: Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.",V),Xd[V]=!0)}if(typeof c.getDerivedStateFromProps=="function"){var te=qt(c)||"Unknown";Ws[te]||(Ke(!1,"%s: Function components do not support getDerivedStateFromProps.",te),Ws[te]=!0)}if(typeof c.contextType=="object"&&c.contextType!==null){var oe=qt(c)||"Unknown";Hs[oe]||(Ke(!1,"%s: Function components do not support contextType.",oe),Hs[oe]=!0)}}var w2={dehydrated:null,retryTime:lt};function $d(a,c,_){return Jc(a,Qc)&&(c===null||c.memoizedState!==null)}function qh(a,c,_){var T=c.mode,R=c.pendingProps;a_(c)&&(c.effectTag|=Hr);var j=xl.current,V=!1,te=(c.effectTag&Hr)!==_i;if(te||$d(j,a,c)?(V=!0,c.effectTag&=~Hr):(a===null||a.memoizedState!==null)&&R.fallback!==void 0&&R.unstable_avoidThisFallback!==!0&&(j=d2(j,Ef)),j=ca(j),Or(c,j),"maxDuration"in R&&(p1||(p1=!0,Kt(!1,"maxDuration has been removed from React. Remove the maxDuration prop."))),a===null){if(R.fallback!==void 0&&(Vd(c),Di)){var oe=c.memoizedState;if(oe!==null){var Ie=oe.dehydrated;if(Ie!==null)return Hh(c,Ie,_)}}if(V){var Ye=R.fallback,pt=Qa(null,T,lt,null);if(pt.return=c,(c.mode&Y)===Sr){var Nt=c.memoizedState,Vt=Nt!==null?c.child.child:c.child;pt.child=Vt;for(var zt=Vt;zt!==null;)zt.return=pt,zt=zt.sibling}var vn=Qa(Ye,T,_,null);return vn.return=c,pt.sibling=vn,c.memoizedState=w2,c.child=pt,vn}else{var xr=R.children;return c.memoizedState=null,c.child=Xc(c,null,xr,_)}}else{var $r=a.memoizedState;if($r!==null){if(Di){var wi=$r.dehydrated;if(wi!==null)if(te){if(c.memoizedState!==null)return c.child=a.child,c.effectTag|=Hr,null;var N0=R.fallback,Vi=Qa(null,T,lt,null);if(Vi.return=c,Vi.child=null,(c.mode&Y)===Sr)for(var it=Vi.child=c.child;it!==null;)it.return=Vi,it=it.sibling;else gf(c,a.child,null,_);if(Zt&&c.mode&Jr){for(var Ot=0,Je=Vi.child;Je!==null;)Ot+=Je.treeBaseDuration,Je=Je.sibling;Vi.treeBaseDuration=Ot}var Bt=Qa(N0,T,_,null);return Bt.return=c,Vi.sibling=Bt,Bt.effectTag|=ai,Vi.childExpirationTime=lt,c.memoizedState=w2,c.child=Vi,Bt}else return Wh(a,c,wi,$r,_)}var Mn=a.child,pn=Mn.sibling;if(V){var Pi=R.fallback,oi=wo(Mn,Mn.pendingProps,lt);if(oi.return=c,(c.mode&Y)===Sr){var qu=c.memoizedState,ar=qu!==null?c.child.child:c.child;if(ar!==Mn.child){oi.child=ar;for(var ou=ar;ou!==null;)ou.return=oi,ou=ou.sibling}}if(Zt&&c.mode&Jr){for(var qr=0,_u=oi.child;_u!==null;)qr+=_u.treeBaseDuration,_u=_u.sibling;oi.treeBaseDuration=qr}var _0=wo(pn,Pi,pn.expirationTime);return _0.return=c,oi.sibling=_0,oi.childExpirationTime=lt,c.memoizedState=w2,c.child=oi,_0}else{var H0=R.children,Cs=Mn.child,Hu=gf(c,Cs,H0,_);return c.memoizedState=null,c.child=Hu}}else{var pl=a.child;if(V){var Ja=R.fallback,jo=Qa(null,T,lt,null);if(jo.return=c,jo.child=pl,pl!==null&&(pl.return=jo),(c.mode&Y)===Sr){var xs=c.memoizedState,X2=xs!==null?c.child.child:c.child;jo.child=X2;for(var Uf=X2;Uf!==null;)Uf.return=jo,Uf=Uf.sibling}if(Zt&&c.mode&Jr){for(var Rc=0,Pl=jo.child;Pl!==null;)Rc+=Pl.treeBaseDuration,Pl=Pl.sibling;jo.treeBaseDuration=Rc}var zo=Qa(Ja,T,_,null);return zo.return=c,jo.sibling=zo,zo.effectTag|=ai,jo.childExpirationTime=lt,c.memoizedState=w2,c.child=jo,zo}else{c.memoizedState=null;var O1=R.children;return c.child=gf(c,pl,O1,_)}}}}function ep(a,c,_){c.memoizedState=null;var T=c.pendingProps,R=T.children;return _o(a,c,R,_),c.child}function Hh(a,c,_){if((a.mode&Y)===Sr)Kt(!1,"Cannot hydrate Suspense in legacy mode. Switch from ReactDOM.hydrate(element, container) to ReactDOM.createBlockingRoot(container, { hydrate: true }).render(element) or remove the Suspense components from the server rendered components."),a.expirationTime=bn;else if(h0(c)){var T=Fl(),R=ms(T);Ln&&x(R),a.expirationTime=R}else a.expirationTime=hi,Ln&&x(hi);return null}function Wh(a,c,_,T,R){if(qd(),(c.mode&Y)===Sr||h0(_))return ep(a,c,R);var j=a.childExpirationTime>=R;if(va||j){if(R. Use lowercase "%s" instead.',a,a.toLowerCase());break}case"forward":case"backward":{Kt(!1,'"%s" is not a valid value for revealOrder on . React uses the -s suffix in the spelling. Use "%ss" instead.',a,a.toLowerCase());break}default:Kt(!1,'"%s" is not a supported revealOrder on . Did you mean "together", "forwards" or "backwards"?',a);break}else Kt(!1,'%s is not a supported value for revealOrder on . Did you mean "together", "forwards" or "backwards"?',a)}function Vh(a,c){a!==void 0&&!gc[a]&&(a!=="collapsed"&&a!=="hidden"?(gc[a]=!0,Kt(!1,'"%s" is not a supported value for tail on . Did you mean "collapsed" or "hidden"?',a)):c!=="forwards"&&c!=="backwards"&&(gc[a]=!0,Kt(!1,' is only valid if revealOrder is "forwards" or "backwards". Did you mean to specify revealOrder="forwards"?',a)))}function v1(a,c){{var _=Array.isArray(a),T=!_&&typeof ur(a)=="function";if(_||T){var R=_?"array":"iterable";return Kt(!1,"A nested %s was passed to row #%s in . Wrap it in an additional SuspenseList to configure its revealOrder: ... {%s} ... ",R,c,R),!1}}return!0}function Mm(a,c){if((c==="forwards"||c==="backwards")&&a!==void 0&&a!==null&&a!==!1)if(Array.isArray(a)){for(var _=0;_. This is not useful since it needs multiple rows. Did you mean to pass multiple children or an array?',c)}}function np(a,c,_,T,R,j){var V=a.memoizedState;V===null?a.memoizedState={isBackwards:c,rendering:null,last:T,tail:_,tailExpiration:0,tailMode:R,lastEffect:j}:(V.isBackwards=c,V.rendering=null,V.last=T,V.tail=_,V.tailExpiration=0,V.tailMode=R,V.lastEffect=j)}function rp(a,c,_){var T=c.pendingProps,R=T.revealOrder,j=T.tail,V=T.children;km(R),Vh(j,R),Mm(V,R),_o(a,c,V,_);var te=xl.current,oe=Jc(te,Qc);if(oe)te=c2(te,Qc),c.effectTag|=Hr;else{var Ie=a!==null&&(a.effectTag&Hr)!==_i;Ie&&Rm(c,c.child,_),te=ca(te)}if(Or(c,te),(c.mode&Y)===Sr)c.memoizedState=null;else switch(R){case"forwards":{var Ye=Om(c.child),pt;Ye===null?(pt=c.child,c.child=null):(pt=Ye.sibling,Ye.sibling=null),np(c,!1,pt,Ye,j,c.lastEffect);break}case"backwards":{var Nt=null,Vt=c.child;for(c.child=null;Vt!==null;){var zt=Vt.alternate;if(zt!==null&&Zc(zt)===null){c.child=Vt;break}var vn=Vt.sibling;Vt.sibling=Nt,Nt=Vt,Vt=vn}np(c,!0,Nt,null,j,c.lastEffect);break}case"together":{np(c,!1,null,null,void 0,c.lastEffect);break}default:c.memoizedState=null}return c.child}function Nm(a,c,_){za(c,c.stateNode.containerInfo);var T=c.pendingProps;return a===null?c.child=gf(c,null,T,_):_o(a,c,T,_),c.child}function Lm(a,c,_){var T=c.type,R=T._context,j=c.pendingProps,V=c.memoizedProps,te=j.value;{var oe=c.type.propTypes;oe&&E(oe,j,"prop","Context.Provider",Rr)}if(Er(c,te),V!==null){var Ie=V.value,Ye=iu(R,te,Ie);if(Ye===0){if(V.children===j.children&&!na())return ya(a,c,_)}else Tl(c,R,Ye,_)}var pt=j.children;return _o(a,c,pt,_),c.child}var Gh=!1;function Fm(a,c,_){var T=c.type;T._context===void 0?T!==T.Consumer&&(Gh||(Gh=!0,Kt(!1,"Rendering directly is not supported and will be removed in a future major release. Did you mean to render instead?"))):T=T._context;var R=c.pendingProps,j=R.children;typeof j!="function"&&Ke(!1,"A context consumer was rendered with multiple children, or a child that isn't a function. A context consumer expects a single child that is a function. If you did pass a function, make sure there is no trailing or leading whitespace around it."),e0(c,_);var V=He(T,R.unstable_observedBits),te;return d1.current=c,Ze("render"),te=j(V),Ze(null),c.effectTag|=eu,_o(a,c,te,_),c.child}function bm(a,c,_){var T=c.type.impl;if(T.reconcileChildren===!1)return null;var R=c.pendingProps,j=R.children;return _o(a,c,j,_),c.child}function ip(a,c,_){var T=c.pendingProps,R=T.children;return _o(a,c,R,_),c.child}function up(){va=!0}function ya(a,c,_){Ki(c),a!==null&&(c.dependencies=a.dependencies),Zt&&kh(c);var T=c.expirationTime;T!==lt&&G2(T);var R=c.childExpirationTime;return R<_?null:(gh(a,c),c.child)}function m1(a,c,_){{var T=c.return;if(T===null)throw new Error("Cannot swap the root fiber.");if(a.alternate=null,c.alternate=null,_.index=c.index,_.sibling=c.sibling,_.return=c.return,_.ref=c.ref,c===T.child)T.child=_;else{var R=T.child;if(R===null)throw new Error("Expected parent to have a child.");for(;R.sibling!==c;)if(R=R.sibling,R===null)throw new Error("Expected to find the previous sibling.");R.sibling=_}var j=T.lastEffect;return j!==null?(j.nextEffect=a,T.lastEffect=a):T.firstEffect=T.lastEffect=a,a.nextEffect=null,a.effectTag=W0,_.effectTag|=ai,_}}function op(a,c,_){var T=c.expirationTime;if(c._debugNeedsRemount&&a!==null)return m1(a,c,Sy(c.type,c.key,c.pendingProps,c._debugOwner||null,c.mode,c.expirationTime));if(a!==null){var R=a.memoizedProps,j=c.pendingProps;if(R!==j||na()||c.type!==a.type)va=!0;else if(T<_){switch(va=!1,c.tag){case U:zh(c),c1();break;case W:if(dc(c),c.mode&Qr&&_!==hi&&d0(c.type,j))return Ln&&x(hi),c.expirationTime=c.childExpirationTime=hi,null;break;case N:{var V=c.type;zi(V)&&Fi(c);break}case q:za(c,c.stateNode.containerInfo);break;case he:{var te=c.memoizedProps.value;Er(c,te);break}case ze:if(Zt){var oe=c.childExpirationTime>=_;oe&&(c.effectTag|=mr)}break;case pe:{var Ie=c.memoizedState;if(Ie!==null){if(Di&&Ie.dehydrated!==null){Or(c,ca(xl.current)),c.effectTag|=Hr;break}var Ye=c.child,pt=Ye.childExpirationTime;if(pt!==lt&&pt>=_)return qh(a,c,_);Or(c,ca(xl.current));var Nt=ya(a,c,_);return Nt!==null?Nt.sibling:null}else Or(c,ca(xl.current));break}case wt:{var Vt=(a.effectTag&Hr)!==_i,zt=c.childExpirationTime>=_;if(Vt){if(zt)return rp(a,c,_);c.effectTag|=Hr}var vn=c.memoizedState;if(vn!==null&&(vn.rendering=null,vn.tail=null),Or(c,xl.current),zt)break;return null}}return ya(a,c,_)}else va=!1}else va=!1;switch(c.expirationTime=lt,c.tag){case C:return Am(a,c,c.type,_);case Ue:{var xr=c.elementType;return kf(a,c,xr,T,_)}case L:{var $r=c.type,wi=c.pendingProps,N0=c.elementType===$r?wi:bi($r,wi);return Qd(a,c,$r,N0,_)}case N:{var Vi=c.type,it=c.pendingProps,Ot=c.elementType===Vi?it:bi(Vi,it);return jh(a,c,Vi,Ot,_)}case U:return Cm(a,c,_);case W:return xm(a,c,_);case ne:return Of(a,c);case pe:return qh(a,c,_);case q:return Nm(a,c,_);case ge:{var Je=c.type,Bt=c.pendingProps,Mn=c.elementType===Je?Bt:bi(Je,Bt);return bh(a,c,Je,Mn,_)}case m:return Tm(a,c,_);case we:return Bh(a,c,_);case ze:return Uh(a,c,_);case he:return Lm(a,c,_);case Se:return Fm(a,c,_);case Oe:{var pn=c.type,Pi=c.pendingProps,oi=bi(pn,Pi);if(c.type!==c.elementType){var qu=pn.propTypes;qu&&E(qu,oi,"prop",qt(pn),Rr)}return oi=bi(pn.type,oi),Ph(a,c,pn,oi,T,_)}case le:return Ih(a,c,c.type,c.pendingProps,T,_);case Ge:{var ar=c.type,ou=c.pendingProps,qr=c.elementType===ar?ou:bi(ar,ou);return D2(a,c,ar,qr,_)}case wt:return rp(a,c,_);case xt:{if(Ht)return bm(a,c,_);break}case $e:{if(Du)return ip(a,c,_);break}}throw Error("Unknown unit of work tag ("+c.tag+"). This error is likely caused by a bug in React. Please file an issue.")}function Yh(a,c,_,T){return{currentFiber:a,impl:_,instance:null,prevProps:null,props:c,state:T}}function S2(a){return a.tag===pe&&a.memoizedState!==null}function y1(a){return a.child.sibling.child}var Kh={};function lp(a,c,_){if(Du){if(a.tag===W){var T=a.type,R=a.memoizedProps,j=a.stateNode,V=Ro(j);V!==null&&c(T,R||Kh,V)===!0&&_.push(V)}var te=a.child;S2(a)&&(te=y1(a)),te!==null&&sp(te,c,_)}}function Xh(a,c){if(Du){if(a.tag===W){var _=a.type,T=a.memoizedProps,R=a.stateNode,j=Ro(R);if(j!==null&&c(_,T,j)===!0)return j}var V=a.child;if(S2(a)&&(V=y1(a)),V!==null)return Qh(V,c)}return null}function sp(a,c,_){for(var T=a;T!==null;)lp(T,c,_),T=T.sibling}function Qh(a,c){for(var _=a;_!==null;){var T=Xh(_,c);if(T!==null)return T;_=_.sibling}return null}function Jh(a,c,_){if(T2(a,c))_.push(a.stateNode.methods);else{var T=a.child;S2(a)&&(T=y1(a)),T!==null&&ap(T,c,_)}}function ap(a,c,_){for(var T=a;T!==null;)Jh(T,c,_),T=T.sibling}function T2(a,c){return a.tag===$e&&a.type===c&&a.stateNode!==null}function C2(a,c){return{getChildren:function(){var _=c.fiber,T=_.child,R=[];return T!==null&&ap(T,a,R),R.length===0?null:R},getChildrenFromRoot:function(){for(var _=c.fiber,T=_;T!==null;){var R=T.return;if(R===null||(T=R,T.tag===$e&&T.type===a))break}var j=[];return ap(T.child,a,j),j.length===0?null:j},getParent:function(){for(var _=c.fiber.return;_!==null;){if(_.tag===$e&&_.type===a)return _.stateNode.methods;_=_.return}return null},getProps:function(){var _=c.fiber;return _.memoizedProps},queryAllNodes:function(_){var T=c.fiber,R=T.child,j=[];return R!==null&&sp(R,_,j),j.length===0?null:j},queryFirstNode:function(_){var T=c.fiber,R=T.child;return R!==null?Qh(R,_):null},containsNode:function(_){for(var T=or(_);T!==null;){if(T.tag===$e&&T.type===a&&T.stateNode===c)return!0;T=T.return}return!1}}}function z0(a){a.effectTag|=mr}function x2(a){a.effectTag|=To}var ga,Ya,A2,R2;if(P0)ga=function(a,c,_,T){for(var R=c.child;R!==null;){if(R.tag===W||R.tag===ne)Wr(a,R.stateNode);else if(Ht&&R.tag===xt)Wr(a,R.stateNode.instance);else if(R.tag!==q){if(R.child!==null){R.child.return=R,R=R.child;continue}}if(R===c)return;for(;R.sibling===null;){if(R.return===null||R.return===c)return;R=R.return}R.sibling.return=R.return,R=R.sibling}},Ya=function(a){},A2=function(a,c,_,T,R){var j=a.memoizedProps;if(j!==T){var V=c.stateNode,te=ul(),oe=c0(V,_,j,T,R,te);c.updateQueue=oe,oe&&z0(c)}},R2=function(a,c,_,T){_!==T&&z0(c)};else if(X){ga=function(a,c,_,T){for(var R=c.child;R!==null;){e:if(R.tag===W){var j=R.stateNode;if(_&&T){var V=R.memoizedProps,te=R.type;j=Gr(j,te,V,R)}Wr(a,j)}else if(R.tag===ne){var oe=R.stateNode;if(_&&T){var Ie=R.memoizedProps;oe=Yl(oe,Ie,R)}Wr(a,oe)}else if(Ht&&R.tag===xt){var Ye=R.stateNode.instance;if(_&&T){var pt=R.memoizedProps,Nt=R.type;Ye=Gr(Ye,Nt,pt,R)}Wr(a,Ye)}else if(R.tag!==q){if(R.tag===pe){if((R.effectTag&mr)!==_i){var Vt=R.memoizedState!==null;if(Vt){var zt=R.child;if(zt!==null){zt.child!==null&&(zt.child.return=zt,ga(a,zt,!0,Vt));var vn=zt.sibling;if(vn!==null){vn.return=R,R=vn;continue}}}}if(R.child!==null){R.child.return=R,R=R.child;continue}}else if(R.child!==null){R.child.return=R,R=R.child;continue}}if(R=R,R===c)return;for(;R.sibling===null;){if(R.return===null||R.return===c)return;R=R.return}R.sibling.return=R.return,R=R.sibling}};var fp=function(a,c,_,T){for(var R=c.child;R!==null;){e:if(R.tag===W){var j=R.stateNode;if(_&&T){var V=R.memoizedProps,te=R.type;j=Gr(j,te,V,R)}Gn(a,j)}else if(R.tag===ne){var oe=R.stateNode;if(_&&T){var Ie=R.memoizedProps;oe=Yl(oe,Ie,R)}Gn(a,oe)}else if(Ht&&R.tag===xt){var Ye=R.stateNode.instance;if(_&&T){var pt=R.memoizedProps,Nt=R.type;Ye=Gr(Ye,Nt,pt,R)}Gn(a,Ye)}else if(R.tag!==q){if(R.tag===pe){if((R.effectTag&mr)!==_i){var Vt=R.memoizedState!==null;if(Vt){var zt=R.child;if(zt!==null){zt.child!==null&&(zt.child.return=zt,fp(a,zt,!0,Vt));var vn=zt.sibling;if(vn!==null){vn.return=R,R=vn;continue}}}}if(R.child!==null){R.child.return=R,R=R.child;continue}}else if(R.child!==null){R.child.return=R,R=R.child;continue}}if(R=R,R===c)return;for(;R.sibling===null;){if(R.return===null||R.return===c)return;R=R.return}R.sibling.return=R.return,R=R.sibling}};Ya=function(a){var c=a.stateNode,_=a.firstEffect===null;if(!_){var T=c.containerInfo,R=w0(T);fp(R,a,!1,!1),c.pendingChildren=R,z0(a),ic(T,R)}},A2=function(a,c,_,T,R){var j=a.stateNode,V=a.memoizedProps,te=c.firstEffect===null;if(te&&V===T){c.stateNode=j;return}var oe=c.stateNode,Ie=ul(),Ye=null;if(V!==T&&(Ye=c0(oe,_,V,T,R,Ie)),te&&Ye===null){c.stateNode=j;return}var pt=cs(j,Ye,_,V,T,c,te,oe);wu(pt,_,T,R,Ie)&&z0(c),c.stateNode=pt,te?z0(c):ga(pt,c,!1,!1)},R2=function(a,c,_,T){if(_!==T){var R=Jl(),j=ul();c.stateNode=as(T,R,j,c),z0(c)}}}else Ya=function(a){},A2=function(a,c,_,T,R){},R2=function(a,c,_,T){};function O2(a,c){switch(a.tailMode){case"hidden":{for(var _=a.tail,T=null;_!==null;)_.alternate!==null&&(T=_),_=_.sibling;T===null?a.tail=null:T.sibling=null;break}case"collapsed":{for(var R=a.tail,j=null;R!==null;)R.alternate!==null&&(j=R),R=R.sibling;j===null?!c&&a.tail!==null?a.tail.sibling=null:a.tail=null:j.sibling=null;break}}}function Zh(a,c,_){var T=c.pendingProps;switch(c.tag){case C:break;case Ue:break;case le:case L:break;case N:{var R=c.type;zi(R)&&Is(c);break}case U:{no(c),x0(c);var j=c.stateNode;if(j.pendingContext&&(j.context=j.pendingContext,j.pendingContext=null),a===null||a.child===null){var V=f1(c);V&&z0(c)}Ya(c);break}case W:{Od(c);var te=Jl(),oe=c.type;if(a!==null&&c.stateNode!=null){if(A2(a,c,oe,T,te),ci){var Ie=a.memoizedProps.listeners,Ye=T.listeners;Ie!==Ye&&z0(c)}a.ref!==c.ref&&x2(c)}else{if(!T){if(c.stateNode===null)throw Error("We must have new props for new mounts. This error is likely caused by a bug in React. Please file an issue.");break}var pt=ul(),Nt=f1(c);if(Nt){if(Dm(c,te,pt)&&z0(c),ci){var Vt=T.listeners;Vt!=null&&dn(Vt,c,te)}}else{var zt=ji(oe,T,te,pt,c);if(ga(zt,c,!1,!1),c.stateNode=zt,ci){var vn=T.listeners;vn!=null&&dn(vn,c,te)}wu(zt,oe,T,te,pt)&&z0(c)}c.ref!==null&&x2(c)}break}case ne:{var xr=T;if(a&&c.stateNode!=null){var $r=a.memoizedProps;R2(a,c,$r,xr)}else{if(typeof xr!="string"&&c.stateNode===null)throw Error("We must have new props for new mounts. This error is likely caused by a bug in React. Please file an issue.");var wi=Jl(),N0=ul(),Vi=f1(c);Vi?wm(c)&&z0(c):c.stateNode=as(xr,wi,N0,c)}break}case ge:break;case pe:{da(c);var it=c.memoizedState;if(Di&&it!==null&&it.dehydrated!==null)if(a===null){var Ot=f1(c);if(!Ot)throw Error("A dehydrated suspense component was completed without a hydrated node. This is probably a bug in React.");return Lh(c),Ln&&x(hi),null}else return c1(),(c.effectTag&Hr)===_i&&(c.memoizedState=null),c.effectTag|=mr,null;if((c.effectTag&Hr)!==_i)return c.expirationTime=_,c;var Je=it!==null,Bt=!1;if(a===null)c.memoizedProps.fallback!==void 0&&f1(c);else{var Mn=a.memoizedState;if(Bt=Mn!==null,!Je&&Mn!==null){var pn=a.child.sibling;if(pn!==null){var Pi=c.firstEffect;Pi!==null?(c.firstEffect=pn,pn.nextEffect=Pi):(c.firstEffect=c.lastEffect=pn,pn.nextEffect=null),pn.effectTag=W0}}}if(Je&&!Bt&&(c.mode&Y)!==Sr){var oi=a===null&&c.memoizedProps.unstable_avoidThisFallback!==!0;oi||Jc(xl.current,Ef)?mv():yv()}X&&Je&&(c.effectTag|=mr),P0&&(Je||Bt)&&(c.effectTag|=mr),Ui&&c.updateQueue!==null&&c.memoizedProps.suspenseCallback!=null&&(c.effectTag|=mr);break}case m:break;case we:break;case ze:break;case q:no(c),Ya(c);break;case he:$u(c);break;case Se:break;case Oe:break;case Ge:{var qu=c.type;zi(qu)&&Is(c);break}case wt:{da(c);var ar=c.memoizedState;if(ar===null)break;var ou=(c.effectTag&Hr)!==_i,qr=ar.rendering;if(qr===null)if(ou)O2(ar,!1);else{var _u=gv()&&(a===null||(a.effectTag&Hr)===_i);if(!_u)for(var _0=c.child;_0!==null;){var H0=Zc(_0);if(H0!==null){ou=!0,c.effectTag|=Hr,O2(ar,!1);var Cs=H0.updateQueue;return Cs!==null&&(c.updateQueue=Cs,c.effectTag|=mr),ar.lastEffect===null&&(c.firstEffect=null),c.lastEffect=ar.lastEffect,vm(c,_),Or(c,c2(xl.current,Qc)),c.child}_0=_0.sibling}}else{if(!ou){var Hu=Zc(qr);if(Hu!==null){c.effectTag|=Hr,ou=!0;var pl=Hu.updateQueue;if(pl!==null&&(c.updateQueue=pl,c.effectTag|=mr),O2(ar,!0),ar.tail===null&&ar.tailMode==="hidden"&&!qr.alternate){var Ja=c.lastEffect=ar.lastEffect;return Ja!==null&&(Ja.nextEffect=null),null}}else if(vt()>ar.tailExpiration&&_>hi){c.effectTag|=Hr,ou=!0,O2(ar,!1);var jo=_-1;c.expirationTime=c.childExpirationTime=jo,Ln&&x(jo)}}if(ar.isBackwards)qr.sibling=c.child,c.child=qr;else{var xs=ar.last;xs!==null?xs.sibling=qr:c.child=qr,ar.last=qr}}if(ar.tail!==null){if(ar.tailExpiration===0){var X2=500;ar.tailExpiration=vt()+X2}var Uf=ar.tail;ar.rendering=Uf,ar.tail=Uf.sibling,ar.lastEffect=c.lastEffect,Uf.sibling=null;var Rc=xl.current;return ou?Rc=c2(Rc,Qc):Rc=ca(Rc),Or(c,Rc),Uf}break}case xt:{if(Ht){var Pl=c.type.impl,zo=c.stateNode;if(zo===null){var O1=Pl.getInitialState,m_;O1!==void 0&&(m_=O1(T)),zo=c.stateNode=Yh(c,T,Pl,m_||{});var y_=dt(zo);if(zo.instance=y_,Pl.reconcileChildren===!1)return null;ga(y_,c,!1,!1),Hn(zo)}else{var yE=zo.props;if(zo.prevProps=yE,zo.props=T,zo.currentFiber=c,X){var g_=ea(zo);zo.instance=g_,ga(g_,c,!1,!1)}var gE=Dn(zo);gE&&z0(c)}}break}case $e:{if(Du)if(a===null){var _E=c.type,Ly={fiber:c,methods:null};if(c.stateNode=Ly,Ly.methods=C2(_E,Ly),ci){var __=T.listeners;if(__!=null){var EE=Jl();dn(__,c,EE)}}c.ref!==null&&(x2(c),z0(c))}else{if(ci){var DE=a.memoizedProps.listeners,wE=T.listeners;(DE!==wE||c.ref!==null)&&z0(c)}else c.ref!==null&&z0(c);a.ref!==c.ref&&x2(c)}break}default:throw Error("Unknown unit of work tag ("+c.tag+"). This error is likely caused by a bug in React. Please file an issue.")}return null}function Pm(a,c){switch(a.tag){case N:{var _=a.type;zi(_)&&Is(a);var T=a.effectTag;return T&f0?(a.effectTag=T&~f0|Hr,a):null}case U:{no(a),x0(a);var R=a.effectTag;if((R&Hr)!==_i)throw Error("The root failed to unmount after an error. This is likely a bug in React. Please file an issue.");return a.effectTag=R&~f0|Hr,a}case W:return Od(a),null;case pe:{if(da(a),Di){var j=a.memoizedState;if(j!==null&&j.dehydrated!==null){if(a.alternate===null)throw Error("Threw in newly mounted dehydrated component. This is likely a bug in React. Please file an issue.");c1()}}var V=a.effectTag;return V&f0?(a.effectTag=V&~f0|Hr,a):null}case wt:return da(a),null;case q:return no(a),null;case he:return $u(a),null;default:return null}}function $h(a){switch(a.tag){case N:{var c=a.type.childContextTypes;c!=null&&Is(a);break}case U:{no(a),x0(a);break}case W:{Od(a);break}case q:no(a);break;case pe:da(a);break;case wt:da(a);break;case he:$u(a);break;default:break}}function cp(a,c){return{value:a,source:c,stack:_r(c)}}var dp=function(a,c,_,T,R,j,V,te,oe){var Ie=Array.prototype.slice.call(arguments,3);try{c.apply(_,Ie)}catch(Ye){this.onError(Ye)}};if(typeof window!="undefined"&&typeof window.dispatchEvent=="function"&&typeof document!="undefined"&&typeof document.createEvent=="function"){var pp=document.createElement("react"),Im=function(a,c,_,T,R,j,V,te,oe){if(typeof document=="undefined")throw Error("The `document` global was defined when React was initialized, but is not defined anymore. This can happen in a test environment if a component schedules an update from an asynchronous callback, but the test has already finished running. To solve this, you can either unmount the component at the end of your test (and ensure that any asynchronous operations get canceled in `componentWillUnmount`), or you can change the test itself to be asynchronous.");var Ie=document.createEvent("Event"),Ye=!0,pt=window.event,Nt=Object.getOwnPropertyDescriptor(window,"event"),Vt=Array.prototype.slice.call(arguments,3);function zt(){pp.removeEventListener(N0,zt,!1),typeof window.event!="undefined"&&window.hasOwnProperty("event")&&(window.event=pt),c.apply(_,Vt),Ye=!1}var vn,xr=!1,$r=!1;function wi(Vi){if(vn=Vi.error,xr=!0,vn===null&&Vi.colno===0&&Vi.lineno===0&&($r=!0),Vi.defaultPrevented&&vn!=null&&typeof vn=="object")try{vn._suppressLogging=!0}catch(it){}}var N0="react-"+(a||"invokeguardedcallback");window.addEventListener("error",wi),pp.addEventListener(N0,zt,!1),Ie.initEvent(N0,!1,!1),pp.dispatchEvent(Ie),Nt&&Object.defineProperty(window,"event",Nt),Ye&&(xr?$r&&(vn=new Error("A cross-origin error was thrown. React doesn't have access to the actual error object in development. See https://fb.me/react-crossorigin-error for more information.")):vn=new Error(`An error was thrown inside one of your components, but React doesn't know what it was. This is likely due to browser flakiness. React does its best to preserve the "Pause on exceptions" behavior of the DevTools, which requires some DEV-mode only tricks. It's possible that these don't work in your browser. Try triggering the error in production mode, or switching to a modern browser. If you suspect that this is actually an issue with React, please file an issue.`),this.onError(vn)),window.removeEventListener("error",wi)};dp=Im}var Bm=dp,Eo=!1,k2=null,Um={onError:function(a){Eo=!0,k2=a}};function sl(a,c,_,T,R,j,V,te,oe){Eo=!1,k2=null,Bm.apply(Um,arguments)}function Jn(){return Eo}function Vs(){if(Eo){var a=k2;return Eo=!1,k2=null,a}else throw Error("clearCaughtError was called but no error was captured. This error is likely caused by a bug in React. Please file an issue.")}function al(a){return!0}function n0(a){var c=al(a);if(c!==!1){var _=a.error;{var T=a.componentName,R=a.componentStack,j=a.errorBoundaryName,V=a.errorBoundaryFound,te=a.willRetry;if(_!=null&&_._suppressLogging){if(V&&te)return;console.error(_)}var oe=T?"The above error occurred in the <"+T+"> component:":"The above error occurred in one of your React components:",Ie;V&&j?te?Ie="React will try to recreate this component tree from scratch "+("using the error boundary you provided, "+j+"."):Ie="This error was initially handled by the error boundary "+j+`. +Recreating the tree from scratch failed so React will unmount the tree.`:Ie=`Consider adding an error boundary to your tree to customize error handling behavior. +Visit https://fb.me/react-error-boundaries to learn more about error boundaries.`;var Ye=""+oe+R+` + +`+(""+Ie);console.error(Ye)}}}var ev=null;ev=new Set;var Gs=typeof WeakSet=="function"?WeakSet:Set;function hp(a,c){var _=c.source,T=c.stack;T===null&&_!==null&&(T=_r(_));var R={componentName:_!==null?qt(_.type):null,componentStack:T!==null?T:"",error:c.value,errorBoundary:null,errorBoundaryName:null,errorBoundaryFound:!1,willRetry:!1};a!==null&&a.tag===N&&(R.errorBoundary=a.stateNode,R.errorBoundaryName=qt(a.type),R.errorBoundaryFound=!0,R.willRetry=!0);try{n0(R)}catch(j){setTimeout(function(){throw j})}}var jm=function(a,c){Oi(a,"componentWillUnmount"),c.props=a.memoizedProps,c.state=a.memoizedState,c.componentWillUnmount(),gi()};function tv(a,c){if(sl(null,jm,null,a,c),Jn()){var _=Vs();Pf(a,_)}}function vp(a){var c=a.ref;if(c!==null)if(typeof c=="function"){if(sl(null,c,null,null),Jn()){var _=Vs();Pf(a,_)}}else c.current=null}function zm(a,c){if(sl(null,c,null),Jn()){var _=Vs();Pf(a,_)}}function mp(a,c){switch(c.tag){case L:case ge:case le:{_c(ym,wf,c);return}case N:{if(c.effectTag&Co&&a!==null){var _=a.memoizedProps,T=a.memoizedState;Oi(c,"getSnapshotBeforeUpdate");var R=c.stateNode;c.type===c.elementType&&!ma&&(R.props!==c.memoizedProps&&Kt(!1,"Expected %s props to match memoized props before getSnapshotBeforeUpdate. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(c.type)||"instance"),R.state!==c.memoizedState&&Kt(!1,"Expected %s state to match memoized state before getSnapshotBeforeUpdate. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(c.type)||"instance"));var j=R.getSnapshotBeforeUpdate(c.elementType===c.type?_:bi(c.type,_),T);{var V=ev;j===void 0&&!V.has(c.type)&&(V.add(c.type),Ke(!1,"%s.getSnapshotBeforeUpdate(): A snapshot value (or null) must be returned. You have returned undefined.",qt(c.type)))}R.__reactInternalSnapshotBeforeUpdate=j,gi()}return}case U:case W:case ne:case q:case Ge:return;default:throw Error("This unit of work tag should not have side-effects. This error is likely caused by a bug in React. Please file an issue.")}}function _c(a,c,_){var T=_.updateQueue,R=T!==null?T.lastEffect:null;if(R!==null){var j=R.next,V=j;do{if((V.tag&a)!==wf){var te=V.destroy;V.destroy=void 0,te!==void 0&&te()}if((V.tag&c)!==wf){var oe=V.create;V.destroy=oe();{var Ie=V.destroy;if(Ie!==void 0&&typeof Ie!="function"){var Ye=void 0;Ie===null?Ye=" You returned null. If your effect does not require clean up, return undefined (or nothing).":typeof Ie.then=="function"?Ye=` + +It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the async function inside your effect and call it immediately: + +useEffect(() => { + async function fetchData() { + // You can await here + const response = await MyAPI.getData(someId); + // ... + } + fetchData(); +}, [someId]); // Or [] if effect doesn't need props or state + +Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching`:Ye=" You returned: "+Ie,Ke(!1,"An effect function must not return anything besides a function, which is used for clean-up.%s%s",Ye,_r(_))}}}V=V.next}while(V!==j)}}function Ea(a){if((a.effectTag&L0)!==_i)switch(a.tag){case L:case ge:case le:{_c(rr,wf,a),_c(wf,$c,a);break}default:break}}function yp(a,c,_,T){switch(_.tag){case L:case ge:case le:{_c(gm,ol,_);break}case N:{var R=_.stateNode;if(_.effectTag&mr)if(c===null)Oi(_,"componentDidMount"),_.type===_.elementType&&!ma&&(R.props!==_.memoizedProps&&Kt(!1,"Expected %s props to match memoized props before componentDidMount. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(_.type)||"instance"),R.state!==_.memoizedState&&Kt(!1,"Expected %s state to match memoized state before componentDidMount. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(_.type)||"instance")),R.componentDidMount(),gi();else{var j=_.elementType===_.type?c.memoizedProps:bi(_.type,c.memoizedProps),V=c.memoizedState;Oi(_,"componentDidUpdate"),_.type===_.elementType&&!ma&&(R.props!==_.memoizedProps&&Kt(!1,"Expected %s props to match memoized props before componentDidUpdate. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(_.type)||"instance"),R.state!==_.memoizedState&&Kt(!1,"Expected %s state to match memoized state before componentDidUpdate. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(_.type)||"instance")),R.componentDidUpdate(j,V,R.__reactInternalSnapshotBeforeUpdate),gi()}var te=_.updateQueue;te!==null&&(_.type===_.elementType&&!ma&&(R.props!==_.memoizedProps&&Kt(!1,"Expected %s props to match memoized props before processing the update queue. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(_.type)||"instance"),R.state!==_.memoizedState&&Kt(!1,"Expected %s state to match memoized state before processing the update queue. This might either be because of a bug in React, or because a component reassigns its own `this.props`. Please file an issue.",qt(_.type)||"instance")),vo(_,te,R,T));return}case U:{var oe=_.updateQueue;if(oe!==null){var Ie=null;if(_.child!==null)switch(_.child.tag){case W:Ie=Ro(_.child.stateNode);break;case N:Ie=_.child.stateNode;break}vo(_,oe,Ie,T)}return}case W:{var Ye=_.stateNode;if(c===null&&_.effectTag&mr){var pt=_.type,Nt=_.memoizedProps;Pu(Ye,pt,Nt,_)}return}case ne:return;case q:return;case ze:{if(Zt){var Vt=_.memoizedProps.onRender;typeof Vt=="function"&&(Ln?Vt(_.memoizedProps.id,c===null?"mount":"update",_.actualDuration,_.treeBaseDuration,_.actualStartTime,kl(),a.memoizedInteractions):Vt(_.memoizedProps.id,c===null?"mount":"update",_.actualDuration,_.treeBaseDuration,_.actualStartTime,kl()))}return}case pe:{Nl(a,_);return}case wt:case Ge:case xt:case $e:return;default:throw Error("This unit of work tag should not have side-effects. This error is likely caused by a bug in React. Please file an issue.")}}function M2(a,c){if(P0)for(var _=a;;){if(_.tag===W){var T=_.stateNode;c?Oa(T):Zs(_.stateNode,_.memoizedProps)}else if(_.tag===ne){var R=_.stateNode;c?p0(R):K0(R,_.memoizedProps)}else if(_.tag===pe&&_.memoizedState!==null&&_.memoizedState.dehydrated===null){var j=_.child.sibling;j.return=_,_=j;continue}else if(_.child!==null){_.child.return=_,_=_.child;continue}if(_===a)return;for(;_.sibling===null;){if(_.return===null||_.return===a)return;_=_.return}_.sibling.return=_.return,_=_.sibling}}function ku(a){var c=a.ref;if(c!==null){var _=a.stateNode,T;switch(a.tag){case W:T=Ro(_);break;default:T=_}Du&&a.tag===$e&&(T=_.methods),typeof c=="function"?c(T):(c.hasOwnProperty("current")||Ke(!1,"Unexpected ref object provided for %s. Use either a ref-setter function or React.createRef().%s",qt(a.type),_r(a)),c.current=T)}}function zu(a){var c=a.ref;c!==null&&(typeof c=="function"?c(null):c.current=null)}function gp(a,c,_){switch(Rn(c),c.tag){case L:case ge:case Oe:case le:{var T=c.updateQueue;if(T!==null){var R=T.lastEffect;if(R!==null){var j=R.next,V=_>Wn?Wn:_;_n(V,function(){var $r=j;do{var wi=$r.destroy;wi!==void 0&&zm(c,wi),$r=$r.next}while($r!==j)})}}break}case N:{vp(c);var te=c.stateNode;typeof te.componentWillUnmount=="function"&&tv(c,te);return}case W:{if(ci){var oe=c.dependencies;if(oe!==null){var Ie=oe.responders;if(Ie!==null){for(var Ye=Array.from(Ie.values()),pt=0,Nt=Ye.length;pt component higher in the tree to provide a loading indicator or placeholder to display.`+_r(_))}Rp(),T=cp(T,_);var Nt=c;do{switch(Nt.tag){case U:{var Vt=T;Nt.effectTag|=f0,Nt.expirationTime=R;var zt=uv(Nt,Vt,R);r2(Nt,zt);return}case N:var vn=T,xr=Nt.type,$r=Nt.stateNode;if((Nt.effectTag&Hr)===_i&&(typeof xr.getDerivedStateFromError=="function"||$r!==null&&typeof $r.componentDidCatch=="function"&&!Lp($r))){Nt.effectTag|=f0,Nt.expirationTime=R;var wi=ov(Nt,vn,R);r2(Nt,wi);return}break;default:break}Nt=Nt.return}while(Nt!==null)}var wa=Math.ceil,Cr=at.ReactCurrentDispatcher,Ep=at.ReactCurrentOwner,fl=at.IsSomeRendererActing,cu=0,E1=1,ki=2,Dp=4,F2=8,Do=16,Ss=32,Mf=0,b2=1,wp=2,D1=3,w1=4,Sp=5,Zn=cu,cl=null,qn=null,q0=lt,k0=Mf,P2=null,Ll=bn,S1=bn,Dc=null,wc=lt,I2=!1,Tp=0,M0=500,fn=null,B2=!1,U2=null,Sc=null,Tc=!1,Cc=null,T1=y0,Cp=lt,Ka=null,Km=50,xc=0,j2=null,sv=50,C1=0,Nf=null,Lf=null,x1=lt;function Fl(){return(Zn&(Do|Ss))!==cu?Ju(vt()):(x1!==lt||(x1=Ju(vt())),x1)}function Ac(){return Ju(vt())}function Ff(a,c,_){var T=c.mode;if((T&Y)===Sr)return bn;var R=Xt();if((T&Qr)===Sr)return R===Ci?bn:Qu;if((Zn&Do)!==cu)return q0;var j;if(_!==null)j=ia(a,_.timeoutMs|0||pf);else switch(R){case Ci:j=bn;break;case Xr:j=La(a);break;case Wn:case Xu:j=ms(a);break;case m0:j=Qi;break;default:throw Error("Expected a valid priority level")}return cl!==null&&j===q0&&(j-=1),j}function Xm(a,c){hy(),gy(a);var _=z2(a,c);if(_===null){my(a);return}Up(a,c),ta();var T=Xt();if(c===bn?(Zn&F2)!==cu&&(Zn&(Do|Ss))===cu?(H(_,c),A1(_)):(Uo(_),H(_,c),Zn===cu&&It()):(Uo(_),H(_,c)),(Zn&Dp)!==cu&&(T===Xr||T===Ci))if(Ka===null)Ka=new Map([[_,c]]);else{var R=Ka.get(_);(R===void 0||R>c)&&Ka.set(_,c)}}var dl=Xm;function z2(a,c){a.expirationTimeR?T:R}function Uo(a){var c=a.lastExpiredTime;if(c!==lt){a.callbackExpirationTime=bn,a.callbackPriority=Ci,a.callbackNode=En(A1.bind(null,a));return}var _=q2(a),T=a.callbackNode;if(_===lt){T!==null&&(a.callbackNode=null,a.callbackExpirationTime=lt,a.callbackPriority=y0);return}var R=Fl(),j=$1(R,_);if(T!==null){var V=a.callbackPriority,te=a.callbackExpirationTime;if(te===_&&V>=j)return;er(T)}a.callbackExpirationTime=_,a.callbackPriority=j;var oe;_===bn?oe=En(A1.bind(null,a)):oo?oe=yn(j,H2.bind(null,a)):oe=yn(j,H2.bind(null,a),{timeout:bo(_)-vt()}),a.callbackNode=oe}function H2(a,c){if(x1=lt,c){var _=Fl();return qp(a,_),Uo(a),null}var T=q2(a);if(T!==lt){var R=a.callbackNode;if((Zn&(Do|Ss))!==cu)throw Error("Should not already be working.");if(Xa(),(a!==cl||T!==q0)&&(bf(a,T),ee(a,T)),qn!==null){var j=Zn;Zn|=Do;var V=pv(a),te=W2(a);ff(qn);do try{oy();break}catch(Ye){dv(a,Ye)}while(!0);if(mt(),Zn=j,hv(V),Ln&&V2(te),k0===b2){var oe=P2;throw Bp(),bf(a,T),Bf(a,T),Uo(a),oe}if(qn!==null)Bp();else{Tv();var Ie=a.finishedWork=a.current.alternate;a.finishedExpirationTime=T,Qm(a,Ie,k0,T)}if(Uo(a),a.callbackNode===R)return H2.bind(null,a)}}return null}function Qm(a,c,_,T){switch(cl=null,_){case Mf:case b2:throw Error("Root did not complete. This is a bug in React.");case wp:{qp(a,T>Qi?Qi:T);break}case D1:{Bf(a,T);var R=a.lastSuspendedTime;T===R&&(a.nextKnownPendingLevel=Op(c)),d();var j=Ll===bn;if(j&&!(Y0&&If.current)){var V=Tp+M0-vt();if(V>10){if(I2){var te=a.lastPingedTime;if(te===lt||te>=T){a.lastPingedTime=T,bf(a,T);break}}var oe=q2(a);if(oe!==lt&&oe!==T)break;if(R!==lt&&R!==T){a.lastPingedTime=R;break}a.timeoutHandle=St(r0.bind(null,a),V);break}}r0(a);break}case w1:{Bf(a,T);var Ie=a.lastSuspendedTime;if(T===Ie&&(a.nextKnownPendingLevel=Op(c)),d(),!(Y0&&If.current)){if(I2){var Ye=a.lastPingedTime;if(Ye===lt||Ye>=T){a.lastPingedTime=T,bf(a,T);break}}var pt=q2(a);if(pt!==lt&&pt!==T)break;if(Ie!==lt&&Ie!==T){a.lastPingedTime=Ie;break}var Nt;if(S1!==bn)Nt=bo(S1)-vt();else if(Ll===bn)Nt=0;else{var Vt=_v(Ll),zt=vt(),vn=bo(T)-zt,xr=zt-Vt;xr<0&&(xr=0),Nt=Pp(xr)-xr,vn10){a.timeoutHandle=St(r0.bind(null,a),Nt);break}}r0(a);break}case Sp:{if(!(Y0&&If.current)&&Ll!==bn&&Dc!==null){var $r=Ip(Ll,T,Dc);if($r>10){Bf(a,T),a.timeoutHandle=St(r0.bind(null,a),$r);break}}r0(a);break}default:throw Error("Unknown root exit status.")}}function A1(a){var c=a.lastExpiredTime,_=c!==lt?c:bn;if(a.finishedExpirationTime===_)r0(a);else{if((Zn&(Do|Ss))!==cu)throw Error("Should not already be working.");if(Xa(),(a!==cl||_!==q0)&&(bf(a,_),ee(a,_)),qn!==null){var T=Zn;Zn|=Do;var R=pv(a),j=W2(a);ff(qn);do try{Ev();break}catch(te){dv(a,te)}while(!0);if(mt(),Zn=T,hv(R),Ln&&V2(j),k0===b2){var V=P2;throw Bp(),bf(a,_),Bf(a,_),Uo(a),V}if(qn!==null)throw Error("Cannot commit an incomplete root. This error is likely caused by a bug in React. Please file an issue.");Tv(),a.finishedWork=a.current.alternate,a.finishedExpirationTime=_,Jm(a,k0,_),Uo(a)}}return null}function Jm(a,c,_){cl=null,(c===D1||c===w1)&&d(),r0(a)}function Zm(a,c){qp(a,c),Uo(a),(Zn&(Do|Ss))===cu&&It()}function av(){if((Zn&(E1|Do|Ss))!==cu){(Zn&Do)!==cu&&Kt(!1,"unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering.");return}ey(),Xa()}function $m(a){return _n(Wn,a)}function fv(a,c,_,T){return _n(Ci,a.bind(null,c,_,T))}function ey(){if(Ka!==null){var a=Ka;Ka=null,a.forEach(function(c,_){qp(_,c),Uo(_)}),It()}}function ty(a,c){var _=Zn;Zn|=E1;try{return a(c)}finally{Zn=_,Zn===cu&&It()}}function ny(a,c){var _=Zn;Zn|=ki;try{return a(c)}finally{Zn=_,Zn===cu&&It()}}function cv(a,c,_,T){var R=Zn;Zn|=Dp;try{return _n(Xr,a.bind(null,c,_,T))}finally{Zn=R,Zn===cu&&It()}}function ry(a,c){var _=Zn;Zn&=~E1,Zn|=F2;try{return a(c)}finally{Zn=_,Zn===cu&&It()}}function xp(a,c){if((Zn&(Do|Ss))!==cu)throw Error("flushSync was called from inside a lifecycle method. It cannot be called when React is already rendering.");var _=Zn;Zn|=E1;try{return _n(Ci,a.bind(null,c))}finally{Zn=_,It()}}function iy(a){var c=Zn;Zn|=E1;try{_n(Ci,a)}finally{Zn=c,Zn===cu&&It()}}function bf(a,c){a.finishedWork=null,a.finishedExpirationTime=lt;var _=a.timeoutHandle;if(_!==Jo&&(a.timeoutHandle=Jo,so(_)),qn!==null)for(var T=qn.return;T!==null;)$h(T),T=T.return;cl=a,qn=wo(a.current,null,c),q0=c,k0=Mf,P2=null,Ll=bn,S1=bn,Dc=null,wc=lt,I2=!1,Ln&&(Lf=null),wl.discardPendingWarnings(),Ys=null}function dv(a,c){do{try{if(mt(),m2(),nt(),qn===null||qn.return===null)return k0=b2,P2=c,null;Zt&&qn.mode&Jr&&a1(qn,!0),lv(a,qn.return,qn,c,q0),qn=Dv(qn)}catch(_){c=_;continue}return}while(!0)}function pv(a){var c=Cr.current;return Cr.current=o1,c===null?o1:c}function hv(a){Cr.current=a}function W2(a){if(Ln){var c=k.__interactionsRef.current;return k.__interactionsRef.current=a.memoizedInteractions,c}return null}function V2(a){Ln&&(k.__interactionsRef.current=a)}function Ap(){Tp=vt()}function vv(a,c){aQi&&(Ll=a),c!==null&&aQi&&(S1=a,Dc=c)}function G2(a){a>wc&&(wc=a)}function mv(){k0===Mf&&(k0=D1)}function yv(){(k0===Mf||k0===D1)&&(k0=w1),wc!==lt&&cl!==null&&(Bf(cl,q0),o_(cl,wc))}function Rp(){k0!==Sp&&(k0=wp)}function gv(){return k0===Mf}function _v(a){var c=bo(a);return c-pf}function uy(a,c){var _=bo(a);return _-(c.timeoutMs|0||pf)}function Ev(){for(;qn!==null;)qn=Y2(qn)}function oy(){for(;qn!==null&&!kn();)qn=Y2(qn)}function Y2(a){var c=a.alternate;Kl(a),_t(a);var _;return Zt&&(a.mode&Jr)!==Sr?(zd(a),_=R1(c,a,q0),a1(a,!0)):_=R1(c,a,q0),nt(),a.memoizedProps=a.pendingProps,_===null&&(_=Dv(a)),Ep.current=null,_}function Dv(a){qn=a;do{var c=qn.alternate,_=qn.return;if((qn.effectTag&F0)===_i){_t(qn);var T=void 0;if(!Zt||(qn.mode&Jr)===Sr?T=Zh(c,qn,q0):(zd(qn),T=Zh(c,qn,q0),a1(qn,!1)),Yr(qn),nt(),ly(qn),T!==null)return T;if(_!==null&&(_.effectTag&F0)===_i){_.firstEffect===null&&(_.firstEffect=qn.firstEffect),qn.lastEffect!==null&&(_.lastEffect!==null&&(_.lastEffect.nextEffect=qn.firstEffect),_.lastEffect=qn.lastEffect);var R=qn.effectTag;R>eu&&(_.lastEffect!==null?_.lastEffect.nextEffect=qn:_.firstEffect=qn,_.lastEffect=qn)}}else{var j=Pm(qn,q0);if(Zt&&(qn.mode&Jr)!==Sr){a1(qn,!1);for(var V=qn.actualDuration,te=qn.child;te!==null;)V+=te.actualDuration,te=te.sibling;qn.actualDuration=V}if(j!==null)return fo(qn),j.effectTag&=Hl,j;Yr(qn),_!==null&&(_.firstEffect=_.lastEffect=null,_.effectTag|=F0)}var oe=qn.sibling;if(oe!==null)return oe;qn=_}while(qn!==null);return k0===Mf&&(k0=Sp),null}function Op(a){var c=a.expirationTime,_=a.childExpirationTime;return c>_?c:_}function ly(a){if(!(q0!==hi&&a.childExpirationTime===hi)){var c=lt;if(Zt&&(a.mode&Jr)!==Sr){for(var _=a.actualDuration,T=a.selfBaseDuration,R=a.alternate===null||a.child!==a.alternate.child,j=a.child;j!==null;){var V=j.expirationTime,te=j.childExpirationTime;V>c&&(c=V),te>c&&(c=te),R&&(_+=j.actualDuration),T+=j.treeBaseDuration,j=j.sibling}a.actualDuration=_,a.treeBaseDuration=T}else for(var oe=a.child;oe!==null;){var Ie=oe.expirationTime,Ye=oe.childExpirationTime;Ie>c&&(c=Ie),Ye>c&&(c=Ye),oe=oe.sibling}a.childExpirationTime=c}}function r0(a){var c=Xt();return _n(Ci,kp.bind(null,a,c)),null}function kp(a,c){do Xa();while(Cc!==null);if(vy(),(Zn&(Do|Ss))!==cu)throw Error("Should not already be working.");var _=a.finishedWork,T=a.finishedExpirationTime;if(_===null)return null;if(a.finishedWork=null,a.finishedExpirationTime=lt,_===a.current)throw Error("Cannot commit the same tree as before. This error is likely caused by a bug in React. Please file an issue.");a.callbackNode=null,a.callbackExpirationTime=lt,a.callbackPriority=y0,a.nextKnownPendingLevel=lt,J0();var R=Op(_);iE(a,T,R),a===cl&&(cl=null,qn=null,q0=lt);var j;if(_.effectTag>eu?_.lastEffect!==null?(_.lastEffect.nextEffect=_,j=_.firstEffect):j=_:j=_.firstEffect,j!==null){var V=Zn;Zn|=Ss;var te=W2(a);Ep.current=null,Te(),Bn(a.containerInfo),fn=j;do if(sl(null,sy,null),Jn()){if(fn===null)throw Error("Should be working on an effect.");var oe=Vs();Pf(fn,oe),fn=fn.nextEffect}while(fn!==null);et(),Zt&&Oh(),Ve(),fn=j;do if(sl(null,ay,null,a,c),Jn()){if(fn===null)throw Error("Should be working on an effect.");var Ie=Vs();Pf(fn,Ie),fn=fn.nextEffect}while(fn!==null);Gt(),Ir(a.containerInfo),a.current=_,Yt(),fn=j;do if(sl(null,Mp,null,a,T),Jn()){if(fn===null)throw Error("Should be working on an effect.");var Ye=Vs();Pf(fn,Ye),fn=fn.nextEffect}while(fn!==null);sr(),fn=null,se(),Ln&&V2(te),Zn=V}else a.current=_,Te(),et(),Zt&&Oh(),Ve(),Gt(),Yt(),sr();Z0();var pt=Tc;if(Tc)Tc=!1,Cc=a,Cp=T,T1=c;else for(fn=j;fn!==null;){var Nt=fn.nextEffect;fn.nextEffect=null,fn=Nt}var Vt=a.firstPendingTime;if(Vt!==lt){if(Ln){if(Lf!==null){var zt=Lf;Lf=null;for(var vn=0;vnWn?Wn:T1;return T1=y0,_n(a,Np)}}function Np(){if(Cc===null)return!1;var a=Cc,c=Cp;if(Cc=null,Cp=lt,(Zn&(Do|Ss))!==cu)throw Error("Cannot flush passive effects while already rendering.");var _=Zn;Zn|=Ss;for(var T=W2(a),R=a.current.firstEffect;R!==null;){{if(_t(R),sl(null,Ea,null,R),Jn()){if(R===null)throw Error("Should be working on an effect.");var j=Vs();Pf(R,j)}nt()}var V=R.nextEffect;R.nextEffect=null,R=V}return Ln&&(V2(T),de(a,c)),Zn=_,It(),C1=Cc===null?0:C1+1,!0}function Lp(a){return Sc!==null&&Sc.has(a)}function Fp(a){Sc===null?Sc=new Set([a]):Sc.add(a)}function fy(a){B2||(B2=!0,U2=a)}var cy=fy;function wv(a,c,_){var T=cp(_,c),R=uv(a,T,bn);Ua(a,R);var j=z2(a,bn);j!==null&&(Uo(j),H(j,bn))}function Pf(a,c){if(a.tag===U){wv(a,a,c);return}for(var _=a.return;_!==null;){if(_.tag===U){wv(_,a,c);return}else if(_.tag===N){var T=_.type,R=_.stateNode;if(typeof T.getDerivedStateFromError=="function"||typeof R.componentDidCatch=="function"&&!Lp(R)){var j=cp(c,a),V=ov(_,j,bn);Ua(_,V);var te=z2(_,bn);te!==null&&(Uo(te),H(te,bn));return}}_=_.return}}function bp(a,c,_){var T=a.pingCache;if(T!==null&&T.delete(c),cl===a&&q0===_){k0===w1||k0===D1&&Ll===bn&&vt()-TpKm)throw xc=0,j2=null,Error("Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.");C1>sv&&(C1=0,Kt(!1,"Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render."))}function vy(){wl.flushLegacyContextWarning(),fi&&wl.flushPendingUnsafeLifecycleWarnings()}function Tv(){var a=!0;cf(Nf,a),Nf=null}function Bp(){var a=!1;cf(Nf,a),Nf=null}function Up(a,c){Pr&&cl!==null&&c>q0&&(Nf=a)}var K2=null;function my(a){{var c=a.tag;if(c!==U&&c!==N&&c!==L&&c!==ge&&c!==Oe&&c!==le)return;var _=qt(a.type)||"ReactComponent";if(K2!==null){if(K2.has(_))return;K2.add(_)}else K2=new Set([_]);Ke(!1,"Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in %s.%s",c===N?"the componentWillUnmount method":"a useEffect cleanup function",_r(a))}}var R1;if(G0){var yy=null;R1=function(a,c,_){var T=i_(yy,c);try{return op(a,c,_)}catch(j){if(j!==null&&typeof j=="object"&&typeof j.then=="function")throw j;if(mt(),m2(),$h(c),i_(c,T),Zt&&c.mode&Jr&&zd(c),sl(null,op,null,a,c,_),Jn()){var R=Vs();throw R}else throw j}}}else R1=op;var Cv=!1,xv=!1;function gy(a){if(a.tag===N)switch(Ar){case"getChildContext":if(xv)return;Ke(!1,"setState(...): Cannot call setState() inside getChildContext()"),xv=!0;break;case"render":if(Cv)return;Ke(!1,"Cannot update during an existing state transition (such as within `render`). Render methods should be a pure function of props and state."),Cv=!0;break}}var If={current:!1};function jp(a){fs===!0&&fl.current===!0&&If.current!==!0&&Ke(!1,`It looks like you're using the wrong act() around your test interactions. +Be sure to use the matching version of act() corresponding to your renderer: + +// for react-dom: +import {act} from 'react-dom/test-utils'; +// ... +act(() => ...); + +// for react-test-renderer: +import TestRenderer from 'react-test-renderer'; +const {act} = TestRenderer; +// ... +act(() => ...);%s`,_r(a))}function Av(a){fs===!0&&(a.mode&cr)!==Sr&&fl.current===!1&&If.current===!1&&Ke(!1,`An update to %s ran an effect, but was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act%s`,qt(a.type),_r(a))}function _y(a){fs===!0&&Zn===cu&&fl.current===!1&&If.current===!1&&Ke(!1,`An update to %s inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act%s`,qt(a.type),_r(a))}var Ey=_y,zp=!1;function Dy(a){zp===!1&&t.unstable_flushAllWithoutAsserting===void 0&&(a.mode&Y||a.mode&Qr?(zp=!0,Ke(!1,`In Concurrent or Sync modes, the "scheduler" module needs to be mocked to guarantee consistent behaviour across tests and browsers. For example, with jest: +jest.mock('scheduler', () => require('scheduler/unstable_mock')); + +For more info, visit https://fb.me/react-mock-scheduler`)):Yi===!0&&(zp=!0,Ke(!1,`Starting from React v17, the "scheduler" module will need to be mocked to guarantee consistent behaviour across tests and browsers. For example, with jest: +jest.mock('scheduler', () => require('scheduler/unstable_mock')); + +For more info, visit https://fb.me/react-mock-scheduler`)))}var Ys=null;function wy(a){{var c=Xt();if((a.mode&Qr)!==_i&&(c===Xr||c===Ci))for(var _=a;_!==null;){var T=_.alternate;if(T!==null)switch(_.tag){case N:var R=T.updateQueue;if(R!==null)for(var j=R.firstUpdate;j!==null;){var V=j.priority;if(V===Xr||V===Ci){Ys===null?Ys=new Set([qt(_.type)]):Ys.add(qt(_.type));break}j=j.next}break;case L:case ge:case le:if(_.memoizedState!==null&&_.memoizedState.baseUpdate!==null)for(var te=_.memoizedState.baseUpdate;te!==null;){var oe=te.priority;if(oe===Xr||oe===Ci){Ys===null?Ys=new Set([qt(_.type)]):Ys.add(qt(_.type));break}if(te.next===_.memoizedState.baseUpdate)break;te=te.next}break;default:break}_=_.return}}}function d(){if(Ys!==null){var a=[];Ys.forEach(function(c){return a.push(c)}),Ys=null,a.length>0&&Ke(!1,`%s triggered a user-blocking update that suspended. + +The fix is to split the update into multiple parts: a user-blocking update to provide immediate feedback, and another update that triggers the bulk of the changes. + +Refer to the documentation for useTransition to learn how to implement this pattern.`,a.sort().join(", "))}}function v(a,c){return c*1e3+a.interactionThreadID}function x(a){!Ln||(Lf===null?Lf=[a]:Lf.push(a))}function b(a,c,_){if(!!Ln&&_.size>0){var T=a.pendingInteractionMap,R=T.get(c);R!=null?_.forEach(function(te){R.has(te)||te.__count++,R.add(te)}):(T.set(c,new Set(_)),_.forEach(function(te){te.__count++}));var j=k.__subscriberRef.current;if(j!==null){var V=v(a,c);j.onWorkScheduled(_,V)}}}function H(a,c){!Ln||b(a,c,k.__interactionsRef.current)}function ee(a,c){if(!!Ln){var _=new Set;if(a.pendingInteractionMap.forEach(function(j,V){V>=c&&j.forEach(function(te){return _.add(te)})}),a.memoizedInteractions=_,_.size>0){var T=k.__subscriberRef.current;if(T!==null){var R=v(a,c);try{T.onWorkStarted(_,R)}catch(j){yn(Ci,function(){throw j})}}}}}function de(a,c){if(!!Ln){var _=a.firstPendingTime,T;try{if(T=k.__subscriberRef.current,T!==null&&a.memoizedInteractions.size>0){var R=v(a,c);T.onWorkStopped(a.memoizedInteractions,R)}}catch(V){yn(Ci,function(){throw V})}finally{var j=a.pendingInteractionMap;j.forEach(function(V,te){te>_&&(j.delete(te),V.forEach(function(oe){if(oe.__count--,T!==null&&oe.__count===0)try{T.onInteractionScheduledWorkCompleted(oe)}catch(Ie){yn(Ci,function(){throw Ie})}}))})}}}var ye=null,be=null,gt=!1,Dt=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__!="undefined";function Rt(a){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__=="undefined")return!1;var c=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(c.isDisabled)return!0;if(!c.supportsFiber)return Ke(!1,"The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://fb.me/react-devtools"),!0;try{var _=c.inject(a);ye=function(T,R){try{var j=(T.current.effectTag&Hr)===Hr;if(Zt){var V=Ac(),te=$1(V,R);c.onCommitFiberRoot(_,T,te,j)}else c.onCommitFiberRoot(_,T,void 0,j)}catch(oe){gt||(gt=!0,Ke(!1,"React DevTools encountered an error: %s",oe))}},be=function(T){try{c.onCommitFiberUnmount(_,T)}catch(R){gt||(gt=!0,Ke(!1,"React DevTools encountered an error: %s",R))}}}catch(T){Ke(!1,"React DevTools encountered an error: %s.",T)}return!0}function rn(a,c){typeof ye=="function"&&ye(a,c)}function Rn(a){typeof be=="function"&&be(a)}var $n;{$n=!1;try{var Nr=Object.preventExtensions({}),ir=new Map([[Nr,null]]),Zr=new Set([Nr]);ir.set(0,0),Zr.add(0)}catch(a){$n=!0}}var ui=1;function bl(a,c,_,T){this.tag=a,this.key=_,this.elementType=null,this.type=null,this.stateNode=null,this.return=null,this.child=null,this.sibling=null,this.index=0,this.ref=null,this.pendingProps=c,this.memoizedProps=null,this.updateQueue=null,this.memoizedState=null,this.dependencies=null,this.mode=T,this.effectTag=_i,this.nextEffect=null,this.firstEffect=null,this.lastEffect=null,this.expirationTime=lt,this.childExpirationTime=lt,this.alternate=null,Zt&&(this.actualDuration=Number.NaN,this.actualStartTime=Number.NaN,this.selfBaseDuration=Number.NaN,this.treeBaseDuration=Number.NaN,this.actualDuration=0,this.actualStartTime=-1,this.selfBaseDuration=0,this.treeBaseDuration=0),Pr&&(this._debugID=ui++,this._debugIsCurrentlyTiming=!1),this._debugSource=null,this._debugOwner=null,this._debugNeedsRemount=!1,this._debugHookTypes=null,!$n&&typeof Object.preventExtensions=="function"&&Object.preventExtensions(this)}var Wi=function(a,c,_,T){return new bl(a,c,_,T)};function uo(a){var c=a.prototype;return!!(c&&c.isReactComponent)}function i0(a){return typeof a=="function"&&!uo(a)&&a.defaultProps===void 0}function Ts(a){if(typeof a=="function")return uo(a)?N:L;if(a!=null){var c=a.$$typeof;if(c===An)return ge;if(c===Wt)return Oe}return C}function wo(a,c,_){var T=a.alternate;T===null?(T=Wi(a.tag,c,a.key,a.mode),T.elementType=a.elementType,T.type=a.type,T.stateNode=a.stateNode,T._debugID=a._debugID,T._debugSource=a._debugSource,T._debugOwner=a._debugOwner,T._debugHookTypes=a._debugHookTypes,T.alternate=a,a.alternate=T):(T.pendingProps=c,T.effectTag=_i,T.nextEffect=null,T.firstEffect=null,T.lastEffect=null,Zt&&(T.actualDuration=0,T.actualStartTime=-1)),T.childExpirationTime=a.childExpirationTime,T.expirationTime=a.expirationTime,T.child=a.child,T.memoizedProps=a.memoizedProps,T.memoizedState=a.memoizedState,T.updateQueue=a.updateQueue;var R=a.dependencies;switch(T.dependencies=R===null?null:{expirationTime:R.expirationTime,firstContext:R.firstContext,responders:R.responders},T.sibling=a.sibling,T.index=a.index,T.ref=a.ref,Zt&&(T.selfBaseDuration=a.selfBaseDuration,T.treeBaseDuration=a.treeBaseDuration),T._debugNeedsRemount=a._debugNeedsRemount,T.tag){case C:case L:case le:T.type=Zu(a.type);break;case N:T.type=U0(a.type);break;case ge:T.type=vf(a.type);break;default:break}return T}function Rv(a,c){a.effectTag&=ai,a.nextEffect=null,a.firstEffect=null,a.lastEffect=null;var _=a.alternate;if(_===null)a.childExpirationTime=lt,a.expirationTime=c,a.child=null,a.memoizedProps=null,a.memoizedState=null,a.updateQueue=null,a.dependencies=null,Zt&&(a.selfBaseDuration=0,a.treeBaseDuration=0);else{a.childExpirationTime=_.childExpirationTime,a.expirationTime=_.expirationTime,a.child=_.child,a.memoizedProps=_.memoizedProps,a.memoizedState=_.memoizedState,a.updateQueue=_.updateQueue;var T=_.dependencies;a.dependencies=T===null?null:{expirationTime:T.expirationTime,firstContext:T.firstContext,responders:T.responders},Zt&&(a.selfBaseDuration=_.selfBaseDuration,a.treeBaseDuration=_.treeBaseDuration)}return a}function X4(a){var c;return a===R0?c=Qr|Y|cr:a===I0?c=Y|cr:c=Sr,Zt&&Dt&&(c|=Jr),Wi(U,null,null,c)}function Sy(a,c,_,T,R,j){var V,te=C,oe=a;if(typeof a=="function")uo(a)?(te=N,oe=U0(oe)):oe=Zu(oe);else if(typeof a=="string")te=W;else{e:switch(a){case ue:return Qa(_.children,R,j,c);case ln:te=we,R|=Qr|Y|cr;break;case je:te=we,R|=cr;break;case ct:return J4(_,R,j,c);case nr:return Z4(_,R,j,c);case un:return $4(_,R,j,c);default:{if(typeof a=="object"&&a!==null)switch(a.$$typeof){case At:te=he;break e;case en:te=Se;break e;case An:te=ge,oe=vf(oe);break e;case Wt:te=Oe;break e;case vr:te=Ue,oe=null;break e;case w:if(Ht)return r_(a,_,R,j,c);break;case Vn:if(Du)return Q4(a,_,R,j,c)}var Ie="";{(a===void 0||typeof a=="object"&&a!==null&&Object.keys(a).length===0)&&(Ie+=" You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.");var Ye=T?qt(T.type):null;Ye&&(Ie+=` + +Check the render method of \``+Ye+"`.")}throw Error("Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: "+(a==null?a:typeof a)+"."+Ie)}}}return V=Wi(te,_,c,R),V.elementType=a,V.type=oe,V.expirationTime=j,V}function Ty(a,c,_){var T=null;T=a._owner;var R=a.type,j=a.key,V=a.props,te=Sy(R,j,V,T,c,_);return te._debugSource=a._source,te._debugOwner=a._owner,te}function Qa(a,c,_,T){var R=Wi(m,a,T,c);return R.expirationTime=_,R}function r_(a,c,_,T,R){var j=Wi(xt,c,R,_);return j.elementType=a,j.type=a,j.expirationTime=T,j}function Q4(a,c,_,T,R){var j=Wi($e,c,R,_);return j.type=a,j.elementType=a,j.expirationTime=T,j}function J4(a,c,_,T){(typeof a.id!="string"||typeof a.onRender!="function")&&Ke(!1,'Profiler must specify an "id" string and "onRender" function as props');var R=Wi(ze,a,T,c|Jr);return R.elementType=ct,R.type=ct,R.expirationTime=_,R}function Z4(a,c,_,T){var R=Wi(pe,a,T,c);return R.type=nr,R.elementType=nr,R.expirationTime=_,R}function $4(a,c,_,T){var R=Wi(wt,a,T,c);return R.type=un,R.elementType=un,R.expirationTime=_,R}function Cy(a,c,_){var T=Wi(ne,a,null,c);return T.expirationTime=_,T}function eE(){var a=Wi(W,null,null,Sr);return a.elementType="DELETED",a.type="DELETED",a}function tE(a){var c=Wi(rt,null,null,Sr);return c.stateNode=a,c}function xy(a,c,_){var T=a.children!==null?a.children:[],R=Wi(q,T,a.key,c);return R.expirationTime=_,R.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation},R}function i_(a,c){return a===null&&(a=Wi(C,null,null,Sr)),a.tag=c.tag,a.key=c.key,a.elementType=c.elementType,a.type=c.type,a.stateNode=c.stateNode,a.return=c.return,a.child=c.child,a.sibling=c.sibling,a.index=c.index,a.ref=c.ref,a.pendingProps=c.pendingProps,a.memoizedProps=c.memoizedProps,a.updateQueue=c.updateQueue,a.memoizedState=c.memoizedState,a.dependencies=c.dependencies,a.mode=c.mode,a.effectTag=c.effectTag,a.nextEffect=c.nextEffect,a.firstEffect=c.firstEffect,a.lastEffect=c.lastEffect,a.expirationTime=c.expirationTime,a.childExpirationTime=c.childExpirationTime,a.alternate=c.alternate,Zt&&(a.actualDuration=c.actualDuration,a.actualStartTime=c.actualStartTime,a.selfBaseDuration=c.selfBaseDuration,a.treeBaseDuration=c.treeBaseDuration),a._debugID=c._debugID,a._debugSource=c._debugSource,a._debugOwner=c._debugOwner,a._debugIsCurrentlyTiming=c._debugIsCurrentlyTiming,a._debugNeedsRemount=c._debugNeedsRemount,a._debugHookTypes=c._debugHookTypes,a}function nE(a,c,_){this.tag=c,this.current=null,this.containerInfo=a,this.pendingChildren=null,this.pingCache=null,this.finishedExpirationTime=lt,this.finishedWork=null,this.timeoutHandle=Jo,this.context=null,this.pendingContext=null,this.hydrate=_,this.callbackNode=null,this.callbackPriority=y0,this.firstPendingTime=lt,this.firstSuspendedTime=lt,this.lastSuspendedTime=lt,this.nextKnownPendingLevel=lt,this.lastPingedTime=lt,this.lastExpiredTime=lt,Ln&&(this.interactionThreadID=k.unstable_getThreadID(),this.memoizedInteractions=new Set,this.pendingInteractionMap=new Map),Ui&&(this.hydrationCallbacks=null)}function rE(a,c,_,T){var R=new nE(a,c,_);Ui&&(R.hydrationCallbacks=T);var j=X4(c);return R.current=j,j.stateNode=R,R}function u_(a,c){var _=a.firstSuspendedTime,T=a.lastSuspendedTime;return _!==lt&&_>=c&&T<=c}function Bf(a,c){var _=a.firstSuspendedTime,T=a.lastSuspendedTime;_c||_===lt)&&(a.lastSuspendedTime=c),c<=a.lastPingedTime&&(a.lastPingedTime=lt),c<=a.lastExpiredTime&&(a.lastExpiredTime=lt)}function o_(a,c){var _=a.firstPendingTime;c>_&&(a.firstPendingTime=c);var T=a.firstSuspendedTime;T!==lt&&(c>=T?a.firstSuspendedTime=a.lastSuspendedTime=a.nextKnownPendingLevel=lt:c>=a.lastSuspendedTime&&(a.lastSuspendedTime=c+1),c>a.nextKnownPendingLevel&&(a.nextKnownPendingLevel=c))}function iE(a,c,_){a.firstPendingTime=_,c<=a.lastSuspendedTime?a.firstSuspendedTime=a.lastSuspendedTime=a.nextKnownPendingLevel=lt:c<=a.firstSuspendedTime&&(a.firstSuspendedTime=c-1),c<=a.lastPingedTime&&(a.lastPingedTime=lt),c<=a.lastExpiredTime&&(a.lastExpiredTime=lt)}function qp(a,c){var _=a.lastExpiredTime;(_===lt||_>c)&&(a.lastExpiredTime=c)}var uE={debugTool:null},Ov=uE,Ay,Ry;Ay=!1,Ry={};function oE(a){if(!a)return Sn;var c=jt(a),_=El(c);if(c.tag===N){var T=c.type;if(zi(T))return A0(c,T,_)}return _}function Oy(a){var c=jt(a);if(c===void 0)throw typeof a.render=="function"?Error("Unable to find node on an unmounted component."):Error("Argument appears to not be a ReactComponent. Keys: "+Object.keys(a));var _=b0(c);return _===null?null:_.stateNode}function lE(a,c){{var _=jt(a);if(_===void 0)throw typeof a.render=="function"?Error("Unable to find node on an unmounted component."):Error("Argument appears to not be a ReactComponent. Keys: "+Object.keys(a));var T=b0(_);if(T===null)return null;if(T.mode&cr){var R=qt(_.type)||"Component";Ry[R]||(Ry[R]=!0,_.mode&cr?Ke(!1,"%s is deprecated in StrictMode. %s was passed an instance of %s which is inside StrictMode. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here: https://fb.me/react-strict-mode-find-node%s",c,c,R,_r(T)):Ke(!1,"%s is deprecated in StrictMode. %s was passed an instance of %s which renders StrictMode children. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here: https://fb.me/react-strict-mode-find-node%s",c,c,R,_r(T)))}return T.stateNode}return Oy(a)}function sE(a,c,_,T){return rE(a,c,_,T)}function l_(a,c,_,T){var R=c.current,j=Fl();typeof jest!="undefined"&&(Dy(R),jp(R));var V=mo(),te=Ff(j,R,V);Ov.debugTool&&(R.alternate===null?Ov.debugTool.onMountContainer(c):a===null?Ov.debugTool.onUnmountContainer(c):Ov.debugTool.onUpdateContainer(c));var oe=oE(_);c.context===null?c.context=oe:c.pendingContext=oe,Ar==="render"&&Cn!==null&&!Ay&&(Ay=!0,Ke(!1,`Render methods should be a pure function of props and state; triggering nested component updates from render is not allowed. If necessary, trigger nested updates in componentDidUpdate. + +Check the render method of %s.`,qt(Cn.type)||"Unknown"));var Ie=gu(te,V);return Ie.payload={element:a},T=T===void 0?null:T,T!==null&&(typeof T!="function"&&Ke(!1,"render(...): Expected the last optional `callback` argument to be a function. Instead received: %s.",T),Ie.callback=T),Ua(R,Ie),dl(R,te),te}function aE(a){var c=a.current;if(!c.child)return null;switch(c.child.tag){case W:return Ro(c.child.stateNode);default:return c.child.stateNode}}function fE(a){switch(a.tag){case U:var c=a.stateNode;c.hydrate&&Zm(c,c.firstPendingTime);break;case pe:xp(function(){return dl(a,bn)});var _=La(Fl());kv(a,_);break}}function s_(a,c){var _=a.memoizedState;_!==null&&_.dehydrated!==null&&_.retryTime=c.length)return T;var R=c[_],j=Array.isArray(a)?a.slice():f({},a);return j[R]=Ny(a[R],c,_+1,T),j},h_=function(a,c,_){return Ny(a,c,0,_)};f_=function(a,c,_,T){for(var R=a.memoizedState;R!==null&&c>0;)R=R.next,c--;if(R!==null){var j=h_(R.memoizedState,_,T);R.memoizedState=j,R.baseState=j,a.memoizedProps=f({},a.memoizedProps),dl(a,bn)}},c_=function(a,c,_){a.pendingProps=h_(a.memoizedProps,c,_),a.alternate&&(a.alternate.pendingProps=a.pendingProps),dl(a,bn)},d_=function(a){dl(a,bn)},p_=function(a){My=a}}function hE(a){var c=a.findFiberByHostInstance,_=at.ReactCurrentDispatcher;return Rt(f({},a,{overrideHookState:f_,overrideProps:c_,setSuspenseHandler:p_,scheduleUpdate:d_,currentDispatcherRef:_,findHostInstanceByFiber:function(T){var R=b0(T);return R===null?null:R.stateNode},findFiberByHostInstance:function(T){return c?c(T):null},findHostInstancesForRefresh:n2,scheduleRefresh:Sl,scheduleRoot:_s,setRefreshHandler:Ia,getCurrentFiber:function(){return Cn}}))}var v_=Object.freeze({createContainer:sE,updateContainer:l_,batchedEventUpdates:ny,batchedUpdates:ty,unbatchedUpdates:ry,deferredUpdates:$m,syncUpdates:fv,discreteUpdates:cv,flushDiscreteUpdates:av,flushControlled:iy,flushSync:xp,flushPassiveEffects:Xa,IsThisRendererActing:If,getPublicRootInstance:aE,attemptSynchronousHydration:fE,attemptUserBlockingHydration:cE,attemptContinuousHydration:ky,attemptHydrationAtCurrentPriority:dE,findHostInstance:Oy,findHostInstanceWithWarning:lE,findHostInstanceWithNoPortals:pE,shouldSuspend:a_,injectIntoDevTools:hE}),vE=v_.default||v_;hg.exports=vE;var mE=hg.exports;return hg.exports=i,mE})});var D9=ce((zne,cw)=>{"use strict";process.env.NODE_ENV==="production"?cw.exports=m9():cw.exports=E9()});var S9=ce((qne,w9)=>{"use strict";var ZK={ALIGN_COUNT:8,ALIGN_AUTO:0,ALIGN_FLEX_START:1,ALIGN_CENTER:2,ALIGN_FLEX_END:3,ALIGN_STRETCH:4,ALIGN_BASELINE:5,ALIGN_SPACE_BETWEEN:6,ALIGN_SPACE_AROUND:7,DIMENSION_COUNT:2,DIMENSION_WIDTH:0,DIMENSION_HEIGHT:1,DIRECTION_COUNT:3,DIRECTION_INHERIT:0,DIRECTION_LTR:1,DIRECTION_RTL:2,DISPLAY_COUNT:2,DISPLAY_FLEX:0,DISPLAY_NONE:1,EDGE_COUNT:9,EDGE_LEFT:0,EDGE_TOP:1,EDGE_RIGHT:2,EDGE_BOTTOM:3,EDGE_START:4,EDGE_END:5,EDGE_HORIZONTAL:6,EDGE_VERTICAL:7,EDGE_ALL:8,EXPERIMENTAL_FEATURE_COUNT:1,EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS:0,FLEX_DIRECTION_COUNT:4,FLEX_DIRECTION_COLUMN:0,FLEX_DIRECTION_COLUMN_REVERSE:1,FLEX_DIRECTION_ROW:2,FLEX_DIRECTION_ROW_REVERSE:3,JUSTIFY_COUNT:6,JUSTIFY_FLEX_START:0,JUSTIFY_CENTER:1,JUSTIFY_FLEX_END:2,JUSTIFY_SPACE_BETWEEN:3,JUSTIFY_SPACE_AROUND:4,JUSTIFY_SPACE_EVENLY:5,LOG_LEVEL_COUNT:6,LOG_LEVEL_ERROR:0,LOG_LEVEL_WARN:1,LOG_LEVEL_INFO:2,LOG_LEVEL_DEBUG:3,LOG_LEVEL_VERBOSE:4,LOG_LEVEL_FATAL:5,MEASURE_MODE_COUNT:3,MEASURE_MODE_UNDEFINED:0,MEASURE_MODE_EXACTLY:1,MEASURE_MODE_AT_MOST:2,NODE_TYPE_COUNT:2,NODE_TYPE_DEFAULT:0,NODE_TYPE_TEXT:1,OVERFLOW_COUNT:3,OVERFLOW_VISIBLE:0,OVERFLOW_HIDDEN:1,OVERFLOW_SCROLL:2,POSITION_TYPE_COUNT:2,POSITION_TYPE_RELATIVE:0,POSITION_TYPE_ABSOLUTE:1,PRINT_OPTIONS_COUNT:3,PRINT_OPTIONS_LAYOUT:1,PRINT_OPTIONS_STYLE:2,PRINT_OPTIONS_CHILDREN:4,UNIT_COUNT:4,UNIT_UNDEFINED:0,UNIT_POINT:1,UNIT_PERCENT:2,UNIT_AUTO:3,WRAP_COUNT:3,WRAP_NO_WRAP:0,WRAP_WRAP:1,WRAP_WRAP_REVERSE:2};w9.exports=ZK});var A9=ce((Hne,T9)=>{"use strict";var $K=Object.assign||function(i){for(var o=1;o"}}]),i}(),C9=function(){v4(i,null,[{key:"fromJS",value:function(f){var p=f.width,E=f.height;return new i(p,E)}}]);function i(o,f){pw(this,i),this.width=o,this.height=f}return v4(i,[{key:"fromJS",value:function(f){f(this.width,this.height)}},{key:"toString",value:function(){return""}}]),i}(),x9=function(){function i(o,f){pw(this,i),this.unit=o,this.value=f}return v4(i,[{key:"fromJS",value:function(f){f(this.unit,this.value)}},{key:"toString",value:function(){switch(this.unit){case Jf.UNIT_POINT:return String(this.value);case Jf.UNIT_PERCENT:return this.value+"%";case Jf.UNIT_AUTO:return"auto";default:return this.value+"?"}}},{key:"valueOf",value:function(){return this.value}}]),i}();T9.exports=function(i,o){function f(k,L,N){var C=k[L];k[L]=function(){for(var U=arguments.length,q=Array(U),W=0;W1?q-1:0),ne=1;ne1&&arguments[1]!==void 0?arguments[1]:NaN,N=arguments.length>2&&arguments[2]!==void 0?arguments[2]:NaN,C=arguments.length>3&&arguments[3]!==void 0?arguments[3]:Jf.DIRECTION_LTR;return k.call(this,L,N,C)}),$K({Config:o.Config,Node:o.Node,Layout:i("Layout",eX),Size:i("Size",C9),Value:i("Value",x9),getInstanceCount:function(){return o.getInstanceCount.apply(o,arguments)}},Jf)}});var R9=ce((exports,module)=>{(function(i,o){typeof define=="function"&&define.amd?define([],function(){return o}):typeof module=="object"&&module.exports?module.exports=o:(i.nbind=i.nbind||{}).init=o})(exports,function(Module,cb){typeof Module=="function"&&(cb=Module,Module={}),Module.onRuntimeInitialized=function(i,o){return function(){i&&i.apply(this,arguments);try{Module.ccall("nbind_init")}catch(f){o(f);return}o(null,{bind:Module._nbind_value,reflect:Module.NBind.reflect,queryType:Module.NBind.queryType,toggleLightGC:Module.toggleLightGC,lib:Module})}}(Module.onRuntimeInitialized,cb);var Module;Module||(Module=(typeof Module!="undefined"?Module:null)||{});var moduleOverrides={};for(var key in Module)Module.hasOwnProperty(key)&&(moduleOverrides[key]=Module[key]);var ENVIRONMENT_IS_WEB=!1,ENVIRONMENT_IS_WORKER=!1,ENVIRONMENT_IS_NODE=!1,ENVIRONMENT_IS_SHELL=!1;if(Module.ENVIRONMENT)if(Module.ENVIRONMENT==="WEB")ENVIRONMENT_IS_WEB=!0;else if(Module.ENVIRONMENT==="WORKER")ENVIRONMENT_IS_WORKER=!0;else if(Module.ENVIRONMENT==="NODE")ENVIRONMENT_IS_NODE=!0;else if(Module.ENVIRONMENT==="SHELL")ENVIRONMENT_IS_SHELL=!0;else throw new Error("The provided Module['ENVIRONMENT'] value is not valid. It must be one of: WEB|WORKER|NODE|SHELL.");else ENVIRONMENT_IS_WEB=typeof window=="object",ENVIRONMENT_IS_WORKER=typeof importScripts=="function",ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof require=="function"&&!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_WORKER,ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){Module.print||(Module.print=console.log),Module.printErr||(Module.printErr=console.warn);var nodeFS,nodePath;Module.read=function(o,f){nodeFS||(nodeFS={}("")),nodePath||(nodePath={}("")),o=nodePath.normalize(o);var p=nodeFS.readFileSync(o);return f?p:p.toString()},Module.readBinary=function(o){var f=Module.read(o,!0);return f.buffer||(f=new Uint8Array(f)),assert(f.buffer),f},Module.load=function(o){globalEval(read(o))},Module.thisProgram||(process.argv.length>1?Module.thisProgram=process.argv[1].replace(/\\/g,"/"):Module.thisProgram="unknown-program"),Module.arguments=process.argv.slice(2),typeof module!="undefined"&&(module.exports=Module),Module.inspect=function(){return"[Emscripten Module object]"}}else if(ENVIRONMENT_IS_SHELL)Module.print||(Module.print=print),typeof printErr!="undefined"&&(Module.printErr=printErr),typeof read!="undefined"?Module.read=read:Module.read=function(){throw"no read() available"},Module.readBinary=function(o){if(typeof readbuffer=="function")return new Uint8Array(readbuffer(o));var f=read(o,"binary");return assert(typeof f=="object"),f},typeof scriptArgs!="undefined"?Module.arguments=scriptArgs:typeof arguments!="undefined"&&(Module.arguments=arguments),typeof quit=="function"&&(Module.quit=function(i,o){quit(i)});else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(Module.read=function(o){var f=new XMLHttpRequest;return f.open("GET",o,!1),f.send(null),f.responseText},ENVIRONMENT_IS_WORKER&&(Module.readBinary=function(o){var f=new XMLHttpRequest;return f.open("GET",o,!1),f.responseType="arraybuffer",f.send(null),new Uint8Array(f.response)}),Module.readAsync=function(o,f,p){var E=new XMLHttpRequest;E.open("GET",o,!0),E.responseType="arraybuffer",E.onload=function(){E.status==200||E.status==0&&E.response?f(E.response):p()},E.onerror=p,E.send(null)},typeof arguments!="undefined"&&(Module.arguments=arguments),typeof console!="undefined")Module.print||(Module.print=function(o){console.log(o)}),Module.printErr||(Module.printErr=function(o){console.warn(o)});else{var TRY_USE_DUMP=!1;Module.print||(Module.print=TRY_USE_DUMP&&typeof dump!="undefined"?function(i){dump(i)}:function(i){})}ENVIRONMENT_IS_WORKER&&(Module.load=importScripts),typeof Module.setWindowTitle=="undefined"&&(Module.setWindowTitle=function(i){document.title=i})}else throw"Unknown runtime environment. Where are we?";function globalEval(i){eval.call(null,i)}!Module.load&&Module.read&&(Module.load=function(o){globalEval(Module.read(o))}),Module.print||(Module.print=function(){}),Module.printErr||(Module.printErr=Module.print),Module.arguments||(Module.arguments=[]),Module.thisProgram||(Module.thisProgram="./this.program"),Module.quit||(Module.quit=function(i,o){throw o}),Module.print=Module.print,Module.printErr=Module.printErr,Module.preRun=[],Module.postRun=[];for(var key in moduleOverrides)moduleOverrides.hasOwnProperty(key)&&(Module[key]=moduleOverrides[key]);moduleOverrides=void 0;var Runtime={setTempRet0:function(i){return tempRet0=i,i},getTempRet0:function(){return tempRet0},stackSave:function(){return STACKTOP},stackRestore:function(i){STACKTOP=i},getNativeTypeSize:function(i){switch(i){case"i1":case"i8":return 1;case"i16":return 2;case"i32":return 4;case"i64":return 8;case"float":return 4;case"double":return 8;default:{if(i[i.length-1]==="*")return Runtime.QUANTUM_SIZE;if(i[0]==="i"){var o=parseInt(i.substr(1));return assert(o%8==0),o/8}else return 0}}},getNativeFieldSize:function(i){return Math.max(Runtime.getNativeTypeSize(i),Runtime.QUANTUM_SIZE)},STACK_ALIGN:16,prepVararg:function(i,o){return o==="double"||o==="i64"?i&7&&(assert((i&7)==4),i+=4):assert((i&3)==0),i},getAlignSize:function(i,o,f){return!f&&(i=="i64"||i=="double")?8:i?Math.min(o||(i?Runtime.getNativeFieldSize(i):0),Runtime.QUANTUM_SIZE):Math.min(o,8)},dynCall:function(i,o,f){return f&&f.length?Module["dynCall_"+i].apply(null,[o].concat(f)):Module["dynCall_"+i].call(null,o)},functionPointers:[],addFunction:function(i){for(var o=0;o>2],f=(o+i+15|0)&-16;if(HEAP32[DYNAMICTOP_PTR>>2]=f,f>=TOTAL_MEMORY){var p=enlargeMemory();if(!p)return HEAP32[DYNAMICTOP_PTR>>2]=o,0}return o},alignMemory:function(i,o){var f=i=Math.ceil(i/(o||16))*(o||16);return f},makeBigInt:function(i,o,f){var p=f?+(i>>>0)+ +(o>>>0)*4294967296:+(i>>>0)+ +(o|0)*4294967296;return p},GLOBAL_BASE:8,QUANTUM_SIZE:4,__dummy__:0};Module.Runtime=Runtime;var ABORT=0,EXITSTATUS=0;function assert(i,o){i||abort("Assertion failed: "+o)}function getCFunc(ident){var func=Module["_"+ident];if(!func)try{func=eval("_"+ident)}catch(i){}return assert(func,"Cannot call unknown function "+ident+" (perhaps LLVM optimizations or closure removed it?)"),func}var cwrap,ccall;(function(){var JSfuncs={stackSave:function(){Runtime.stackSave()},stackRestore:function(){Runtime.stackRestore()},arrayToC:function(i){var o=Runtime.stackAlloc(i.length);return writeArrayToMemory(i,o),o},stringToC:function(i){var o=0;if(i!=null&&i!==0){var f=(i.length<<2)+1;o=Runtime.stackAlloc(f),stringToUTF8(i,o,f)}return o}},toC={string:JSfuncs.stringToC,array:JSfuncs.arrayToC};ccall=function(o,f,p,E,t){var k=getCFunc(o),L=[],N=0;if(E)for(var C=0;C>0]=o;break;case"i8":HEAP8[i>>0]=o;break;case"i16":HEAP16[i>>1]=o;break;case"i32":HEAP32[i>>2]=o;break;case"i64":tempI64=[o>>>0,(tempDouble=o,+Math_abs(tempDouble)>=1?tempDouble>0?(Math_min(+Math_floor(tempDouble/4294967296),4294967295)|0)>>>0:~~+Math_ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[i>>2]=tempI64[0],HEAP32[i+4>>2]=tempI64[1];break;case"float":HEAPF32[i>>2]=o;break;case"double":HEAPF64[i>>3]=o;break;default:abort("invalid type for setValue: "+f)}}Module.setValue=setValue;function getValue(i,o,f){switch(o=o||"i8",o.charAt(o.length-1)==="*"&&(o="i32"),o){case"i1":return HEAP8[i>>0];case"i8":return HEAP8[i>>0];case"i16":return HEAP16[i>>1];case"i32":return HEAP32[i>>2];case"i64":return HEAP32[i>>2];case"float":return HEAPF32[i>>2];case"double":return HEAPF64[i>>3];default:abort("invalid type for setValue: "+o)}return null}Module.getValue=getValue;var ALLOC_NORMAL=0,ALLOC_STACK=1,ALLOC_STATIC=2,ALLOC_DYNAMIC=3,ALLOC_NONE=4;Module.ALLOC_NORMAL=ALLOC_NORMAL,Module.ALLOC_STACK=ALLOC_STACK,Module.ALLOC_STATIC=ALLOC_STATIC,Module.ALLOC_DYNAMIC=ALLOC_DYNAMIC,Module.ALLOC_NONE=ALLOC_NONE;function allocate(i,o,f,p){var E,t;typeof i=="number"?(E=!0,t=i):(E=!1,t=i.length);var k=typeof o=="string"?o:null,L;if(f==ALLOC_NONE?L=p:L=[typeof _malloc=="function"?_malloc:Runtime.staticAlloc,Runtime.stackAlloc,Runtime.staticAlloc,Runtime.dynamicAlloc][f===void 0?ALLOC_STATIC:f](Math.max(t,k?1:o.length)),E){var p=L,N;for(assert((L&3)==0),N=L+(t&~3);p>2]=0;for(N=L+t;p>0]=0;return L}if(k==="i8")return i.subarray||i.slice?HEAPU8.set(i,L):HEAPU8.set(new Uint8Array(i),L),L;for(var C=0,U,q,W;C>0],f|=p,!(p==0&&!o||(E++,o&&E==o)););o||(o=E);var t="";if(f<128){for(var k=1024,L;o>0;)L=String.fromCharCode.apply(String,HEAPU8.subarray(i,i+Math.min(o,k))),t=t?t+L:L,i+=k,o-=k;return t}return Module.UTF8ToString(i)}Module.Pointer_stringify=Pointer_stringify;function AsciiToString(i){for(var o="";;){var f=HEAP8[i++>>0];if(!f)return o;o+=String.fromCharCode(f)}}Module.AsciiToString=AsciiToString;function stringToAscii(i,o){return writeAsciiToMemory(i,o,!1)}Module.stringToAscii=stringToAscii;var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):void 0;function UTF8ArrayToString(i,o){for(var f=o;i[f];)++f;if(f-o>16&&i.subarray&&UTF8Decoder)return UTF8Decoder.decode(i.subarray(o,f));for(var p,E,t,k,L,N,C="";;){if(p=i[o++],!p)return C;if(!(p&128)){C+=String.fromCharCode(p);continue}if(E=i[o++]&63,(p&224)==192){C+=String.fromCharCode((p&31)<<6|E);continue}if(t=i[o++]&63,(p&240)==224?p=(p&15)<<12|E<<6|t:(k=i[o++]&63,(p&248)==240?p=(p&7)<<18|E<<12|t<<6|k:(L=i[o++]&63,(p&252)==248?p=(p&3)<<24|E<<18|t<<12|k<<6|L:(N=i[o++]&63,p=(p&1)<<30|E<<24|t<<18|k<<12|L<<6|N))),p<65536)C+=String.fromCharCode(p);else{var U=p-65536;C+=String.fromCharCode(55296|U>>10,56320|U&1023)}}}Module.UTF8ArrayToString=UTF8ArrayToString;function UTF8ToString(i){return UTF8ArrayToString(HEAPU8,i)}Module.UTF8ToString=UTF8ToString;function stringToUTF8Array(i,o,f,p){if(!(p>0))return 0;for(var E=f,t=f+p-1,k=0;k=55296&&L<=57343&&(L=65536+((L&1023)<<10)|i.charCodeAt(++k)&1023),L<=127){if(f>=t)break;o[f++]=L}else if(L<=2047){if(f+1>=t)break;o[f++]=192|L>>6,o[f++]=128|L&63}else if(L<=65535){if(f+2>=t)break;o[f++]=224|L>>12,o[f++]=128|L>>6&63,o[f++]=128|L&63}else if(L<=2097151){if(f+3>=t)break;o[f++]=240|L>>18,o[f++]=128|L>>12&63,o[f++]=128|L>>6&63,o[f++]=128|L&63}else if(L<=67108863){if(f+4>=t)break;o[f++]=248|L>>24,o[f++]=128|L>>18&63,o[f++]=128|L>>12&63,o[f++]=128|L>>6&63,o[f++]=128|L&63}else{if(f+5>=t)break;o[f++]=252|L>>30,o[f++]=128|L>>24&63,o[f++]=128|L>>18&63,o[f++]=128|L>>12&63,o[f++]=128|L>>6&63,o[f++]=128|L&63}}return o[f]=0,f-E}Module.stringToUTF8Array=stringToUTF8Array;function stringToUTF8(i,o,f){return stringToUTF8Array(i,HEAPU8,o,f)}Module.stringToUTF8=stringToUTF8;function lengthBytesUTF8(i){for(var o=0,f=0;f=55296&&p<=57343&&(p=65536+((p&1023)<<10)|i.charCodeAt(++f)&1023),p<=127?++o:p<=2047?o+=2:p<=65535?o+=3:p<=2097151?o+=4:p<=67108863?o+=5:o+=6}return o}Module.lengthBytesUTF8=lengthBytesUTF8;var UTF16Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf-16le"):void 0;function demangle(i){var o=Module.___cxa_demangle||Module.__cxa_demangle;if(o){try{var f=i.substr(1),p=lengthBytesUTF8(f)+1,E=_malloc(p);stringToUTF8(f,E,p);var t=_malloc(4),k=o(E,0,0,t);if(getValue(t,"i32")===0&&k)return Pointer_stringify(k)}catch(L){}finally{E&&_free(E),t&&_free(t),k&&_free(k)}return i}return Runtime.warnOnce("warning: build with -s DEMANGLE_SUPPORT=1 to link in libcxxabi demangling"),i}function demangleAll(i){var o=/__Z[\w\d_]+/g;return i.replace(o,function(f){var p=demangle(f);return f===p?f:f+" ["+p+"]"})}function jsStackTrace(){var i=new Error;if(!i.stack){try{throw new Error(0)}catch(o){i=o}if(!i.stack)return"(no stack trace available)"}return i.stack.toString()}function stackTrace(){var i=jsStackTrace();return Module.extraStackTrace&&(i+=` +`+Module.extraStackTrace()),demangleAll(i)}Module.stackTrace=stackTrace;var HEAP,buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBufferViews(){Module.HEAP8=HEAP8=new Int8Array(buffer),Module.HEAP16=HEAP16=new Int16Array(buffer),Module.HEAP32=HEAP32=new Int32Array(buffer),Module.HEAPU8=HEAPU8=new Uint8Array(buffer),Module.HEAPU16=HEAPU16=new Uint16Array(buffer),Module.HEAPU32=HEAPU32=new Uint32Array(buffer),Module.HEAPF32=HEAPF32=new Float32Array(buffer),Module.HEAPF64=HEAPF64=new Float64Array(buffer)}var STATIC_BASE,STATICTOP,staticSealed,STACK_BASE,STACKTOP,STACK_MAX,DYNAMIC_BASE,DYNAMICTOP_PTR;STATIC_BASE=STATICTOP=STACK_BASE=STACKTOP=STACK_MAX=DYNAMIC_BASE=DYNAMICTOP_PTR=0,staticSealed=!1;function abortOnCannotGrowMemory(){abort("Cannot enlarge memory arrays. Either (1) compile with -s TOTAL_MEMORY=X with X higher than the current value "+TOTAL_MEMORY+", (2) compile with -s ALLOW_MEMORY_GROWTH=1 which allows increasing the size at runtime but prevents some optimizations, (3) set Module.TOTAL_MEMORY to a higher value before the program runs, or (4) if you want malloc to return NULL (0) instead of this abort, compile with -s ABORTING_MALLOC=0 ")}function enlargeMemory(){abortOnCannotGrowMemory()}var TOTAL_STACK=Module.TOTAL_STACK||5242880,TOTAL_MEMORY=Module.TOTAL_MEMORY||134217728;TOTAL_MEMORY0;){var o=i.shift();if(typeof o=="function"){o();continue}var f=o.func;typeof f=="number"?o.arg===void 0?Module.dynCall_v(f):Module.dynCall_vi(f,o.arg):f(o.arg===void 0?null:o.arg)}}var __ATPRERUN__=[],__ATINIT__=[],__ATMAIN__=[],__ATEXIT__=[],__ATPOSTRUN__=[],runtimeInitialized=!1,runtimeExited=!1;function preRun(){if(Module.preRun)for(typeof Module.preRun=="function"&&(Module.preRun=[Module.preRun]);Module.preRun.length;)addOnPreRun(Module.preRun.shift());callRuntimeCallbacks(__ATPRERUN__)}function ensureInitRuntime(){runtimeInitialized||(runtimeInitialized=!0,callRuntimeCallbacks(__ATINIT__))}function preMain(){callRuntimeCallbacks(__ATMAIN__)}function exitRuntime(){callRuntimeCallbacks(__ATEXIT__),runtimeExited=!0}function postRun(){if(Module.postRun)for(typeof Module.postRun=="function"&&(Module.postRun=[Module.postRun]);Module.postRun.length;)addOnPostRun(Module.postRun.shift());callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(i){__ATPRERUN__.unshift(i)}Module.addOnPreRun=addOnPreRun;function addOnInit(i){__ATINIT__.unshift(i)}Module.addOnInit=addOnInit;function addOnPreMain(i){__ATMAIN__.unshift(i)}Module.addOnPreMain=addOnPreMain;function addOnExit(i){__ATEXIT__.unshift(i)}Module.addOnExit=addOnExit;function addOnPostRun(i){__ATPOSTRUN__.unshift(i)}Module.addOnPostRun=addOnPostRun;function intArrayFromString(i,o,f){var p=f>0?f:lengthBytesUTF8(i)+1,E=new Array(p),t=stringToUTF8Array(i,E,0,E.length);return o&&(E.length=t),E}Module.intArrayFromString=intArrayFromString;function intArrayToString(i){for(var o=[],f=0;f255&&(p&=255),o.push(String.fromCharCode(p))}return o.join("")}Module.intArrayToString=intArrayToString;function writeStringToMemory(i,o,f){Runtime.warnOnce("writeStringToMemory is deprecated and should not be called! Use stringToUTF8() instead!");var p,E;f&&(E=o+lengthBytesUTF8(i),p=HEAP8[E]),stringToUTF8(i,o,Infinity),f&&(HEAP8[E]=p)}Module.writeStringToMemory=writeStringToMemory;function writeArrayToMemory(i,o){HEAP8.set(i,o)}Module.writeArrayToMemory=writeArrayToMemory;function writeAsciiToMemory(i,o,f){for(var p=0;p>0]=i.charCodeAt(p);f||(HEAP8[o>>0]=0)}if(Module.writeAsciiToMemory=writeAsciiToMemory,(!Math.imul||Math.imul(4294967295,5)!==-5)&&(Math.imul=function(o,f){var p=o>>>16,E=o&65535,t=f>>>16,k=f&65535;return E*k+(p*k+E*t<<16)|0}),Math.imul=Math.imul,!Math.fround){var froundBuffer=new Float32Array(1);Math.fround=function(i){return froundBuffer[0]=i,froundBuffer[0]}}Math.fround=Math.fround,Math.clz32||(Math.clz32=function(i){i=i>>>0;for(var o=0;o<32;o++)if(i&1<<31-o)return o;return 32}),Math.clz32=Math.clz32,Math.trunc||(Math.trunc=function(i){return i<0?Math.ceil(i):Math.floor(i)}),Math.trunc=Math.trunc;var Math_abs=Math.abs,Math_cos=Math.cos,Math_sin=Math.sin,Math_tan=Math.tan,Math_acos=Math.acos,Math_asin=Math.asin,Math_atan=Math.atan,Math_atan2=Math.atan2,Math_exp=Math.exp,Math_log=Math.log,Math_sqrt=Math.sqrt,Math_ceil=Math.ceil,Math_floor=Math.floor,Math_pow=Math.pow,Math_imul=Math.imul,Math_fround=Math.fround,Math_round=Math.round,Math_min=Math.min,Math_clz32=Math.clz32,Math_trunc=Math.trunc,runDependencies=0,runDependencyWatcher=null,dependenciesFulfilled=null;function getUniqueRunDependency(i){return i}function addRunDependency(i){runDependencies++,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies)}Module.addRunDependency=addRunDependency;function removeRunDependency(i){if(runDependencies--,Module.monitorRunDependencies&&Module.monitorRunDependencies(runDependencies),runDependencies==0&&(runDependencyWatcher!==null&&(clearInterval(runDependencyWatcher),runDependencyWatcher=null),dependenciesFulfilled)){var o=dependenciesFulfilled;dependenciesFulfilled=null,o()}}Module.removeRunDependency=removeRunDependency,Module.preloadedImages={},Module.preloadedAudios={};var ASM_CONSTS=[function(i,o,f,p,E,t,k,L){return _nbind.callbackSignatureList[i].apply(this,arguments)}];function _emscripten_asm_const_iiiiiiii(i,o,f,p,E,t,k,L){return ASM_CONSTS[i](o,f,p,E,t,k,L)}function _emscripten_asm_const_iiiii(i,o,f,p,E){return ASM_CONSTS[i](o,f,p,E)}function _emscripten_asm_const_iiidddddd(i,o,f,p,E,t,k,L,N){return ASM_CONSTS[i](o,f,p,E,t,k,L,N)}function _emscripten_asm_const_iiididi(i,o,f,p,E,t,k){return ASM_CONSTS[i](o,f,p,E,t,k)}function _emscripten_asm_const_iiii(i,o,f,p){return ASM_CONSTS[i](o,f,p)}function _emscripten_asm_const_iiiid(i,o,f,p,E){return ASM_CONSTS[i](o,f,p,E)}function _emscripten_asm_const_iiiiii(i,o,f,p,E,t){return ASM_CONSTS[i](o,f,p,E,t)}STATIC_BASE=Runtime.GLOBAL_BASE,STATICTOP=STATIC_BASE+12800,__ATINIT__.push({func:function(){__GLOBAL__sub_I_Yoga_cpp()}},{func:function(){__GLOBAL__sub_I_nbind_cc()}},{func:function(){__GLOBAL__sub_I_common_cc()}},{func:function(){__GLOBAL__sub_I_Binding_cc()}}),allocate([0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,192,127,0,0,192,127,3,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,3,0,0,0,0,0,192,127,3,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,192,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192,127,0,0,192,127,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,128,191,0,0,128,191,0,0,192,127,0,0,0,0,0,0,0,0,0,0,128,63,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,3,0,0,0,1,0,0,0,2,0,0,0,0,0,0,0,190,12,0,0,200,12,0,0,208,12,0,0,216,12,0,0,230,12,0,0,242,12,0,0,1,0,0,0,3,0,0,0,0,0,0,0,2,0,0,0,0,0,192,127,3,0,0,0,180,45,0,0,181,45,0,0,182,45,0,0,181,45,0,0,182,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0,1,0,0,0,4,0,0,0,183,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,181,45,0,0,184,45,0,0,185,45,0,0,181,45,0,0,181,45,0,0,182,45,0,0,186,45,0,0,185,45,0,0,148,4,0,0,3,0,0,0,187,45,0,0,164,4,0,0,188,45,0,0,2,0,0,0,189,45,0,0,164,4,0,0,188,45,0,0,185,45,0,0,164,4,0,0,185,45,0,0,164,4,0,0,188,45,0,0,181,45,0,0,182,45,0,0,181,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,5,0,0,0,6,0,0,0,1,0,0,0,7,0,0,0,183,45,0,0,182,45,0,0,181,45,0,0,190,45,0,0,190,45,0,0,182,45,0,0,182,45,0,0,185,45,0,0,181,45,0,0,185,45,0,0,182,45,0,0,181,45,0,0,185,45,0,0,182,45,0,0,185,45,0,0,48,5,0,0,3,0,0,0,56,5,0,0,1,0,0,0,189,45,0,0,185,45,0,0,164,4,0,0,76,5,0,0,2,0,0,0,191,45,0,0,186,45,0,0,182,45,0,0,185,45,0,0,192,45,0,0,185,45,0,0,182,45,0,0,186,45,0,0,185,45,0,0,76,5,0,0,76,5,0,0,136,5,0,0,182,45,0,0,181,45,0,0,2,0,0,0,190,45,0,0,136,5,0,0,56,19,0,0,156,5,0,0,2,0,0,0,184,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,9,0,0,0,1,0,0,0,10,0,0,0,204,5,0,0,181,45,0,0,181,45,0,0,2,0,0,0,180,45,0,0,204,5,0,0,2,0,0,0,195,45,0,0,236,5,0,0,97,19,0,0,198,45,0,0,211,45,0,0,212,45,0,0,213,45,0,0,214,45,0,0,215,45,0,0,188,45,0,0,182,45,0,0,216,45,0,0,217,45,0,0,218,45,0,0,219,45,0,0,192,45,0,0,181,45,0,0,0,0,0,0,185,45,0,0,110,19,0,0,186,45,0,0,115,19,0,0,221,45,0,0,120,19,0,0,148,4,0,0,132,19,0,0,96,6,0,0,145,19,0,0,222,45,0,0,164,19,0,0,223,45,0,0,173,19,0,0,0,0,0,0,3,0,0,0,104,6,0,0,1,0,0,0,187,45,0,0,0,0,0,0,0,0,0,0,1,0,0,0,11,0,0,0,12,0,0,0,1,0,0,0,13,0,0,0,185,45,0,0,224,45,0,0,164,6,0,0,188,45,0,0,172,6,0,0,180,6,0,0,2,0,0,0,188,6,0,0,7,0,0,0,224,45,0,0,7,0,0,0,164,6,0,0,1,0,0,0,213,45,0,0,185,45,0,0,224,45,0,0,172,6,0,0,185,45,0,0,224,45,0,0,164,6,0,0,185,45,0,0,224,45,0,0,211,45,0,0,211,45,0,0,222,45,0,0,211,45,0,0,224,45,0,0,222,45,0,0,211,45,0,0,224,45,0,0,172,6,0,0,222,45,0,0,211,45,0,0,224,45,0,0,188,45,0,0,222,45,0,0,211,45,0,0,40,7,0,0,188,45,0,0,2,0,0,0,224,45,0,0,185,45,0,0,188,45,0,0,188,45,0,0,188,45,0,0,188,45,0,0,222,45,0,0,224,45,0,0,148,4,0,0,185,45,0,0,148,4,0,0,148,4,0,0,148,4,0,0,148,4,0,0,148,4,0,0,185,45,0,0,164,6,0,0,148,4,0,0,0,0,0,0,0,0,0,0,1,0,0,0,14,0,0,0,15,0,0,0,1,0,0,0,16,0,0,0,148,7,0,0,2,0,0,0,225,45,0,0,183,45,0,0,188,45,0,0,168,7,0,0,5,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,2,0,0,0,234,45,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,148,45,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,28,9,0,0,5,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,2,0,0,0,242,45,0,0,0,4,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,67,111,117,108,100,32,110,111,116,32,97,108,108,111,99,97,116,101,32,109,101,109,111,114,121,32,102,111,114,32,110,111,100,101,0,67,97,110,110,111,116,32,114,101,115,101,116,32,97,32,110,111,100,101,32,119,104,105,99,104,32,115,116,105,108,108,32,104,97,115,32,99,104,105,108,100,114,101,110,32,97,116,116,97,99,104,101,100,0,67,97,110,110,111,116,32,114,101,115,101,116,32,97,32,110,111,100,101,32,115,116,105,108,108,32,97,116,116,97,99,104,101,100,32,116,111,32,97,32,112,97,114,101,110,116,0,67,111,117,108,100,32,110,111,116,32,97,108,108,111,99,97,116,101,32,109,101,109,111,114,121,32,102,111,114,32,99,111,110,102,105,103,0,67,97,110,110,111,116,32,115,101,116,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,58,32,78,111,100,101,115,32,119,105,116,104,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,32,99,97,110,110,111,116,32,104,97,118,101,32,99,104,105,108,100,114,101,110,46,0,67,104,105,108,100,32,97,108,114,101,97,100,121,32,104,97,115,32,97,32,112,97,114,101,110,116,44,32,105,116,32,109,117,115,116,32,98,101,32,114,101,109,111,118,101,100,32,102,105,114,115,116,46,0,67,97,110,110,111,116,32,97,100,100,32,99,104,105,108,100,58,32,78,111,100,101,115,32,119,105,116,104,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,32,99,97,110,110,111,116,32,104,97,118,101,32,99,104,105,108,100,114,101,110,46,0,79,110,108,121,32,108,101,97,102,32,110,111,100,101,115,32,119,105,116,104,32,99,117,115,116,111,109,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,115,115,104,111,117,108,100,32,109,97,110,117,97,108,108,121,32,109,97,114,107,32,116,104,101,109,115,101,108,118,101,115,32,97,115,32,100,105,114,116,121,0,67,97,110,110,111,116,32,103,101,116,32,108,97,121,111,117,116,32,112,114,111,112,101,114,116,105,101,115,32,111,102,32,109,117,108,116,105,45,101,100,103,101,32,115,104,111,114,116,104,97,110,100,115,0,37,115,37,100,46,123,91,115,107,105,112,112,101,100,93,32,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,97,119,58,32,37,102,32,97,104,58,32,37,102,32,61,62,32,100,58,32,40,37,102,44,32,37,102,41,32,37,115,10,0,37,115,37,100,46,123,37,115,0,42,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,97,119,58,32,37,102,32,97,104,58,32,37,102,32,37,115,10,0,37,115,37,100,46,125,37,115,0,119,109,58,32,37,115,44,32,104,109,58,32,37,115,44,32,100,58,32,40,37,102,44,32,37,102,41,32,37,115,10,0,79,117,116,32,111,102,32,99,97,99,104,101,32,101,110,116,114,105,101,115,33,10,0,83,99,97,108,101,32,102,97,99,116,111,114,32,115,104,111,117,108,100,32,110,111,116,32,98,101,32,108,101,115,115,32,116,104,97,110,32,122,101,114,111,0,105,110,105,116,105,97,108,0,37,115,10,0,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,32,0,85,78,68,69,70,73,78,69,68,0,69,88,65,67,84,76,89,0,65,84,95,77,79,83,84,0,76,65,89,95,85,78,68,69,70,73,78,69,68,0,76,65,89,95,69,88,65,67,84,76,89,0,76,65,89,95,65,84,95,77,79,83,84,0,97,118,97,105,108,97,98,108,101,87,105,100,116,104,32,105,115,32,105,110,100,101,102,105,110,105,116,101,32,115,111,32,119,105,100,116,104,77,101,97,115,117,114,101,77,111,100,101,32,109,117,115,116,32,98,101,32,89,71,77,101,97,115,117,114,101,77,111,100,101,85,110,100,101,102,105,110,101,100,0,97,118,97,105,108,97,98,108,101,72,101,105,103,104,116,32,105,115,32,105,110,100,101,102,105,110,105,116,101,32,115,111,32,104,101,105,103,104,116,77,101,97,115,117,114,101,77,111,100,101,32,109,117,115,116,32,98,101,32,89,71,77,101,97,115,117,114,101,77,111,100,101,85,110,100,101,102,105,110,101,100,0,102,108,101,120,0,115,116,114,101,116,99,104,0,109,117,108,116,105,108,105,110,101,45,115,116,114,101,116,99,104,0,69,120,112,101,99,116,101,100,32,110,111,100,101,32,116,111,32,104,97,118,101,32,99,117,115,116,111,109,32,109,101,97,115,117,114,101,32,102,117,110,99,116,105,111,110,0,109,101,97,115,117,114,101,0,69,120,112,101,99,116,32,99,117,115,116,111,109,32,98,97,115,101,108,105,110,101,32,102,117,110,99,116,105,111,110,32,116,111,32,110,111,116,32,114,101,116,117,114,110,32,78,97,78,0,97,98,115,45,109,101,97,115,117,114,101,0,97,98,115,45,108,97,121,111,117,116,0,78,111,100,101,0,99,114,101,97,116,101,68,101,102,97,117,108,116,0,99,114,101,97,116,101,87,105,116,104,67,111,110,102,105,103,0,100,101,115,116,114,111,121,0,114,101,115,101,116,0,99,111,112,121,83,116,121,108,101,0,115,101,116,80,111,115,105,116,105,111,110,84,121,112,101,0,115,101,116,80,111,115,105,116,105,111,110,0,115,101,116,80,111,115,105,116,105,111,110,80,101,114,99,101,110,116,0,115,101,116,65,108,105,103,110,67,111,110,116,101,110,116,0,115,101,116,65,108,105,103,110,73,116,101,109,115,0,115,101,116,65,108,105,103,110,83,101,108,102,0,115,101,116,70,108,101,120,68,105,114,101,99,116,105,111,110,0,115,101,116,70,108,101,120,87,114,97,112,0,115,101,116,74,117,115,116,105,102,121,67,111,110,116,101,110,116,0,115,101,116,77,97,114,103,105,110,0,115,101,116,77,97,114,103,105,110,80,101,114,99,101,110,116,0,115,101,116,77,97,114,103,105,110,65,117,116,111,0,115,101,116,79,118,101,114,102,108,111,119,0,115,101,116,68,105,115,112,108,97,121,0,115,101,116,70,108,101,120,0,115,101,116,70,108,101,120,66,97,115,105,115,0,115,101,116,70,108,101,120,66,97,115,105,115,80,101,114,99,101,110,116,0,115,101,116,70,108,101,120,71,114,111,119,0,115,101,116,70,108,101,120,83,104,114,105,110,107,0,115,101,116,87,105,100,116,104,0,115,101,116,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,87,105,100,116,104,65,117,116,111,0,115,101,116,72,101,105,103,104,116,0,115,101,116,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,72,101,105,103,104,116,65,117,116,111,0,115,101,116,77,105,110,87,105,100,116,104,0,115,101,116,77,105,110,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,77,105,110,72,101,105,103,104,116,0,115,101,116,77,105,110,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,77,97,120,87,105,100,116,104,0,115,101,116,77,97,120,87,105,100,116,104,80,101,114,99,101,110,116,0,115,101,116,77,97,120,72,101,105,103,104,116,0,115,101,116,77,97,120,72,101,105,103,104,116,80,101,114,99,101,110,116,0,115,101,116,65,115,112,101,99,116,82,97,116,105,111,0,115,101,116,66,111,114,100,101,114,0,115,101,116,80,97,100,100,105,110,103,0,115,101,116,80,97,100,100,105,110,103,80,101,114,99,101,110,116,0,103,101,116,80,111,115,105,116,105,111,110,84,121,112,101,0,103,101,116,80,111,115,105,116,105,111,110,0,103,101,116,65,108,105,103,110,67,111,110,116,101,110,116,0,103,101,116,65,108,105,103,110,73,116,101,109,115,0,103,101,116,65,108,105,103,110,83,101,108,102,0,103,101,116,70,108,101,120,68,105,114,101,99,116,105,111,110,0,103,101,116,70,108,101,120,87,114,97,112,0,103,101,116,74,117,115,116,105,102,121,67,111,110,116,101,110,116,0,103,101,116,77,97,114,103,105,110,0,103,101,116,70,108,101,120,66,97,115,105,115,0,103,101,116,70,108,101,120,71,114,111,119,0,103,101,116,70,108,101,120,83,104,114,105,110,107,0,103,101,116,87,105,100,116,104,0,103,101,116,72,101,105,103,104,116,0,103,101,116,77,105,110,87,105,100,116,104,0,103,101,116,77,105,110,72,101,105,103,104,116,0,103,101,116,77,97,120,87,105,100,116,104,0,103,101,116,77,97,120,72,101,105,103,104,116,0,103,101,116,65,115,112,101,99,116,82,97,116,105,111,0,103,101,116,66,111,114,100,101,114,0,103,101,116,79,118,101,114,102,108,111,119,0,103,101,116,68,105,115,112,108,97,121,0,103,101,116,80,97,100,100,105,110,103,0,105,110,115,101,114,116,67,104,105,108,100,0,114,101,109,111,118,101,67,104,105,108,100,0,103,101,116,67,104,105,108,100,67,111,117,110,116,0,103,101,116,80,97,114,101,110,116,0,103,101,116,67,104,105,108,100,0,115,101,116,77,101,97,115,117,114,101,70,117,110,99,0,117,110,115,101,116,77,101,97,115,117,114,101,70,117,110,99,0,109,97,114,107,68,105,114,116,121,0,105,115,68,105,114,116,121,0,99,97,108,99,117,108,97,116,101,76,97,121,111,117,116,0,103,101,116,67,111,109,112,117,116,101,100,76,101,102,116,0,103,101,116,67,111,109,112,117,116,101,100,82,105,103,104,116,0,103,101,116,67,111,109,112,117,116,101,100,84,111,112,0,103,101,116,67,111,109,112,117,116,101,100,66,111,116,116,111,109,0,103,101,116,67,111,109,112,117,116,101,100,87,105,100,116,104,0,103,101,116,67,111,109,112,117,116,101,100,72,101,105,103,104,116,0,103,101,116,67,111,109,112,117,116,101,100,76,97,121,111,117,116,0,103,101,116,67,111,109,112,117,116,101,100,77,97,114,103,105,110,0,103,101,116,67,111,109,112,117,116,101,100,66,111,114,100,101,114,0,103,101,116,67,111,109,112,117,116,101,100,80,97,100,100,105,110,103,0,67,111,110,102,105,103,0,99,114,101,97,116,101,0,115,101,116,69,120,112,101,114,105,109,101,110,116,97,108,70,101,97,116,117,114,101,69,110,97,98,108,101,100,0,115,101,116,80,111,105,110,116,83,99,97,108,101,70,97,99,116,111,114,0,105,115,69,120,112,101,114,105,109,101,110,116,97,108,70,101,97,116,117,114,101,69,110,97,98,108,101,100,0,86,97,108,117,101,0,76,97,121,111,117,116,0,83,105,122,101,0,103,101,116,73,110,115,116,97,110,99,101,67,111,117,110,116,0,73,110,116,54,52,0,1,1,1,2,2,4,4,4,4,8,8,4,8,118,111,105,100,0,98,111,111,108,0,115,116,100,58,58,115,116,114,105,110,103,0,99,98,70,117,110,99,116,105,111,110,32,38,0,99,111,110,115,116,32,99,98,70,117,110,99,116,105,111,110,32,38,0,69,120,116,101,114,110,97,108,0,66,117,102,102,101,114,0,78,66,105,110,100,73,68,0,78,66,105,110,100,0,98,105,110,100,95,118,97,108,117,101,0,114,101,102,108,101,99,116,0,113,117,101,114,121,84,121,112,101,0,108,97,108,108,111,99,0,108,114,101,115,101,116,0,123,114,101,116,117,114,110,40,95,110,98,105,110,100,46,99,97,108,108,98,97,99,107,83,105,103,110,97,116,117,114,101,76,105,115,116,91,36,48,93,46,97,112,112,108,121,40,116,104,105,115,44,97,114,103,117,109,101,110,116,115,41,41,59,125,0,95,110,98,105,110,100,95,110,101,119,0,17,0,10,0,17,17,17,0,0,0,0,5,0,0,0,0,0,0,9,0,0,0,0,11,0,0,0,0,0,0,0,0,17,0,15,10,17,17,17,3,10,7,0,1,19,9,11,11,0,0,9,6,11,0,0,11,0,6,17,0,0,0,17,17,17,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,17,0,10,10,17,17,17,0,10,0,0,2,0,9,11,0,0,0,9,0,11,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,12,0,0,0,0,9,12,0,0,0,0,0,12,0,0,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,4,13,0,0,0,0,9,14,0,0,0,0,0,14,0,0,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16,0,0,0,0,0,0,0,0,0,0,0,15,0,0,0,0,15,0,0,0,0,9,16,0,0,0,0,0,16,0,0,16,0,0,18,0,0,0,18,18,18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,0,0,0,18,18,18,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,10,0,0,0,0,9,11,0,0,0,0,0,11,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,0,0,0,0,0,0,0,12,0,0,0,0,12,0,0,0,0,9,12,0,0,0,0,0,12,0,0,12,0,0,45,43,32,32,32,48,88,48,120,0,40,110,117,108,108,41,0,45,48,88,43,48,88,32,48,88,45,48,120,43,48,120,32,48,120,0,105,110,102,0,73,78,70,0,110,97,110,0,78,65,78,0,48,49,50,51,52,53,54,55,56,57,65,66,67,68,69,70,46,0,84,33,34,25,13,1,2,3,17,75,28,12,16,4,11,29,18,30,39,104,110,111,112,113,98,32,5,6,15,19,20,21,26,8,22,7,40,36,23,24,9,10,14,27,31,37,35,131,130,125,38,42,43,60,61,62,63,67,71,74,77,88,89,90,91,92,93,94,95,96,97,99,100,101,102,103,105,106,107,108,114,115,116,121,122,123,124,0,73,108,108,101,103,97,108,32,98,121,116,101,32,115,101,113,117,101,110,99,101,0,68,111,109,97,105,110,32,101,114,114,111,114,0,82,101,115,117,108,116,32,110,111,116,32,114,101,112,114,101,115,101,110,116,97,98,108,101,0,78,111,116,32,97,32,116,116,121,0,80,101,114,109,105,115,115,105,111,110,32,100,101,110,105,101,100,0,79,112,101,114,97,116,105,111,110,32,110,111,116,32,112,101,114,109,105,116,116,101,100,0,78,111,32,115,117,99,104,32,102,105,108,101,32,111,114,32,100,105,114,101,99,116,111,114,121,0,78,111,32,115,117,99,104,32,112,114,111,99,101,115,115,0,70,105,108,101,32,101,120,105,115,116,115,0,86,97,108,117,101,32,116,111,111,32,108,97,114,103,101,32,102,111,114,32,100,97,116,97,32,116,121,112,101,0,78,111,32,115,112,97,99,101,32,108,101,102,116,32,111,110,32,100,101,118,105,99,101,0,79,117,116,32,111,102,32,109,101,109,111,114,121,0,82,101,115,111,117,114,99,101,32,98,117,115,121,0,73,110,116,101,114,114,117,112,116,101,100,32,115,121,115,116,101,109,32,99,97,108,108,0,82,101,115,111,117,114,99,101,32,116,101,109,112,111,114,97,114,105,108,121,32,117,110,97,118,97,105,108,97,98,108,101,0,73,110,118,97,108,105,100,32,115,101,101,107,0,67,114,111,115,115,45,100,101,118,105,99,101,32,108,105,110,107,0,82,101,97,100,45,111,110,108,121,32,102,105,108,101,32,115,121,115,116,101,109,0,68,105,114,101,99,116,111,114,121,32,110,111,116,32,101,109,112,116,121,0,67,111,110,110,101,99,116,105,111,110,32,114,101,115,101,116,32,98,121,32,112,101,101,114,0,79,112,101,114,97,116,105,111,110,32,116,105,109,101,100,32,111,117,116,0,67,111,110,110,101,99,116,105,111,110,32,114,101,102,117,115,101,100,0,72,111,115,116,32,105,115,32,100,111,119,110,0,72,111,115,116,32,105,115,32,117,110,114,101,97,99,104,97,98,108,101,0,65,100,100,114,101,115,115,32,105,110,32,117,115,101,0,66,114,111,107,101,110,32,112,105,112,101,0,73,47,79,32,101,114,114,111,114,0,78,111,32,115,117,99,104,32,100,101,118,105,99,101,32,111,114,32,97,100,100,114,101,115,115,0,66,108,111,99,107,32,100,101,118,105,99,101,32,114,101,113,117,105,114,101,100,0,78,111,32,115,117,99,104,32,100,101,118,105,99,101,0,78,111,116,32,97,32,100,105,114,101,99,116,111,114,121,0,73,115,32,97,32,100,105,114,101,99,116,111,114,121,0,84,101,120,116,32,102,105,108,101,32,98,117,115,121,0,69,120,101,99,32,102,111,114,109,97,116,32,101,114,114,111,114,0,73,110,118,97,108,105,100,32,97,114,103,117,109,101,110,116,0,65,114,103,117,109,101,110,116,32,108,105,115,116,32,116,111,111,32,108,111,110,103,0,83,121,109,98,111,108,105,99,32,108,105,110,107,32,108,111,111,112,0,70,105,108,101,110,97,109,101,32,116,111,111,32,108,111,110,103,0,84,111,111,32,109,97,110,121,32,111,112,101,110,32,102,105,108,101,115,32,105,110,32,115,121,115,116,101,109,0,78,111,32,102,105,108,101,32,100,101,115,99,114,105,112,116,111,114,115,32,97,118,97,105,108,97,98,108,101,0,66,97,100,32,102,105,108,101,32,100,101,115,99,114,105,112,116,111,114,0,78,111,32,99,104,105,108,100,32,112,114,111,99,101,115,115,0,66,97,100,32,97,100,100,114,101,115,115,0,70,105,108,101,32,116,111,111,32,108,97,114,103,101,0,84,111,111,32,109,97,110,121,32,108,105,110,107,115,0,78,111,32,108,111,99,107,115,32,97,118,97,105,108,97,98,108,101,0,82,101,115,111,117,114,99,101,32,100,101,97,100,108,111,99,107,32,119,111,117,108,100,32,111,99,99,117,114,0,83,116,97,116,101,32,110,111,116,32,114,101,99,111,118,101,114,97,98,108,101,0,80,114,101,118,105,111,117,115,32,111,119,110,101,114,32,100,105,101,100,0,79,112,101,114,97,116,105,111,110,32,99,97,110,99,101,108,101,100,0,70,117,110,99,116,105,111,110,32,110,111,116,32,105,109,112,108,101,109,101,110,116,101,100,0,78,111,32,109,101,115,115,97,103,101,32,111,102,32,100,101,115,105,114,101,100,32,116,121,112,101,0,73,100,101,110,116,105,102,105,101,114,32,114,101,109,111,118,101,100,0,68,101,118,105,99,101,32,110,111,116,32,97,32,115,116,114,101,97,109,0,78,111,32,100,97,116,97,32,97,118,97,105,108,97,98,108,101,0,68,101,118,105,99,101,32,116,105,109,101,111,117,116,0,79,117,116,32,111,102,32,115,116,114,101,97,109,115,32,114,101,115,111,117,114,99,101,115,0,76,105,110,107,32,104,97,115,32,98,101,101,110,32,115,101,118,101,114,101,100,0,80,114,111,116,111,99,111,108,32,101,114,114,111,114,0,66,97,100,32,109,101,115,115,97,103,101,0,70,105,108,101,32,100,101,115,99,114,105,112,116,111,114,32,105,110,32,98,97,100,32,115,116,97,116,101,0,78,111,116,32,97,32,115,111,99,107,101,116,0,68,101,115,116,105,110,97,116,105,111,110,32,97,100,100,114,101,115,115,32,114,101,113,117,105,114,101,100,0,77,101,115,115,97,103,101,32,116,111,111,32,108,97,114,103,101,0,80,114,111,116,111,99,111,108,32,119,114,111,110,103,32,116,121,112,101,32,102,111,114,32,115,111,99,107,101,116,0,80,114,111,116,111,99,111,108,32,110,111,116,32,97,118,97,105,108,97,98,108,101,0,80,114,111,116,111,99,111,108,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,83,111,99,107,101,116,32,116,121,112,101,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,78,111,116,32,115,117,112,112,111,114,116,101,100,0,80,114,111,116,111,99,111,108,32,102,97,109,105,108,121,32,110,111,116,32,115,117,112,112,111,114,116,101,100,0,65,100,100,114,101,115,115,32,102,97,109,105,108,121,32,110,111,116,32,115,117,112,112,111,114,116,101,100,32,98,121,32,112,114,111,116,111,99,111,108,0,65,100,100,114,101,115,115,32,110,111,116,32,97,118,97,105,108,97,98,108,101,0,78,101,116,119,111,114,107,32,105,115,32,100,111,119,110,0,78,101,116,119,111,114,107,32,117,110,114,101,97,99,104,97,98,108,101,0,67,111,110,110,101,99,116,105,111,110,32,114,101,115,101,116,32,98,121,32,110,101,116,119,111,114,107,0,67,111,110,110,101,99,116,105,111,110,32,97,98,111,114,116,101,100,0,78,111,32,98,117,102,102,101,114,32,115,112,97,99,101,32,97,118,97,105,108,97,98,108,101,0,83,111,99,107,101,116,32,105,115,32,99,111,110,110,101,99,116,101,100,0,83,111,99,107,101,116,32,110,111,116,32,99,111,110,110,101,99,116,101,100,0,67,97,110,110,111,116,32,115,101,110,100,32,97,102,116,101,114,32,115,111,99,107,101,116,32,115,104,117,116,100,111,119,110,0,79,112,101,114,97,116,105,111,110,32,97,108,114,101,97,100,121,32,105,110,32,112,114,111,103,114,101,115,115,0,79,112,101,114,97,116,105,111,110,32,105,110,32,112,114,111,103,114,101,115,115,0,83,116,97,108,101,32,102,105,108,101,32,104,97,110,100,108,101,0,82,101,109,111,116,101,32,73,47,79,32,101,114,114,111,114,0,81,117,111,116,97,32,101,120,99,101,101,100,101,100,0,78,111,32,109,101,100,105,117,109,32,102,111,117,110,100,0,87,114,111,110,103,32,109,101,100,105,117,109,32,116,121,112,101,0,78,111,32,101,114,114,111,114,32,105,110,102,111,114,109,97,116,105,111,110,0,0],"i8",ALLOC_NONE,Runtime.GLOBAL_BASE);var tempDoublePtr=STATICTOP;STATICTOP+=16;function _atexit(i,o){__ATEXIT__.unshift({func:i,arg:o})}function ___cxa_atexit(){return _atexit.apply(null,arguments)}function _abort(){Module.abort()}function __ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj(){Module.printErr("missing function: _ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj"),abort(-1)}function __decorate(i,o,f,p){var E=arguments.length,t=E<3?o:p===null?p=Object.getOwnPropertyDescriptor(o,f):p,k;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")t=Reflect.decorate(i,o,f,p);else for(var L=i.length-1;L>=0;L--)(k=i[L])&&(t=(E<3?k(t):E>3?k(o,f,t):k(o,f))||t);return E>3&&t&&Object.defineProperty(o,f,t),t}function _defineHidden(i){return function(o,f){Object.defineProperty(o,f,{configurable:!1,enumerable:!1,value:i,writable:!0})}}var _nbind={};function __nbind_free_external(i){_nbind.externalList[i].dereference(i)}function __nbind_reference_external(i){_nbind.externalList[i].reference()}function _llvm_stackrestore(i){var o=_llvm_stacksave,f=o.LLVM_SAVEDSTACKS[i];o.LLVM_SAVEDSTACKS.splice(i,1),Runtime.stackRestore(f)}function __nbind_register_pool(i,o,f,p){_nbind.Pool.pageSize=i,_nbind.Pool.usedPtr=o/4,_nbind.Pool.rootPtr=f,_nbind.Pool.pagePtr=p/4,HEAP32[o/4]=16909060,HEAP8[o]==1&&(_nbind.bigEndian=!0),HEAP32[o/4]=0,_nbind.makeTypeKindTbl=(t={},t[1024]=_nbind.PrimitiveType,t[64]=_nbind.Int64Type,t[2048]=_nbind.BindClass,t[3072]=_nbind.BindClassPtr,t[4096]=_nbind.SharedClassPtr,t[5120]=_nbind.ArrayType,t[6144]=_nbind.ArrayType,t[7168]=_nbind.CStringType,t[9216]=_nbind.CallbackType,t[10240]=_nbind.BindType,t),_nbind.makeTypeNameTbl={Buffer:_nbind.BufferType,External:_nbind.ExternalType,Int64:_nbind.Int64Type,_nbind_new:_nbind.CreateValueType,bool:_nbind.BooleanType,"cbFunction &":_nbind.CallbackType,"const cbFunction &":_nbind.CallbackType,"const std::string &":_nbind.StringType,"std::string":_nbind.StringType},Module.toggleLightGC=_nbind.toggleLightGC,_nbind.callUpcast=Module.dynCall_ii;var E=_nbind.makeType(_nbind.constructType,{flags:2048,id:0,name:""});E.proto=Module,_nbind.BindClass.list.push(E);var t}function _emscripten_set_main_loop_timing(i,o){if(Browser.mainLoop.timingMode=i,Browser.mainLoop.timingValue=o,!Browser.mainLoop.func)return 1;if(i==0)Browser.mainLoop.scheduler=function(){var k=Math.max(0,Browser.mainLoop.tickStartTime+o-_emscripten_get_now())|0;setTimeout(Browser.mainLoop.runner,k)},Browser.mainLoop.method="timeout";else if(i==1)Browser.mainLoop.scheduler=function(){Browser.requestAnimationFrame(Browser.mainLoop.runner)},Browser.mainLoop.method="rAF";else if(i==2){if(!window.setImmediate){let t=function(k){k.source===window&&k.data===p&&(k.stopPropagation(),f.shift()())};var E=t,f=[],p="setimmediate";window.addEventListener("message",t,!0),window.setImmediate=function(L){f.push(L),ENVIRONMENT_IS_WORKER?(Module.setImmediates===void 0&&(Module.setImmediates=[]),Module.setImmediates.push(L),window.postMessage({target:p})):window.postMessage(p,"*")}}Browser.mainLoop.scheduler=function(){window.setImmediate(Browser.mainLoop.runner)},Browser.mainLoop.method="immediate"}return 0}function _emscripten_get_now(){abort()}function _emscripten_set_main_loop(i,o,f,p,E){Module.noExitRuntime=!0,assert(!Browser.mainLoop.func,"emscripten_set_main_loop: there can only be one main loop function at once: call emscripten_cancel_main_loop to cancel the previous one before setting a new one with different parameters."),Browser.mainLoop.func=i,Browser.mainLoop.arg=p;var t;typeof p!="undefined"?t=function(){Module.dynCall_vi(i,p)}:t=function(){Module.dynCall_v(i)};var k=Browser.mainLoop.currentlyRunningMainloop;if(Browser.mainLoop.runner=function(){if(!ABORT){if(Browser.mainLoop.queue.length>0){var N=Date.now(),C=Browser.mainLoop.queue.shift();if(C.func(C.arg),Browser.mainLoop.remainingBlockers){var U=Browser.mainLoop.remainingBlockers,q=U%1==0?U-1:Math.floor(U);C.counted?Browser.mainLoop.remainingBlockers=q:(q=q+.5,Browser.mainLoop.remainingBlockers=(8*U+q)/9)}if(console.log('main loop blocker "'+C.name+'" took '+(Date.now()-N)+" ms"),Browser.mainLoop.updateStatus(),k1&&Browser.mainLoop.currentFrameNumber%Browser.mainLoop.timingValue!=0){Browser.mainLoop.scheduler();return}else Browser.mainLoop.timingMode==0&&(Browser.mainLoop.tickStartTime=_emscripten_get_now());Browser.mainLoop.method==="timeout"&&Module.ctx&&(Module.printErr("Looks like you are rendering without using requestAnimationFrame for the main loop. You should use 0 for the frame rate in emscripten_set_main_loop in order to use requestAnimationFrame, as that can greatly improve your frame rates!"),Browser.mainLoop.method=""),Browser.mainLoop.runIter(t),!(k0?_emscripten_set_main_loop_timing(0,1e3/o):_emscripten_set_main_loop_timing(1,1),Browser.mainLoop.scheduler()),f)throw"SimulateInfiniteLoop"}var Browser={mainLoop:{scheduler:null,method:"",currentlyRunningMainloop:0,func:null,arg:0,timingMode:0,timingValue:0,currentFrameNumber:0,queue:[],pause:function(){Browser.mainLoop.scheduler=null,Browser.mainLoop.currentlyRunningMainloop++},resume:function(){Browser.mainLoop.currentlyRunningMainloop++;var i=Browser.mainLoop.timingMode,o=Browser.mainLoop.timingValue,f=Browser.mainLoop.func;Browser.mainLoop.func=null,_emscripten_set_main_loop(f,0,!1,Browser.mainLoop.arg,!0),_emscripten_set_main_loop_timing(i,o),Browser.mainLoop.scheduler()},updateStatus:function(){if(Module.setStatus){var i=Module.statusMessage||"Please wait...",o=Browser.mainLoop.remainingBlockers,f=Browser.mainLoop.expectedBlockers;o?o=6;){var rt=le>>Ue-6&63;Ue-=6,Oe+=ze[rt]}return Ue==2?(Oe+=ze[(le&3)<<4],Oe+=pe+pe):Ue==4&&(Oe+=ze[(le&15)<<2],Oe+=pe),Oe}m.src="data:audio/x-"+k.substr(-3)+";base64,"+he(t),U(m)},m.src=ne,Browser.safeSetTimeout(function(){U(m)},1e4)}else return q()},Module.preloadPlugins.push(o);function f(){Browser.pointerLock=document.pointerLockElement===Module.canvas||document.mozPointerLockElement===Module.canvas||document.webkitPointerLockElement===Module.canvas||document.msPointerLockElement===Module.canvas}var p=Module.canvas;p&&(p.requestPointerLock=p.requestPointerLock||p.mozRequestPointerLock||p.webkitRequestPointerLock||p.msRequestPointerLock||function(){},p.exitPointerLock=document.exitPointerLock||document.mozExitPointerLock||document.webkitExitPointerLock||document.msExitPointerLock||function(){},p.exitPointerLock=p.exitPointerLock.bind(document),document.addEventListener("pointerlockchange",f,!1),document.addEventListener("mozpointerlockchange",f,!1),document.addEventListener("webkitpointerlockchange",f,!1),document.addEventListener("mspointerlockchange",f,!1),Module.elementPointerLock&&p.addEventListener("click",function(E){!Browser.pointerLock&&Module.canvas.requestPointerLock&&(Module.canvas.requestPointerLock(),E.preventDefault())},!1))},createContext:function(i,o,f,p){if(o&&Module.ctx&&i==Module.canvas)return Module.ctx;var E,t;if(o){var k={antialias:!1,alpha:!1};if(p)for(var L in p)k[L]=p[L];t=GL.createContext(i,k),t&&(E=GL.getContext(t).GLctx)}else E=i.getContext("2d");return E?(f&&(o||assert(typeof GLctx=="undefined","cannot set in module if GLctx is used, but we are a non-GL context that would replace it"),Module.ctx=E,o&&GL.makeContextCurrent(t),Module.useWebGL=o,Browser.moduleContextCreatedCallbacks.forEach(function(N){N()}),Browser.init()),E):null},destroyContext:function(i,o,f){},fullscreenHandlersInstalled:!1,lockPointer:void 0,resizeCanvas:void 0,requestFullscreen:function(i,o,f){Browser.lockPointer=i,Browser.resizeCanvas=o,Browser.vrDevice=f,typeof Browser.lockPointer=="undefined"&&(Browser.lockPointer=!0),typeof Browser.resizeCanvas=="undefined"&&(Browser.resizeCanvas=!1),typeof Browser.vrDevice=="undefined"&&(Browser.vrDevice=null);var p=Module.canvas;function E(){Browser.isFullscreen=!1;var k=p.parentNode;(document.fullscreenElement||document.mozFullScreenElement||document.msFullscreenElement||document.webkitFullscreenElement||document.webkitCurrentFullScreenElement)===k?(p.exitFullscreen=document.exitFullscreen||document.cancelFullScreen||document.mozCancelFullScreen||document.msExitFullscreen||document.webkitCancelFullScreen||function(){},p.exitFullscreen=p.exitFullscreen.bind(document),Browser.lockPointer&&p.requestPointerLock(),Browser.isFullscreen=!0,Browser.resizeCanvas&&Browser.setFullscreenCanvasSize()):(k.parentNode.insertBefore(p,k),k.parentNode.removeChild(k),Browser.resizeCanvas&&Browser.setWindowedCanvasSize()),Module.onFullScreen&&Module.onFullScreen(Browser.isFullscreen),Module.onFullscreen&&Module.onFullscreen(Browser.isFullscreen),Browser.updateCanvasDimensions(p)}Browser.fullscreenHandlersInstalled||(Browser.fullscreenHandlersInstalled=!0,document.addEventListener("fullscreenchange",E,!1),document.addEventListener("mozfullscreenchange",E,!1),document.addEventListener("webkitfullscreenchange",E,!1),document.addEventListener("MSFullscreenChange",E,!1));var t=document.createElement("div");p.parentNode.insertBefore(t,p),t.appendChild(p),t.requestFullscreen=t.requestFullscreen||t.mozRequestFullScreen||t.msRequestFullscreen||(t.webkitRequestFullscreen?function(){t.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT)}:null)||(t.webkitRequestFullScreen?function(){t.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT)}:null),f?t.requestFullscreen({vrDisplay:f}):t.requestFullscreen()},requestFullScreen:function(i,o,f){return Module.printErr("Browser.requestFullScreen() is deprecated. Please call Browser.requestFullscreen instead."),Browser.requestFullScreen=function(p,E,t){return Browser.requestFullscreen(p,E,t)},Browser.requestFullscreen(i,o,f)},nextRAF:0,fakeRequestAnimationFrame:function(i){var o=Date.now();if(Browser.nextRAF===0)Browser.nextRAF=o+1e3/60;else for(;o+2>=Browser.nextRAF;)Browser.nextRAF+=1e3/60;var f=Math.max(Browser.nextRAF-o,0);setTimeout(i,f)},requestAnimationFrame:function(o){typeof window=="undefined"?Browser.fakeRequestAnimationFrame(o):(window.requestAnimationFrame||(window.requestAnimationFrame=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame||window.oRequestAnimationFrame||Browser.fakeRequestAnimationFrame),window.requestAnimationFrame(o))},safeCallback:function(i){return function(){if(!ABORT)return i.apply(null,arguments)}},allowAsyncCallbacks:!0,queuedAsyncCallbacks:[],pauseAsyncCallbacks:function(){Browser.allowAsyncCallbacks=!1},resumeAsyncCallbacks:function(){if(Browser.allowAsyncCallbacks=!0,Browser.queuedAsyncCallbacks.length>0){var i=Browser.queuedAsyncCallbacks;Browser.queuedAsyncCallbacks=[],i.forEach(function(o){o()})}},safeRequestAnimationFrame:function(i){return Browser.requestAnimationFrame(function(){ABORT||(Browser.allowAsyncCallbacks?i():Browser.queuedAsyncCallbacks.push(i))})},safeSetTimeout:function(i,o){return Module.noExitRuntime=!0,setTimeout(function(){ABORT||(Browser.allowAsyncCallbacks?i():Browser.queuedAsyncCallbacks.push(i))},o)},safeSetInterval:function(i,o){return Module.noExitRuntime=!0,setInterval(function(){ABORT||Browser.allowAsyncCallbacks&&i()},o)},getMimetype:function(i){return{jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",bmp:"image/bmp",ogg:"audio/ogg",wav:"audio/wav",mp3:"audio/mpeg"}[i.substr(i.lastIndexOf(".")+1)]},getUserMedia:function(i){window.getUserMedia||(window.getUserMedia=navigator.getUserMedia||navigator.mozGetUserMedia),window.getUserMedia(i)},getMovementX:function(i){return i.movementX||i.mozMovementX||i.webkitMovementX||0},getMovementY:function(i){return i.movementY||i.mozMovementY||i.webkitMovementY||0},getMouseWheelDelta:function(i){var o=0;switch(i.type){case"DOMMouseScroll":o=i.detail;break;case"mousewheel":o=i.wheelDelta;break;case"wheel":o=i.deltaY;break;default:throw"unrecognized mouse wheel event: "+i.type}return o},mouseX:0,mouseY:0,mouseMovementX:0,mouseMovementY:0,touches:{},lastTouches:{},calculateMouseEvent:function(i){if(Browser.pointerLock)i.type!="mousemove"&&"mozMovementX"in i?Browser.mouseMovementX=Browser.mouseMovementY=0:(Browser.mouseMovementX=Browser.getMovementX(i),Browser.mouseMovementY=Browser.getMovementY(i)),typeof SDL!="undefined"?(Browser.mouseX=SDL.mouseX+Browser.mouseMovementX,Browser.mouseY=SDL.mouseY+Browser.mouseMovementY):(Browser.mouseX+=Browser.mouseMovementX,Browser.mouseY+=Browser.mouseMovementY);else{var o=Module.canvas.getBoundingClientRect(),f=Module.canvas.width,p=Module.canvas.height,E=typeof window.scrollX!="undefined"?window.scrollX:window.pageXOffset,t=typeof window.scrollY!="undefined"?window.scrollY:window.pageYOffset;if(i.type==="touchstart"||i.type==="touchend"||i.type==="touchmove"){var k=i.touch;if(k===void 0)return;var L=k.pageX-(E+o.left),N=k.pageY-(t+o.top);L=L*(f/o.width),N=N*(p/o.height);var C={x:L,y:N};if(i.type==="touchstart")Browser.lastTouches[k.identifier]=C,Browser.touches[k.identifier]=C;else if(i.type==="touchend"||i.type==="touchmove"){var U=Browser.touches[k.identifier];U||(U=C),Browser.lastTouches[k.identifier]=U,Browser.touches[k.identifier]=C}return}var q=i.pageX-(E+o.left),W=i.pageY-(t+o.top);q=q*(f/o.width),W=W*(p/o.height),Browser.mouseMovementX=q-Browser.mouseX,Browser.mouseMovementY=W-Browser.mouseY,Browser.mouseX=q,Browser.mouseY=W}},asyncLoad:function(i,o,f,p){var E=p?"":getUniqueRunDependency("al "+i);Module.readAsync(i,function(t){assert(t,'Loading data file "'+i+'" failed (no arrayBuffer).'),o(new Uint8Array(t)),E&&removeRunDependency(E)},function(t){if(f)f();else throw'Loading data file "'+i+'" failed.'}),E&&addRunDependency(E)},resizeListeners:[],updateResizeListeners:function(){var i=Module.canvas;Browser.resizeListeners.forEach(function(o){o(i.width,i.height)})},setCanvasSize:function(i,o,f){var p=Module.canvas;Browser.updateCanvasDimensions(p,i,o),f||Browser.updateResizeListeners()},windowedWidth:0,windowedHeight:0,setFullscreenCanvasSize:function(){if(typeof SDL!="undefined"){var i=HEAPU32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2];i=i|8388608,HEAP32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2]=i}Browser.updateResizeListeners()},setWindowedCanvasSize:function(){if(typeof SDL!="undefined"){var i=HEAPU32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2];i=i&~8388608,HEAP32[SDL.screen+Runtime.QUANTUM_SIZE*0>>2]=i}Browser.updateResizeListeners()},updateCanvasDimensions:function(i,o,f){o&&f?(i.widthNative=o,i.heightNative=f):(o=i.widthNative,f=i.heightNative);var p=o,E=f;if(Module.forcedAspectRatio&&Module.forcedAspectRatio>0&&(p/E>2];return o},getStr:function(){var i=Pointer_stringify(SYSCALLS.get());return i},get64:function(){var i=SYSCALLS.get(),o=SYSCALLS.get();return i>=0?assert(o===0):assert(o===-1),i},getZero:function(){assert(SYSCALLS.get()===0)}};function ___syscall6(i,o){SYSCALLS.varargs=o;try{var f=SYSCALLS.getStreamFromFD();return FS.close(f),0}catch(p){return(typeof FS=="undefined"||!(p instanceof FS.ErrnoError))&&abort(p),-p.errno}}function ___syscall54(i,o){SYSCALLS.varargs=o;try{return 0}catch(f){return(typeof FS=="undefined"||!(f instanceof FS.ErrnoError))&&abort(f),-f.errno}}function _typeModule(i){var o=[[0,1,"X"],[1,1,"const X"],[128,1,"X *"],[256,1,"X &"],[384,1,"X &&"],[512,1,"std::shared_ptr"],[640,1,"std::unique_ptr"],[5120,1,"std::vector"],[6144,2,"std::array"],[9216,-1,"std::function"]];function f(N,C,U,q,W,ne){if(C==1){var m=q&896;(m==128||m==256||m==384)&&(N="X const")}var we;return ne?we=U.replace("X",N).replace("Y",W):we=N.replace("X",U).replace("Y",W),we.replace(/([*&]) (?=[*&])/g,"$1")}function p(N,C,U,q,W){throw new Error(N+" type "+U.replace("X",C+"?")+(q?" with flag "+q:"")+" in "+W)}function E(N,C,U,q,W,ne,m,we){ne===void 0&&(ne="X"),we===void 0&&(we=1);var Se=U(N);if(Se)return Se;var he=q(N),ge=he.placeholderFlag,ze=o[ge];m&&ze&&(ne=f(m[2],m[0],ne,ze[0],"?",!0));var pe;ge==0&&(pe="Unbound"),ge>=10&&(pe="Corrupt"),we>20&&(pe="Deeply nested"),pe&&p(pe,N,ne,ge,W||"?");var Oe=he.paramList[0],le=E(Oe,C,U,q,W,ne,ze,we+1),Ue,Ge={flags:ze[0],id:N,name:"",paramList:[le]},rt=[],wt="?";switch(he.placeholderFlag){case 1:Ue=le.spec;break;case 2:if((le.flags&15360)==1024&&le.spec.ptrSize==1){Ge.flags=7168;break}case 3:case 6:case 5:Ue=le.spec,(le.flags&15360)!=2048;break;case 8:wt=""+he.paramList[1],Ge.paramList.push(he.paramList[1]);break;case 9:for(var xt=0,$e=he.paramList[1];xt<$e.length;xt++){var ft=$e[xt],Ke=E(ft,C,U,q,W,ne,ze,we+1);rt.push(Ke.name),Ge.paramList.push(Ke)}wt=rt.join(", ");break;default:break}if(Ge.name=f(ze[2],ze[0],le.name,le.flags,wt),Ue){for(var jt=0,$t=Object.keys(Ue);jt<$t.length;jt++){var at=$t[jt];Ge[at]=Ge[at]||Ue[at]}Ge.flags|=Ue.flags}return t(C,Ge)}function t(N,C){var U=C.flags,q=U&896,W=U&15360;return!C.name&&W==1024&&(C.ptrSize==1?C.name=(U&16?"":(U&8?"un":"")+"signed ")+"char":C.name=(U&8?"u":"")+(U&32?"float":"int")+(C.ptrSize*8+"_t")),C.ptrSize==8&&!(U&32)&&(W=64),W==2048&&(q==512||q==640?W=4096:q&&(W=3072)),N(W,C)}var k=function(){function N(C){this.id=C.id,this.name=C.name,this.flags=C.flags,this.spec=C}return N.prototype.toString=function(){return this.name},N}(),L={Type:k,getComplexType:E,makeType:t,structureList:o};return i.output=L,i.output||L}function __nbind_register_type(i,o){var f=_nbind.readAsciiString(o),p={flags:10240,id:i,name:f};_nbind.makeType(_nbind.constructType,p)}function __nbind_register_callback_signature(i,o){var f=_nbind.readTypeIdList(i,o),p=_nbind.callbackSignatureList.length;return _nbind.callbackSignatureList[p]=_nbind.makeJSCaller(f),p}function __extends(i,o){for(var f in o)o.hasOwnProperty(f)&&(i[f]=o[f]);function p(){this.constructor=i}p.prototype=o.prototype,i.prototype=new p}function __nbind_register_class(i,o,f,p,E,t,k){var L=_nbind.readAsciiString(k),N=_nbind.readPolicyList(o),C=HEAPU32.subarray(i/4,i/4+2),U={flags:2048|(N.Value?2:0),id:C[0],name:L},q=_nbind.makeType(_nbind.constructType,U);q.ptrType=_nbind.getComplexType(C[1],_nbind.constructType,_nbind.getType,_nbind.queryType),q.destroy=_nbind.makeMethodCaller(q.ptrType,{boundID:U.id,flags:0,name:"destroy",num:0,ptr:t,title:q.name+".free",typeList:["void","uint32_t","uint32_t"]}),E&&(q.superIdList=Array.prototype.slice.call(HEAPU32.subarray(f/4,f/4+E)),q.upcastList=Array.prototype.slice.call(HEAPU32.subarray(p/4,p/4+E))),Module[q.name]=q.makeBound(N),_nbind.BindClass.list.push(q)}function _removeAccessorPrefix(i){var o=/^[Gg]et_?([A-Z]?([A-Z]?))/;return i.replace(o,function(f,p,E){return E?p:p.toLowerCase()})}function __nbind_register_function(i,o,f,p,E,t,k,L,N,C){var U=_nbind.getType(i),q=_nbind.readPolicyList(o),W=_nbind.readTypeIdList(f,p),ne;if(k==5)ne=[{direct:E,name:"__nbindConstructor",ptr:0,title:U.name+" constructor",typeList:["uint32_t"].concat(W.slice(1))},{direct:t,name:"__nbindValueConstructor",ptr:0,title:U.name+" value constructor",typeList:["void","uint32_t"].concat(W.slice(1))}];else{var m=_nbind.readAsciiString(L),we=(U.name&&U.name+".")+m;(k==3||k==4)&&(m=_removeAccessorPrefix(m)),ne=[{boundID:i,direct:t,name:m,ptr:E,title:we,typeList:W}]}for(var Se=0,he=ne;Se>2]=i),i}function _llvm_stacksave(){var i=_llvm_stacksave;return i.LLVM_SAVEDSTACKS||(i.LLVM_SAVEDSTACKS=[]),i.LLVM_SAVEDSTACKS.push(Runtime.stackSave()),i.LLVM_SAVEDSTACKS.length-1}function ___syscall140(i,o){SYSCALLS.varargs=o;try{var f=SYSCALLS.getStreamFromFD(),p=SYSCALLS.get(),E=SYSCALLS.get(),t=SYSCALLS.get(),k=SYSCALLS.get(),L=E;return FS.llseek(f,L,k),HEAP32[t>>2]=f.position,f.getdents&&L===0&&k===0&&(f.getdents=null),0}catch(N){return(typeof FS=="undefined"||!(N instanceof FS.ErrnoError))&&abort(N),-N.errno}}function ___syscall146(i,o){SYSCALLS.varargs=o;try{var f=SYSCALLS.get(),p=SYSCALLS.get(),E=SYSCALLS.get(),t=0;___syscall146.buffer||(___syscall146.buffers=[null,[],[]],___syscall146.printChar=function(U,q){var W=___syscall146.buffers[U];assert(W),q===0||q===10?((U===1?Module.print:Module.printErr)(UTF8ArrayToString(W,0)),W.length=0):W.push(q)});for(var k=0;k>2],N=HEAP32[p+(k*8+4)>>2],C=0;Ci.pageSize/2||o>i.pageSize-f){var p=_nbind.typeNameTbl.NBind.proto;return p.lalloc(o)}else return HEAPU32[i.usedPtr]=f+o,i.rootPtr+f},i.lreset=function(o,f){var p=HEAPU32[i.pagePtr];if(p){var E=_nbind.typeNameTbl.NBind.proto;E.lreset(o,f)}else HEAPU32[i.usedPtr]=o},i}();_nbind.Pool=Pool;function constructType(i,o){var f=i==10240?_nbind.makeTypeNameTbl[o.name]||_nbind.BindType:_nbind.makeTypeKindTbl[i],p=new f(o);return typeIdTbl[o.id]=p,_nbind.typeNameTbl[o.name]=p,p}_nbind.constructType=constructType;function getType(i){return typeIdTbl[i]}_nbind.getType=getType;function queryType(i){var o=HEAPU8[i],f=_nbind.structureList[o][1];i/=4,f<0&&(++i,f=HEAPU32[i]+1);var p=Array.prototype.slice.call(HEAPU32.subarray(i+1,i+1+f));return o==9&&(p=[p[0],p.slice(1)]),{paramList:p,placeholderFlag:o}}_nbind.queryType=queryType;function getTypes(i,o){return i.map(function(f){return typeof f=="number"?_nbind.getComplexType(f,constructType,getType,queryType,o):_nbind.typeNameTbl[f]})}_nbind.getTypes=getTypes;function readTypeIdList(i,o){return Array.prototype.slice.call(HEAPU32,i/4,i/4+o)}_nbind.readTypeIdList=readTypeIdList;function readAsciiString(i){for(var o=i;HEAPU8[o++];);return String.fromCharCode.apply("",HEAPU8.subarray(i,o-1))}_nbind.readAsciiString=readAsciiString;function readPolicyList(i){var o={};if(i)for(;;){var f=HEAPU32[i/4];if(!f)break;o[readAsciiString(f)]=!0,i+=4}return o}_nbind.readPolicyList=readPolicyList;function getDynCall(i,o){var f={float32_t:"d",float64_t:"d",int64_t:"d",uint64_t:"d",void:"v"},p=i.map(function(t){return f[t.name]||"i"}).join(""),E=Module["dynCall_"+p];if(!E)throw new Error("dynCall_"+p+" not found for "+o+"("+i.map(function(t){return t.name}).join(", ")+")");return E}_nbind.getDynCall=getDynCall;function addMethod(i,o,f,p){var E=i[o];i.hasOwnProperty(o)&&E?((E.arity||E.arity===0)&&(E=_nbind.makeOverloader(E,E.arity),i[o]=E),E.addMethod(f,p)):(f.arity=p,i[o]=f)}_nbind.addMethod=addMethod;function throwError(i){throw new Error(i)}_nbind.throwError=throwError,_nbind.bigEndian=!1,_a=_typeModule(_typeModule),_nbind.Type=_a.Type,_nbind.makeType=_a.makeType,_nbind.getComplexType=_a.getComplexType,_nbind.structureList=_a.structureList;var BindType=function(i){__extends(o,i);function o(){var f=i!==null&&i.apply(this,arguments)||this;return f.heap=HEAPU32,f.ptrSize=4,f}return o.prototype.needsWireRead=function(f){return!!this.wireRead||!!this.makeWireRead},o.prototype.needsWireWrite=function(f){return!!this.wireWrite||!!this.makeWireWrite},o}(_nbind.Type);_nbind.BindType=BindType;var PrimitiveType=function(i){__extends(o,i);function o(f){var p=i.call(this,f)||this,E=f.flags&32?{32:HEAPF32,64:HEAPF64}:f.flags&8?{8:HEAPU8,16:HEAPU16,32:HEAPU32}:{8:HEAP8,16:HEAP16,32:HEAP32};return p.heap=E[f.ptrSize*8],p.ptrSize=f.ptrSize,p}return o.prototype.needsWireWrite=function(f){return!!f&&!!f.Strict},o.prototype.makeWireWrite=function(f,p){return p&&p.Strict&&function(E){if(typeof E=="number")return E;throw new Error("Type mismatch")}},o}(BindType);_nbind.PrimitiveType=PrimitiveType;function pushCString(i,o){if(i==null){if(o&&o.Nullable)return 0;throw new Error("Type mismatch")}if(o&&o.Strict){if(typeof i!="string")throw new Error("Type mismatch")}else i=i.toString();var f=Module.lengthBytesUTF8(i)+1,p=_nbind.Pool.lalloc(f);return Module.stringToUTF8Array(i,HEAPU8,p,f),p}_nbind.pushCString=pushCString;function popCString(i){return i===0?null:Module.Pointer_stringify(i)}_nbind.popCString=popCString;var CStringType=function(i){__extends(o,i);function o(){var f=i!==null&&i.apply(this,arguments)||this;return f.wireRead=popCString,f.wireWrite=pushCString,f.readResources=[_nbind.resources.pool],f.writeResources=[_nbind.resources.pool],f}return o.prototype.makeWireWrite=function(f,p){return function(E){return pushCString(E,p)}},o}(BindType);_nbind.CStringType=CStringType;var BooleanType=function(i){__extends(o,i);function o(){var f=i!==null&&i.apply(this,arguments)||this;return f.wireRead=function(p){return!!p},f}return o.prototype.needsWireWrite=function(f){return!!f&&!!f.Strict},o.prototype.makeWireRead=function(f){return"!!("+f+")"},o.prototype.makeWireWrite=function(f,p){return p&&p.Strict&&function(E){if(typeof E=="boolean")return E;throw new Error("Type mismatch")}||f},o}(BindType);_nbind.BooleanType=BooleanType;var Wrapper=function(){function i(){}return i.prototype.persist=function(){this.__nbindState|=1},i}();_nbind.Wrapper=Wrapper;function makeBound(i,o){var f=function(p){__extends(E,p);function E(t,k,L,N){var C=p.call(this)||this;if(!(C instanceof E))return new(Function.prototype.bind.apply(E,Array.prototype.concat.apply([null],arguments)));var U=k,q=L,W=N;if(t!==_nbind.ptrMarker){var ne=C.__nbindConstructor.apply(C,arguments);U=4096|512,W=HEAPU32[ne/4],q=HEAPU32[ne/4+1]}var m={configurable:!0,enumerable:!1,value:null,writable:!1},we={__nbindFlags:U,__nbindPtr:q};W&&(we.__nbindShared=W,_nbind.mark(C));for(var Se=0,he=Object.keys(we);Se>=1;var f=_nbind.valueList[i];return _nbind.valueList[i]=firstFreeValue,firstFreeValue=i,f}else{if(o)return _nbind.popShared(i,o);throw new Error("Invalid value slot "+i)}}_nbind.popValue=popValue;var valueBase=18446744073709552e3;function push64(i){return typeof i=="number"?i:pushValue(i)*4096+valueBase}function pop64(i){return i=3?k=Buffer.from(t):k=new Buffer(t),k.copy(p)}else getBuffer(p).set(t)}}_nbind.commitBuffer=commitBuffer;var dirtyList=[],gcTimer=0;function sweep(){for(var i=0,o=dirtyList;i>2]=DYNAMIC_BASE,staticSealed=!0;function invoke_viiiii(i,o,f,p,E,t){try{Module.dynCall_viiiii(i,o,f,p,E,t)}catch(k){if(typeof k!="number"&&k!=="longjmp")throw k;Module.setThrew(1,0)}}function invoke_vif(i,o,f){try{Module.dynCall_vif(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_vid(i,o,f){try{Module.dynCall_vid(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_fiff(i,o,f,p){try{return Module.dynCall_fiff(i,o,f,p)}catch(E){if(typeof E!="number"&&E!=="longjmp")throw E;Module.setThrew(1,0)}}function invoke_vi(i,o){try{Module.dynCall_vi(i,o)}catch(f){if(typeof f!="number"&&f!=="longjmp")throw f;Module.setThrew(1,0)}}function invoke_vii(i,o,f){try{Module.dynCall_vii(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_ii(i,o){try{return Module.dynCall_ii(i,o)}catch(f){if(typeof f!="number"&&f!=="longjmp")throw f;Module.setThrew(1,0)}}function invoke_viddi(i,o,f,p,E){try{Module.dynCall_viddi(i,o,f,p,E)}catch(t){if(typeof t!="number"&&t!=="longjmp")throw t;Module.setThrew(1,0)}}function invoke_vidd(i,o,f,p){try{Module.dynCall_vidd(i,o,f,p)}catch(E){if(typeof E!="number"&&E!=="longjmp")throw E;Module.setThrew(1,0)}}function invoke_iiii(i,o,f,p){try{return Module.dynCall_iiii(i,o,f,p)}catch(E){if(typeof E!="number"&&E!=="longjmp")throw E;Module.setThrew(1,0)}}function invoke_diii(i,o,f,p){try{return Module.dynCall_diii(i,o,f,p)}catch(E){if(typeof E!="number"&&E!=="longjmp")throw E;Module.setThrew(1,0)}}function invoke_di(i,o){try{return Module.dynCall_di(i,o)}catch(f){if(typeof f!="number"&&f!=="longjmp")throw f;Module.setThrew(1,0)}}function invoke_iid(i,o,f){try{return Module.dynCall_iid(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_iii(i,o,f){try{return Module.dynCall_iii(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_viiddi(i,o,f,p,E,t){try{Module.dynCall_viiddi(i,o,f,p,E,t)}catch(k){if(typeof k!="number"&&k!=="longjmp")throw k;Module.setThrew(1,0)}}function invoke_viiiiii(i,o,f,p,E,t,k){try{Module.dynCall_viiiiii(i,o,f,p,E,t,k)}catch(L){if(typeof L!="number"&&L!=="longjmp")throw L;Module.setThrew(1,0)}}function invoke_dii(i,o,f){try{return Module.dynCall_dii(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_i(i){try{return Module.dynCall_i(i)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_iiiiii(i,o,f,p,E,t){try{return Module.dynCall_iiiiii(i,o,f,p,E,t)}catch(k){if(typeof k!="number"&&k!=="longjmp")throw k;Module.setThrew(1,0)}}function invoke_viiid(i,o,f,p,E){try{Module.dynCall_viiid(i,o,f,p,E)}catch(t){if(typeof t!="number"&&t!=="longjmp")throw t;Module.setThrew(1,0)}}function invoke_viififi(i,o,f,p,E,t,k){try{Module.dynCall_viififi(i,o,f,p,E,t,k)}catch(L){if(typeof L!="number"&&L!=="longjmp")throw L;Module.setThrew(1,0)}}function invoke_viii(i,o,f,p){try{Module.dynCall_viii(i,o,f,p)}catch(E){if(typeof E!="number"&&E!=="longjmp")throw E;Module.setThrew(1,0)}}function invoke_v(i){try{Module.dynCall_v(i)}catch(o){if(typeof o!="number"&&o!=="longjmp")throw o;Module.setThrew(1,0)}}function invoke_viid(i,o,f,p){try{Module.dynCall_viid(i,o,f,p)}catch(E){if(typeof E!="number"&&E!=="longjmp")throw E;Module.setThrew(1,0)}}function invoke_idd(i,o,f){try{return Module.dynCall_idd(i,o,f)}catch(p){if(typeof p!="number"&&p!=="longjmp")throw p;Module.setThrew(1,0)}}function invoke_viiii(i,o,f,p,E){try{Module.dynCall_viiii(i,o,f,p,E)}catch(t){if(typeof t!="number"&&t!=="longjmp")throw t;Module.setThrew(1,0)}}Module.asmGlobalArg={Math,Int8Array,Int16Array,Int32Array,Uint8Array,Uint16Array,Uint32Array,Float32Array,Float64Array,NaN:NaN,Infinity:Infinity},Module.asmLibraryArg={abort,assert,enlargeMemory,getTotalMemory,abortOnCannotGrowMemory,invoke_viiiii,invoke_vif,invoke_vid,invoke_fiff,invoke_vi,invoke_vii,invoke_ii,invoke_viddi,invoke_vidd,invoke_iiii,invoke_diii,invoke_di,invoke_iid,invoke_iii,invoke_viiddi,invoke_viiiiii,invoke_dii,invoke_i,invoke_iiiiii,invoke_viiid,invoke_viififi,invoke_viii,invoke_v,invoke_viid,invoke_idd,invoke_viiii,_emscripten_asm_const_iiiii,_emscripten_asm_const_iiidddddd,_emscripten_asm_const_iiiid,__nbind_reference_external,_emscripten_asm_const_iiiiiiii,_removeAccessorPrefix,_typeModule,__nbind_register_pool,__decorate,_llvm_stackrestore,___cxa_atexit,__extends,__nbind_get_value_object,__ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj,_emscripten_set_main_loop_timing,__nbind_register_primitive,__nbind_register_type,_emscripten_memcpy_big,__nbind_register_function,___setErrNo,__nbind_register_class,__nbind_finish,_abort,_nbind_value,_llvm_stacksave,___syscall54,_defineHidden,_emscripten_set_main_loop,_emscripten_get_now,__nbind_register_callback_signature,_emscripten_asm_const_iiiiii,__nbind_free_external,_emscripten_asm_const_iiii,_emscripten_asm_const_iiididi,___syscall6,_atexit,___syscall140,___syscall146,DYNAMICTOP_PTR,tempDoublePtr,ABORT,STACKTOP,STACK_MAX,cttz_i8,___dso_handle};var asm=function(i,o,f){var p=new i.Int8Array(f),E=new i.Int16Array(f),t=new i.Int32Array(f),k=new i.Uint8Array(f),L=new i.Uint16Array(f),N=new i.Uint32Array(f),C=new i.Float32Array(f),U=new i.Float64Array(f),q=o.DYNAMICTOP_PTR|0,W=o.tempDoublePtr|0,ne=o.ABORT|0,m=o.STACKTOP|0,we=o.STACK_MAX|0,Se=o.cttz_i8|0,he=o.___dso_handle|0,ge=0,ze=0,pe=0,Oe=0,le=i.NaN,Ue=i.Infinity,Ge=0,rt=0,wt=0,xt=0,$e=0,ft=0,Ke=i.Math.floor,jt=i.Math.abs,$t=i.Math.sqrt,at=i.Math.pow,Q=i.Math.cos,ae=i.Math.sin,Ce=i.Math.tan,ue=i.Math.acos,je=i.Math.asin,ct=i.Math.atan,At=i.Math.atan2,en=i.Math.exp,ln=i.Math.log,An=i.Math.ceil,nr=i.Math.imul,un=i.Math.min,Wt=i.Math.max,vr=i.Math.clz32,w=i.Math.fround,Ut=o.abort,Vn=o.assert,fr=o.enlargeMemory,Fr=o.getTotalMemory,ur=o.abortOnCannotGrowMemory,br=o.invoke_viiiii,Kt=o.invoke_vif,vu=o.invoke_vid,a0=o.invoke_fiff,So=o.invoke_vi,Go=o.invoke_vii,Os=o.invoke_ii,Yo=o.invoke_viddi,Ko=o.invoke_vidd,qt=o.invoke_iiii,_i=o.invoke_diii,eu=o.invoke_di,ai=o.invoke_iid,mr=o.invoke_iii,Xo=o.invoke_viiddi,W0=o.invoke_viiiiii,Lu=o.invoke_dii,V0=o.invoke_i,Hr=o.invoke_iiiiii,To=o.invoke_viiid,Co=o.invoke_viififi,L0=o.invoke_viii,tu=o.invoke_v,Si=o.invoke_viid,ks=o.invoke_idd,Hl=o.invoke_viiii,F0=o._emscripten_asm_const_iiiii,f0=o._emscripten_asm_const_iiidddddd,Pr=o._emscripten_asm_const_iiiid,Ei=o.__nbind_reference_external,G0=o._emscripten_asm_const_iiiiiiii,fi=o._removeAccessorPrefix,Zt=o._typeModule,Ln=o.__nbind_register_pool,Di=o.__decorate,ci=o._llvm_stackrestore,Ht=o.___cxa_atexit,Du=o.__extends,Yi=o.__nbind_get_value_object,Y0=o.__ZN8facebook4yoga14YGNodeToStringEPNSt3__212basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEEP6YGNode14YGPrintOptionsj,Ui=o._emscripten_set_main_loop_timing,Wl=o.__nbind_register_primitive,xo=o.__nbind_register_type,ni=o._emscripten_memcpy_big,oo=o.__nbind_register_function,Vl=o.___setErrNo,Ao=o.__nbind_register_class,Ms=o.__nbind_finish,Xn=o._abort,Qo=o._nbind_value,lo=o._llvm_stacksave,b0=o.___syscall54,yl=o._defineHidden,Ro=o._emscripten_set_main_loop,Et=o._emscripten_get_now,Pt=o.__nbind_register_callback_signature,Bn=o._emscripten_asm_const_iiiiii,Ir=o.__nbind_free_external,ji=o._emscripten_asm_const_iiii,Wr=o._emscripten_asm_const_iiididi,wu=o.___syscall6,c0=o._atexit,Ti=o.___syscall140,d0=o.___syscall146,as=w(0);let St=w(0);function so(e){e=e|0;var n=0;return n=m,m=m+e|0,m=m+15&-16,n|0}function Jo(){return m|0}function Gl(e){e=e|0,m=e}function Fu(e,n){e=e|0,n=n|0,m=e,we=n}function fs(e,n){e=e|0,n=n|0,ge||(ge=e,ze=n)}function P0(e){e=e|0,ft=e}function X(){return ft|0}function _e(){var e=0,n=0;pr(8104,8,400)|0,pr(8504,408,540)|0,e=9044,n=e+44|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));p[9088]=0,p[9089]=1,t[2273]=0,t[2274]=948,t[2275]=948,Ht(17,8104,he|0)|0}function Ne(e){e=e|0,ic(e+948|0)}function Me(e){return e=w(e),((cr(e)|0)&2147483647)>>>0>2139095040|0}function dt(e,n,r){e=e|0,n=n|0,r=r|0;e:do if(t[e+(n<<3)+4>>2]|0)e=e+(n<<3)|0;else{if((n|2|0)==3?t[e+60>>2]|0:0){e=e+56|0;break}switch(n|0){case 0:case 2:case 4:case 5:{if(t[e+52>>2]|0){e=e+48|0;break e}break}default:}if(t[e+68>>2]|0){e=e+64|0;break}else{e=(n|1|0)==5?948:r;break}}while(0);return e|0}function Hn(e){e=e|0;var n=0;return n=C_(1e3)|0,Dn(e,(n|0)!=0,2456),t[2276]=(t[2276]|0)+1,pr(n|0,8104,1e3)|0,p[e+2>>0]|0&&(t[n+4>>2]=2,t[n+12>>2]=4),t[n+976>>2]=e,n|0}function Dn(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;l=m,m=m+16|0,u=l,n||(t[u>>2]=r,_l(e,5,3197,u)),m=l}function or(){return Hn(956)|0}function mi(e){e=e|0;var n=0;return n=cn(1e3)|0,Su(n,e),Dn(t[e+976>>2]|0,1,2456),t[2276]=(t[2276]|0)+1,t[n+944>>2]=0,n|0}function Su(e,n){e=e|0,n=n|0;var r=0;pr(e|0,n|0,948)|0,na(e+948|0,n+948|0),r=e+960|0,e=n+960|0,n=r+40|0;do t[r>>2]=t[e>>2],r=r+4|0,e=e+4|0;while((r|0)<(n|0))}function bu(e){e=e|0;var n=0,r=0,u=0,l=0;if(n=e+944|0,r=t[n>>2]|0,r|0&&(Pu(r+948|0,e)|0,t[n>>2]=0),r=mu(e)|0,r|0){n=0;do t[(yi(e,n)|0)+944>>2]=0,n=n+1|0;while((n|0)!=(r|0))}r=e+948|0,u=t[r>>2]|0,l=e+952|0,n=t[l>>2]|0,(n|0)!=(u|0)&&(t[l>>2]=n+(~((n+-4-u|0)>>>2)<<2)),Oo(r),x_(e),t[2276]=(t[2276]|0)+-1}function Pu(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0;u=t[e>>2]|0,D=e+4|0,r=t[D>>2]|0,s=r;e:do if((u|0)==(r|0))l=u,h=4;else for(e=u;;){if((t[e>>2]|0)==(n|0)){l=e,h=4;break e}if(e=e+4|0,(e|0)==(r|0)){e=0;break}}while(0);return(h|0)==4&&((l|0)!=(r|0)?(u=l+4|0,e=s-u|0,n=e>>2,n&&(Iy(l|0,u|0,e|0)|0,r=t[D>>2]|0),e=l+(n<<2)|0,(r|0)==(e|0)||(t[D>>2]=r+(~((r+-4-e|0)>>>2)<<2)),e=1):e=0),e|0}function mu(e){return e=e|0,(t[e+952>>2]|0)-(t[e+948>>2]|0)>>2|0}function yi(e,n){e=e|0,n=n|0;var r=0;return r=t[e+948>>2]|0,(t[e+952>>2]|0)-r>>2>>>0>n>>>0?e=t[r+(n<<2)>>2]|0:e=0,e|0}function Oo(e){e=e|0;var n=0,r=0,u=0,l=0;u=m,m=m+32|0,n=u,l=t[e>>2]|0,r=(t[e+4>>2]|0)-l|0,((t[e+8>>2]|0)-l|0)>>>0>r>>>0&&(l=r>>2,Y(n,l,l,e+8|0),Qr(e,n),Jr(n)),m=u}function Tu(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0;M=mu(e)|0;do if(M|0){if((t[(yi(e,0)|0)+944>>2]|0)==(e|0)){if(!(Pu(e+948|0,n)|0))break;pr(n+400|0,8504,540)|0,t[n+944>>2]=0,Gn(e);break}h=t[(t[e+976>>2]|0)+12>>2]|0,D=e+948|0,S=(h|0)==0,r=0,s=0;do u=t[(t[D>>2]|0)+(s<<2)>>2]|0,(u|0)==(n|0)?Gn(e):(l=mi(u)|0,t[(t[D>>2]|0)+(r<<2)>>2]=l,t[l+944>>2]=e,S||$E[h&15](u,l,e,r),r=r+1|0),s=s+1|0;while((s|0)!=(M|0));if(r>>>0>>0){S=e+948|0,D=e+952|0,h=r,r=t[D>>2]|0;do s=(t[S>>2]|0)+(h<<2)|0,u=s+4|0,l=r-u|0,n=l>>2,n&&(Iy(s|0,u|0,l|0)|0,r=t[D>>2]|0),l=r,u=s+(n<<2)|0,(l|0)!=(u|0)&&(r=l+(~((l+-4-u|0)>>>2)<<2)|0,t[D>>2]=r),h=h+1|0;while((h|0)!=(M|0))}}while(0)}function ao(e){e=e|0;var n=0,r=0,u=0,l=0;Iu(e,(mu(e)|0)==0,2491),Iu(e,(t[e+944>>2]|0)==0,2545),n=e+948|0,r=t[n>>2]|0,u=e+952|0,l=t[u>>2]|0,(l|0)!=(r|0)&&(t[u>>2]=l+(~((l+-4-r|0)>>>2)<<2)),Oo(n),n=e+976|0,r=t[n>>2]|0,pr(e|0,8104,1e3)|0,p[r+2>>0]|0&&(t[e+4>>2]=2,t[e+12>>2]=4),t[n>>2]=r}function Iu(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;l=m,m=m+16|0,u=l,n||(t[u>>2]=r,sr(e,5,3197,u)),m=l}function Oa(){return t[2276]|0}function p0(){var e=0;return e=C_(20)|0,Zs((e|0)!=0,2592),t[2277]=(t[2277]|0)+1,t[e>>2]=t[239],t[e+4>>2]=t[240],t[e+8>>2]=t[241],t[e+12>>2]=t[242],t[e+16>>2]=t[243],e|0}function Zs(e,n){e=e|0,n=n|0;var r=0,u=0;u=m,m=m+16|0,r=u,e||(t[r>>2]=n,sr(0,5,3197,r)),m=u}function K0(e){e=e|0,x_(e),t[2277]=(t[2277]|0)+-1}function $s(e,n){e=e|0,n=n|0;var r=0;n?(Iu(e,(mu(e)|0)==0,2629),r=1):(r=0,n=0),t[e+964>>2]=n,t[e+988>>2]=r}function ka(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,s=u+8|0,l=u+4|0,h=u,t[l>>2]=n,Iu(e,(t[n+944>>2]|0)==0,2709),Iu(e,(t[e+964>>2]|0)==0,2763),cs(e),n=e+948|0,t[h>>2]=(t[n>>2]|0)+(r<<2),t[s>>2]=t[h>>2],w0(n,s,l)|0,t[(t[l>>2]|0)+944>>2]=e,Gn(e),m=u}function cs(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0;if(r=mu(e)|0,r|0?(t[(yi(e,0)|0)+944>>2]|0)!=(e|0):0){u=t[(t[e+976>>2]|0)+12>>2]|0,l=e+948|0,s=(u|0)==0,n=0;do h=t[(t[l>>2]|0)+(n<<2)>>2]|0,D=mi(h)|0,t[(t[l>>2]|0)+(n<<2)>>2]=D,t[D+944>>2]=e,s||$E[u&15](h,D,e,n),n=n+1|0;while((n|0)!=(r|0))}}function w0(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0,Qe=0,We=0;Qe=m,m=m+64|0,P=Qe+52|0,D=Qe+48|0,K=Qe+28|0,Pe=Qe+24|0,Ee=Qe+20|0,ve=Qe,u=t[e>>2]|0,s=u,n=u+((t[n>>2]|0)-s>>2<<2)|0,u=e+4|0,l=t[u>>2]|0,h=e+8|0;do if(l>>>0<(t[h>>2]|0)>>>0){if((n|0)==(l|0)){t[n>>2]=t[r>>2],t[u>>2]=(t[u>>2]|0)+4;break}Ur(e,n,l,n+4|0),n>>>0<=r>>>0&&(r=(t[u>>2]|0)>>>0>r>>>0?r+4|0:r),t[n>>2]=t[r>>2]}else{u=(l-s>>2)+1|0,l=x0(e)|0,l>>>0>>0&&li(e),O=t[e>>2]|0,M=(t[h>>2]|0)-O|0,s=M>>1,Y(ve,M>>2>>>0>>1>>>0?s>>>0>>0?u:s:l,n-O>>2,e+8|0),O=ve+8|0,u=t[O>>2]|0,s=ve+12|0,M=t[s>>2]|0,h=M,S=u;do if((u|0)==(M|0)){if(M=ve+4|0,u=t[M>>2]|0,We=t[ve>>2]|0,l=We,u>>>0<=We>>>0){u=h-l>>1,u=(u|0)==0?1:u,Y(K,u,u>>>2,t[ve+16>>2]|0),t[Pe>>2]=t[M>>2],t[Ee>>2]=t[O>>2],t[D>>2]=t[Pe>>2],t[P>>2]=t[Ee>>2],hi(K,D,P),u=t[ve>>2]|0,t[ve>>2]=t[K>>2],t[K>>2]=u,u=K+4|0,We=t[M>>2]|0,t[M>>2]=t[u>>2],t[u>>2]=We,u=K+8|0,We=t[O>>2]|0,t[O>>2]=t[u>>2],t[u>>2]=We,u=K+12|0,We=t[s>>2]|0,t[s>>2]=t[u>>2],t[u>>2]=We,Jr(K),u=t[O>>2]|0;break}s=u,h=((s-l>>2)+1|0)/-2|0,D=u+(h<<2)|0,l=S-s|0,s=l>>2,s&&(Iy(D|0,u|0,l|0)|0,u=t[M>>2]|0),We=D+(s<<2)|0,t[O>>2]=We,t[M>>2]=u+(h<<2),u=We}while(0);t[u>>2]=t[r>>2],t[O>>2]=(t[O>>2]|0)+4,n=lt(e,ve,n)|0,Jr(ve)}while(0);return m=Qe,n|0}function Gn(e){e=e|0;var n=0;do{if(n=e+984|0,p[n>>0]|0)break;p[n>>0]=1,C[e+504>>2]=w(le),e=t[e+944>>2]|0}while((e|0)!=0)}function ic(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-4-u|0)>>>2)<<2)),yt(r))}function ri(e){return e=e|0,t[e+944>>2]|0}function Gr(e){e=e|0,Iu(e,(t[e+964>>2]|0)!=0,2832),Gn(e)}function Yl(e){return e=e|0,(p[e+984>>0]|0)!=0|0}function ea(e,n){e=e|0,n=n|0,MI(e,n,400)|0&&(pr(e|0,n|0,400)|0,Gn(e))}function lf(e){e=e|0;var n=St;return n=w(C[e+44>>2]),e=Me(n)|0,w(e?w(0):n)}function Ns(e){e=e|0;var n=St;return n=w(C[e+48>>2]),Me(n)|0&&(n=p[(t[e+976>>2]|0)+2>>0]|0?w(1):w(0)),w(n)}function Ma(e,n){e=e|0,n=n|0,t[e+980>>2]=n}function Ls(e){return e=e|0,t[e+980>>2]|0}function h0(e,n){e=e|0,n=n|0;var r=0;r=e+4|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function Fs(e){return e=e|0,t[e+4>>2]|0}function Ni(e,n){e=e|0,n=n|0;var r=0;r=e+8|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function B(e){return e=e|0,t[e+8>>2]|0}function z(e,n){e=e|0,n=n|0;var r=0;r=e+12|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function G(e){return e=e|0,t[e+12>>2]|0}function $(e,n){e=e|0,n=n|0;var r=0;r=e+16|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function De(e){return e=e|0,t[e+16>>2]|0}function me(e,n){e=e|0,n=n|0;var r=0;r=e+20|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function xe(e){return e=e|0,t[e+20>>2]|0}function Z(e,n){e=e|0,n=n|0;var r=0;r=e+24|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function ke(e){return e=e|0,t[e+24>>2]|0}function Xe(e,n){e=e|0,n=n|0;var r=0;r=e+28|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function ht(e){return e=e|0,t[e+28>>2]|0}function ie(e,n){e=e|0,n=n|0;var r=0;r=e+32|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function qe(e){return e=e|0,t[e+32>>2]|0}function tt(e,n){e=e|0,n=n|0;var r=0;r=e+36|0,(t[r>>2]|0)!=(n|0)&&(t[r>>2]=n,Gn(e))}function Tt(e){return e=e|0,t[e+36>>2]|0}function kt(e,n){e=e|0,n=w(n);var r=0;r=e+40|0,w(C[r>>2])!=n&&(C[r>>2]=n,Gn(e))}function bt(e,n){e=e|0,n=w(n);var r=0;r=e+44|0,w(C[r>>2])!=n&&(C[r>>2]=n,Gn(e))}function on(e,n){e=e|0,n=w(n);var r=0;r=e+48|0,w(C[r>>2])!=n&&(C[r>>2]=n,Gn(e))}function tn(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+52|0,l=e+56|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function Lt(e,n){e=e|0,n=w(n);var r=0,u=0;u=e+52|0,r=e+56|0,(w(C[u>>2])==n?(t[r>>2]|0)==2:0)||(C[u>>2]=n,u=Me(n)|0,t[r>>2]=u?3:2,Gn(e))}function gn(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+52|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function lr(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=(s^1)&1,l=e+132+(n<<3)|0,n=e+132+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function Qn(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=s?0:2,l=e+132+(n<<3)|0,n=e+132+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function _r(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=n+132+(r<<3)|0,n=t[u+4>>2]|0,r=e,t[r>>2]=t[u>>2],t[r+4>>2]=n}function Cn(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=(s^1)&1,l=e+60+(n<<3)|0,n=e+60+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function Ar(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=s?0:2,l=e+60+(n<<3)|0,n=e+60+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function v0(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=n+60+(r<<3)|0,n=t[u+4>>2]|0,r=e,t[r>>2]=t[u>>2],t[r+4>>2]=n}function Rr(e,n){e=e|0,n=n|0;var r=0;r=e+60+(n<<3)+4|0,(t[r>>2]|0)!=3&&(C[e+60+(n<<3)>>2]=w(le),t[r>>2]=3,Gn(e))}function nt(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=(s^1)&1,l=e+204+(n<<3)|0,n=e+204+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function _t(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=s?0:2,l=e+204+(n<<3)|0,n=e+204+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function Ze(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=n+204+(r<<3)|0,n=t[u+4>>2]|0,r=e,t[r>>2]=t[u>>2],t[r+4>>2]=n}function Ft(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0,s=0;s=Me(r)|0,u=(s^1)&1,l=e+276+(n<<3)|0,n=e+276+(n<<3)+4|0,(s|w(C[l>>2])==r?(t[n>>2]|0)==(u|0):0)||(C[l>>2]=r,t[n>>2]=u,Gn(e))}function nn(e,n){return e=e|0,n=n|0,w(C[e+276+(n<<3)>>2])}function sn(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+348|0,l=e+352|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function Yn(e,n){e=e|0,n=w(n);var r=0,u=0;u=e+348|0,r=e+352|0,(w(C[u>>2])==n?(t[r>>2]|0)==2:0)||(C[u>>2]=n,u=Me(n)|0,t[r>>2]=u?3:2,Gn(e))}function yr(e){e=e|0;var n=0;n=e+352|0,(t[n>>2]|0)!=3&&(C[e+348>>2]=w(le),t[n>>2]=3,Gn(e))}function nu(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+348|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function Cu(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+356|0,l=e+360|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function S0(e,n){e=e|0,n=w(n);var r=0,u=0;u=e+356|0,r=e+360|0,(w(C[u>>2])==n?(t[r>>2]|0)==2:0)||(C[u>>2]=n,u=Me(n)|0,t[r>>2]=u?3:2,Gn(e))}function X0(e){e=e|0;var n=0;n=e+360|0,(t[n>>2]|0)!=3&&(C[e+356>>2]=w(le),t[n>>2]=3,Gn(e))}function xu(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+356|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function di(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+364|0,l=e+368|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function ko(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=s?0:2,u=e+364|0,l=e+368|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function Zo(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+364|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function sf(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+372|0,l=e+376|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function gl(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=s?0:2,u=e+372|0,l=e+376|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function af(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+372|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function Mo(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+380|0,l=e+384|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function ds(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=s?0:2,u=e+380|0,l=e+384|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function bs(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+380|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function No(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=(s^1)&1,u=e+388|0,l=e+392|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function Lo(e,n){e=e|0,n=w(n);var r=0,u=0,l=0,s=0;s=Me(n)|0,r=s?0:2,u=e+388|0,l=e+392|0,(s|w(C[u>>2])==n?(t[l>>2]|0)==(r|0):0)||(C[u>>2]=n,t[l>>2]=r,Gn(e))}function ps(e,n){e=e|0,n=n|0;var r=0,u=0;u=n+388|0,r=t[u+4>>2]|0,n=e,t[n>>2]=t[u>>2],t[n+4>>2]=r}function Vu(e,n){e=e|0,n=w(n);var r=0;r=e+396|0,w(C[r>>2])!=n&&(C[r>>2]=n,Gn(e))}function yu(e){return e=e|0,w(C[e+396>>2])}function pi(e){return e=e|0,w(C[e+400>>2])}function T0(e){return e=e|0,w(C[e+404>>2])}function Q0(e){return e=e|0,w(C[e+408>>2])}function Fo(e){return e=e|0,w(C[e+412>>2])}function ta(e){return e=e|0,w(C[e+416>>2])}function Kl(e){return e=e|0,w(C[e+420>>2])}function Ki(e,n){switch(e=e|0,n=n|0,Iu(e,(n|0)<6,2918),n|0){case 0:{n=(t[e+496>>2]|0)==2?5:4;break}case 2:{n=(t[e+496>>2]|0)==2?4:5;break}default:}return w(C[e+424+(n<<2)>>2])}function Yr(e,n){switch(e=e|0,n=n|0,Iu(e,(n|0)<6,2918),n|0){case 0:{n=(t[e+496>>2]|0)==2?5:4;break}case 2:{n=(t[e+496>>2]|0)==2?4:5;break}default:}return w(C[e+448+(n<<2)>>2])}function fo(e,n){switch(e=e|0,n=n|0,Iu(e,(n|0)<6,2918),n|0){case 0:{n=(t[e+496>>2]|0)==2?5:4;break}case 2:{n=(t[e+496>>2]|0)==2?4:5;break}default:}return w(C[e+472+(n<<2)>>2])}function Oi(e,n){e=e|0,n=n|0;var r=0,u=St;return r=t[e+4>>2]|0,(r|0)==(t[n+4>>2]|0)?r?(u=w(C[e>>2]),e=w(jt(w(u-w(C[n>>2]))))>2]=0,t[u+4>>2]=0,t[u+8>>2]=0,Y0(u|0,e|0,n|0,0),sr(e,3,(p[u+11>>0]|0)<0?t[u>>2]|0:u,r),eB(u),m=r}function J0(e,n,r,u){e=w(e),n=w(n),r=r|0,u=u|0;var l=St;e=w(e*n),l=w(YE(e,w(1)));do if(gi(l,w(0))|0)e=w(e-l);else{if(e=w(e-l),gi(l,w(1))|0){e=w(e+w(1));break}if(r){e=w(e+w(1));break}u||(l>w(.5)?l=w(1):(u=gi(l,w(.5))|0,l=w(u?1:0)),e=w(e+l))}while(0);return w(e/n)}function Z0(e,n,r,u,l,s,h,D,S,M,O,P,K){e=e|0,n=w(n),r=r|0,u=w(u),l=l|0,s=w(s),h=h|0,D=w(D),S=w(S),M=w(M),O=w(O),P=w(P),K=K|0;var Pe=0,Ee=St,ve=St,Qe=St,We=St,st=St,Re=St;return S>2]),Ee!=w(0)):0)?(Qe=w(J0(n,Ee,0,0)),We=w(J0(u,Ee,0,0)),ve=w(J0(s,Ee,0,0)),Ee=w(J0(D,Ee,0,0))):(ve=s,Qe=n,Ee=D,We=u),(l|0)==(e|0)?Pe=gi(ve,Qe)|0:Pe=0,(h|0)==(r|0)?K=gi(Ee,We)|0:K=0,((Pe?0:(st=w(n-O),!(Te(e,st,S)|0)))?!(et(e,st,l,S)|0):0)?Pe=Ve(e,st,l,s,S)|0:Pe=1,((K?0:(Re=w(u-P),!(Te(r,Re,M)|0)))?!(et(r,Re,h,M)|0):0)?K=Ve(r,Re,h,D,M)|0:K=1,K=Pe&K),K|0}function Te(e,n,r){return e=e|0,n=w(n),r=w(r),(e|0)==1?e=gi(n,r)|0:e=0,e|0}function et(e,n,r,u){return e=e|0,n=w(n),r=r|0,u=w(u),(e|0)==2&(r|0)==0?n>=u?e=1:e=gi(n,u)|0:e=0,e|0}function Ve(e,n,r,u,l){return e=e|0,n=w(n),r=r|0,u=w(u),l=w(l),(e|0)==2&(r|0)==2&u>n?l<=n?e=1:e=gi(n,l)|0:e=0,e|0}function Gt(e,n,r,u,l,s,h,D,S,M,O){e=e|0,n=w(n),r=w(r),u=u|0,l=l|0,s=s|0,h=w(h),D=w(D),S=S|0,M=M|0,O=O|0;var P=0,K=0,Pe=0,Ee=0,ve=St,Qe=St,We=0,st=0,Re=0,Fe=0,Qt=0,Lr=0,Nn=0,mn=0,hr=0,kr=0,On=0,Zi=St,ts=St,ns=St,rs=0,Xs=0;On=m,m=m+160|0,mn=On+152|0,Nn=On+120|0,Lr=On+104|0,Re=On+72|0,Ee=On+56|0,Qt=On+8|0,st=On,Fe=(t[2279]|0)+1|0,t[2279]=Fe,hr=e+984|0,((p[hr>>0]|0)!=0?(t[e+512>>2]|0)!=(t[2278]|0):0)?We=4:(t[e+516>>2]|0)==(u|0)?kr=0:We=4,(We|0)==4&&(t[e+520>>2]=0,t[e+924>>2]=-1,t[e+928>>2]=-1,C[e+932>>2]=w(-1),C[e+936>>2]=w(-1),kr=1);e:do if(t[e+964>>2]|0)if(ve=w(Yt(e,2,h)),Qe=w(Yt(e,0,h)),P=e+916|0,ns=w(C[P>>2]),ts=w(C[e+920>>2]),Zi=w(C[e+932>>2]),Z0(l,n,s,r,t[e+924>>2]|0,ns,t[e+928>>2]|0,ts,Zi,w(C[e+936>>2]),ve,Qe,O)|0)We=22;else if(Pe=t[e+520>>2]|0,!Pe)We=21;else for(K=0;;){if(P=e+524+(K*24|0)|0,Zi=w(C[P>>2]),ts=w(C[e+524+(K*24|0)+4>>2]),ns=w(C[e+524+(K*24|0)+16>>2]),Z0(l,n,s,r,t[e+524+(K*24|0)+8>>2]|0,Zi,t[e+524+(K*24|0)+12>>2]|0,ts,ns,w(C[e+524+(K*24|0)+20>>2]),ve,Qe,O)|0){We=22;break e}if(K=K+1|0,K>>>0>=Pe>>>0){We=21;break}}else{if(S){if(P=e+916|0,!(gi(w(C[P>>2]),n)|0)){We=21;break}if(!(gi(w(C[e+920>>2]),r)|0)){We=21;break}if((t[e+924>>2]|0)!=(l|0)){We=21;break}P=(t[e+928>>2]|0)==(s|0)?P:0,We=22;break}if(Pe=t[e+520>>2]|0,!Pe)We=21;else for(K=0;;){if(P=e+524+(K*24|0)|0,((gi(w(C[P>>2]),n)|0?gi(w(C[e+524+(K*24|0)+4>>2]),r)|0:0)?(t[e+524+(K*24|0)+8>>2]|0)==(l|0):0)?(t[e+524+(K*24|0)+12>>2]|0)==(s|0):0){We=22;break e}if(K=K+1|0,K>>>0>=Pe>>>0){We=21;break}}}while(0);do if((We|0)==21)p[11697]|0?(P=0,We=28):(P=0,We=31);else if((We|0)==22){if(K=(p[11697]|0)!=0,!((P|0)!=0&(kr^1)))if(K){We=28;break}else{We=31;break}Ee=P+16|0,t[e+908>>2]=t[Ee>>2],Pe=P+20|0,t[e+912>>2]=t[Pe>>2],(p[11698]|0)==0|K^1||(t[st>>2]=Br(Fe)|0,t[st+4>>2]=Fe,sr(e,4,2972,st),K=t[e+972>>2]|0,K|0&&M1[K&127](e),l=wn(l,S)|0,s=wn(s,S)|0,Xs=+w(C[Ee>>2]),rs=+w(C[Pe>>2]),t[Qt>>2]=l,t[Qt+4>>2]=s,U[Qt+8>>3]=+n,U[Qt+16>>3]=+r,U[Qt+24>>3]=Xs,U[Qt+32>>3]=rs,t[Qt+40>>2]=M,sr(e,4,2989,Qt))}while(0);return(We|0)==28&&(K=Br(Fe)|0,t[Ee>>2]=K,t[Ee+4>>2]=Fe,t[Ee+8>>2]=kr?3047:11699,sr(e,4,3038,Ee),K=t[e+972>>2]|0,K|0&&M1[K&127](e),Qt=wn(l,S)|0,We=wn(s,S)|0,t[Re>>2]=Qt,t[Re+4>>2]=We,U[Re+8>>3]=+n,U[Re+16>>3]=+r,t[Re+24>>2]=M,sr(e,4,3049,Re),We=31),(We|0)==31&&(fu(e,n,r,u,l,s,h,D,S,O),p[11697]|0&&(K=t[2279]|0,Qt=Br(K)|0,t[Lr>>2]=Qt,t[Lr+4>>2]=K,t[Lr+8>>2]=kr?3047:11699,sr(e,4,3083,Lr),K=t[e+972>>2]|0,K|0&&M1[K&127](e),Qt=wn(l,S)|0,Lr=wn(s,S)|0,rs=+w(C[e+908>>2]),Xs=+w(C[e+912>>2]),t[Nn>>2]=Qt,t[Nn+4>>2]=Lr,U[Nn+8>>3]=rs,U[Nn+16>>3]=Xs,t[Nn+24>>2]=M,sr(e,4,3092,Nn)),t[e+516>>2]=u,P||(K=e+520|0,P=t[K>>2]|0,(P|0)==16&&(p[11697]|0&&sr(e,4,3124,mn),t[K>>2]=0,P=0),S?P=e+916|0:(t[K>>2]=P+1,P=e+524+(P*24|0)|0),C[P>>2]=n,C[P+4>>2]=r,t[P+8>>2]=l,t[P+12>>2]=s,t[P+16>>2]=t[e+908>>2],t[P+20>>2]=t[e+912>>2],P=0)),S&&(t[e+416>>2]=t[e+908>>2],t[e+420>>2]=t[e+912>>2],p[e+985>>0]=1,p[hr>>0]=0),t[2279]=(t[2279]|0)+-1,t[e+512>>2]=t[2278],m=On,kr|(P|0)==0|0}function Yt(e,n,r){e=e|0,n=n|0,r=w(r);var u=St;return u=w(Li(e,n,r)),w(u+w(A0(e,n,r)))}function sr(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=m,m=m+16|0,l=s,t[l>>2]=u,e?u=t[e+976>>2]|0:u=0,Ps(u,e,n,r,l),m=s}function Br(e){return e=e|0,(e>>>0>60?3201:3201+(60-e)|0)|0}function wn(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;return l=m,m=m+32|0,r=l+12|0,u=l,t[r>>2]=t[254],t[r+4>>2]=t[255],t[r+8>>2]=t[256],t[u>>2]=t[257],t[u+4>>2]=t[258],t[u+8>>2]=t[259],(e|0)>2?e=11699:e=t[(n?u:r)+(e<<2)>>2]|0,m=l,e|0}function fu(e,n,r,u,l,s,h,D,S,M){e=e|0,n=w(n),r=w(r),u=u|0,l=l|0,s=s|0,h=w(h),D=w(D),S=S|0,M=M|0;var O=0,P=0,K=0,Pe=0,Ee=St,ve=St,Qe=St,We=St,st=St,Re=St,Fe=St,Qt=0,Lr=0,Nn=0,mn=St,hr=St,kr=0,On=St,Zi=0,ts=0,ns=0,rs=0,Xs=0,$2=0,ed=0,Za=0,td=0,Oc=0,kc=0,nd=0,rd=0,id=0,si=0,$a=0,ud=0,zf=0,od=St,ld=St,Mc=St,Nc=St,qf=St,Il=0,Aa=0,As=0,ef=0,L1=0,F1=St,Lc=St,b1=St,P1=St,Bl=St,vl=St,tf=0,lu=St,I1=St,is=St,Hf=St,us=St,Wf=St,B1=0,U1=0,Vf=St,Ul=St,nf=0,j1=0,z1=0,q1=0,gr=St,Mu=0,ml=0,os=0,jl=0,Tr=0,Fn=0,rf=0,hn=St,H1=0,u0=0;rf=m,m=m+16|0,Il=rf+12|0,Aa=rf+8|0,As=rf+4|0,ef=rf,Iu(e,(l|0)==0|(Me(n)|0)^1,3326),Iu(e,(s|0)==0|(Me(r)|0)^1,3406),ml=El(e,u)|0,t[e+496>>2]=ml,Tr=I0(2,ml)|0,Fn=I0(0,ml)|0,C[e+440>>2]=w(Li(e,Tr,h)),C[e+444>>2]=w(A0(e,Tr,h)),C[e+428>>2]=w(Li(e,Fn,h)),C[e+436>>2]=w(A0(e,Fn,h)),C[e+464>>2]=w(R0(e,Tr)),C[e+468>>2]=w(co(e,Tr)),C[e+452>>2]=w(R0(e,Fn)),C[e+460>>2]=w(co(e,Fn)),C[e+488>>2]=w(Ru(e,Tr,h)),C[e+492>>2]=w(Yu(e,Tr,h)),C[e+476>>2]=w(Ru(e,Fn,h)),C[e+484>>2]=w(Yu(e,Fn,h));do if(t[e+964>>2]|0)Xl(e,n,r,l,s,h,D);else{if(os=e+948|0,jl=(t[e+952>>2]|0)-(t[os>>2]|0)>>2,!jl){hs(e,n,r,l,s,h,D);break}if(S?0:ra(e,n,r,l,s,h,D)|0)break;cs(e),$a=e+508|0,p[$a>>0]=0,Tr=I0(t[e+4>>2]|0,ml)|0,Fn=df(Tr,ml)|0,Mu=Fi(Tr)|0,ud=t[e+8>>2]|0,j1=e+28|0,zf=(t[j1>>2]|0)!=0,us=Mu?h:D,Vf=Mu?D:h,od=w(Ku(e,Tr,h)),ld=w(vs(e,Tr,h)),Ee=w(Ku(e,Fn,h)),Wf=w(wr(e,Tr,h)),Ul=w(wr(e,Fn,h)),Nn=Mu?l:s,nf=Mu?s:l,gr=Mu?Wf:Ul,st=Mu?Ul:Wf,Hf=w(Yt(e,2,h)),We=w(Yt(e,0,h)),ve=w(w(Sn(e+364|0,h))-gr),Qe=w(w(Sn(e+380|0,h))-gr),Re=w(w(Sn(e+372|0,D))-st),Fe=w(w(Sn(e+388|0,D))-st),Mc=Mu?ve:Re,Nc=Mu?Qe:Fe,Hf=w(n-Hf),n=w(Hf-gr),Me(n)|0?gr=n:gr=w(Eu(w(Yp(n,Qe)),ve)),I1=w(r-We),n=w(I1-st),Me(n)|0?is=n:is=w(Eu(w(Yp(n,Fe)),Re)),ve=Mu?gr:is,lu=Mu?is:gr;e:do if((Nn|0)==1)for(u=0,P=0;;){if(O=yi(e,P)|0,!u)(w(Xi(O))>w(0)?w(ru(O))>w(0):0)?u=O:u=0;else if($0(O)|0){Pe=0;break e}if(P=P+1|0,P>>>0>=jl>>>0){Pe=u;break}}else Pe=0;while(0);Qt=Pe+500|0,Lr=Pe+504|0,u=0,O=0,n=w(0),K=0;do{if(P=t[(t[os>>2]|0)+(K<<2)>>2]|0,(t[P+36>>2]|0)==1)Ci(P),p[P+985>>0]=1,p[P+984>>0]=0;else{Vr(P),S&&C0(P,El(P,ml)|0,ve,lu,gr);do if((t[P+24>>2]|0)!=1)if((P|0)==(Pe|0)){t[Qt>>2]=t[2278],C[Lr>>2]=w(0);break}else{Xr(e,P,gr,l,is,gr,is,s,ml,M);break}else O|0&&(t[O+960>>2]=P),t[P+960>>2]=0,O=P,u=(u|0)==0?P:u;while(0);vl=w(C[P+504>>2]),n=w(n+w(vl+w(Yt(P,Tr,gr))))}K=K+1|0}while((K|0)!=(jl|0));for(ns=n>ve,tf=zf&((Nn|0)==2&ns)?1:Nn,Zi=(nf|0)==1,Xs=Zi&(S^1),$2=(tf|0)==1,ed=(tf|0)==2,Za=976+(Tr<<2)|0,td=(nf|2|0)==2,id=Zi&(zf^1),Oc=1040+(Fn<<2)|0,kc=1040+(Tr<<2)|0,nd=976+(Fn<<2)|0,rd=(nf|0)!=1,ns=zf&((Nn|0)!=0&ns),ts=e+976|0,Zi=Zi^1,n=ve,kr=0,rs=0,vl=w(0),qf=w(0);;){e:do if(kr>>>0>>0)for(Lr=t[os>>2]|0,K=0,Fe=w(0),Re=w(0),Qe=w(0),ve=w(0),P=0,O=0,Pe=kr;;){if(Qt=t[Lr+(Pe<<2)>>2]|0,(t[Qt+36>>2]|0)!=1?(t[Qt+940>>2]=rs,(t[Qt+24>>2]|0)!=1):0){if(We=w(Yt(Qt,Tr,gr)),si=t[Za>>2]|0,r=w(Sn(Qt+380+(si<<3)|0,us)),st=w(C[Qt+504>>2]),r=w(Yp(r,st)),r=w(Eu(w(Sn(Qt+364+(si<<3)|0,us)),r)),zf&(K|0)!=0&w(We+w(Re+r))>n){s=K,We=Fe,Nn=Pe;break e}We=w(We+r),r=w(Re+We),We=w(Fe+We),$0(Qt)|0&&(Qe=w(Qe+w(Xi(Qt))),ve=w(ve-w(st*w(ru(Qt))))),O|0&&(t[O+960>>2]=Qt),t[Qt+960>>2]=0,K=K+1|0,O=Qt,P=(P|0)==0?Qt:P}else We=Fe,r=Re;if(Pe=Pe+1|0,Pe>>>0>>0)Fe=We,Re=r;else{s=K,Nn=Pe;break}}else s=0,We=w(0),Qe=w(0),ve=w(0),P=0,Nn=kr;while(0);si=Qe>w(0)&Qew(0)&veNc&((Me(Nc)|0)^1))n=Nc,si=51;else if(p[(t[ts>>2]|0)+3>>0]|0)si=51;else{if(mn!=w(0)?w(Xi(e))!=w(0):0){si=53;break}n=We,si=53}while(0);if((si|0)==51&&(si=0,Me(n)|0?si=53:(hr=w(n-We),On=n)),(si|0)==53&&(si=0,We>2]|0,Pe=hrw(0),Re=w(hr/mn),Qe=w(0),We=w(0),n=w(0),O=P;do r=w(Sn(O+380+(K<<3)|0,us)),ve=w(Sn(O+364+(K<<3)|0,us)),ve=w(Yp(r,w(Eu(ve,w(C[O+504>>2]))))),Pe?(r=w(ve*w(ru(O))),(r!=w(-0)?(hn=w(ve-w(st*r)),F1=w(Wn(O,Tr,hn,On,gr)),hn!=F1):0)&&(Qe=w(Qe-w(F1-ve)),n=w(n+r))):((Qt?(Lc=w(Xi(O)),Lc!=w(0)):0)?(hn=w(ve+w(Re*Lc)),b1=w(Wn(O,Tr,hn,On,gr)),hn!=b1):0)&&(Qe=w(Qe-w(b1-ve)),We=w(We-Lc)),O=t[O+960>>2]|0;while((O|0)!=0);if(n=w(Fe+n),ve=w(hr+Qe),L1)n=w(0);else{st=w(mn+We),Pe=t[Za>>2]|0,Qt=vew(0),st=w(ve/st),n=w(0);do{hn=w(Sn(P+380+(Pe<<3)|0,us)),Qe=w(Sn(P+364+(Pe<<3)|0,us)),Qe=w(Yp(hn,w(Eu(Qe,w(C[P+504>>2]))))),Qt?(hn=w(Qe*w(ru(P))),ve=w(-hn),hn!=w(-0)?(hn=w(Re*ve),ve=w(Wn(P,Tr,w(Qe+(Lr?ve:hn)),On,gr))):ve=Qe):(K?(P1=w(Xi(P)),P1!=w(0)):0)?ve=w(Wn(P,Tr,w(Qe+w(st*P1)),On,gr)):ve=Qe,n=w(n-w(ve-Qe)),We=w(Yt(P,Tr,gr)),r=w(Yt(P,Fn,gr)),ve=w(ve+We),C[Aa>>2]=ve,t[ef>>2]=1,Qe=w(C[P+396>>2]);e:do if(Me(Qe)|0){O=Me(lu)|0;do if(!O){if(ns|(Bu(P,Fn,lu)|0|Zi)||(Xu(e,P)|0)!=4||(t[(m0(P,Fn)|0)+4>>2]|0)==3||(t[(y0(P,Fn)|0)+4>>2]|0)==3)break;C[Il>>2]=lu,t[As>>2]=1;break e}while(0);if(Bu(P,Fn,lu)|0){O=t[P+992+(t[nd>>2]<<2)>>2]|0,hn=w(r+w(Sn(O,lu))),C[Il>>2]=hn,O=rd&(t[O+4>>2]|0)==2,t[As>>2]=((Me(hn)|0|O)^1)&1;break}else{C[Il>>2]=lu,t[As>>2]=O?0:2;break}}else hn=w(ve-We),mn=w(hn/Qe),hn=w(Qe*hn),t[As>>2]=1,C[Il>>2]=w(r+(Mu?mn:hn));while(0);kn(P,Tr,On,gr,ef,Aa),kn(P,Fn,lu,gr,As,Il);do if(Bu(P,Fn,lu)|0?0:(Xu(e,P)|0)==4){if((t[(m0(P,Fn)|0)+4>>2]|0)==3){O=0;break}O=(t[(y0(P,Fn)|0)+4>>2]|0)!=3}else O=0;while(0);hn=w(C[Aa>>2]),mn=w(C[Il>>2]),H1=t[ef>>2]|0,u0=t[As>>2]|0,Gt(P,Mu?hn:mn,Mu?mn:hn,ml,Mu?H1:u0,Mu?u0:H1,gr,is,S&(O^1),3488,M)|0,p[$a>>0]=p[$a>>0]|p[P+508>>0],P=t[P+960>>2]|0}while((P|0)!=0)}}else n=w(0);if(n=w(hr+n),u0=n>0]=u0|k[$a>>0],ed&n>w(0)?(O=t[Za>>2]|0,((t[e+364+(O<<3)+4>>2]|0)!=0?(Bl=w(Sn(e+364+(O<<3)|0,us)),Bl>=w(0)):0)?ve=w(Eu(w(0),w(Bl-w(On-n)))):ve=w(0)):ve=n,Qt=kr>>>0>>0,Qt){Pe=t[os>>2]|0,K=kr,O=0;do P=t[Pe+(K<<2)>>2]|0,t[P+24>>2]|0||(O=((t[(m0(P,Tr)|0)+4>>2]|0)==3&1)+O|0,O=O+((t[(y0(P,Tr)|0)+4>>2]|0)==3&1)|0),K=K+1|0;while((K|0)!=(Nn|0));O?(We=w(0),r=w(0)):si=101}else si=101;e:do if((si|0)==101)switch(si=0,ud|0){case 1:{O=0,We=w(ve*w(.5)),r=w(0);break e}case 2:{O=0,We=ve,r=w(0);break e}case 3:{if(s>>>0<=1){O=0,We=w(0),r=w(0);break e}r=w((s+-1|0)>>>0),O=0,We=w(0),r=w(w(Eu(ve,w(0)))/r);break e}case 5:{r=w(ve/w((s+1|0)>>>0)),O=0,We=r;break e}case 4:{r=w(ve/w(s>>>0)),O=0,We=w(r*w(.5));break e}default:{O=0,We=w(0),r=w(0);break e}}while(0);if(n=w(od+We),Qt){Qe=w(ve/w(O|0)),K=t[os>>2]|0,P=kr,ve=w(0);do{O=t[K+(P<<2)>>2]|0;e:do if((t[O+36>>2]|0)!=1){switch(t[O+24>>2]|0){case 1:{if(se(O,Tr)|0){if(!S)break e;hn=w(re(O,Tr,On)),hn=w(hn+w(R0(e,Tr))),hn=w(hn+w(Li(O,Tr,gr))),C[O+400+(t[kc>>2]<<2)>>2]=hn;break e}break}case 0:if(u0=(t[(m0(O,Tr)|0)+4>>2]|0)==3,hn=w(Qe+n),n=u0?hn:n,S&&(u0=O+400+(t[kc>>2]<<2)|0,C[u0>>2]=w(n+w(C[u0>>2]))),u0=(t[(y0(O,Tr)|0)+4>>2]|0)==3,hn=w(Qe+n),n=u0?hn:n,Xs){hn=w(r+w(Yt(O,Tr,gr))),ve=lu,n=w(n+w(hn+w(C[O+504>>2])));break e}else{n=w(n+w(r+w(Le(O,Tr,gr)))),ve=w(Eu(ve,w(Le(O,Fn,gr))));break e}default:}S&&(hn=w(We+w(R0(e,Tr))),u0=O+400+(t[kc>>2]<<2)|0,C[u0>>2]=w(hn+w(C[u0>>2])))}while(0);P=P+1|0}while((P|0)!=(Nn|0))}else ve=w(0);if(r=w(ld+n),td?We=w(w(Wn(e,Fn,w(Ul+ve),Vf,h))-Ul):We=lu,Qe=w(w(Wn(e,Fn,w(Ul+(id?lu:ve)),Vf,h))-Ul),Qt&S){P=kr;do{K=t[(t[os>>2]|0)+(P<<2)>>2]|0;do if((t[K+36>>2]|0)!=1){if((t[K+24>>2]|0)==1){if(se(K,Fn)|0){if(hn=w(re(K,Fn,lu)),hn=w(hn+w(R0(e,Fn))),hn=w(hn+w(Li(K,Fn,gr))),O=t[Oc>>2]|0,C[K+400+(O<<2)>>2]=hn,!(Me(hn)|0))break}else O=t[Oc>>2]|0;hn=w(R0(e,Fn)),C[K+400+(O<<2)>>2]=w(hn+w(Li(K,Fn,gr)));break}O=Xu(e,K)|0;do if((O|0)==4){if((t[(m0(K,Fn)|0)+4>>2]|0)==3){si=139;break}if((t[(y0(K,Fn)|0)+4>>2]|0)==3){si=139;break}if(Bu(K,Fn,lu)|0){n=Ee;break}H1=t[K+908+(t[Za>>2]<<2)>>2]|0,t[Il>>2]=H1,n=w(C[K+396>>2]),u0=Me(n)|0,ve=(t[W>>2]=H1,w(C[W>>2])),u0?n=Qe:(hr=w(Yt(K,Fn,gr)),hn=w(ve/n),n=w(n*ve),n=w(hr+(Mu?hn:n))),C[Aa>>2]=n,C[Il>>2]=w(w(Yt(K,Tr,gr))+ve),t[As>>2]=1,t[ef>>2]=1,kn(K,Tr,On,gr,As,Il),kn(K,Fn,lu,gr,ef,Aa),n=w(C[Il>>2]),hr=w(C[Aa>>2]),hn=Mu?n:hr,n=Mu?hr:n,u0=((Me(hn)|0)^1)&1,Gt(K,hn,n,ml,u0,((Me(n)|0)^1)&1,gr,is,1,3493,M)|0,n=Ee}else si=139;while(0);e:do if((si|0)==139){si=0,n=w(We-w(Le(K,Fn,gr)));do if((t[(m0(K,Fn)|0)+4>>2]|0)==3){if((t[(y0(K,Fn)|0)+4>>2]|0)!=3)break;n=w(Ee+w(Eu(w(0),w(n*w(.5)))));break e}while(0);if((t[(y0(K,Fn)|0)+4>>2]|0)==3){n=Ee;break}if((t[(m0(K,Fn)|0)+4>>2]|0)==3){n=w(Ee+w(Eu(w(0),n)));break}switch(O|0){case 1:{n=Ee;break e}case 2:{n=w(Ee+w(n*w(.5)));break e}default:{n=w(Ee+n);break e}}}while(0);hn=w(vl+n),u0=K+400+(t[Oc>>2]<<2)|0,C[u0>>2]=w(hn+w(C[u0>>2]))}while(0);P=P+1|0}while((P|0)!=(Nn|0))}if(vl=w(vl+Qe),qf=w(Eu(qf,r)),s=rs+1|0,Nn>>>0>=jl>>>0)break;n=On,kr=Nn,rs=s}do if(S){if(O=s>>>0>1,O?0:!(Ae(e)|0))break;if(!(Me(lu)|0)){n=w(lu-vl);e:do switch(t[e+12>>2]|0){case 3:{Ee=w(Ee+n),Re=w(0);break}case 2:{Ee=w(Ee+w(n*w(.5))),Re=w(0);break}case 4:{lu>vl?Re=w(n/w(s>>>0)):Re=w(0);break}case 7:if(lu>vl){Ee=w(Ee+w(n/w(s<<1>>>0))),Re=w(n/w(s>>>0)),Re=O?Re:w(0);break e}else{Ee=w(Ee+w(n*w(.5))),Re=w(0);break e}case 6:{Re=w(n/w(rs>>>0)),Re=lu>vl&O?Re:w(0);break}default:Re=w(0)}while(0);if(s|0)for(Qt=1040+(Fn<<2)|0,Lr=976+(Fn<<2)|0,Pe=0,P=0;;){e:do if(P>>>0>>0)for(ve=w(0),Qe=w(0),n=w(0),K=P;;){O=t[(t[os>>2]|0)+(K<<2)>>2]|0;do if((t[O+36>>2]|0)!=1?(t[O+24>>2]|0)==0:0){if((t[O+940>>2]|0)!=(Pe|0))break e;if(ot(O,Fn)|0&&(hn=w(C[O+908+(t[Lr>>2]<<2)>>2]),n=w(Eu(n,w(hn+w(Yt(O,Fn,gr)))))),(Xu(e,O)|0)!=5)break;Bl=w(vt(O)),Bl=w(Bl+w(Li(O,0,gr))),hn=w(C[O+912>>2]),hn=w(w(hn+w(Yt(O,0,gr)))-Bl),Bl=w(Eu(Qe,Bl)),hn=w(Eu(ve,hn)),ve=hn,Qe=Bl,n=w(Eu(n,w(Bl+hn)))}while(0);if(O=K+1|0,O>>>0>>0)K=O;else{K=O;break}}else Qe=w(0),n=w(0),K=P;while(0);if(st=w(Re+n),r=Ee,Ee=w(Ee+st),P>>>0>>0){We=w(r+Qe),O=P;do{P=t[(t[os>>2]|0)+(O<<2)>>2]|0;e:do if((t[P+36>>2]|0)!=1?(t[P+24>>2]|0)==0:0)switch(Xu(e,P)|0){case 1:{hn=w(r+w(Li(P,Fn,gr))),C[P+400+(t[Qt>>2]<<2)>>2]=hn;break e}case 3:{hn=w(w(Ee-w(A0(P,Fn,gr)))-w(C[P+908+(t[Lr>>2]<<2)>>2])),C[P+400+(t[Qt>>2]<<2)>>2]=hn;break e}case 2:{hn=w(r+w(w(st-w(C[P+908+(t[Lr>>2]<<2)>>2]))*w(.5))),C[P+400+(t[Qt>>2]<<2)>>2]=hn;break e}case 4:{if(hn=w(r+w(Li(P,Fn,gr))),C[P+400+(t[Qt>>2]<<2)>>2]=hn,Bu(P,Fn,lu)|0||(Mu?(ve=w(C[P+908>>2]),n=w(ve+w(Yt(P,Tr,gr))),Qe=st):(Qe=w(C[P+912>>2]),Qe=w(Qe+w(Yt(P,Fn,gr))),n=st,ve=w(C[P+908>>2])),gi(n,ve)|0?gi(Qe,w(C[P+912>>2]))|0:0))break e;Gt(P,n,Qe,ml,1,1,gr,is,1,3501,M)|0;break e}case 5:{C[P+404>>2]=w(w(We-w(vt(P)))+w(re(P,0,lu)));break e}default:break e}while(0);O=O+1|0}while((O|0)!=(K|0))}if(Pe=Pe+1|0,(Pe|0)==(s|0))break;P=K}}}while(0);if(C[e+908>>2]=w(Wn(e,2,Hf,h,h)),C[e+912>>2]=w(Wn(e,0,I1,D,h)),((tf|0)!=0?(B1=t[e+32>>2]|0,U1=(tf|0)==2,!(U1&(B1|0)!=2)):0)?U1&(B1|0)==2&&(n=w(Wf+On),n=w(Eu(w(Yp(n,w(Xt(e,Tr,qf,us)))),Wf)),si=198):(n=w(Wn(e,Tr,qf,us,h)),si=198),(si|0)==198&&(C[e+908+(t[976+(Tr<<2)>>2]<<2)>>2]=n),((nf|0)!=0?(z1=t[e+32>>2]|0,q1=(nf|0)==2,!(q1&(z1|0)!=2)):0)?q1&(z1|0)==2&&(n=w(Ul+lu),n=w(Eu(w(Yp(n,w(Xt(e,Fn,w(Ul+vl),Vf)))),Ul)),si=204):(n=w(Wn(e,Fn,w(Ul+vl),Vf,h)),si=204),(si|0)==204&&(C[e+908+(t[976+(Fn<<2)>>2]<<2)>>2]=n),S){if((t[j1>>2]|0)==2){P=976+(Fn<<2)|0,K=1040+(Fn<<2)|0,O=0;do Pe=yi(e,O)|0,t[Pe+24>>2]|0||(H1=t[P>>2]|0,hn=w(C[e+908+(H1<<2)>>2]),u0=Pe+400+(t[K>>2]<<2)|0,hn=w(hn-w(C[u0>>2])),C[u0>>2]=w(hn-w(C[Pe+908+(H1<<2)>>2]))),O=O+1|0;while((O|0)!=(jl|0))}if(u|0){O=Mu?tf:l;do xn(e,u,gr,O,is,ml,M),u=t[u+960>>2]|0;while((u|0)!=0)}if(O=(Tr|2|0)==3,P=(Fn|2|0)==3,O|P){u=0;do K=t[(t[os>>2]|0)+(u<<2)>>2]|0,(t[K+36>>2]|0)!=1&&(O&&_n(e,K,Tr),P&&_n(e,K,Fn)),u=u+1|0;while((u|0)!=(jl|0))}}}while(0);m=rf}function Gu(e,n){e=e|0,n=w(n);var r=0;Dn(e,n>=w(0),3147),r=n==w(0),C[e+4>>2]=r?w(0):n}function Kr(e,n,r,u){e=e|0,n=w(n),r=w(r),u=u|0;var l=St,s=St,h=0,D=0,S=0;t[2278]=(t[2278]|0)+1,Vr(e),Bu(e,2,n)|0?(l=w(Sn(t[e+992>>2]|0,n)),S=1,l=w(l+w(Yt(e,2,n)))):(l=w(Sn(e+380|0,n)),l>=w(0)?S=2:(S=((Me(n)|0)^1)&1,l=n)),Bu(e,0,r)|0?(s=w(Sn(t[e+996>>2]|0,r)),D=1,s=w(s+w(Yt(e,0,n)))):(s=w(Sn(e+388|0,r)),s>=w(0)?D=2:(D=((Me(r)|0)^1)&1,s=r)),h=e+976|0,(Gt(e,l,s,u,S,D,n,r,1,3189,t[h>>2]|0)|0?(C0(e,t[e+496>>2]|0,n,r,n),Au(e,w(C[(t[h>>2]|0)+4>>2]),w(0),w(0)),p[11696]|0):0)&&ff(e,7)}function Vr(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;D=m,m=m+32|0,h=D+24|0,s=D+16|0,u=D+8|0,l=D,r=0;do n=e+380+(r<<3)|0,((t[e+380+(r<<3)+4>>2]|0)!=0?(S=n,M=t[S+4>>2]|0,O=u,t[O>>2]=t[S>>2],t[O+4>>2]=M,O=e+364+(r<<3)|0,M=t[O+4>>2]|0,S=l,t[S>>2]=t[O>>2],t[S+4>>2]=M,t[s>>2]=t[u>>2],t[s+4>>2]=t[u+4>>2],t[h>>2]=t[l>>2],t[h+4>>2]=t[l+4>>2],Oi(s,h)|0):0)||(n=e+348+(r<<3)|0),t[e+992+(r<<2)>>2]=n,r=r+1|0;while((r|0)!=2);m=D}function Bu(e,n,r){e=e|0,n=n|0,r=w(r);var u=0;switch(e=t[e+992+(t[976+(n<<2)>>2]<<2)>>2]|0,t[e+4>>2]|0){case 0:case 3:{e=0;break}case 1:{w(C[e>>2])>2])>2]|0){case 2:{n=w(w(w(C[e>>2])*n)/w(100));break}case 1:{n=w(C[e>>2]);break}default:n=w(le)}return w(n)}function C0(e,n,r,u,l){e=e|0,n=n|0,r=w(r),u=w(u),l=w(l);var s=0,h=St;n=t[e+944>>2]|0?n:1,s=I0(t[e+4>>2]|0,n)|0,n=df(s,n)|0,r=w(Sr(e,s,r)),u=w(Sr(e,n,u)),h=w(r+w(Li(e,s,l))),C[e+400+(t[1040+(s<<2)>>2]<<2)>>2]=h,r=w(r+w(A0(e,s,l))),C[e+400+(t[1e3+(s<<2)>>2]<<2)>>2]=r,r=w(u+w(Li(e,n,l))),C[e+400+(t[1040+(n<<2)>>2]<<2)>>2]=r,l=w(u+w(A0(e,n,l))),C[e+400+(t[1e3+(n<<2)>>2]<<2)>>2]=l}function Au(e,n,r,u){e=e|0,n=w(n),r=w(r),u=w(u);var l=0,s=0,h=St,D=St,S=0,M=0,O=St,P=0,K=St,Pe=St,Ee=St,ve=St;if(n!=w(0)&&(l=e+400|0,ve=w(C[l>>2]),s=e+404|0,Ee=w(C[s>>2]),P=e+416|0,Pe=w(C[P>>2]),M=e+420|0,h=w(C[M>>2]),K=w(ve+r),O=w(Ee+u),u=w(K+Pe),D=w(O+h),S=(t[e+988>>2]|0)==1,C[l>>2]=w(J0(ve,n,0,S)),C[s>>2]=w(J0(Ee,n,0,S)),r=w(YE(w(Pe*n),w(1))),gi(r,w(0))|0?s=0:s=(gi(r,w(1))|0)^1,r=w(YE(w(h*n),w(1))),gi(r,w(0))|0?l=0:l=(gi(r,w(1))|0)^1,ve=w(J0(u,n,S&s,S&(s^1))),C[P>>2]=w(ve-w(J0(K,n,0,S))),ve=w(J0(D,n,S&l,S&(l^1))),C[M>>2]=w(ve-w(J0(O,n,0,S))),s=(t[e+952>>2]|0)-(t[e+948>>2]|0)>>2,s|0)){l=0;do Au(yi(e,l)|0,n,K,O),l=l+1|0;while((l|0)!=(s|0))}}function ei(e,n,r,u,l){switch(e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,r|0){case 5:case 0:{e=F8(t[489]|0,u,l)|0;break}default:e=QI(u,l)|0}return e|0}function _l(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;l=m,m=m+16|0,s=l,t[s>>2]=u,Ps(e,0,n,r,s),m=l}function Ps(e,n,r,u,l){if(e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,e=e|0?e:956,tS[t[e+8>>2]&1](e,n,r,u,l)|0,(r|0)==5)Xn();else return}function Uu(e,n,r){e=e|0,n=n|0,r=r|0,p[e+n>>0]=r&1}function na(e,n){e=e|0,n=n|0;var r=0,u=0;t[e>>2]=0,t[e+4>>2]=0,t[e+8>>2]=0,r=n+4|0,u=(t[r>>2]|0)-(t[n>>2]|0)>>2,u|0&&(zi(e,u),Is(e,t[n>>2]|0,t[r>>2]|0,u))}function zi(e,n){e=e|0,n=n|0;var r=0;if((x0(e)|0)>>>0>>0&&li(e),n>>>0>1073741823)Xn();else{r=cn(n<<2)|0,t[e+4>>2]=r,t[e>>2]=r,t[e+8>>2]=r+(n<<2);return}}function Is(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,u=e+4|0,e=r-n|0,(e|0)>0&&(pr(t[u>>2]|0,n|0,e|0)|0,t[u>>2]=(t[u>>2]|0)+(e>>>2<<2))}function x0(e){return e=e|0,1073741823}function Li(e,n,r){return e=e|0,n=n|0,r=w(r),(Fi(n)|0?(t[e+96>>2]|0)!=0:0)?e=e+92|0:e=dt(e+60|0,t[1040+(n<<2)>>2]|0,992)|0,w($o(e,r))}function A0(e,n,r){return e=e|0,n=n|0,r=w(r),(Fi(n)|0?(t[e+104>>2]|0)!=0:0)?e=e+100|0:e=dt(e+60|0,t[1e3+(n<<2)>>2]|0,992)|0,w($o(e,r))}function Fi(e){return e=e|0,(e|1|0)==3|0}function $o(e,n){return e=e|0,n=w(n),(t[e+4>>2]|0)==3?n=w(0):n=w(Sn(e,n)),w(n)}function El(e,n){return e=e|0,n=n|0,e=t[e>>2]|0,((e|0)==0?(n|0)>1?n:1:e)|0}function I0(e,n){e=e|0,n=n|0;var r=0;e:do if((n|0)==2){switch(e|0){case 2:{e=3;break e}case 3:break;default:{r=4;break e}}e=2}else r=4;while(0);return e|0}function R0(e,n){e=e|0,n=n|0;var r=St;return((Fi(n)|0?(t[e+312>>2]|0)!=0:0)?(r=w(C[e+308>>2]),r>=w(0)):0)||(r=w(Eu(w(C[(dt(e+276|0,t[1040+(n<<2)>>2]|0,992)|0)>>2]),w(0)))),w(r)}function co(e,n){e=e|0,n=n|0;var r=St;return((Fi(n)|0?(t[e+320>>2]|0)!=0:0)?(r=w(C[e+316>>2]),r>=w(0)):0)||(r=w(Eu(w(C[(dt(e+276|0,t[1e3+(n<<2)>>2]|0,992)|0)>>2]),w(0)))),w(r)}function Ru(e,n,r){e=e|0,n=n|0,r=w(r);var u=St;return((Fi(n)|0?(t[e+240>>2]|0)!=0:0)?(u=w(Sn(e+236|0,r)),u>=w(0)):0)||(u=w(Eu(w(Sn(dt(e+204|0,t[1040+(n<<2)>>2]|0,992)|0,r)),w(0)))),w(u)}function Yu(e,n,r){e=e|0,n=n|0,r=w(r);var u=St;return((Fi(n)|0?(t[e+248>>2]|0)!=0:0)?(u=w(Sn(e+244|0,r)),u>=w(0)):0)||(u=w(Eu(w(Sn(dt(e+204|0,t[1e3+(n<<2)>>2]|0,992)|0,r)),w(0)))),w(u)}function Xl(e,n,r,u,l,s,h){e=e|0,n=w(n),r=w(r),u=u|0,l=l|0,s=w(s),h=w(h);var D=St,S=St,M=St,O=St,P=St,K=St,Pe=0,Ee=0,ve=0;ve=m,m=m+16|0,Pe=ve,Ee=e+964|0,Iu(e,(t[Ee>>2]|0)!=0,3519),D=w(wr(e,2,n)),S=w(wr(e,0,n)),M=w(Yt(e,2,n)),O=w(Yt(e,0,n)),Me(n)|0?P=n:P=w(Eu(w(0),w(w(n-M)-D))),Me(r)|0?K=r:K=w(Eu(w(0),w(w(r-O)-S))),(u|0)==1&(l|0)==1?(C[e+908>>2]=w(Wn(e,2,w(n-M),s,s)),n=w(Wn(e,0,w(r-O),h,s))):(nS[t[Ee>>2]&1](Pe,e,P,u,K,l),P=w(D+w(C[Pe>>2])),K=w(n-M),C[e+908>>2]=w(Wn(e,2,(u|2|0)==2?P:K,s,s)),K=w(S+w(C[Pe+4>>2])),n=w(r-O),n=w(Wn(e,0,(l|2|0)==2?K:n,h,s))),C[e+912>>2]=n,m=ve}function hs(e,n,r,u,l,s,h){e=e|0,n=w(n),r=w(r),u=u|0,l=l|0,s=w(s),h=w(h);var D=St,S=St,M=St,O=St;M=w(wr(e,2,s)),D=w(wr(e,0,s)),O=w(Yt(e,2,s)),S=w(Yt(e,0,s)),n=w(n-O),C[e+908>>2]=w(Wn(e,2,(u|2|0)==2?M:n,s,s)),r=w(r-S),C[e+912>>2]=w(Wn(e,0,(l|2|0)==2?D:r,h,s))}function ra(e,n,r,u,l,s,h){e=e|0,n=w(n),r=w(r),u=u|0,l=l|0,s=w(s),h=w(h);var D=0,S=St,M=St;return D=(u|0)==2,((n<=w(0)&D?0:!(r<=w(0)&(l|0)==2))?!((u|0)==1&(l|0)==1):0)?e=0:(S=w(Yt(e,0,s)),M=w(Yt(e,2,s)),D=n>2]=w(Wn(e,2,D?w(0):n,s,s)),n=w(r-S),D=r>2]=w(Wn(e,0,D?w(0):n,h,s)),e=1),e|0}function df(e,n){return e=e|0,n=n|0,yn(e)|0?e=I0(2,n)|0:e=0,e|0}function Ku(e,n,r){return e=e|0,n=n|0,r=w(r),r=w(Ru(e,n,r)),w(r+w(R0(e,n)))}function vs(e,n,r){return e=e|0,n=n|0,r=w(r),r=w(Yu(e,n,r)),w(r+w(co(e,n)))}function wr(e,n,r){e=e|0,n=n|0,r=w(r);var u=St;return u=w(Ku(e,n,r)),w(u+w(vs(e,n,r)))}function $0(e){return e=e|0,t[e+24>>2]|0?e=0:w(Xi(e))!=w(0)?e=1:e=w(ru(e))!=w(0),e|0}function Xi(e){e=e|0;var n=St;if(t[e+944>>2]|0){if(n=w(C[e+44>>2]),Me(n)|0)return n=w(C[e+40>>2]),e=n>w(0)&((Me(n)|0)^1),w(e?n:w(0))}else n=w(0);return w(n)}function ru(e){e=e|0;var n=St,r=0,u=St;do if(t[e+944>>2]|0){if(n=w(C[e+48>>2]),Me(n)|0){if(r=p[(t[e+976>>2]|0)+2>>0]|0,r<<24>>24==0?(u=w(C[e+40>>2]),u>24?w(1):w(0)}}else n=w(0);while(0);return w(n)}function Ci(e){e=e|0;var n=0,r=0;if(Iv(e+400|0,0,540)|0,p[e+985>>0]=1,cs(e),r=mu(e)|0,r|0){n=e+948|0,e=0;do Ci(t[(t[n>>2]|0)+(e<<2)>>2]|0),e=e+1|0;while((e|0)!=(r|0))}}function Xr(e,n,r,u,l,s,h,D,S,M){e=e|0,n=n|0,r=w(r),u=u|0,l=w(l),s=w(s),h=w(h),D=D|0,S=S|0,M=M|0;var O=0,P=St,K=0,Pe=0,Ee=St,ve=St,Qe=0,We=St,st=0,Re=St,Fe=0,Qt=0,Lr=0,Nn=0,mn=0,hr=0,kr=0,On=0,Zi=0,ts=0;Zi=m,m=m+16|0,Lr=Zi+12|0,Nn=Zi+8|0,mn=Zi+4|0,hr=Zi,On=I0(t[e+4>>2]|0,S)|0,Fe=Fi(On)|0,P=w(Sn(En(n)|0,Fe?s:h)),Qt=Bu(n,2,s)|0,kr=Bu(n,0,h)|0;do if(Me(P)|0?0:!(Me(Fe?r:l)|0)){if(O=n+504|0,!(Me(w(C[O>>2]))|0)&&(!(er(t[n+976>>2]|0,0)|0)||(t[n+500>>2]|0)==(t[2278]|0)))break;C[O>>2]=w(Eu(P,w(wr(n,On,s))))}else K=7;while(0);do if((K|0)==7){if(st=Fe^1,!(st|Qt^1)){h=w(Sn(t[n+992>>2]|0,s)),C[n+504>>2]=w(Eu(h,w(wr(n,2,s))));break}if(!(Fe|kr^1)){h=w(Sn(t[n+996>>2]|0,h)),C[n+504>>2]=w(Eu(h,w(wr(n,0,s))));break}C[Lr>>2]=w(le),C[Nn>>2]=w(le),t[mn>>2]=0,t[hr>>2]=0,We=w(Yt(n,2,s)),Re=w(Yt(n,0,s)),Qt?(Ee=w(We+w(Sn(t[n+992>>2]|0,s))),C[Lr>>2]=Ee,t[mn>>2]=1,Pe=1):(Pe=0,Ee=w(le)),kr?(P=w(Re+w(Sn(t[n+996>>2]|0,h))),C[Nn>>2]=P,t[hr>>2]=1,O=1):(O=0,P=w(le)),K=t[e+32>>2]|0,Fe&(K|0)==2?K=2:(Me(Ee)|0?!(Me(r)|0):0)&&(C[Lr>>2]=r,t[mn>>2]=2,Pe=2,Ee=r),(((K|0)==2&st?0:Me(P)|0)?!(Me(l)|0):0)&&(C[Nn>>2]=l,t[hr>>2]=2,O=2,P=l),ve=w(C[n+396>>2]),Qe=Me(ve)|0;do if(Qe)K=Pe;else{if((Pe|0)==1&st){C[Nn>>2]=w(w(Ee-We)/ve),t[hr>>2]=1,O=1,K=1;break}Fe&(O|0)==1?(C[Lr>>2]=w(ve*w(P-Re)),t[mn>>2]=1,O=1,K=1):K=Pe}while(0);ts=Me(r)|0,Pe=(Xu(e,n)|0)!=4,(Fe|Qt|((u|0)!=1|ts)|(Pe|(K|0)==1)?0:(C[Lr>>2]=r,t[mn>>2]=1,!Qe))&&(C[Nn>>2]=w(w(r-We)/ve),t[hr>>2]=1,O=1),(kr|st|((D|0)!=1|(Me(l)|0))|(Pe|(O|0)==1)?0:(C[Nn>>2]=l,t[hr>>2]=1,!Qe))&&(C[Lr>>2]=w(ve*w(l-Re)),t[mn>>2]=1),kn(n,2,s,s,mn,Lr),kn(n,0,h,s,hr,Nn),r=w(C[Lr>>2]),l=w(C[Nn>>2]),Gt(n,r,l,S,t[mn>>2]|0,t[hr>>2]|0,s,h,0,3565,M)|0,h=w(C[n+908+(t[976+(On<<2)>>2]<<2)>>2]),C[n+504>>2]=w(Eu(h,w(wr(n,On,s))))}while(0);t[n+500>>2]=t[2278],m=Zi}function Wn(e,n,r,u,l){return e=e|0,n=n|0,r=w(r),u=w(u),l=w(l),u=w(Xt(e,n,r,u)),w(Eu(u,w(wr(e,n,l))))}function Xu(e,n){return e=e|0,n=n|0,n=n+20|0,n=t[((t[n>>2]|0)==0?e+16|0:n)>>2]|0,((n|0)==5?yn(t[e+4>>2]|0)|0:0)&&(n=1),n|0}function m0(e,n){return e=e|0,n=n|0,(Fi(n)|0?(t[e+96>>2]|0)!=0:0)?n=4:n=t[1040+(n<<2)>>2]|0,e+60+(n<<3)|0}function y0(e,n){return e=e|0,n=n|0,(Fi(n)|0?(t[e+104>>2]|0)!=0:0)?n=5:n=t[1e3+(n<<2)>>2]|0,e+60+(n<<3)|0}function kn(e,n,r,u,l,s){switch(e=e|0,n=n|0,r=w(r),u=w(u),l=l|0,s=s|0,r=w(Sn(e+380+(t[976+(n<<2)>>2]<<3)|0,r)),r=w(r+w(Yt(e,n,u))),t[l>>2]|0){case 2:case 1:{l=Me(r)|0,u=w(C[s>>2]),C[s>>2]=l|u>2]=2,C[s>>2]=r);break}default:}}function se(e,n){return e=e|0,n=n|0,e=e+132|0,(Fi(n)|0?(t[(dt(e,4,948)|0)+4>>2]|0)!=0:0)?e=1:e=(t[(dt(e,t[1040+(n<<2)>>2]|0,948)|0)+4>>2]|0)!=0,e|0}function re(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0;return e=e+132|0,(Fi(n)|0?(u=dt(e,4,948)|0,(t[u+4>>2]|0)!=0):0)?l=4:(u=dt(e,t[1040+(n<<2)>>2]|0,948)|0,t[u+4>>2]|0?l=4:r=w(0)),(l|0)==4&&(r=w(Sn(u,r))),w(r)}function Le(e,n,r){e=e|0,n=n|0,r=w(r);var u=St;return u=w(C[e+908+(t[976+(n<<2)>>2]<<2)>>2]),u=w(u+w(Li(e,n,r))),w(u+w(A0(e,n,r)))}function Ae(e){e=e|0;var n=0,r=0,u=0;e:do if(yn(t[e+4>>2]|0)|0)n=0;else if((t[e+16>>2]|0)!=5)if(r=mu(e)|0,!r)n=0;else for(n=0;;){if(u=yi(e,n)|0,(t[u+24>>2]|0)==0?(t[u+20>>2]|0)==5:0){n=1;break e}if(n=n+1|0,n>>>0>=r>>>0){n=0;break}}else n=1;while(0);return n|0}function ot(e,n){e=e|0,n=n|0;var r=St;return r=w(C[e+908+(t[976+(n<<2)>>2]<<2)>>2]),r>=w(0)&((Me(r)|0)^1)|0}function vt(e){e=e|0;var n=St,r=0,u=0,l=0,s=0,h=0,D=0,S=St;if(r=t[e+968>>2]|0,r)S=w(C[e+908>>2]),n=w(C[e+912>>2]),n=w(J8[r&0](e,S,n)),Iu(e,(Me(n)|0)^1,3573);else{s=mu(e)|0;do if(s|0){for(r=0,l=0;;){if(u=yi(e,l)|0,t[u+940>>2]|0){h=8;break}if((t[u+24>>2]|0)!=1)if(D=(Xu(e,u)|0)==5,D){r=u;break}else r=(r|0)==0?u:r;if(l=l+1|0,l>>>0>=s>>>0){h=8;break}}if((h|0)==8&&!r)break;return n=w(vt(r)),w(n+w(C[r+404>>2]))}while(0);n=w(C[e+912>>2])}return w(n)}function Xt(e,n,r,u){e=e|0,n=n|0,r=w(r),u=w(u);var l=St,s=0;return yn(n)|0?(n=1,s=3):Fi(n)|0?(n=0,s=3):(u=w(le),l=w(le)),(s|0)==3&&(l=w(Sn(e+364+(n<<3)|0,u)),u=w(Sn(e+380+(n<<3)|0,u))),s=u=w(0)&((Me(u)|0)^1)),r=s?u:r,s=l>=w(0)&((Me(l)|0)^1)&r>2]|0,s)|0,Ee=df(Qe,s)|0,ve=Fi(Qe)|0,P=w(Yt(n,2,r)),K=w(Yt(n,0,r)),Bu(n,2,r)|0?D=w(P+w(Sn(t[n+992>>2]|0,r))):(se(n,2)|0?It(n,2)|0:0)?(D=w(C[e+908>>2]),S=w(R0(e,2)),S=w(D-w(S+w(co(e,2)))),D=w(re(n,2,r)),D=w(Wn(n,2,w(S-w(D+w(xi(n,2,r)))),r,r))):D=w(le),Bu(n,0,l)|0?S=w(K+w(Sn(t[n+996>>2]|0,l))):(se(n,0)|0?It(n,0)|0:0)?(S=w(C[e+912>>2]),st=w(R0(e,0)),st=w(S-w(st+w(co(e,0)))),S=w(re(n,0,l)),S=w(Wn(n,0,w(st-w(S+w(xi(n,0,l)))),l,r))):S=w(le),M=Me(D)|0,O=Me(S)|0;do if(M^O?(Pe=w(C[n+396>>2]),!(Me(Pe)|0)):0)if(M){D=w(P+w(w(S-K)*Pe));break}else{st=w(K+w(w(D-P)/Pe)),S=O?st:S;break}while(0);O=Me(D)|0,M=Me(S)|0,O|M&&(Re=(O^1)&1,u=r>w(0)&((u|0)!=0&O),D=ve?D:u?r:D,Gt(n,D,S,s,ve?Re:u?2:Re,O&(M^1)&1,D,S,0,3623,h)|0,D=w(C[n+908>>2]),D=w(D+w(Yt(n,2,r))),S=w(C[n+912>>2]),S=w(S+w(Yt(n,0,r)))),Gt(n,D,S,s,1,1,D,S,1,3635,h)|0,(It(n,Qe)|0?!(se(n,Qe)|0):0)?(Re=t[976+(Qe<<2)>>2]|0,st=w(C[e+908+(Re<<2)>>2]),st=w(st-w(C[n+908+(Re<<2)>>2])),st=w(st-w(co(e,Qe))),st=w(st-w(A0(n,Qe,r))),st=w(st-w(xi(n,Qe,ve?r:l))),C[n+400+(t[1040+(Qe<<2)>>2]<<2)>>2]=st):We=21;do if((We|0)==21){if(se(n,Qe)|0?0:(t[e+8>>2]|0)==1){Re=t[976+(Qe<<2)>>2]|0,st=w(C[e+908+(Re<<2)>>2]),st=w(w(st-w(C[n+908+(Re<<2)>>2]))*w(.5)),C[n+400+(t[1040+(Qe<<2)>>2]<<2)>>2]=st;break}(se(n,Qe)|0?0:(t[e+8>>2]|0)==2)&&(Re=t[976+(Qe<<2)>>2]|0,st=w(C[e+908+(Re<<2)>>2]),st=w(st-w(C[n+908+(Re<<2)>>2])),C[n+400+(t[1040+(Qe<<2)>>2]<<2)>>2]=st)}while(0);(It(n,Ee)|0?!(se(n,Ee)|0):0)?(Re=t[976+(Ee<<2)>>2]|0,st=w(C[e+908+(Re<<2)>>2]),st=w(st-w(C[n+908+(Re<<2)>>2])),st=w(st-w(co(e,Ee))),st=w(st-w(A0(n,Ee,r))),st=w(st-w(xi(n,Ee,ve?l:r))),C[n+400+(t[1040+(Ee<<2)>>2]<<2)>>2]=st):We=30;do if((We|0)==30?!(se(n,Ee)|0):0){if((Xu(e,n)|0)==2){Re=t[976+(Ee<<2)>>2]|0,st=w(C[e+908+(Re<<2)>>2]),st=w(w(st-w(C[n+908+(Re<<2)>>2]))*w(.5)),C[n+400+(t[1040+(Ee<<2)>>2]<<2)>>2]=st;break}Re=(Xu(e,n)|0)==3,Re^(t[e+28>>2]|0)==2&&(Re=t[976+(Ee<<2)>>2]|0,st=w(C[e+908+(Re<<2)>>2]),st=w(st-w(C[n+908+(Re<<2)>>2])),C[n+400+(t[1040+(Ee<<2)>>2]<<2)>>2]=st)}while(0)}function _n(e,n,r){e=e|0,n=n|0,r=r|0;var u=St,l=0;l=t[976+(r<<2)>>2]|0,u=w(C[n+908+(l<<2)>>2]),u=w(w(C[e+908+(l<<2)>>2])-u),u=w(u-w(C[n+400+(t[1040+(r<<2)>>2]<<2)>>2])),C[n+400+(t[1e3+(r<<2)>>2]<<2)>>2]=u}function yn(e){return e=e|0,(e|1|0)==1|0}function En(e){e=e|0;var n=St;switch(t[e+56>>2]|0){case 0:case 3:{n=w(C[e+40>>2]),n>w(0)&((Me(n)|0)^1)?e=p[(t[e+976>>2]|0)+2>>0]|0?1056:992:e=1056;break}default:e=e+52|0}return e|0}function er(e,n){return e=e|0,n=n|0,(p[e+n>>0]|0)!=0|0}function It(e,n){return e=e|0,n=n|0,e=e+132|0,(Fi(n)|0?(t[(dt(e,5,948)|0)+4>>2]|0)!=0:0)?e=1:e=(t[(dt(e,t[1e3+(n<<2)>>2]|0,948)|0)+4>>2]|0)!=0,e|0}function xi(e,n,r){e=e|0,n=n|0,r=w(r);var u=0,l=0;return e=e+132|0,(Fi(n)|0?(u=dt(e,5,948)|0,(t[u+4>>2]|0)!=0):0)?l=4:(u=dt(e,t[1e3+(n<<2)>>2]|0,948)|0,t[u+4>>2]|0?l=4:r=w(0)),(l|0)==4&&(r=w(Sn(u,r))),w(r)}function Sr(e,n,r){return e=e|0,n=n|0,r=w(r),se(e,n)|0?r=w(re(e,n,r)):r=w(-w(xi(e,n,r))),w(r)}function cr(e){return e=w(e),C[W>>2]=e,t[W>>2]|0|0}function Y(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>1073741823)Xn();else{l=cn(n<<2)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<2)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<2)}function Qr(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>2)<<2)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Jr(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-4-n|0)>>>2)<<2)),e=t[e>>2]|0,e|0&&yt(e)}function Ur(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;if(h=e+4|0,D=t[h>>2]|0,l=D-u|0,s=l>>2,e=n+(s<<2)|0,e>>>0>>0){u=D;do t[u>>2]=t[e>>2],e=e+4|0,u=(t[h>>2]|0)+4|0,t[h>>2]=u;while(e>>>0>>0)}s|0&&Iy(D+(0-s<<2)|0,n|0,l|0)|0}function lt(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0;return D=n+4|0,S=t[D>>2]|0,l=t[e>>2]|0,h=r,s=h-l|0,u=S+(0-(s>>2)<<2)|0,t[D>>2]=u,(s|0)>0&&pr(u|0,l|0,s|0)|0,l=e+4|0,s=n+8|0,u=(t[l>>2]|0)-h|0,(u|0)>0&&(pr(t[s>>2]|0,r|0,u|0)|0,t[s>>2]=(t[s>>2]|0)+(u>>>2<<2)),h=t[e>>2]|0,t[e>>2]=t[D>>2],t[D>>2]=h,h=t[l>>2]|0,t[l>>2]=t[s>>2],t[s>>2]=h,h=e+8|0,r=n+12|0,e=t[h>>2]|0,t[h>>2]=t[r>>2],t[r>>2]=e,t[n>>2]=t[D>>2],S|0}function hi(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;if(h=t[n>>2]|0,s=t[r>>2]|0,(h|0)!=(s|0)){l=e+8|0,r=((s+-4-h|0)>>>2)+1|0,e=h,u=t[l>>2]|0;do t[u>>2]=t[e>>2],u=(t[l>>2]|0)+4|0,t[l>>2]=u,e=e+4|0;while((e|0)!=(s|0));t[n>>2]=h+(r<<2)}}function Qi(){_e()}function g0(){var e=0;return e=cn(4)|0,bn(e),e|0}function bn(e){e=e|0,t[e>>2]=p0()|0}function Qu(e){e=e|0,e|0&&(eo(e),yt(e))}function eo(e){e=e|0,K0(t[e>>2]|0)}function po(e,n,r){e=e|0,n=n|0,r=r|0,Uu(t[e>>2]|0,n,r)}function Ju(e,n){e=e|0,n=w(n),Gu(t[e>>2]|0,n)}function bo(e,n){return e=e|0,n=n|0,er(t[e>>2]|0,n)|0}function to(){var e=0;return e=cn(8)|0,Na(e,0),e|0}function Na(e,n){e=e|0,n=n|0,n?n=Hn(t[n>>2]|0)|0:n=or()|0,t[e>>2]=n,t[e+4>>2]=0,Ma(n,e)}function pf(e){e=e|0;var n=0;return n=cn(8)|0,Na(n,e),n|0}function uc(e){e=e|0,e|0&&(ms(e),yt(e))}function ms(e){e=e|0;var n=0;bu(t[e>>2]|0),n=e+4|0,e=t[n>>2]|0,t[n>>2]=0,e|0&&(ia(e),yt(e))}function ia(e){e=e|0,B0(e)}function B0(e){e=e|0,e=t[e>>2]|0,e|0&&Ir(e|0)}function oc(e){return e=e|0,Ls(e)|0}function La(e){e=e|0;var n=0,r=0;r=e+4|0,n=t[r>>2]|0,t[r>>2]=0,n|0&&(ia(n),yt(n)),ao(t[e>>2]|0)}function gd(e,n){e=e|0,n=n|0,ea(t[e>>2]|0,t[n>>2]|0)}function $1(e,n){e=e|0,n=n|0,Z(t[e>>2]|0,n)}function e2(e,n,r){e=e|0,n=n|0,r=+r,lr(t[e>>2]|0,n,w(r))}function ho(e,n,r){e=e|0,n=n|0,r=+r,Qn(t[e>>2]|0,n,w(r))}function Uc(e,n){e=e|0,n=n|0,z(t[e>>2]|0,n)}function Dl(e,n){e=e|0,n=n|0,$(t[e>>2]|0,n)}function el(e,n){e=e|0,n=n|0,me(t[e>>2]|0,n)}function _d(e,n){e=e|0,n=n|0,h0(t[e>>2]|0,n)}function Bs(e,n){e=e|0,n=n|0,Xe(t[e>>2]|0,n)}function wl(e,n){e=e|0,n=n|0,Ni(t[e>>2]|0,n)}function t2(e,n,r){e=e|0,n=n|0,r=+r,Cn(t[e>>2]|0,n,w(r))}function Po(e,n,r){e=e|0,n=n|0,r=+r,Ar(t[e>>2]|0,n,w(r))}function Fa(e,n){e=e|0,n=n|0,Rr(t[e>>2]|0,n)}function ba(e,n){e=e|0,n=n|0,ie(t[e>>2]|0,n)}function Pa(e,n){e=e|0,n=n|0,tt(t[e>>2]|0,n)}function ua(e,n){e=e|0,n=+n,kt(t[e>>2]|0,w(n))}function ys(e,n){e=e|0,n=+n,tn(t[e>>2]|0,w(n))}function gs(e,n){e=e|0,n=+n,Lt(t[e>>2]|0,w(n))}function Ql(e,n){e=e|0,n=+n,bt(t[e>>2]|0,w(n))}function Io(e,n){e=e|0,n=+n,on(t[e>>2]|0,w(n))}function hf(e,n){e=e|0,n=+n,sn(t[e>>2]|0,w(n))}function tl(e,n){e=e|0,n=+n,Yn(t[e>>2]|0,w(n))}function ju(e){e=e|0,yr(t[e>>2]|0)}function Ia(e,n){e=e|0,n=+n,Cu(t[e>>2]|0,w(n))}function Zu(e,n){e=e|0,n=+n,S0(t[e>>2]|0,w(n))}function U0(e){e=e|0,X0(t[e>>2]|0)}function vf(e,n){e=e|0,n=+n,di(t[e>>2]|0,w(n))}function jc(e,n){e=e|0,n=+n,ko(t[e>>2]|0,w(n))}function lc(e,n){e=e|0,n=+n,sf(t[e>>2]|0,w(n))}function Sl(e,n){e=e|0,n=+n,gl(t[e>>2]|0,w(n))}function _s(e,n){e=e|0,n=+n,Mo(t[e>>2]|0,w(n))}function oa(e,n){e=e|0,n=+n,ds(t[e>>2]|0,w(n))}function n2(e,n){e=e|0,n=+n,No(t[e>>2]|0,w(n))}function la(e,n){e=e|0,n=+n,Lo(t[e>>2]|0,w(n))}function sc(e,n){e=e|0,n=+n,Vu(t[e>>2]|0,w(n))}function zc(e,n,r){e=e|0,n=n|0,r=+r,Ft(t[e>>2]|0,n,w(r))}function bi(e,n,r){e=e|0,n=n|0,r=+r,nt(t[e>>2]|0,n,w(r))}function g(e,n,r){e=e|0,n=n|0,r=+r,_t(t[e>>2]|0,n,w(r))}function y(e){return e=e|0,ke(t[e>>2]|0)|0}function A(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;u=m,m=m+16|0,l=u,_r(l,t[n>>2]|0,r),F(e,l),m=u}function F(e,n){e=e|0,n=n|0,I(e,t[n+4>>2]|0,+w(C[n>>2]))}function I(e,n,r){e=e|0,n=n|0,r=+r,t[e>>2]=n,U[e+8>>3]=r}function J(e){return e=e|0,G(t[e>>2]|0)|0}function fe(e){return e=e|0,De(t[e>>2]|0)|0}function mt(e){return e=e|0,xe(t[e>>2]|0)|0}function Ct(e){return e=e|0,Fs(t[e>>2]|0)|0}function Mt(e){return e=e|0,ht(t[e>>2]|0)|0}function Er(e){return e=e|0,B(t[e>>2]|0)|0}function $u(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;u=m,m=m+16|0,l=u,v0(l,t[n>>2]|0,r),F(e,l),m=u}function iu(e){return e=e|0,qe(t[e>>2]|0)|0}function j0(e){return e=e|0,Tt(t[e>>2]|0)|0}function Tl(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,gn(u,t[n>>2]|0),F(e,u),m=r}function e0(e){return e=e|0,+ +w(lf(t[e>>2]|0))}function He(e){return e=e|0,+ +w(Ns(t[e>>2]|0))}function Be(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,nu(u,t[n>>2]|0),F(e,u),m=r}function ut(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,xu(u,t[n>>2]|0),F(e,u),m=r}function Jt(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,Zo(u,t[n>>2]|0),F(e,u),m=r}function jn(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,af(u,t[n>>2]|0),F(e,u),m=r}function ti(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,bs(u,t[n>>2]|0),F(e,u),m=r}function tr(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,ps(u,t[n>>2]|0),F(e,u),m=r}function ii(e){return e=e|0,+ +w(yu(t[e>>2]|0))}function qi(e,n){return e=e|0,n=n|0,+ +w(nn(t[e>>2]|0,n))}function jr(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;u=m,m=m+16|0,l=u,Ze(l,t[n>>2]|0,r),F(e,l),m=u}function gu(e,n,r){e=e|0,n=n|0,r=r|0,ka(t[e>>2]|0,t[n>>2]|0,r)}function Ba(e,n){e=e|0,n=n|0,Tu(t[e>>2]|0,t[n>>2]|0)}function Ua(e){return e=e|0,mu(t[e>>2]|0)|0}function r2(e){return e=e|0,e=ri(t[e>>2]|0)|0,e?e=oc(e)|0:e=0,e|0}function Ed(e,n){return e=e|0,n=n|0,e=yi(t[e>>2]|0,n)|0,e?e=oc(e)|0:e=0,e|0}function Dd(e,n){e=e|0,n=n|0;var r=0,u=0;u=cn(4)|0,mf(u,n),r=e+4|0,n=t[r>>2]|0,t[r>>2]=u,n|0&&(ia(n),yt(n)),$s(t[e>>2]|0,1)}function mf(e,n){e=e|0,n=n|0,rl(e,n)}function i2(e,n,r,u,l,s){e=e|0,n=n|0,r=w(r),u=u|0,l=w(l),s=s|0;var h=0,D=0;h=m,m=m+16|0,D=h,ch(D,Ls(n)|0,+r,u,+l,s),C[e>>2]=w(+U[D>>3]),C[e+4>>2]=w(+U[D+8>>3]),m=h}function ch(e,n,r,u,l,s){e=e|0,n=n|0,r=+r,u=u|0,l=+l,s=s|0;var h=0,D=0,S=0,M=0,O=0;h=m,m=m+32|0,O=h+8|0,M=h+20|0,S=h,D=h+16|0,U[O>>3]=r,t[M>>2]=u,U[S>>3]=l,t[D>>2]=s,qc(e,t[n+4>>2]|0,O,M,S,D),m=h}function qc(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0;var h=0,D=0;h=m,m=m+16|0,D=h,Ta(D),n=vo(n)|0,dh(e,n,+U[r>>3],t[u>>2]|0,+U[l>>3],t[s>>2]|0),Ca(D),m=h}function vo(e){return e=e|0,t[e>>2]|0}function dh(e,n,r,u,l,s){e=e|0,n=n|0,r=+r,u=u|0,l=+l,s=s|0;var h=0;h=mo(ph()|0)|0,r=+Cl(r),u=u2(u)|0,l=+Cl(l),o2(e,Wr(0,h|0,n|0,+r,u|0,+l,u2(s)|0)|0)}function ph(){var e=0;return p[7608]|0||(Wc(9120),e=7608,t[e>>2]=1,t[e+4>>2]=0),9120}function mo(e){return e=e|0,t[e+8>>2]|0}function Cl(e){return e=+e,+ +ja(e)}function u2(e){return e=e|0,s2(e)|0}function o2(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;l=m,m=m+32|0,r=l,u=n,u&1?(wd(r,0),Yi(u|0,r|0)|0,Hc(e,r),Mr(r)):(t[e>>2]=t[n>>2],t[e+4>>2]=t[n+4>>2],t[e+8>>2]=t[n+8>>2],t[e+12>>2]=t[n+12>>2]),m=l}function wd(e,n){e=e|0,n=n|0,l2(e,n),t[e+8>>2]=0,p[e+24>>0]=0}function Hc(e,n){e=e|0,n=n|0,n=n+8|0,t[e>>2]=t[n>>2],t[e+4>>2]=t[n+4>>2],t[e+8>>2]=t[n+8>>2],t[e+12>>2]=t[n+12>>2]}function Mr(e){e=e|0,p[e+24>>0]=0}function l2(e,n){e=e|0,n=n|0,t[e>>2]=n}function s2(e){return e=e|0,e|0}function ja(e){return e=+e,+e}function Wc(e){e=e|0,nl(e,Sd()|0,4)}function Sd(){return 1064}function nl(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r,t[e+8>>2]=Pt(n|0,r+1|0)|0}function rl(e,n){e=e|0,n=n|0,n=t[n>>2]|0,t[e>>2]=n,Ei(n|0)}function hh(e){e=e|0;var n=0,r=0;r=e+4|0,n=t[r>>2]|0,t[r>>2]=0,n|0&&(ia(n),yt(n)),$s(t[e>>2]|0,0)}function yf(e){e=e|0,Gr(t[e>>2]|0)}function Vc(e){return e=e|0,Yl(t[e>>2]|0)|0}function Td(e,n,r,u){e=e|0,n=+n,r=+r,u=u|0,Kr(t[e>>2]|0,w(n),w(r),u)}function vh(e){return e=e|0,+ +w(pi(t[e>>2]|0))}function il(e){return e=e|0,+ +w(Q0(t[e>>2]|0))}function sa(e){return e=e|0,+ +w(T0(t[e>>2]|0))}function Cd(e){return e=e|0,+ +w(Fo(t[e>>2]|0))}function xd(e){return e=e|0,+ +w(ta(t[e>>2]|0))}function ac(e){return e=e|0,+ +w(Kl(t[e>>2]|0))}function mh(e,n){e=e|0,n=n|0,U[e>>3]=+w(pi(t[n>>2]|0)),U[e+8>>3]=+w(Q0(t[n>>2]|0)),U[e+16>>3]=+w(T0(t[n>>2]|0)),U[e+24>>3]=+w(Fo(t[n>>2]|0)),U[e+32>>3]=+w(ta(t[n>>2]|0)),U[e+40>>3]=+w(Kl(t[n>>2]|0))}function Ad(e,n){return e=e|0,n=n|0,+ +w(Ki(t[e>>2]|0,n))}function a2(e,n){return e=e|0,n=n|0,+ +w(Yr(t[e>>2]|0,n))}function Gc(e,n){return e=e|0,n=n|0,+ +w(fo(t[e>>2]|0,n))}function Yc(){return Oa()|0}function Us(){Rd(),aa(),Kc(),fc(),cc(),f2()}function Rd(){bN(11713,4938,1)}function aa(){eN(10448)}function Kc(){bM(10408)}function fc(){iM(10324)}function cc(){yE(10096)}function f2(){yh(9132)}function yh(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0,Qe=0,We=0,st=0,Re=0,Fe=0,Qt=0,Lr=0,Nn=0,mn=0,hr=0,kr=0,On=0,Zi=0,ts=0,ns=0,rs=0,Xs=0,$2=0,ed=0,Za=0,td=0,Oc=0,kc=0,nd=0,rd=0,id=0,si=0,$a=0,ud=0,zf=0,od=0,ld=0,Mc=0,Nc=0,qf=0,Il=0,Aa=0,As=0,ef=0,L1=0,F1=0,Lc=0,b1=0,P1=0,Bl=0,vl=0,tf=0,lu=0,I1=0,is=0,Hf=0,us=0,Wf=0,B1=0,U1=0,Vf=0,Ul=0,nf=0,j1=0,z1=0,q1=0,gr=0,Mu=0,ml=0,os=0,jl=0,Tr=0,Fn=0,rf=0;n=m,m=m+672|0,r=n+656|0,rf=n+648|0,Fn=n+640|0,Tr=n+632|0,jl=n+624|0,os=n+616|0,ml=n+608|0,Mu=n+600|0,gr=n+592|0,q1=n+584|0,z1=n+576|0,j1=n+568|0,nf=n+560|0,Ul=n+552|0,Vf=n+544|0,U1=n+536|0,B1=n+528|0,Wf=n+520|0,us=n+512|0,Hf=n+504|0,is=n+496|0,I1=n+488|0,lu=n+480|0,tf=n+472|0,vl=n+464|0,Bl=n+456|0,P1=n+448|0,b1=n+440|0,Lc=n+432|0,F1=n+424|0,L1=n+416|0,ef=n+408|0,As=n+400|0,Aa=n+392|0,Il=n+384|0,qf=n+376|0,Nc=n+368|0,Mc=n+360|0,ld=n+352|0,od=n+344|0,zf=n+336|0,ud=n+328|0,$a=n+320|0,si=n+312|0,id=n+304|0,rd=n+296|0,nd=n+288|0,kc=n+280|0,Oc=n+272|0,td=n+264|0,Za=n+256|0,ed=n+248|0,$2=n+240|0,Xs=n+232|0,rs=n+224|0,ns=n+216|0,ts=n+208|0,Zi=n+200|0,On=n+192|0,kr=n+184|0,hr=n+176|0,mn=n+168|0,Nn=n+160|0,Lr=n+152|0,Qt=n+144|0,Fe=n+136|0,Re=n+128|0,st=n+120|0,We=n+112|0,Qe=n+104|0,ve=n+96|0,Ee=n+88|0,Pe=n+80|0,K=n+72|0,P=n+64|0,O=n+56|0,M=n+48|0,S=n+40|0,D=n+32|0,h=n+24|0,s=n+16|0,l=n+8|0,u=n,gf(e,3646),Xc(e,3651,2)|0,gh(e,3665,2)|0,vm(e,3682,18)|0,t[rf>>2]=19,t[rf+4>>2]=0,t[r>>2]=t[rf>>2],t[r+4>>2]=t[rf+4>>2],js(e,3690,r)|0,t[Fn>>2]=1,t[Fn+4>>2]=0,t[r>>2]=t[Fn>>2],t[r+4>>2]=t[Fn+4>>2],fa(e,3696,r)|0,t[Tr>>2]=2,t[Tr+4>>2]=0,t[r>>2]=t[Tr>>2],t[r+4>>2]=t[Tr+4>>2],Ji(e,3706,r)|0,t[jl>>2]=1,t[jl+4>>2]=0,t[r>>2]=t[jl>>2],t[r+4>>2]=t[jl+4>>2],O0(e,3722,r)|0,t[os>>2]=2,t[os+4>>2]=0,t[r>>2]=t[os>>2],t[r+4>>2]=t[os+4>>2],O0(e,3734,r)|0,t[ml>>2]=3,t[ml+4>>2]=0,t[r>>2]=t[ml>>2],t[r+4>>2]=t[ml+4>>2],Ji(e,3753,r)|0,t[Mu>>2]=4,t[Mu+4>>2]=0,t[r>>2]=t[Mu>>2],t[r+4>>2]=t[Mu+4>>2],Ji(e,3769,r)|0,t[gr>>2]=5,t[gr+4>>2]=0,t[r>>2]=t[gr>>2],t[r+4>>2]=t[gr+4>>2],Ji(e,3783,r)|0,t[q1>>2]=6,t[q1+4>>2]=0,t[r>>2]=t[q1>>2],t[r+4>>2]=t[q1+4>>2],Ji(e,3796,r)|0,t[z1>>2]=7,t[z1+4>>2]=0,t[r>>2]=t[z1>>2],t[r+4>>2]=t[z1+4>>2],Ji(e,3813,r)|0,t[j1>>2]=8,t[j1+4>>2]=0,t[r>>2]=t[j1>>2],t[r+4>>2]=t[j1+4>>2],Ji(e,3825,r)|0,t[nf>>2]=3,t[nf+4>>2]=0,t[r>>2]=t[nf>>2],t[r+4>>2]=t[nf+4>>2],O0(e,3843,r)|0,t[Ul>>2]=4,t[Ul+4>>2]=0,t[r>>2]=t[Ul>>2],t[r+4>>2]=t[Ul+4>>2],O0(e,3853,r)|0,t[Vf>>2]=9,t[Vf+4>>2]=0,t[r>>2]=t[Vf>>2],t[r+4>>2]=t[Vf+4>>2],Ji(e,3870,r)|0,t[U1>>2]=10,t[U1+4>>2]=0,t[r>>2]=t[U1>>2],t[r+4>>2]=t[U1+4>>2],Ji(e,3884,r)|0,t[B1>>2]=11,t[B1+4>>2]=0,t[r>>2]=t[B1>>2],t[r+4>>2]=t[B1+4>>2],Ji(e,3896,r)|0,t[Wf>>2]=1,t[Wf+4>>2]=0,t[r>>2]=t[Wf>>2],t[r+4>>2]=t[Wf+4>>2],t0(e,3907,r)|0,t[us>>2]=2,t[us+4>>2]=0,t[r>>2]=t[us>>2],t[r+4>>2]=t[us+4>>2],t0(e,3915,r)|0,t[Hf>>2]=3,t[Hf+4>>2]=0,t[r>>2]=t[Hf>>2],t[r+4>>2]=t[Hf+4>>2],t0(e,3928,r)|0,t[is>>2]=4,t[is+4>>2]=0,t[r>>2]=t[is>>2],t[r+4>>2]=t[is+4>>2],t0(e,3948,r)|0,t[I1>>2]=5,t[I1+4>>2]=0,t[r>>2]=t[I1>>2],t[r+4>>2]=t[I1+4>>2],t0(e,3960,r)|0,t[lu>>2]=6,t[lu+4>>2]=0,t[r>>2]=t[lu>>2],t[r+4>>2]=t[lu+4>>2],t0(e,3974,r)|0,t[tf>>2]=7,t[tf+4>>2]=0,t[r>>2]=t[tf>>2],t[r+4>>2]=t[tf+4>>2],t0(e,3983,r)|0,t[vl>>2]=20,t[vl+4>>2]=0,t[r>>2]=t[vl>>2],t[r+4>>2]=t[vl+4>>2],js(e,3999,r)|0,t[Bl>>2]=8,t[Bl+4>>2]=0,t[r>>2]=t[Bl>>2],t[r+4>>2]=t[Bl+4>>2],t0(e,4012,r)|0,t[P1>>2]=9,t[P1+4>>2]=0,t[r>>2]=t[P1>>2],t[r+4>>2]=t[P1+4>>2],t0(e,4022,r)|0,t[b1>>2]=21,t[b1+4>>2]=0,t[r>>2]=t[b1>>2],t[r+4>>2]=t[b1+4>>2],js(e,4039,r)|0,t[Lc>>2]=10,t[Lc+4>>2]=0,t[r>>2]=t[Lc>>2],t[r+4>>2]=t[Lc+4>>2],t0(e,4053,r)|0,t[F1>>2]=11,t[F1+4>>2]=0,t[r>>2]=t[F1>>2],t[r+4>>2]=t[F1+4>>2],t0(e,4065,r)|0,t[L1>>2]=12,t[L1+4>>2]=0,t[r>>2]=t[L1>>2],t[r+4>>2]=t[L1+4>>2],t0(e,4084,r)|0,t[ef>>2]=13,t[ef+4>>2]=0,t[r>>2]=t[ef>>2],t[r+4>>2]=t[ef+4>>2],t0(e,4097,r)|0,t[As>>2]=14,t[As+4>>2]=0,t[r>>2]=t[As>>2],t[r+4>>2]=t[As+4>>2],t0(e,4117,r)|0,t[Aa>>2]=15,t[Aa+4>>2]=0,t[r>>2]=t[Aa>>2],t[r+4>>2]=t[Aa+4>>2],t0(e,4129,r)|0,t[Il>>2]=16,t[Il+4>>2]=0,t[r>>2]=t[Il>>2],t[r+4>>2]=t[Il+4>>2],t0(e,4148,r)|0,t[qf>>2]=17,t[qf+4>>2]=0,t[r>>2]=t[qf>>2],t[r+4>>2]=t[qf+4>>2],t0(e,4161,r)|0,t[Nc>>2]=18,t[Nc+4>>2]=0,t[r>>2]=t[Nc>>2],t[r+4>>2]=t[Nc+4>>2],t0(e,4181,r)|0,t[Mc>>2]=5,t[Mc+4>>2]=0,t[r>>2]=t[Mc>>2],t[r+4>>2]=t[Mc+4>>2],O0(e,4196,r)|0,t[ld>>2]=6,t[ld+4>>2]=0,t[r>>2]=t[ld>>2],t[r+4>>2]=t[ld+4>>2],O0(e,4206,r)|0,t[od>>2]=7,t[od+4>>2]=0,t[r>>2]=t[od>>2],t[r+4>>2]=t[od+4>>2],O0(e,4217,r)|0,t[zf>>2]=3,t[zf+4>>2]=0,t[r>>2]=t[zf>>2],t[r+4>>2]=t[zf+4>>2],Jl(e,4235,r)|0,t[ud>>2]=1,t[ud+4>>2]=0,t[r>>2]=t[ud>>2],t[r+4>>2]=t[ud+4>>2],za(e,4251,r)|0,t[$a>>2]=4,t[$a+4>>2]=0,t[r>>2]=t[$a>>2],t[r+4>>2]=t[$a+4>>2],Jl(e,4263,r)|0,t[si>>2]=5,t[si+4>>2]=0,t[r>>2]=t[si>>2],t[r+4>>2]=t[si+4>>2],Jl(e,4279,r)|0,t[id>>2]=6,t[id+4>>2]=0,t[r>>2]=t[id>>2],t[r+4>>2]=t[id+4>>2],Jl(e,4293,r)|0,t[rd>>2]=7,t[rd+4>>2]=0,t[r>>2]=t[rd>>2],t[r+4>>2]=t[rd+4>>2],Jl(e,4306,r)|0,t[nd>>2]=8,t[nd+4>>2]=0,t[r>>2]=t[nd>>2],t[r+4>>2]=t[nd+4>>2],Jl(e,4323,r)|0,t[kc>>2]=9,t[kc+4>>2]=0,t[r>>2]=t[kc>>2],t[r+4>>2]=t[kc+4>>2],Jl(e,4335,r)|0,t[Oc>>2]=2,t[Oc+4>>2]=0,t[r>>2]=t[Oc>>2],t[r+4>>2]=t[Oc+4>>2],za(e,4353,r)|0,t[td>>2]=12,t[td+4>>2]=0,t[r>>2]=t[td>>2],t[r+4>>2]=t[td+4>>2],no(e,4363,r)|0,t[Za>>2]=1,t[Za+4>>2]=0,t[r>>2]=t[Za>>2],t[r+4>>2]=t[Za+4>>2],ul(e,4376,r)|0,t[ed>>2]=2,t[ed+4>>2]=0,t[r>>2]=t[ed>>2],t[r+4>>2]=t[ed+4>>2],ul(e,4388,r)|0,t[$2>>2]=13,t[$2+4>>2]=0,t[r>>2]=t[$2>>2],t[r+4>>2]=t[$2+4>>2],no(e,4402,r)|0,t[Xs>>2]=14,t[Xs+4>>2]=0,t[r>>2]=t[Xs>>2],t[r+4>>2]=t[Xs+4>>2],no(e,4411,r)|0,t[rs>>2]=15,t[rs+4>>2]=0,t[r>>2]=t[rs>>2],t[r+4>>2]=t[rs+4>>2],no(e,4421,r)|0,t[ns>>2]=16,t[ns+4>>2]=0,t[r>>2]=t[ns>>2],t[r+4>>2]=t[ns+4>>2],no(e,4433,r)|0,t[ts>>2]=17,t[ts+4>>2]=0,t[r>>2]=t[ts>>2],t[r+4>>2]=t[ts+4>>2],no(e,4446,r)|0,t[Zi>>2]=18,t[Zi+4>>2]=0,t[r>>2]=t[Zi>>2],t[r+4>>2]=t[Zi+4>>2],no(e,4458,r)|0,t[On>>2]=3,t[On+4>>2]=0,t[r>>2]=t[On>>2],t[r+4>>2]=t[On+4>>2],ul(e,4471,r)|0,t[kr>>2]=1,t[kr+4>>2]=0,t[r>>2]=t[kr>>2],t[r+4>>2]=t[kr+4>>2],dc(e,4486,r)|0,t[hr>>2]=10,t[hr+4>>2]=0,t[r>>2]=t[hr>>2],t[r+4>>2]=t[hr+4>>2],Jl(e,4496,r)|0,t[mn>>2]=11,t[mn+4>>2]=0,t[r>>2]=t[mn>>2],t[r+4>>2]=t[mn+4>>2],Jl(e,4508,r)|0,t[Nn>>2]=3,t[Nn+4>>2]=0,t[r>>2]=t[Nn>>2],t[r+4>>2]=t[Nn+4>>2],za(e,4519,r)|0,t[Lr>>2]=4,t[Lr+4>>2]=0,t[r>>2]=t[Lr>>2],t[r+4>>2]=t[Lr+4>>2],Od(e,4530,r)|0,t[Qt>>2]=19,t[Qt+4>>2]=0,t[r>>2]=t[Qt>>2],t[r+4>>2]=t[Qt+4>>2],_h(e,4542,r)|0,t[Fe>>2]=12,t[Fe+4>>2]=0,t[r>>2]=t[Fe>>2],t[r+4>>2]=t[Fe+4>>2],_f(e,4554,r)|0,t[Re>>2]=13,t[Re+4>>2]=0,t[r>>2]=t[Re>>2],t[r+4>>2]=t[Re+4>>2],Ef(e,4568,r)|0,t[st>>2]=2,t[st+4>>2]=0,t[r>>2]=t[st>>2],t[r+4>>2]=t[st+4>>2],Qc(e,4578,r)|0,t[We>>2]=20,t[We+4>>2]=0,t[r>>2]=t[We>>2],t[r+4>>2]=t[We+4>>2],xl(e,4587,r)|0,t[Qe>>2]=22,t[Qe+4>>2]=0,t[r>>2]=t[Qe>>2],t[r+4>>2]=t[Qe+4>>2],js(e,4602,r)|0,t[ve>>2]=23,t[ve+4>>2]=0,t[r>>2]=t[ve>>2],t[r+4>>2]=t[ve+4>>2],js(e,4619,r)|0,t[Ee>>2]=14,t[Ee+4>>2]=0,t[r>>2]=t[Ee>>2],t[r+4>>2]=t[Ee+4>>2],Jc(e,4629,r)|0,t[Pe>>2]=1,t[Pe+4>>2]=0,t[r>>2]=t[Pe>>2],t[r+4>>2]=t[Pe+4>>2],ca(e,4637,r)|0,t[K>>2]=4,t[K+4>>2]=0,t[r>>2]=t[K>>2],t[r+4>>2]=t[K+4>>2],ul(e,4653,r)|0,t[P>>2]=5,t[P+4>>2]=0,t[r>>2]=t[P>>2],t[r+4>>2]=t[P+4>>2],ul(e,4669,r)|0,t[O>>2]=6,t[O+4>>2]=0,t[r>>2]=t[O>>2],t[r+4>>2]=t[O+4>>2],ul(e,4686,r)|0,t[M>>2]=7,t[M+4>>2]=0,t[r>>2]=t[M>>2],t[r+4>>2]=t[M+4>>2],ul(e,4701,r)|0,t[S>>2]=8,t[S+4>>2]=0,t[r>>2]=t[S>>2],t[r+4>>2]=t[S+4>>2],ul(e,4719,r)|0,t[D>>2]=9,t[D+4>>2]=0,t[r>>2]=t[D>>2],t[r+4>>2]=t[D+4>>2],ul(e,4736,r)|0,t[h>>2]=21,t[h+4>>2]=0,t[r>>2]=t[h>>2],t[r+4>>2]=t[h+4>>2],c2(e,4754,r)|0,t[s>>2]=2,t[s+4>>2]=0,t[r>>2]=t[s>>2],t[r+4>>2]=t[s+4>>2],dc(e,4772,r)|0,t[l>>2]=3,t[l+4>>2]=0,t[r>>2]=t[l>>2],t[r+4>>2]=t[l+4>>2],dc(e,4790,r)|0,t[u>>2]=4,t[u+4>>2]=0,t[r>>2]=t[u>>2],t[r+4>>2]=t[u+4>>2],dc(e,4808,r)|0,m=n}function gf(e,n){e=e|0,n=n|0;var r=0;r=Ja()|0,t[e>>2]=r,jo(r,n),Q2(t[e>>2]|0)}function Xc(e,n,r){return e=e|0,n=n|0,r=r|0,Ot(e,Or(n)|0,r,0),e|0}function gh(e,n,r){return e=e|0,n=n|0,r=r|0,c(e,Or(n)|0,r,0),e|0}function vm(e,n,r){return e=e|0,n=n|0,r=r|0,cE(e,Or(n)|0,r,0),e|0}function js(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],rE(e,n,l),m=u,e|0}function fa(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],bl(e,n,l),m=u,e|0}function Ji(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],d(e,n,l),m=u,e|0}function O0(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Tv(e,n,l),m=u,e|0}function t0(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],sy(e,n,l),m=u,e|0}function Jl(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],W2(e,n,l),m=u,e|0}function za(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],H2(e,n,l),m=u,e|0}function no(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],k0(e,n,l),m=u,e|0}function ul(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Ep(e,n,l),m=u,e|0}function dc(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Hm(e,n,l),m=u,e|0}function Od(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],n0(e,n,l),m=u,e|0}function _h(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],x2(e,n,l),m=u,e|0}function _f(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Fm(e,n,l),m=u,e|0}function Ef(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Zd(e,n,l),m=u,e|0}function Qc(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],p1(e,n,l),m=u,e|0}function xl(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Ga(e,n,l),m=u,e|0}function Jc(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Id(e,n,l),m=u,e|0}function ca(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Nd(e,n,l),m=u,e|0}function c2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],d2(e,n,l),m=u,e|0}function d2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],da(e,r,l,1),m=u}function Or(e){return e=e|0,e|0}function da(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=kd()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=Zc(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,p2(s,u)|0,u),m=l}function kd(){var e=0,n=0;if(p[7616]|0||(ol(9136),Ht(24,9136,he|0)|0,n=7616,t[n>>2]=1,t[n+4>>2]=0),!(rr(9136)|0)){e=9136,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));ol(9136)}return 9136}function Zc(e){return e=e|0,0}function p2(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=kd()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Df(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(wf(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function vi(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0;var h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0;h=m,m=m+32|0,K=h+24|0,P=h+20|0,S=h+16|0,O=h+12|0,M=h+8|0,D=h+4|0,Pe=h,t[P>>2]=n,t[S>>2]=r,t[O>>2]=u,t[M>>2]=l,t[D>>2]=s,s=e+28|0,t[Pe>>2]=t[s>>2],t[K>>2]=t[Pe>>2],Md(e+24|0,K,P,O,M,S,D)|0,t[s>>2]=t[t[s>>2]>>2],m=h}function Md(e,n,r,u,l,s,h){return e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,h=h|0,e=mm(n)|0,n=cn(24)|0,h2(n+4|0,t[r>>2]|0,t[u>>2]|0,t[l>>2]|0,t[s>>2]|0,t[h>>2]|0),t[n>>2]=t[e>>2],t[e>>2]=n,n|0}function mm(e){return e=e|0,t[e>>2]|0}function h2(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,t[e>>2]=n,t[e+4>>2]=r,t[e+8>>2]=u,t[e+12>>2]=l,t[e+16>>2]=s}function dn(e,n){return e=e|0,n=n|0,n|e|0}function Df(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function wf(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=ym(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Sf(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Df(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Eh(e,D),gm(D),m=M;return}}function ym(e){return e=e|0,357913941}function Sf(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Eh(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function gm(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function ol(e){e=e|0,Bo(e)}function $c(e){e=e|0,Un(e+24|0)}function rr(e){return e=e|0,t[e>>2]|0}function Un(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Bo(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,3,n,zn()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function dr(){return 9228}function zn(){return 1140}function ll(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;return r=m,m=m+16|0,u=r+8|0,l=r,s=yo(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],n=pc(n,u)|0,m=r,n|0}function Pn(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,t[e>>2]=n,t[e+4>>2]=r,t[e+8>>2]=u,t[e+12>>2]=l,t[e+16>>2]=s}function yo(e){return e=e|0,(t[(kd()|0)+24>>2]|0)+(e*12|0)|0}function pc(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;return l=m,m=m+48|0,u=l,r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),N1[r&31](u,e),u=ro(u)|0,m=l,u|0}function ro(e){e=e|0;var n=0,r=0,u=0,l=0;return l=m,m=m+32|0,n=l+12|0,r=l,u=Ou(qa()|0)|0,u?(Zl(n,u),Tf(r,n),hc(e,r),e=Es(n)|0):e=vc(e)|0,m=l,e|0}function qa(){var e=0;return p[7632]|0||(xf(9184),Ht(25,9184,he|0)|0,e=7632,t[e>>2]=1,t[e+4>>2]=0),9184}function Ou(e){return e=e|0,t[e+36>>2]|0}function Zl(e,n){e=e|0,n=n|0,t[e>>2]=n,t[e+4>>2]=e,t[e+8>>2]=0}function Tf(e,n){e=e|0,n=n|0,t[e>>2]=t[n>>2],t[e+4>>2]=t[n+4>>2],t[e+8>>2]=0}function hc(e,n){e=e|0,n=n|0,io(n,e,e+8|0,e+16|0,e+24|0,e+32|0,e+40|0)|0}function Es(e){return e=e|0,t[(t[e+4>>2]|0)+8>>2]|0}function vc(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0,S=0;S=m,m=m+16|0,r=S+4|0,u=S,l=Sa(8)|0,s=l,h=cn(48)|0,D=h,n=D+48|0;do t[D>>2]=t[e>>2],D=D+4|0,e=e+4|0;while((D|0)<(n|0));return n=s+4|0,t[n>>2]=h,D=cn(8)|0,h=t[n>>2]|0,t[u>>2]=0,t[r>>2]=t[u>>2],Dh(D,h,r),t[l>>2]=D,m=S,s|0}function Dh(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,r=cn(16)|0,t[r+4>>2]=0,t[r+8>>2]=0,t[r>>2]=1092,t[r+12>>2]=n,t[e+4>>2]=r}function an(e){e=e|0,Pv(e),yt(e)}function $l(e){e=e|0,e=t[e+12>>2]|0,e|0&&yt(e)}function go(e){e=e|0,yt(e)}function io(e,n,r,u,l,s,h){return e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,h=h|0,s=Hi(t[e>>2]|0,n,r,u,l,s,h)|0,h=e+4|0,t[(t[h>>2]|0)+8>>2]=s,t[(t[h>>2]|0)+8>>2]|0}function Hi(e,n,r,u,l,s,h){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,h=h|0;var D=0,S=0;return D=m,m=m+16|0,S=D,Ta(S),e=vo(e)|0,h=zr(e,+U[n>>3],+U[r>>3],+U[u>>3],+U[l>>3],+U[s>>3],+U[h>>3])|0,Ca(S),m=D,h|0}function zr(e,n,r,u,l,s,h){e=e|0,n=+n,r=+r,u=+u,l=+l,s=+s,h=+h;var D=0;return D=mo(Cf()|0)|0,n=+Cl(n),r=+Cl(r),u=+Cl(u),l=+Cl(l),s=+Cl(s),f0(0,D|0,e|0,+n,+r,+u,+l,+s,+ +Cl(h))|0}function Cf(){var e=0;return p[7624]|0||(_m(9172),e=7624,t[e>>2]=1,t[e+4>>2]=0),9172}function _m(e){e=e|0,nl(e,Al()|0,6)}function Al(){return 1112}function xf(e){e=e|0,Ha(e)}function Af(e){e=e|0,v2(e+24|0),m2(e+16|0)}function v2(e){e=e|0,e1(e)}function m2(e){e=e|0,mc(e)}function mc(e){e=e|0;var n=0,r=0;if(n=t[e>>2]|0,n|0)do r=n,n=t[n>>2]|0,yt(r);while((n|0)!=0);t[e>>2]=0}function e1(e){e=e|0;var n=0,r=0;if(n=t[e>>2]|0,n|0)do r=n,n=t[n>>2]|0,yt(r);while((n|0)!=0);t[e>>2]=0}function Ha(e){e=e|0;var n=0;t[e+16>>2]=0,t[e+20>>2]=0,n=e+24|0,t[n>>2]=0,t[e+28>>2]=n,t[e+36>>2]=0,p[e+40>>0]=0,p[e+41>>0]=0}function Nd(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],y2(e,r,l,0),m=u}function y2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=t1()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=Rf(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,n1(s,u)|0,u),m=l}function t1(){var e=0,n=0;if(p[7640]|0||(Rl(9232),Ht(26,9232,he|0)|0,n=7640,t[n>>2]=1,t[n+4>>2]=0),!(rr(9232)|0)){e=9232,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Rl(9232)}return 9232}function Rf(e){return e=e|0,0}function n1(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=t1()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Wa(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(r1(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Wa(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function r1(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Ld(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,g2(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Wa(s,u,r),t[S>>2]=(t[S>>2]|0)+12,yc(e,D),i1(D),m=M;return}}function Ld(e){return e=e|0,357913941}function g2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function yc(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function i1(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Rl(e){e=e|0,Fd(e)}function pa(e){e=e|0,wh(e+24|0)}function wh(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Fd(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,1,n,bd()|0,3),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function bd(){return 1144}function Sh(e,n,r,u,l){e=e|0,n=n|0,r=+r,u=+u,l=l|0;var s=0,h=0,D=0,S=0;s=m,m=m+16|0,h=s+8|0,D=s,S=_2(e)|0,e=t[S+4>>2]|0,t[D>>2]=t[S>>2],t[D+4>>2]=e,t[h>>2]=t[D>>2],t[h+4>>2]=t[D+4>>2],Th(n,h,r,u,l),m=s}function _2(e){return e=e|0,(t[(t1()|0)+24>>2]|0)+(e*12|0)|0}function Th(e,n,r,u,l){e=e|0,n=n|0,r=+r,u=+u,l=l|0;var s=0,h=0,D=0,S=0,M=0;M=m,m=m+16|0,h=M+2|0,D=M+1|0,S=M,s=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(s=t[(t[e>>2]|0)+s>>2]|0),Ol(h,r),r=+es(h,r),Ol(D,u),u=+es(D,u),Ds(S,l),S=zs(S,l)|0,Z8[s&1](e,r,u,S),m=M}function Ol(e,n){e=e|0,n=+n}function es(e,n){return e=e|0,n=+n,+ +Ch(n)}function Ds(e,n){e=e|0,n=n|0}function zs(e,n){return e=e|0,n=n|0,Pd(n)|0}function Pd(e){return e=e|0,e|0}function Ch(e){return e=+e,+e}function Id(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Bd(e,r,l,1),m=u}function Bd(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=u1()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=o1(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,xh(s,u)|0,u),m=l}function u1(){var e=0,n=0;if(p[7648]|0||(l1(9268),Ht(27,9268,he|0)|0,n=7648,t[n>>2]=1,t[n+4>>2]=0),!(rr(9268)|0)){e=9268,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));l1(9268)}return 9268}function o1(e){return e=e|0,0}function xh(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=u1()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Ud(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(jd(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Ud(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function jd(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=ws(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Va(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Ud(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Ah(e,D),uu(D),m=M;return}}function ws(e){return e=e|0,357913941}function Va(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Ah(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function uu(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function l1(e){e=e|0,kl(e)}function Rh(e){e=e|0,s1(e+24|0)}function s1(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function kl(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,4,n,Oh()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Oh(){return 1160}function zd(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;return r=m,m=m+16|0,u=r+8|0,l=r,s=kh(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],n=a1(n,u)|0,m=r,n|0}function kh(e){return e=e|0,(t[(u1()|0)+24>>2]|0)+(e*12|0)|0}function a1(e,n){e=e|0,n=n|0;var r=0;return r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),Ml(Xp[r&31](e)|0)|0}function Ml(e){return e=e|0,e&1|0}function Ga(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],ha(e,r,l,0),m=u}function ha(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=qd()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=Hd(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,Em(s,u)|0,u),m=l}function qd(){var e=0,n=0;if(p[7656]|0||(Lh(9304),Ht(28,9304,he|0)|0,n=7656,t[n>>2]=1,t[n+4>>2]=0),!(rr(9304)|0)){e=9304,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Lh(9304)}return 9304}function Hd(e){return e=e|0,0}function Em(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=qd()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Wd(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(Mh(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Wd(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function Mh(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Nh(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Vd(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Wd(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Dm(e,D),wm(D),m=M;return}}function Nh(e){return e=e|0,357913941}function Vd(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Dm(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function wm(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Lh(e){e=e|0,f1(e)}function Sm(e){e=e|0,Gd(e+24|0)}function Gd(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function f1(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,5,n,c1()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function c1(){return 1164}function d1(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,l=u+8|0,s=u,h=va(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Yd(n,l,r),m=u}function va(e){return e=e|0,(t[(qd()|0)+24>>2]|0)+(e*12|0)|0}function Yd(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),qs(l,r),r=Hs(l,r)|0,N1[u&31](e,r),Ws(l),m=s}function qs(e,n){e=e|0,n=n|0,Kd(e,n)}function Hs(e,n){return e=e|0,n=n|0,e|0}function Ws(e){e=e|0,ia(e)}function Kd(e,n){e=e|0,n=n|0,ma(e,n)}function ma(e,n){e=e|0,n=n|0,t[e>>2]=n}function p1(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],E2(e,r,l,0),m=u}function E2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=gc()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=Xd(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,_o(s,u)|0,u),m=l}function gc(){var e=0,n=0;if(p[7664]|0||(Uh(9340),Ht(29,9340,he|0)|0,n=7664,t[n>>2]=1,t[n+4>>2]=0),!(rr(9340)|0)){e=9340,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Uh(9340)}return 9340}function Xd(e){return e=e|0,0}function _o(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=gc()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Fh(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(bh(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Fh(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function bh(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Ph(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Ih(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Fh(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Tm(e,D),Bh(D),m=M;return}}function Ph(e){return e=e|0,357913941}function Ih(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Tm(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Bh(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Uh(e){e=e|0,jh(e)}function h1(e){e=e|0,Qd(e+24|0)}function Qd(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function jh(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,4,n,Jd()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Jd(){return 1180}function zh(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=Cm(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],r=xm(n,l,r)|0,m=u,r|0}function Cm(e){return e=e|0,(t[(gc()|0)+24>>2]|0)+(e*12|0)|0}function xm(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;return s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Of(l,r),l=kf(l,r)|0,l=D2(ZE[u&15](e,l)|0)|0,m=s,l|0}function Of(e,n){e=e|0,n=n|0}function kf(e,n){return e=e|0,n=n|0,Am(n)|0}function D2(e){return e=e|0,e|0}function Am(e){return e=e|0,e|0}function Zd(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],w2(e,r,l,0),m=u}function w2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=$d()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=qh(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,ep(s,u)|0,u),m=l}function $d(){var e=0,n=0;if(p[7672]|0||(Vh(9376),Ht(30,9376,he|0)|0,n=7672,t[n>>2]=1,t[n+4>>2]=0),!(rr(9376)|0)){e=9376,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Vh(9376)}return 9376}function qh(e){return e=e|0,0}function ep(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=$d()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Hh(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(Wh(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Hh(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function Wh(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=tp(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Rm(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Hh(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Om(e,D),km(D),m=M;return}}function tp(e){return e=e|0,357913941}function Rm(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Om(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function km(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Vh(e){e=e|0,np(e)}function v1(e){e=e|0,Mm(e+24|0)}function Mm(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function np(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,5,n,rp()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function rp(){return 1196}function Nm(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;return r=m,m=m+16|0,u=r+8|0,l=r,s=Lm(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],n=Gh(n,u)|0,m=r,n|0}function Lm(e){return e=e|0,(t[($d()|0)+24>>2]|0)+(e*12|0)|0}function Gh(e,n){e=e|0,n=n|0;var r=0;return r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),D2(Xp[r&31](e)|0)|0}function Fm(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],bm(e,r,l,1),m=u}function bm(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=ip()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=up(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,ya(s,u)|0,u),m=l}function ip(){var e=0,n=0;if(p[7680]|0||(lp(9412),Ht(31,9412,he|0)|0,n=7680,t[n>>2]=1,t[n+4>>2]=0),!(rr(9412)|0)){e=9412,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));lp(9412)}return 9412}function up(e){return e=e|0,0}function ya(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=ip()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],m1(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(op(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function m1(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function op(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Yh(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,S2(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],m1(s,u,r),t[S>>2]=(t[S>>2]|0)+12,y1(e,D),Kh(D),m=M;return}}function Yh(e){return e=e|0,357913941}function S2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function y1(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Kh(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function lp(e){e=e|0,Qh(e)}function Xh(e){e=e|0,sp(e+24|0)}function sp(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Qh(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,6,n,Jh()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Jh(){return 1200}function ap(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;return r=m,m=m+16|0,u=r+8|0,l=r,s=T2(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],n=C2(n,u)|0,m=r,n|0}function T2(e){return e=e|0,(t[(ip()|0)+24>>2]|0)+(e*12|0)|0}function C2(e,n){e=e|0,n=n|0;var r=0;return r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),z0(Xp[r&31](e)|0)|0}function z0(e){return e=e|0,e|0}function x2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],ga(e,r,l,0),m=u}function ga(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Ya()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=A2(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,R2(s,u)|0,u),m=l}function Ya(){var e=0,n=0;if(p[7688]|0||(dp(9448),Ht(32,9448,he|0)|0,n=7688,t[n>>2]=1,t[n+4>>2]=0),!(rr(9448)|0)){e=9448,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));dp(9448)}return 9448}function A2(e){return e=e|0,0}function R2(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Ya()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],fp(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(O2(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function fp(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function O2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Zh(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Pm(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],fp(s,u,r),t[S>>2]=(t[S>>2]|0)+12,$h(e,D),cp(D),m=M;return}}function Zh(e){return e=e|0,357913941}function Pm(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function $h(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function cp(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function dp(e){e=e|0,Bm(e)}function pp(e){e=e|0,Im(e+24|0)}function Im(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Bm(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,6,n,Eo()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Eo(){return 1204}function k2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,l=u+8|0,s=u,h=Um(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],sl(n,l,r),m=u}function Um(e){return e=e|0,(t[(Ya()|0)+24>>2]|0)+(e*12|0)|0}function sl(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Jn(l,r),l=Vs(l,r)|0,N1[u&31](e,l),m=s}function Jn(e,n){e=e|0,n=n|0}function Vs(e,n){return e=e|0,n=n|0,al(n)|0}function al(e){return e=e|0,e|0}function n0(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],ev(e,r,l,0),m=u}function ev(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Gs()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=hp(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,jm(s,u)|0,u),m=l}function Gs(){var e=0,n=0;if(p[7696]|0||(yp(9484),Ht(33,9484,he|0)|0,n=7696,t[n>>2]=1,t[n+4>>2]=0),!(rr(9484)|0)){e=9484,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));yp(9484)}return 9484}function hp(e){return e=e|0,0}function jm(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Gs()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],tv(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(vp(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function tv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function vp(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=zm(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,mp(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],tv(s,u,r),t[S>>2]=(t[S>>2]|0)+12,_c(e,D),Ea(D),m=M;return}}function zm(e){return e=e|0,357913941}function mp(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function _c(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Ea(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function yp(e){e=e|0,zu(e)}function M2(e){e=e|0,ku(e+24|0)}function ku(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function zu(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,1,n,gp()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function gp(){return 1212}function _p(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;l=m,m=m+16|0,s=l+8|0,h=l,D=nv(e)|0,e=t[D+4>>2]|0,t[h>>2]=t[D>>2],t[h+4>>2]=e,t[s>>2]=t[h>>2],t[s+4>>2]=t[h+4>>2],qm(n,s,r,u),m=l}function nv(e){return e=e|0,(t[(Gs()|0)+24>>2]|0)+(e*12|0)|0}function qm(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;D=m,m=m+16|0,s=D+1|0,h=D,l=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(l=t[(t[e>>2]|0)+l>>2]|0),Jn(s,r),s=Vs(s,r)|0,Of(h,u),h=kf(h,u)|0,jy[l&15](e,s,h),m=D}function Hm(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Wm(e,r,l,1),m=u}function Wm(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=N2()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=rv(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,Ec(s,u)|0,u),m=l}function N2(){var e=0,n=0;if(p[7704]|0||(iv(9520),Ht(34,9520,he|0)|0,n=7704,t[n>>2]=1,t[n+4>>2]=0),!(rr(9520)|0)){e=9520,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));iv(9520)}return 9520}function rv(e){return e=e|0,0}function Ec(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=N2()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],g1(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(Vm(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function g1(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function Vm(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=L2(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,_1(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],g1(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Nl(e,D),Da(D),m=M;return}}function L2(e){return e=e|0,357913941}function _1(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Nl(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Da(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function iv(e){e=e|0,ov(e)}function Gm(e){e=e|0,uv(e+24|0)}function uv(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function ov(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,1,n,Ym()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Ym(){return 1224}function lv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;return l=m,m=m+16|0,s=l+8|0,h=l,D=wa(e)|0,e=t[D+4>>2]|0,t[h>>2]=t[D>>2],t[h+4>>2]=e,t[s>>2]=t[h>>2],t[s+4>>2]=t[h+4>>2],u=+Cr(n,s,r),m=l,+u}function wa(e){return e=e|0,(t[(N2()|0)+24>>2]|0)+(e*12|0)|0}function Cr(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Ds(l,r),l=zs(l,r)|0,h=+ja(+eS[u&7](e,l)),m=s,+h}function Ep(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],fl(e,r,l,1),m=u}function fl(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=cu()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=E1(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,ki(s,u)|0,u),m=l}function cu(){var e=0,n=0;if(p[7712]|0||(wp(9556),Ht(35,9556,he|0)|0,n=7712,t[n>>2]=1,t[n+4>>2]=0),!(rr(9556)|0)){e=9556,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));wp(9556)}return 9556}function E1(e){return e=e|0,0}function ki(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=cu()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Dp(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(F2(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Dp(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function F2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Do(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Ss(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Dp(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Mf(e,D),b2(D),m=M;return}}function Do(e){return e=e|0,357913941}function Ss(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Mf(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function b2(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function wp(e){e=e|0,Sp(e)}function D1(e){e=e|0,w1(e+24|0)}function w1(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Sp(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,5,n,Zn()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Zn(){return 1232}function cl(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=qn(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],r=+q0(n,l),m=u,+r}function qn(e){return e=e|0,(t[(cu()|0)+24>>2]|0)+(e*12|0)|0}function q0(e,n){e=e|0,n=n|0;var r=0;return r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),+ +ja(+$8[r&15](e))}function k0(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],P2(e,r,l,1),m=u}function P2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Ll()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=S1(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,Dc(s,u)|0,u),m=l}function Ll(){var e=0,n=0;if(p[7720]|0||(U2(9592),Ht(36,9592,he|0)|0,n=7720,t[n>>2]=1,t[n+4>>2]=0),!(rr(9592)|0)){e=9592,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));U2(9592)}return 9592}function S1(e){return e=e|0,0}function Dc(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Ll()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],wc(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(I2(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function wc(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function I2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Tp(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,M0(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],wc(s,u,r),t[S>>2]=(t[S>>2]|0)+12,fn(e,D),B2(D),m=M;return}}function Tp(e){return e=e|0,357913941}function M0(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function fn(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function B2(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function U2(e){e=e|0,Cc(e)}function Sc(e){e=e|0,Tc(e+24|0)}function Tc(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Cc(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,7,n,T1()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function T1(){return 1276}function Cp(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;return r=m,m=m+16|0,u=r+8|0,l=r,s=Ka(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],n=Km(n,u)|0,m=r,n|0}function Ka(e){return e=e|0,(t[(Ll()|0)+24>>2]|0)+(e*12|0)|0}function Km(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;return l=m,m=m+16|0,u=l,r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),N1[r&31](u,e),u=xc(u)|0,m=l,u|0}function xc(e){e=e|0;var n=0,r=0,u=0,l=0;return l=m,m=m+32|0,n=l+12|0,r=l,u=Ou(j2()|0)|0,u?(Zl(n,u),Tf(r,n),sv(e,r),e=Es(n)|0):e=C1(e)|0,m=l,e|0}function j2(){var e=0;return p[7736]|0||(Uo(9640),Ht(25,9640,he|0)|0,e=7736,t[e>>2]=1,t[e+4>>2]=0),9640}function sv(e,n){e=e|0,n=n|0,Ac(n,e,e+8|0)|0}function C1(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0;return r=m,m=m+16|0,l=r+4|0,h=r,u=Sa(8)|0,n=u,D=cn(16)|0,t[D>>2]=t[e>>2],t[D+4>>2]=t[e+4>>2],t[D+8>>2]=t[e+8>>2],t[D+12>>2]=t[e+12>>2],s=n+4|0,t[s>>2]=D,e=cn(8)|0,s=t[s>>2]|0,t[h>>2]=0,t[l>>2]=t[h>>2],Nf(e,s,l),t[u>>2]=e,m=r,n|0}function Nf(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,r=cn(16)|0,t[r+4>>2]=0,t[r+8>>2]=0,t[r>>2]=1244,t[r+12>>2]=n,t[e+4>>2]=r}function Lf(e){e=e|0,Pv(e),yt(e)}function x1(e){e=e|0,e=t[e+12>>2]|0,e|0&&yt(e)}function Fl(e){e=e|0,yt(e)}function Ac(e,n,r){return e=e|0,n=n|0,r=r|0,n=Ff(t[e>>2]|0,n,r)|0,r=e+4|0,t[(t[r>>2]|0)+8>>2]=n,t[(t[r>>2]|0)+8>>2]|0}function Ff(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;return u=m,m=m+16|0,l=u,Ta(l),e=vo(e)|0,r=Xm(e,t[n>>2]|0,+U[r>>3])|0,Ca(l),m=u,r|0}function Xm(e,n,r){e=e|0,n=n|0,r=+r;var u=0;return u=mo(dl()|0)|0,n=u2(n)|0,Pr(0,u|0,e|0,n|0,+ +Cl(r))|0}function dl(){var e=0;return p[7728]|0||(z2(9628),e=7728,t[e>>2]=1,t[e+4>>2]=0),9628}function z2(e){e=e|0,nl(e,q2()|0,2)}function q2(){return 1264}function Uo(e){e=e|0,Ha(e)}function H2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Qm(e,r,l,1),m=u}function Qm(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=A1()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=Jm(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,Zm(s,u)|0,u),m=l}function A1(){var e=0,n=0;if(p[7744]|0||(cv(9684),Ht(37,9684,he|0)|0,n=7744,t[n>>2]=1,t[n+4>>2]=0),!(rr(9684)|0)){e=9684,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));cv(9684)}return 9684}function Jm(e){return e=e|0,0}function Zm(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=A1()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],av(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):($m(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function av(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function $m(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=fv(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,ey(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],av(s,u,r),t[S>>2]=(t[S>>2]|0)+12,ty(e,D),ny(D),m=M;return}}function fv(e){return e=e|0,357913941}function ey(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function ty(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function ny(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function cv(e){e=e|0,iy(e)}function ry(e){e=e|0,xp(e+24|0)}function xp(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function iy(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,5,n,bf()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function bf(){return 1280}function dv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=pv(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],r=hv(n,l,r)|0,m=u,r|0}function pv(e){return e=e|0,(t[(A1()|0)+24>>2]|0)+(e*12|0)|0}function hv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return h=m,m=m+32|0,l=h,s=h+16|0,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Ds(s,r),s=zs(s,r)|0,jy[u&15](l,e,s),s=xc(l)|0,m=h,s|0}function W2(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],V2(e,r,l,1),m=u}function V2(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Ap()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=vv(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,G2(s,u)|0,u),m=l}function Ap(){var e=0,n=0;if(p[7752]|0||(Ev(9720),Ht(38,9720,he|0)|0,n=7752,t[n>>2]=1,t[n+4>>2]=0),!(rr(9720)|0)){e=9720,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Ev(9720)}return 9720}function vv(e){return e=e|0,0}function G2(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Ap()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],mv(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(yv(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function mv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function yv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Rp(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,gv(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],mv(s,u,r),t[S>>2]=(t[S>>2]|0)+12,_v(e,D),uy(D),m=M;return}}function Rp(e){return e=e|0,357913941}function gv(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function _v(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function uy(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Ev(e){e=e|0,Dv(e)}function oy(e){e=e|0,Y2(e+24|0)}function Y2(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Dv(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,8,n,Op()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Op(){return 1288}function ly(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;return r=m,m=m+16|0,u=r+8|0,l=r,s=r0(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],n=kp(n,u)|0,m=r,n|0}function r0(e){return e=e|0,(t[(Ap()|0)+24>>2]|0)+(e*12|0)|0}function kp(e,n){e=e|0,n=n|0;var r=0;return r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),s2(Xp[r&31](e)|0)|0}function sy(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],ay(e,r,l,0),m=u}function ay(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Mp()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=Xa(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,Np(s,u)|0,u),m=l}function Mp(){var e=0,n=0;if(p[7760]|0||(bp(9756),Ht(39,9756,he|0)|0,n=7760,t[n>>2]=1,t[n+4>>2]=0),!(rr(9756)|0)){e=9756,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));bp(9756)}return 9756}function Xa(e){return e=e|0,0}function Np(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Mp()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],Lp(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(Fp(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function Lp(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function Fp(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=fy(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,cy(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],Lp(s,u,r),t[S>>2]=(t[S>>2]|0)+12,wv(e,D),Pf(D),m=M;return}}function fy(e){return e=e|0,357913941}function cy(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function wv(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Pf(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function bp(e){e=e|0,py(e)}function Sv(e){e=e|0,dy(e+24|0)}function dy(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function py(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,8,n,Pp()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Pp(){return 1292}function Ip(e,n,r){e=e|0,n=n|0,r=+r;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,l=u+8|0,s=u,h=hy(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],vy(n,l,r),m=u}function hy(e){return e=e|0,(t[(Mp()|0)+24>>2]|0)+(e*12|0)|0}function vy(e,n,r){e=e|0,n=n|0,r=+r;var u=0,l=0,s=0;s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Ol(l,r),r=+es(l,r),Q8[u&31](e,r),m=s}function Tv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Bp(e,r,l,0),m=u}function Bp(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Up()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=K2(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,my(s,u)|0,u),m=l}function Up(){var e=0,n=0;if(p[7768]|0||(jp(9792),Ht(40,9792,he|0)|0,n=7768,t[n>>2]=1,t[n+4>>2]=0),!(rr(9792)|0)){e=9792,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));jp(9792)}return 9792}function K2(e){return e=e|0,0}function my(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Up()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],R1(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(yy(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function R1(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function yy(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Cv(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,xv(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],R1(s,u,r),t[S>>2]=(t[S>>2]|0)+12,gy(e,D),If(D),m=M;return}}function Cv(e){return e=e|0,357913941}function xv(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function gy(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function If(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function jp(e){e=e|0,Ey(e)}function Av(e){e=e|0,_y(e+24|0)}function _y(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Ey(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,1,n,zp()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function zp(){return 1300}function Dy(e,n,r,u){e=e|0,n=n|0,r=r|0,u=+u;var l=0,s=0,h=0,D=0;l=m,m=m+16|0,s=l+8|0,h=l,D=Ys(e)|0,e=t[D+4>>2]|0,t[h>>2]=t[D>>2],t[h+4>>2]=e,t[s>>2]=t[h>>2],t[s+4>>2]=t[h+4>>2],wy(n,s,r,u),m=l}function Ys(e){return e=e|0,(t[(Up()|0)+24>>2]|0)+(e*12|0)|0}function wy(e,n,r,u){e=e|0,n=n|0,r=r|0,u=+u;var l=0,s=0,h=0,D=0;D=m,m=m+16|0,s=D+1|0,h=D,l=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(l=t[(t[e>>2]|0)+l>>2]|0),Ds(s,r),s=zs(s,r)|0,Ol(h,u),u=+es(h,u),iS[l&15](e,s,u),m=D}function d(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],v(e,r,l,0),m=u}function v(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=x()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=b(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,H(s,u)|0,u),m=l}function x(){var e=0,n=0;if(p[7776]|0||(Rt(9828),Ht(41,9828,he|0)|0,n=7776,t[n>>2]=1,t[n+4>>2]=0),!(rr(9828)|0)){e=9828,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Rt(9828)}return 9828}function b(e){return e=e|0,0}function H(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=x()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],ee(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(de(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function ee(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function de(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=ye(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,be(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],ee(s,u,r),t[S>>2]=(t[S>>2]|0)+12,gt(e,D),Dt(D),m=M;return}}function ye(e){return e=e|0,357913941}function be(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function gt(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Dt(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Rt(e){e=e|0,$n(e)}function rn(e){e=e|0,Rn(e+24|0)}function Rn(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function $n(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,7,n,Nr()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Nr(){return 1312}function ir(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,l=u+8|0,s=u,h=Zr(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],ui(n,l,r),m=u}function Zr(e){return e=e|0,(t[(x()|0)+24>>2]|0)+(e*12|0)|0}function ui(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Ds(l,r),l=zs(l,r)|0,N1[u&31](e,l),m=s}function bl(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],Wi(e,r,l,0),m=u}function Wi(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=uo()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=i0(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,Ts(s,u)|0,u),m=l}function uo(){var e=0,n=0;if(p[7784]|0||(r_(9864),Ht(42,9864,he|0)|0,n=7784,t[n>>2]=1,t[n+4>>2]=0),!(rr(9864)|0)){e=9864,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));r_(9864)}return 9864}function i0(e){return e=e|0,0}function Ts(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=uo()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],wo(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(Rv(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function wo(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function Rv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=X4(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Sy(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],wo(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Ty(e,D),Qa(D),m=M;return}}function X4(e){return e=e|0,357913941}function Sy(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Ty(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Qa(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function r_(e){e=e|0,Z4(e)}function Q4(e){e=e|0,J4(e+24|0)}function J4(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function Z4(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,8,n,$4()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function $4(){return 1320}function Cy(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,l=u+8|0,s=u,h=eE(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],tE(n,l,r),m=u}function eE(e){return e=e|0,(t[(uo()|0)+24>>2]|0)+(e*12|0)|0}function tE(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),xy(l,r),l=i_(l,r)|0,N1[u&31](e,l),m=s}function xy(e,n){e=e|0,n=n|0}function i_(e,n){return e=e|0,n=n|0,nE(n)|0}function nE(e){return e=e|0,e|0}function rE(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],u_(e,r,l,0),m=u}function u_(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=Bf()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=o_(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,iE(s,u)|0,u),m=l}function Bf(){var e=0,n=0;if(p[7792]|0||(Oy(9900),Ht(43,9900,he|0)|0,n=7792,t[n>>2]=1,t[n+4>>2]=0),!(rr(9900)|0)){e=9900,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Oy(9900)}return 9900}function o_(e){return e=e|0,0}function iE(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=Bf()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],qp(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(uE(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function qp(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function uE(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=Ov(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,Ay(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],qp(s,u,r),t[S>>2]=(t[S>>2]|0)+12,Ry(e,D),oE(D),m=M;return}}function Ov(e){return e=e|0,357913941}function Ay(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function Ry(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function oE(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function Oy(e){e=e|0,l_(e)}function lE(e){e=e|0,sE(e+24|0)}function sE(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function l_(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,22,n,aE()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function aE(){return 1344}function fE(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0;r=m,m=m+16|0,u=r+8|0,l=r,s=s_(e)|0,e=t[s+4>>2]|0,t[l>>2]=t[s>>2],t[l+4>>2]=e,t[u>>2]=t[l>>2],t[u+4>>2]=t[l+4>>2],kv(n,u),m=r}function s_(e){return e=e|0,(t[(Bf()|0)+24>>2]|0)+(e*12|0)|0}function kv(e,n){e=e|0,n=n|0;var r=0;r=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(r=t[(t[e>>2]|0)+r>>2]|0),M1[r&127](e)}function cE(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=ky()|0,e=dE(r)|0,vi(s,n,l,e,pE(r,u)|0,u)}function ky(){var e=0,n=0;if(p[7800]|0||(Ny(9936),Ht(44,9936,he|0)|0,n=7800,t[n>>2]=1,t[n+4>>2]=0),!(rr(9936)|0)){e=9936,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));Ny(9936)}return 9936}function dE(e){return e=e|0,e|0}function pE(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=ky()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(My(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(a_(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function My(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function a_(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=f_(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,c_(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,My(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,d_(e,l),p_(l),m=D;return}}function f_(e){return e=e|0,536870911}function c_(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function d_(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function p_(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function Ny(e){e=e|0,v_(e)}function h_(e){e=e|0,hE(e+24|0)}function hE(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function v_(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,23,n,Eo()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function vE(e,n){e=e|0,n=n|0,a(t[(mE(e)|0)>>2]|0,n)}function mE(e){return e=e|0,(t[(ky()|0)+24>>2]|0)+(e<<3)|0}function a(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,Jn(u,n),n=Vs(u,n)|0,M1[e&127](n),m=r}function c(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=_()|0,e=T(r)|0,vi(s,n,l,e,R(r,u)|0,u)}function _(){var e=0,n=0;if(p[7808]|0||(pt(9972),Ht(45,9972,he|0)|0,n=7808,t[n>>2]=1,t[n+4>>2]=0),!(rr(9972)|0)){e=9972,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));pt(9972)}return 9972}function T(e){return e=e|0,e|0}function R(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=_()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(j(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(V(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function j(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function V(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=te(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,oe(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,j(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,Ie(e,l),Ye(l),m=D;return}}function te(e){return e=e|0,536870911}function oe(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function Ie(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Ye(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function pt(e){e=e|0,zt(e)}function Nt(e){e=e|0,Vt(e+24|0)}function Vt(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function zt(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,9,n,vn()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function vn(){return 1348}function xr(e,n){return e=e|0,n=n|0,wi(t[($r(e)|0)>>2]|0,n)|0}function $r(e){return e=e|0,(t[(_()|0)+24>>2]|0)+(e<<3)|0}function wi(e,n){e=e|0,n=n|0;var r=0,u=0;return r=m,m=m+16|0,u=r,N0(u,n),n=Vi(u,n)|0,n=D2(Xp[e&31](n)|0)|0,m=r,n|0}function N0(e,n){e=e|0,n=n|0}function Vi(e,n){return e=e|0,n=n|0,it(n)|0}function it(e){return e=e|0,e|0}function Ot(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=Je()|0,e=Bt(r)|0,vi(s,n,l,e,Mn(r,u)|0,u)}function Je(){var e=0,n=0;if(p[7816]|0||(qr(10008),Ht(46,10008,he|0)|0,n=7816,t[n>>2]=1,t[n+4>>2]=0),!(rr(10008)|0)){e=10008,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));qr(10008)}return 10008}function Bt(e){return e=e|0,e|0}function Mn(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=Je()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(pn(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(Pi(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function pn(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function Pi(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=oi(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,qu(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,pn(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,ar(e,l),ou(l),m=D;return}}function oi(e){return e=e|0,536870911}function qu(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function ar(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function ou(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function qr(e){e=e|0,H0(e)}function _u(e){e=e|0,_0(e+24|0)}function _0(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function H0(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,15,n,rp()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Cs(e){return e=e|0,pl(t[(Hu(e)|0)>>2]|0)|0}function Hu(e){return e=e|0,(t[(Je()|0)+24>>2]|0)+(e<<3)|0}function pl(e){return e=e|0,D2(N_[e&7]()|0)|0}function Ja(){var e=0;return p[7832]|0||(y_(10052),Ht(25,10052,he|0)|0,e=7832,t[e>>2]=1,t[e+4>>2]=0),10052}function jo(e,n){e=e|0,n=n|0,t[e>>2]=xs()|0,t[e+4>>2]=X2()|0,t[e+12>>2]=n,t[e+8>>2]=Uf()|0,t[e+32>>2]=2}function xs(){return 11709}function X2(){return 1188}function Uf(){return O1()|0}function Rc(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(zo(r),yt(r)):n|0&&(ms(n),yt(n))}function Pl(e,n){return e=e|0,n=n|0,n&e|0}function zo(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function O1(){var e=0;return p[7824]|0||(t[2511]=m_()|0,t[2512]=0,e=7824,t[e>>2]=1,t[e+4>>2]=0),10044}function m_(){return 0}function y_(e){e=e|0,Ha(e)}function yE(e){e=e|0;var n=0,r=0,u=0,l=0,s=0;n=m,m=m+32|0,r=n+24|0,s=n+16|0,l=n+8|0,u=n,g_(e,4827),gE(e,4834,3)|0,_E(e,3682,47)|0,t[s>>2]=9,t[s+4>>2]=0,t[r>>2]=t[s>>2],t[r+4>>2]=t[s+4>>2],Ly(e,4841,r)|0,t[l>>2]=1,t[l+4>>2]=0,t[r>>2]=t[l>>2],t[r+4>>2]=t[l+4>>2],__(e,4871,r)|0,t[u>>2]=10,t[u+4>>2]=0,t[r>>2]=t[u>>2],t[r+4>>2]=t[u+4>>2],EE(e,4891,r)|0,m=n}function g_(e,n){e=e|0,n=n|0;var r=0;r=Qk()|0,t[e>>2]=r,Jk(r,n),Q2(t[e>>2]|0)}function gE(e,n,r){return e=e|0,n=n|0,r=r|0,Fk(e,Or(n)|0,r,0),e|0}function _E(e,n,r){return e=e|0,n=n|0,r=r|0,_k(e,Or(n)|0,r,0),e|0}function Ly(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],ek(e,n,l),m=u,e|0}function __(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],bO(e,n,l),m=u,e|0}function EE(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=t[r+4>>2]|0,t[s>>2]=t[r>>2],t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],DE(e,n,l),m=u,e|0}function DE(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],wE(e,r,l,1),m=u}function wE(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=SE()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=DO(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,wO(s,u)|0,u),m=l}function SE(){var e=0,n=0;if(p[7840]|0||(L3(10100),Ht(48,10100,he|0)|0,n=7840,t[n>>2]=1,t[n+4>>2]=0),!(rr(10100)|0)){e=10100,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));L3(10100)}return 10100}function DO(e){return e=e|0,0}function wO(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=SE()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],N3(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(SO(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function N3(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function SO(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=TO(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,CO(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],N3(s,u,r),t[S>>2]=(t[S>>2]|0)+12,xO(e,D),AO(D),m=M;return}}function TO(e){return e=e|0,357913941}function CO(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function xO(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function AO(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function L3(e){e=e|0,kO(e)}function RO(e){e=e|0,OO(e+24|0)}function OO(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function kO(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,6,n,MO()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function MO(){return 1364}function NO(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;return u=m,m=m+16|0,l=u+8|0,s=u,h=LO(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],r=FO(n,l,r)|0,m=u,r|0}function LO(e){return e=e|0,(t[(SE()|0)+24>>2]|0)+(e*12|0)|0}function FO(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;return s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),Ds(l,r),l=zs(l,r)|0,l=Ml(ZE[u&15](e,l)|0)|0,m=s,l|0}function bO(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],PO(e,r,l,0),m=u}function PO(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=TE()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=IO(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,BO(s,u)|0,u),m=l}function TE(){var e=0,n=0;if(p[7848]|0||(b3(10136),Ht(49,10136,he|0)|0,n=7848,t[n>>2]=1,t[n+4>>2]=0),!(rr(10136)|0)){e=10136,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));b3(10136)}return 10136}function IO(e){return e=e|0,0}function BO(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=TE()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],F3(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(UO(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function F3(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function UO(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=jO(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,zO(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],F3(s,u,r),t[S>>2]=(t[S>>2]|0)+12,qO(e,D),HO(D),m=M;return}}function jO(e){return e=e|0,357913941}function zO(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function qO(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function HO(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function b3(e){e=e|0,GO(e)}function WO(e){e=e|0,VO(e+24|0)}function VO(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function GO(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,9,n,YO()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function YO(){return 1372}function KO(e,n,r){e=e|0,n=n|0,r=+r;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,l=u+8|0,s=u,h=XO(e)|0,e=t[h+4>>2]|0,t[s>>2]=t[h>>2],t[s+4>>2]=e,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],QO(n,l,r),m=u}function XO(e){return e=e|0,(t[(TE()|0)+24>>2]|0)+(e*12|0)|0}function QO(e,n,r){e=e|0,n=n|0,r=+r;var u=0,l=0,s=0,h=St;s=m,m=m+16|0,l=s,u=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(u=t[(t[e>>2]|0)+u>>2]|0),JO(l,r),h=w(ZO(l,r)),X8[u&1](e,h),m=s}function JO(e,n){e=e|0,n=+n}function ZO(e,n){return e=e|0,n=+n,w($O(n))}function $O(e){return e=+e,w(e)}function ek(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,l=u+8|0,s=u,D=t[r>>2]|0,h=t[r+4>>2]|0,r=Or(n)|0,t[s>>2]=D,t[s+4>>2]=h,t[l>>2]=t[s>>2],t[l+4>>2]=t[s+4>>2],tk(e,r,l,0),m=u}function tk(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0,S=0,M=0,O=0;l=m,m=m+32|0,s=l+16|0,O=l+8|0,D=l,M=t[r>>2]|0,S=t[r+4>>2]|0,h=t[e>>2]|0,e=CE()|0,t[O>>2]=M,t[O+4>>2]=S,t[s>>2]=t[O>>2],t[s+4>>2]=t[O+4>>2],r=nk(s)|0,t[D>>2]=M,t[D+4>>2]=S,t[s>>2]=t[D>>2],t[s+4>>2]=t[D+4>>2],vi(h,n,e,r,rk(s,u)|0,u),m=l}function CE(){var e=0,n=0;if(p[7856]|0||(I3(10172),Ht(50,10172,he|0)|0,n=7856,t[n>>2]=1,t[n+4>>2]=0),!(rr(10172)|0)){e=10172,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));I3(10172)}return 10172}function nk(e){return e=e|0,0}function rk(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0;return O=m,m=m+32|0,l=O+24|0,h=O+16|0,D=O,S=O+8|0,s=t[e>>2]|0,u=t[e+4>>2]|0,t[D>>2]=s,t[D+4>>2]=u,P=CE()|0,M=P+24|0,e=dn(n,4)|0,t[S>>2]=e,n=P+28|0,r=t[n>>2]|0,r>>>0<(t[P+32>>2]|0)>>>0?(t[h>>2]=s,t[h+4>>2]=u,t[l>>2]=t[h>>2],t[l+4>>2]=t[h+4>>2],P3(r,l,e),e=(t[n>>2]|0)+12|0,t[n>>2]=e):(ik(M,D,S),e=t[n>>2]|0),m=O,((e-(t[M>>2]|0)|0)/12|0)+-1|0}function P3(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=t[n+4>>2]|0,t[e>>2]=t[n>>2],t[e+4>>2]=u,t[e+8>>2]=r}function ik(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;if(M=m,m=m+48|0,u=M+32|0,h=M+24|0,D=M,S=e+4|0,l=(((t[S>>2]|0)-(t[e>>2]|0)|0)/12|0)+1|0,s=uk(e)|0,s>>>0>>0)li(e);else{O=t[e>>2]|0,K=((t[e+8>>2]|0)-O|0)/12|0,P=K<<1,ok(D,K>>>0>>1>>>0?P>>>0>>0?l:P:s,((t[S>>2]|0)-O|0)/12|0,e+8|0),S=D+8|0,s=t[S>>2]|0,l=t[n+4>>2]|0,r=t[r>>2]|0,t[h>>2]=t[n>>2],t[h+4>>2]=l,t[u>>2]=t[h>>2],t[u+4>>2]=t[h+4>>2],P3(s,u,r),t[S>>2]=(t[S>>2]|0)+12,lk(e,D),sk(D),m=M;return}}function uk(e){return e=e|0,357913941}function ok(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>357913941)Xn();else{l=cn(n*12|0)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r*12|0)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n*12|0)}function lk(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(((l|0)/-12|0)*12|0)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function sk(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~(((u+-12-n|0)>>>0)/12|0)*12|0)),e=t[e>>2]|0,e|0&&yt(e)}function I3(e){e=e|0,ck(e)}function ak(e){e=e|0,fk(e+24|0)}function fk(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~(((n+-12-u|0)>>>0)/12|0)*12|0)),yt(r))}function ck(e){e=e|0;var n=0;n=dr()|0,Pn(e,2,3,n,dk()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function dk(){return 1380}function pk(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;l=m,m=m+16|0,s=l+8|0,h=l,D=hk(e)|0,e=t[D+4>>2]|0,t[h>>2]=t[D>>2],t[h+4>>2]=e,t[s>>2]=t[h>>2],t[s+4>>2]=t[h+4>>2],vk(n,s,r,u),m=l}function hk(e){return e=e|0,(t[(CE()|0)+24>>2]|0)+(e*12|0)|0}function vk(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;D=m,m=m+16|0,s=D+1|0,h=D,l=t[n>>2]|0,n=t[n+4>>2]|0,e=e+(n>>1)|0,n&1&&(l=t[(t[e>>2]|0)+l>>2]|0),Ds(s,r),s=zs(s,r)|0,mk(h,u),h=yk(h,u)|0,jy[l&15](e,s,h),m=D}function mk(e,n){e=e|0,n=n|0}function yk(e,n){return e=e|0,n=n|0,gk(n)|0}function gk(e){return e=e|0,(e|0)!=0|0}function _k(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=xE()|0,e=Ek(r)|0,vi(s,n,l,e,Dk(r,u)|0,u)}function xE(){var e=0,n=0;if(p[7864]|0||(U3(10208),Ht(51,10208,he|0)|0,n=7864,t[n>>2]=1,t[n+4>>2]=0),!(rr(10208)|0)){e=10208,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));U3(10208)}return 10208}function Ek(e){return e=e|0,e|0}function Dk(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=xE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(B3(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(wk(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function B3(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function wk(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=Sk(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,Tk(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,B3(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,Ck(e,l),xk(l),m=D;return}}function Sk(e){return e=e|0,536870911}function Tk(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function Ck(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function xk(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function U3(e){e=e|0,Ok(e)}function Ak(e){e=e|0,Rk(e+24|0)}function Rk(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function Ok(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,24,n,kk()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function kk(){return 1392}function Mk(e,n){e=e|0,n=n|0,Lk(t[(Nk(e)|0)>>2]|0,n)}function Nk(e){return e=e|0,(t[(xE()|0)+24>>2]|0)+(e<<3)|0}function Lk(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,N0(u,n),n=Vi(u,n)|0,M1[e&127](n),m=r}function Fk(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=AE()|0,e=bk(r)|0,vi(s,n,l,e,Pk(r,u)|0,u)}function AE(){var e=0,n=0;if(p[7872]|0||(z3(10244),Ht(52,10244,he|0)|0,n=7872,t[n>>2]=1,t[n+4>>2]=0),!(rr(10244)|0)){e=10244,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));z3(10244)}return 10244}function bk(e){return e=e|0,e|0}function Pk(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=AE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(j3(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(Ik(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function j3(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function Ik(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=Bk(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,Uk(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,j3(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,jk(e,l),zk(l),m=D;return}}function Bk(e){return e=e|0,536870911}function Uk(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function jk(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function zk(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function z3(e){e=e|0,Wk(e)}function qk(e){e=e|0,Hk(e+24|0)}function Hk(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function Wk(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,16,n,Vk()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Vk(){return 1400}function Gk(e){return e=e|0,Kk(t[(Yk(e)|0)>>2]|0)|0}function Yk(e){return e=e|0,(t[(AE()|0)+24>>2]|0)+(e<<3)|0}function Kk(e){return e=e|0,Xk(N_[e&7]()|0)|0}function Xk(e){return e=e|0,e|0}function Qk(){var e=0;return p[7880]|0||(rM(10280),Ht(25,10280,he|0)|0,e=7880,t[e>>2]=1,t[e+4>>2]=0),10280}function Jk(e,n){e=e|0,n=n|0,t[e>>2]=Zk()|0,t[e+4>>2]=$k()|0,t[e+12>>2]=n,t[e+8>>2]=eM()|0,t[e+32>>2]=4}function Zk(){return 11711}function $k(){return 1356}function eM(){return O1()|0}function tM(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(nM(r),yt(r)):n|0&&(eo(n),yt(n))}function nM(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function rM(e){e=e|0,Ha(e)}function iM(e){e=e|0,uM(e,4920),oM(e)|0,lM(e)|0}function uM(e,n){e=e|0,n=n|0;var r=0;r=j2()|0,t[e>>2]=r,RM(r,n),Q2(t[e>>2]|0)}function oM(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,gM()|0),e|0}function lM(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,sM()|0),e|0}function sM(){var e=0;return p[7888]|0||(q3(10328),Ht(53,10328,he|0)|0,e=7888,t[e>>2]=1,t[e+4>>2]=0),rr(10328)|0||q3(10328),10328}function Hp(e,n){e=e|0,n=n|0,vi(e,0,n,0,0,0)}function q3(e){e=e|0,cM(e),Wp(e,10)}function aM(e){e=e|0,fM(e+24|0)}function fM(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function cM(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,1,n,vM()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function dM(e,n,r){e=e|0,n=n|0,r=+r,pM(e,n,r)}function Wp(e,n){e=e|0,n=n|0,t[e+20>>2]=n}function pM(e,n,r){e=e|0,n=n|0,r=+r;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+16|0,s=u+8|0,D=u+13|0,l=u,h=u+12|0,Ds(D,n),t[s>>2]=zs(D,n)|0,Ol(h,r),U[l>>3]=+es(h,r),hM(e,s,l),m=u}function hM(e,n,r){e=e|0,n=n|0,r=r|0,I(e+8|0,t[n>>2]|0,+U[r>>3]),p[e+24>>0]=1}function vM(){return 1404}function mM(e,n){return e=e|0,n=+n,yM(e,n)|0}function yM(e,n){e=e|0,n=+n;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return u=m,m=m+16|0,s=u+4|0,h=u+8|0,D=u,l=Sa(8)|0,r=l,S=cn(16)|0,Ds(s,e),e=zs(s,e)|0,Ol(h,n),I(S,e,+es(h,n)),h=r+4|0,t[h>>2]=S,e=cn(8)|0,h=t[h>>2]|0,t[D>>2]=0,t[s>>2]=t[D>>2],Nf(e,h,s),t[l>>2]=e,m=u,r|0}function gM(){var e=0;return p[7896]|0||(H3(10364),Ht(54,10364,he|0)|0,e=7896,t[e>>2]=1,t[e+4>>2]=0),rr(10364)|0||H3(10364),10364}function H3(e){e=e|0,DM(e),Wp(e,55)}function _M(e){e=e|0,EM(e+24|0)}function EM(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function DM(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,4,n,CM()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function wM(e){e=e|0,SM(e)}function SM(e){e=e|0,TM(e)}function TM(e){e=e|0,W3(e+8|0),p[e+24>>0]=1}function W3(e){e=e|0,t[e>>2]=0,U[e+8>>3]=0}function CM(){return 1424}function xM(){return AM()|0}function AM(){var e=0,n=0,r=0,u=0,l=0,s=0,h=0;return n=m,m=m+16|0,l=n+4|0,h=n,r=Sa(8)|0,e=r,u=cn(16)|0,W3(u),s=e+4|0,t[s>>2]=u,u=cn(8)|0,s=t[s>>2]|0,t[h>>2]=0,t[l>>2]=t[h>>2],Nf(u,s,l),t[r>>2]=u,m=n,e|0}function RM(e,n){e=e|0,n=n|0,t[e>>2]=OM()|0,t[e+4>>2]=kM()|0,t[e+12>>2]=n,t[e+8>>2]=MM()|0,t[e+32>>2]=5}function OM(){return 11710}function kM(){return 1416}function MM(){return E_()|0}function NM(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(LM(r),yt(r)):n|0&&yt(n)}function LM(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function E_(){var e=0;return p[7904]|0||(t[2600]=FM()|0,t[2601]=0,e=7904,t[e>>2]=1,t[e+4>>2]=0),10400}function FM(){return t[357]|0}function bM(e){e=e|0,PM(e,4926),IM(e)|0}function PM(e,n){e=e|0,n=n|0;var r=0;r=qa()|0,t[e>>2]=r,KM(r,n),Q2(t[e>>2]|0)}function IM(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,BM()|0),e|0}function BM(){var e=0;return p[7912]|0||(V3(10412),Ht(56,10412,he|0)|0,e=7912,t[e>>2]=1,t[e+4>>2]=0),rr(10412)|0||V3(10412),10412}function V3(e){e=e|0,zM(e),Wp(e,57)}function UM(e){e=e|0,jM(e+24|0)}function jM(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function zM(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,5,n,VM()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function qM(e){e=e|0,HM(e)}function HM(e){e=e|0,WM(e)}function WM(e){e=e|0;var n=0,r=0;n=e+8|0,r=n+48|0;do t[n>>2]=0,n=n+4|0;while((n|0)<(r|0));p[e+56>>0]=1}function VM(){return 1432}function GM(){return YM()|0}function YM(){var e=0,n=0,r=0,u=0,l=0,s=0,h=0,D=0;h=m,m=m+16|0,e=h+4|0,n=h,r=Sa(8)|0,u=r,l=cn(48)|0,s=l,D=s+48|0;do t[s>>2]=0,s=s+4|0;while((s|0)<(D|0));return s=u+4|0,t[s>>2]=l,D=cn(8)|0,s=t[s>>2]|0,t[n>>2]=0,t[e>>2]=t[n>>2],Dh(D,s,e),t[r>>2]=D,m=h,u|0}function KM(e,n){e=e|0,n=n|0,t[e>>2]=XM()|0,t[e+4>>2]=QM()|0,t[e+12>>2]=n,t[e+8>>2]=JM()|0,t[e+32>>2]=6}function XM(){return 11704}function QM(){return 1436}function JM(){return E_()|0}function ZM(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&($M(r),yt(r)):n|0&&yt(n)}function $M(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function eN(e){e=e|0,tN(e,4933),nN(e)|0,rN(e)|0}function tN(e,n){e=e|0,n=n|0;var r=0;r=AN()|0,t[e>>2]=r,RN(r,n),Q2(t[e>>2]|0)}function nN(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,yN()|0),e|0}function rN(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,iN()|0),e|0}function iN(){var e=0;return p[7920]|0||(G3(10452),Ht(58,10452,he|0)|0,e=7920,t[e>>2]=1,t[e+4>>2]=0),rr(10452)|0||G3(10452),10452}function G3(e){e=e|0,lN(e),Wp(e,1)}function uN(e){e=e|0,oN(e+24|0)}function oN(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function lN(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,1,n,cN()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function sN(e,n,r){e=e|0,n=+n,r=+r,aN(e,n,r)}function aN(e,n,r){e=e|0,n=+n,r=+r;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+32|0,s=u+8|0,D=u+17|0,l=u,h=u+16|0,Ol(D,n),U[s>>3]=+es(D,n),Ol(h,r),U[l>>3]=+es(h,r),fN(e,s,l),m=u}function fN(e,n,r){e=e|0,n=n|0,r=r|0,Y3(e+8|0,+U[n>>3],+U[r>>3]),p[e+24>>0]=1}function Y3(e,n,r){e=e|0,n=+n,r=+r,U[e>>3]=n,U[e+8>>3]=r}function cN(){return 1472}function dN(e,n){return e=+e,n=+n,pN(e,n)|0}function pN(e,n){e=+e,n=+n;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return u=m,m=m+16|0,h=u+4|0,D=u+8|0,S=u,l=Sa(8)|0,r=l,s=cn(16)|0,Ol(h,e),e=+es(h,e),Ol(D,n),Y3(s,e,+es(D,n)),D=r+4|0,t[D>>2]=s,s=cn(8)|0,D=t[D>>2]|0,t[S>>2]=0,t[h>>2]=t[S>>2],K3(s,D,h),t[l>>2]=s,m=u,r|0}function K3(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,r=cn(16)|0,t[r+4>>2]=0,t[r+8>>2]=0,t[r>>2]=1452,t[r+12>>2]=n,t[e+4>>2]=r}function hN(e){e=e|0,Pv(e),yt(e)}function vN(e){e=e|0,e=t[e+12>>2]|0,e|0&&yt(e)}function mN(e){e=e|0,yt(e)}function yN(){var e=0;return p[7928]|0||(X3(10488),Ht(59,10488,he|0)|0,e=7928,t[e>>2]=1,t[e+4>>2]=0),rr(10488)|0||X3(10488),10488}function X3(e){e=e|0,EN(e),Wp(e,60)}function gN(e){e=e|0,_N(e+24|0)}function _N(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function EN(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,6,n,TN()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function DN(e){e=e|0,wN(e)}function wN(e){e=e|0,SN(e)}function SN(e){e=e|0,Q3(e+8|0),p[e+24>>0]=1}function Q3(e){e=e|0,t[e>>2]=0,t[e+4>>2]=0,t[e+8>>2]=0,t[e+12>>2]=0}function TN(){return 1492}function CN(){return xN()|0}function xN(){var e=0,n=0,r=0,u=0,l=0,s=0,h=0;return n=m,m=m+16|0,l=n+4|0,h=n,r=Sa(8)|0,e=r,u=cn(16)|0,Q3(u),s=e+4|0,t[s>>2]=u,u=cn(8)|0,s=t[s>>2]|0,t[h>>2]=0,t[l>>2]=t[h>>2],K3(u,s,l),t[r>>2]=u,m=n,e|0}function AN(){var e=0;return p[7936]|0||(FN(10524),Ht(25,10524,he|0)|0,e=7936,t[e>>2]=1,t[e+4>>2]=0),10524}function RN(e,n){e=e|0,n=n|0,t[e>>2]=ON()|0,t[e+4>>2]=kN()|0,t[e+12>>2]=n,t[e+8>>2]=MN()|0,t[e+32>>2]=7}function ON(){return 11700}function kN(){return 1484}function MN(){return E_()|0}function NN(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(LN(r),yt(r)):n|0&&yt(n)}function LN(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function FN(e){e=e|0,Ha(e)}function bN(e,n,r){e=e|0,n=n|0,r=r|0,e=Or(n)|0,n=PN(r)|0,r=IN(r,0)|0,pL(e,n,r,RE()|0,0)}function PN(e){return e=e|0,e|0}function IN(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=RE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(Z3(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(WN(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function RE(){var e=0,n=0;if(p[7944]|0||(J3(10568),Ht(61,10568,he|0)|0,n=7944,t[n>>2]=1,t[n+4>>2]=0),!(rr(10568)|0)){e=10568,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));J3(10568)}return 10568}function J3(e){e=e|0,jN(e)}function BN(e){e=e|0,UN(e+24|0)}function UN(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function jN(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,17,n,Jh()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function zN(e){return e=e|0,HN(t[(qN(e)|0)>>2]|0)|0}function qN(e){return e=e|0,(t[(RE()|0)+24>>2]|0)+(e<<3)|0}function HN(e){return e=e|0,z0(N_[e&7]()|0)|0}function Z3(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function WN(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=VN(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,GN(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,Z3(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,YN(e,l),KN(l),m=D;return}}function VN(e){return e=e|0,536870911}function GN(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function YN(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function KN(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function XN(){QN()}function QN(){JN(10604)}function JN(e){e=e|0,ZN(e,4955)}function ZN(e,n){e=e|0,n=n|0;var r=0;r=$N()|0,t[e>>2]=r,eL(r,n),Q2(t[e>>2]|0)}function $N(){var e=0;return p[7952]|0||(aL(10612),Ht(25,10612,he|0)|0,e=7952,t[e>>2]=1,t[e+4>>2]=0),10612}function eL(e,n){e=e|0,n=n|0,t[e>>2]=iL()|0,t[e+4>>2]=uL()|0,t[e+12>>2]=n,t[e+8>>2]=oL()|0,t[e+32>>2]=8}function Q2(e){e=e|0;var n=0,r=0;n=m,m=m+16|0,r=n,Mv()|0,t[r>>2]=e,tL(10608,r),m=n}function Mv(){return p[11714]|0||(t[2652]=0,Ht(62,10608,he|0)|0,p[11714]=1),10608}function tL(e,n){e=e|0,n=n|0;var r=0;r=cn(8)|0,t[r+4>>2]=t[n>>2],t[r>>2]=t[e>>2],t[e>>2]=r}function nL(e){e=e|0,rL(e)}function rL(e){e=e|0;var n=0,r=0;if(n=t[e>>2]|0,n|0)do r=n,n=t[n>>2]|0,yt(r);while((n|0)!=0);t[e>>2]=0}function iL(){return 11715}function uL(){return 1496}function oL(){return O1()|0}function lL(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(sL(r),yt(r)):n|0&&yt(n)}function sL(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function aL(e){e=e|0,Ha(e)}function fL(e,n){e=e|0,n=n|0;var r=0,u=0;Mv()|0,r=t[2652]|0;e:do if(r|0){for(;u=t[r+4>>2]|0,!(u|0?(L8(OE(u)|0,e)|0)==0:0);)if(r=t[r>>2]|0,!r)break e;cL(u,n)}while(0)}function OE(e){return e=e|0,t[e+12>>2]|0}function cL(e,n){e=e|0,n=n|0;var r=0;e=e+36|0,r=t[e>>2]|0,r|0&&(ia(r),yt(r)),r=cn(4)|0,mf(r,n),t[e>>2]=r}function kE(){return p[11716]|0||(t[2664]=0,Ht(63,10656,he|0)|0,p[11716]=1),10656}function $3(){var e=0;return p[11717]|0?e=t[2665]|0:(dL(),t[2665]=1504,p[11717]=1,e=1504),e|0}function dL(){p[11740]|0||(p[11718]=dn(dn(8,0)|0,0)|0,p[11719]=dn(dn(0,0)|0,0)|0,p[11720]=dn(dn(0,16)|0,0)|0,p[11721]=dn(dn(8,0)|0,0)|0,p[11722]=dn(dn(0,0)|0,0)|0,p[11723]=dn(dn(8,0)|0,0)|0,p[11724]=dn(dn(0,0)|0,0)|0,p[11725]=dn(dn(8,0)|0,0)|0,p[11726]=dn(dn(0,0)|0,0)|0,p[11727]=dn(dn(8,0)|0,0)|0,p[11728]=dn(dn(0,0)|0,0)|0,p[11729]=dn(dn(0,0)|0,32)|0,p[11730]=dn(dn(0,0)|0,32)|0,p[11740]=1)}function e8(){return 1572}function pL(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0,M=0,O=0;s=m,m=m+32|0,O=s+16|0,M=s+12|0,S=s+8|0,D=s+4|0,h=s,t[O>>2]=e,t[M>>2]=n,t[S>>2]=r,t[D>>2]=u,t[h>>2]=l,kE()|0,hL(10656,O,M,S,D,h),m=s}function hL(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0;var h=0;h=cn(24)|0,h2(h+4|0,t[n>>2]|0,t[r>>2]|0,t[u>>2]|0,t[l>>2]|0,t[s>>2]|0),t[h>>2]=t[e>>2],t[e>>2]=h}function t8(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0,Qe=0,We=0,st=0;if(st=m,m=m+32|0,Ee=st+20|0,ve=st+8|0,Qe=st+4|0,We=st,n=t[n>>2]|0,n|0){Pe=Ee+4|0,S=Ee+8|0,M=ve+4|0,O=ve+8|0,P=ve+8|0,K=Ee+8|0;do{if(h=n+4|0,D=ME(h)|0,D|0){if(l=Fy(D)|0,t[Ee>>2]=0,t[Pe>>2]=0,t[S>>2]=0,u=(by(D)|0)+1|0,vL(Ee,u),u|0)for(;u=u+-1|0,jf(ve,t[l>>2]|0),s=t[Pe>>2]|0,s>>>0<(t[K>>2]|0)>>>0?(t[s>>2]=t[ve>>2],t[Pe>>2]=(t[Pe>>2]|0)+4):NE(Ee,ve),u;)l=l+4|0;u=Py(D)|0,t[ve>>2]=0,t[M>>2]=0,t[O>>2]=0;e:do if(t[u>>2]|0)for(l=0,s=0;;){if((l|0)==(s|0)?mL(ve,u):(t[l>>2]=t[u>>2],t[M>>2]=(t[M>>2]|0)+4),u=u+4|0,!(t[u>>2]|0))break e;l=t[M>>2]|0,s=t[P>>2]|0}while(0);t[Qe>>2]=D_(h)|0,t[We>>2]=rr(D)|0,yL(r,e,Qe,We,Ee,ve),LE(ve),k1(Ee)}n=t[n>>2]|0}while((n|0)!=0)}m=st}function ME(e){return e=e|0,t[e+12>>2]|0}function Fy(e){return e=e|0,t[e+12>>2]|0}function by(e){return e=e|0,t[e+16>>2]|0}function vL(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;l=m,m=m+32|0,r=l,u=t[e>>2]|0,(t[e+8>>2]|0)-u>>2>>>0>>0&&(a8(r,n,(t[e+4>>2]|0)-u>>2,e+8|0),f8(e,r),c8(r)),m=l}function NE(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0;if(h=m,m=m+32|0,r=h,u=e+4|0,l=((t[u>>2]|0)-(t[e>>2]|0)>>2)+1|0,s=s8(e)|0,s>>>0>>0)li(e);else{D=t[e>>2]|0,M=(t[e+8>>2]|0)-D|0,S=M>>1,a8(r,M>>2>>>0>>1>>>0?S>>>0>>0?l:S:s,(t[u>>2]|0)-D>>2,e+8|0),s=r+8|0,t[t[s>>2]>>2]=t[n>>2],t[s>>2]=(t[s>>2]|0)+4,f8(e,r),c8(r),m=h;return}}function Py(e){return e=e|0,t[e+8>>2]|0}function mL(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0;if(h=m,m=m+32|0,r=h,u=e+4|0,l=((t[u>>2]|0)-(t[e>>2]|0)>>2)+1|0,s=l8(e)|0,s>>>0>>0)li(e);else{D=t[e>>2]|0,M=(t[e+8>>2]|0)-D|0,S=M>>1,PL(r,M>>2>>>0>>1>>>0?S>>>0>>0?l:S:s,(t[u>>2]|0)-D>>2,e+8|0),s=r+8|0,t[t[s>>2]>>2]=t[n>>2],t[s>>2]=(t[s>>2]|0)+4,IL(e,r),BL(r),m=h;return}}function D_(e){return e=e|0,t[e>>2]|0}function yL(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,gL(e,n,r,u,l,s)}function LE(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-4-u|0)>>>2)<<2)),yt(r))}function k1(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-4-u|0)>>>2)<<2)),yt(r))}function gL(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0;var h=0,D=0,S=0,M=0,O=0,P=0;h=m,m=m+48|0,O=h+40|0,D=h+32|0,P=h+24|0,S=h+12|0,M=h,Ta(D),e=vo(e)|0,t[P>>2]=t[n>>2],r=t[r>>2]|0,u=t[u>>2]|0,FE(S,l),_L(M,s),t[O>>2]=t[P>>2],EL(e,O,r,u,S,M),LE(M),k1(S),Ca(D),m=h}function FE(e,n){e=e|0,n=n|0;var r=0,u=0;t[e>>2]=0,t[e+4>>2]=0,t[e+8>>2]=0,r=n+4|0,u=(t[r>>2]|0)-(t[n>>2]|0)>>2,u|0&&(FL(e,u),bL(e,t[n>>2]|0,t[r>>2]|0,u))}function _L(e,n){e=e|0,n=n|0;var r=0,u=0;t[e>>2]=0,t[e+4>>2]=0,t[e+8>>2]=0,r=n+4|0,u=(t[r>>2]|0)-(t[n>>2]|0)>>2,u|0&&(NL(e,u),LL(e,t[n>>2]|0,t[r>>2]|0,u))}function EL(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0;var h=0,D=0,S=0,M=0,O=0,P=0;h=m,m=m+32|0,O=h+28|0,P=h+24|0,D=h+12|0,S=h,M=mo(DL()|0)|0,t[P>>2]=t[n>>2],t[O>>2]=t[P>>2],n=Vp(O)|0,r=n8(r)|0,u=bE(u)|0,t[D>>2]=t[l>>2],O=l+4|0,t[D+4>>2]=t[O>>2],P=l+8|0,t[D+8>>2]=t[P>>2],t[P>>2]=0,t[O>>2]=0,t[l>>2]=0,l=PE(D)|0,t[S>>2]=t[s>>2],O=s+4|0,t[S+4>>2]=t[O>>2],P=s+8|0,t[S+8>>2]=t[P>>2],t[P>>2]=0,t[O>>2]=0,t[s>>2]=0,G0(0,M|0,e|0,n|0,r|0,u|0,l|0,wL(S)|0)|0,LE(S),k1(D),m=h}function DL(){var e=0;return p[7968]|0||(kL(10708),e=7968,t[e>>2]=1,t[e+4>>2]=0),10708}function Vp(e){return e=e|0,i8(e)|0}function n8(e){return e=e|0,r8(e)|0}function bE(e){return e=e|0,z0(e)|0}function PE(e){return e=e|0,TL(e)|0}function wL(e){return e=e|0,SL(e)|0}function SL(e){e=e|0;var n=0,r=0,u=0;if(u=(t[e+4>>2]|0)-(t[e>>2]|0)|0,r=u>>2,u=Sa(u+4|0)|0,t[u>>2]=r,r|0){n=0;do t[u+4+(n<<2)>>2]=r8(t[(t[e>>2]|0)+(n<<2)>>2]|0)|0,n=n+1|0;while((n|0)!=(r|0))}return u|0}function r8(e){return e=e|0,e|0}function TL(e){e=e|0;var n=0,r=0,u=0;if(u=(t[e+4>>2]|0)-(t[e>>2]|0)|0,r=u>>2,u=Sa(u+4|0)|0,t[u>>2]=r,r|0){n=0;do t[u+4+(n<<2)>>2]=i8((t[e>>2]|0)+(n<<2)|0)|0,n=n+1|0;while((n|0)!=(r|0))}return u|0}function i8(e){e=e|0;var n=0,r=0,u=0,l=0;return l=m,m=m+32|0,n=l+12|0,r=l,u=Ou(u8()|0)|0,u?(Zl(n,u),Tf(r,n),lI(e,r),e=Es(n)|0):e=CL(e)|0,m=l,e|0}function u8(){var e=0;return p[7960]|0||(OL(10664),Ht(25,10664,he|0)|0,e=7960,t[e>>2]=1,t[e+4>>2]=0),10664}function CL(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0;return r=m,m=m+16|0,l=r+4|0,h=r,u=Sa(8)|0,n=u,D=cn(4)|0,t[D>>2]=t[e>>2],s=n+4|0,t[s>>2]=D,e=cn(8)|0,s=t[s>>2]|0,t[h>>2]=0,t[l>>2]=t[h>>2],o8(e,s,l),t[u>>2]=e,m=r,n|0}function o8(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,r=cn(16)|0,t[r+4>>2]=0,t[r+8>>2]=0,t[r>>2]=1656,t[r+12>>2]=n,t[e+4>>2]=r}function xL(e){e=e|0,Pv(e),yt(e)}function AL(e){e=e|0,e=t[e+12>>2]|0,e|0&&yt(e)}function RL(e){e=e|0,yt(e)}function OL(e){e=e|0,Ha(e)}function kL(e){e=e|0,nl(e,ML()|0,5)}function ML(){return 1676}function NL(e,n){e=e|0,n=n|0;var r=0;if((l8(e)|0)>>>0>>0&&li(e),n>>>0>1073741823)Xn();else{r=cn(n<<2)|0,t[e+4>>2]=r,t[e>>2]=r,t[e+8>>2]=r+(n<<2);return}}function LL(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,u=e+4|0,e=r-n|0,(e|0)>0&&(pr(t[u>>2]|0,n|0,e|0)|0,t[u>>2]=(t[u>>2]|0)+(e>>>2<<2))}function l8(e){return e=e|0,1073741823}function FL(e,n){e=e|0,n=n|0;var r=0;if((s8(e)|0)>>>0>>0&&li(e),n>>>0>1073741823)Xn();else{r=cn(n<<2)|0,t[e+4>>2]=r,t[e>>2]=r,t[e+8>>2]=r+(n<<2);return}}function bL(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,u=e+4|0,e=r-n|0,(e|0)>0&&(pr(t[u>>2]|0,n|0,e|0)|0,t[u>>2]=(t[u>>2]|0)+(e>>>2<<2))}function s8(e){return e=e|0,1073741823}function PL(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>1073741823)Xn();else{l=cn(n<<2)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<2)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<2)}function IL(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>2)<<2)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function BL(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-4-n|0)>>>2)<<2)),e=t[e>>2]|0,e|0&&yt(e)}function a8(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>1073741823)Xn();else{l=cn(n<<2)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<2)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<2)}function f8(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>2)<<2)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function c8(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-4-n|0)>>>2)<<2)),e=t[e>>2]|0,e|0&&yt(e)}function UL(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0;if(ve=m,m=m+32|0,O=ve+20|0,P=ve+12|0,M=ve+16|0,K=ve+4|0,Pe=ve,Ee=ve+8|0,D=$3()|0,s=t[D>>2]|0,h=t[s>>2]|0,h|0)for(S=t[D+8>>2]|0,D=t[D+4>>2]|0;jf(O,h),jL(e,O,D,S),s=s+4|0,h=t[s>>2]|0,h;)S=S+1|0,D=D+1|0;if(s=e8()|0,h=t[s>>2]|0,h|0)do jf(O,h),t[P>>2]=t[s+4>>2],zL(n,O,P),s=s+8|0,h=t[s>>2]|0;while((h|0)!=0);if(s=t[(Mv()|0)>>2]|0,s|0)do n=t[s+4>>2]|0,jf(O,t[(Nv(n)|0)>>2]|0),t[P>>2]=OE(n)|0,qL(r,O,P),s=t[s>>2]|0;while((s|0)!=0);if(jf(M,0),s=kE()|0,t[O>>2]=t[M>>2],t8(O,s,l),s=t[(Mv()|0)>>2]|0,s|0){e=O+4|0,n=O+8|0,r=O+8|0;do{if(S=t[s+4>>2]|0,jf(P,t[(Nv(S)|0)>>2]|0),HL(K,d8(S)|0),h=t[K>>2]|0,h|0){t[O>>2]=0,t[e>>2]=0,t[n>>2]=0;do jf(Pe,t[(Nv(t[h+4>>2]|0)|0)>>2]|0),D=t[e>>2]|0,D>>>0<(t[r>>2]|0)>>>0?(t[D>>2]=t[Pe>>2],t[e>>2]=(t[e>>2]|0)+4):NE(O,Pe),h=t[h>>2]|0;while((h|0)!=0);WL(u,P,O),k1(O)}t[Ee>>2]=t[P>>2],M=p8(S)|0,t[O>>2]=t[Ee>>2],t8(O,M,l),m2(K),s=t[s>>2]|0}while((s|0)!=0)}m=ve}function jL(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,rF(e,n,r,u)}function zL(e,n,r){e=e|0,n=n|0,r=r|0,nF(e,n,r)}function Nv(e){return e=e|0,e|0}function qL(e,n,r){e=e|0,n=n|0,r=r|0,ZL(e,n,r)}function d8(e){return e=e|0,e+16|0}function HL(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;if(s=m,m=m+16|0,l=s+8|0,r=s,t[e>>2]=0,u=t[n>>2]|0,t[l>>2]=u,t[r>>2]=e,r=JL(r)|0,u|0){if(u=cn(12)|0,h=(h8(l)|0)+4|0,e=t[h+4>>2]|0,n=u+4|0,t[n>>2]=t[h>>2],t[n+4>>2]=e,n=t[t[l>>2]>>2]|0,t[l>>2]=n,!n)e=u;else for(n=u;e=cn(12)|0,S=(h8(l)|0)+4|0,D=t[S+4>>2]|0,h=e+4|0,t[h>>2]=t[S>>2],t[h+4>>2]=D,t[n>>2]=e,h=t[t[l>>2]>>2]|0,t[l>>2]=h,h;)n=e;t[e>>2]=t[r>>2],t[r>>2]=u}m=s}function WL(e,n,r){e=e|0,n=n|0,r=r|0,VL(e,n,r)}function p8(e){return e=e|0,e+24|0}function VL(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+32|0,h=u+24|0,l=u+16|0,D=u+12|0,s=u,Ta(l),e=vo(e)|0,t[D>>2]=t[n>>2],FE(s,r),t[h>>2]=t[D>>2],YL(e,h,s),k1(s),Ca(l),m=u}function YL(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=m,m=m+32|0,h=u+16|0,D=u+12|0,l=u,s=mo(KL()|0)|0,t[D>>2]=t[n>>2],t[h>>2]=t[D>>2],n=Vp(h)|0,t[l>>2]=t[r>>2],h=r+4|0,t[l+4>>2]=t[h>>2],D=r+8|0,t[l+8>>2]=t[D>>2],t[D>>2]=0,t[h>>2]=0,t[r>>2]=0,F0(0,s|0,e|0,n|0,PE(l)|0)|0,k1(l),m=u}function KL(){var e=0;return p[7976]|0||(XL(10720),e=7976,t[e>>2]=1,t[e+4>>2]=0),10720}function XL(e){e=e|0,nl(e,QL()|0,2)}function QL(){return 1732}function JL(e){return e=e|0,t[e>>2]|0}function h8(e){return e=e|0,t[e>>2]|0}function ZL(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+32|0,s=u+16|0,l=u+8|0,h=u,Ta(l),e=vo(e)|0,t[h>>2]=t[n>>2],r=t[r>>2]|0,t[s>>2]=t[h>>2],v8(e,s,r),Ca(l),m=u}function v8(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+16|0,s=u+4|0,h=u,l=mo($L()|0)|0,t[h>>2]=t[n>>2],t[s>>2]=t[h>>2],n=Vp(s)|0,F0(0,l|0,e|0,n|0,n8(r)|0)|0,m=u}function $L(){var e=0;return p[7984]|0||(eF(10732),e=7984,t[e>>2]=1,t[e+4>>2]=0),10732}function eF(e){e=e|0,nl(e,tF()|0,2)}function tF(){return 1744}function nF(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;u=m,m=m+32|0,s=u+16|0,l=u+8|0,h=u,Ta(l),e=vo(e)|0,t[h>>2]=t[n>>2],r=t[r>>2]|0,t[s>>2]=t[h>>2],v8(e,s,r),Ca(l),m=u}function rF(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;l=m,m=m+32|0,h=l+16|0,s=l+8|0,D=l,Ta(s),e=vo(e)|0,t[D>>2]=t[n>>2],r=p[r>>0]|0,u=p[u>>0]|0,t[h>>2]=t[D>>2],iF(e,h,r,u),Ca(s),m=l}function iF(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;l=m,m=m+16|0,h=l+4|0,D=l,s=mo(uF()|0)|0,t[D>>2]=t[n>>2],t[h>>2]=t[D>>2],n=Vp(h)|0,r=Lv(r)|0,Bn(0,s|0,e|0,n|0,r|0,Lv(u)|0)|0,m=l}function uF(){var e=0;return p[7992]|0||(lF(10744),e=7992,t[e>>2]=1,t[e+4>>2]=0),10744}function Lv(e){return e=e|0,oF(e)|0}function oF(e){return e=e|0,e&255|0}function lF(e){e=e|0,nl(e,sF()|0,3)}function sF(){return 1756}function aF(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;switch(K=m,m=m+32|0,D=K+8|0,S=K+4|0,M=K+20|0,O=K,ma(e,0),u=oI(n)|0,t[D>>2]=0,P=D+4|0,t[P>>2]=0,t[D+8>>2]=0,u<<24>>24){case 0:{p[M>>0]=0,fF(S,r,M),w_(e,S)|0,B0(S);break}case 8:{P=qE(n)|0,p[M>>0]=8,jf(O,t[P+4>>2]|0),cF(S,r,M,O,P+8|0),w_(e,S)|0,B0(S);break}case 9:{if(s=qE(n)|0,n=t[s+4>>2]|0,n|0)for(h=D+8|0,l=s+12|0;n=n+-1|0,jf(S,t[l>>2]|0),u=t[P>>2]|0,u>>>0<(t[h>>2]|0)>>>0?(t[u>>2]=t[S>>2],t[P>>2]=(t[P>>2]|0)+4):NE(D,S),n;)l=l+4|0;p[M>>0]=9,jf(O,t[s+8>>2]|0),dF(S,r,M,O,D),w_(e,S)|0,B0(S);break}default:P=qE(n)|0,p[M>>0]=u,jf(O,t[P+4>>2]|0),pF(S,r,M,O),w_(e,S)|0,B0(S)}k1(D),m=K}function fF(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;u=m,m=m+16|0,l=u,Ta(l),n=vo(n)|0,xF(e,n,p[r>>0]|0),Ca(l),m=u}function w_(e,n){e=e|0,n=n|0;var r=0;return r=t[e>>2]|0,r|0&&Ir(r|0),t[e>>2]=t[n>>2],t[n>>2]=0,e|0}function cF(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0;s=m,m=m+32|0,D=s+16|0,h=s+8|0,S=s,Ta(h),n=vo(n)|0,r=p[r>>0]|0,t[S>>2]=t[u>>2],l=t[l>>2]|0,t[D>>2]=t[S>>2],wF(e,n,r,D,l),Ca(h),m=s}function dF(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0,M=0;s=m,m=m+32|0,S=s+24|0,h=s+16|0,M=s+12|0,D=s,Ta(h),n=vo(n)|0,r=p[r>>0]|0,t[M>>2]=t[u>>2],FE(D,l),t[S>>2]=t[M>>2],gF(e,n,r,S,D),k1(D),Ca(h),m=s}function pF(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;l=m,m=m+32|0,h=l+16|0,s=l+8|0,D=l,Ta(s),n=vo(n)|0,r=p[r>>0]|0,t[D>>2]=t[u>>2],t[h>>2]=t[D>>2],hF(e,n,r,h),Ca(s),m=l}function hF(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0,h=0,D=0;l=m,m=m+16|0,s=l+4|0,D=l,h=mo(vF()|0)|0,r=Lv(r)|0,t[D>>2]=t[u>>2],t[s>>2]=t[D>>2],S_(e,F0(0,h|0,n|0,r|0,Vp(s)|0)|0),m=l}function vF(){var e=0;return p[8e3]|0||(mF(10756),e=8e3,t[e>>2]=1,t[e+4>>2]=0),10756}function S_(e,n){e=e|0,n=n|0,ma(e,n)}function mF(e){e=e|0,nl(e,yF()|0,2)}function yF(){return 1772}function gF(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0,M=0;s=m,m=m+32|0,S=s+16|0,M=s+12|0,h=s,D=mo(_F()|0)|0,r=Lv(r)|0,t[M>>2]=t[u>>2],t[S>>2]=t[M>>2],u=Vp(S)|0,t[h>>2]=t[l>>2],S=l+4|0,t[h+4>>2]=t[S>>2],M=l+8|0,t[h+8>>2]=t[M>>2],t[M>>2]=0,t[S>>2]=0,t[l>>2]=0,S_(e,Bn(0,D|0,n|0,r|0,u|0,PE(h)|0)|0),k1(h),m=s}function _F(){var e=0;return p[8008]|0||(EF(10768),e=8008,t[e>>2]=1,t[e+4>>2]=0),10768}function EF(e){e=e|0,nl(e,DF()|0,3)}function DF(){return 1784}function wF(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0;s=m,m=m+16|0,D=s+4|0,S=s,h=mo(SF()|0)|0,r=Lv(r)|0,t[S>>2]=t[u>>2],t[D>>2]=t[S>>2],u=Vp(D)|0,S_(e,Bn(0,h|0,n|0,r|0,u|0,bE(l)|0)|0),m=s}function SF(){var e=0;return p[8016]|0||(TF(10780),e=8016,t[e>>2]=1,t[e+4>>2]=0),10780}function TF(e){e=e|0,nl(e,CF()|0,3)}function CF(){return 1800}function xF(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;u=mo(AF()|0)|0,S_(e,ji(0,u|0,n|0,Lv(r)|0)|0)}function AF(){var e=0;return p[8024]|0||(RF(10792),e=8024,t[e>>2]=1,t[e+4>>2]=0),10792}function RF(e){e=e|0,nl(e,OF()|0,1)}function OF(){return 1816}function kF(){MF(),NF(),LF()}function MF(){t[2702]=H8(65536)|0}function NF(){$F(10856)}function LF(){FF(10816)}function FF(e){e=e|0,bF(e,5044),PF(e)|0}function bF(e,n){e=e|0,n=n|0;var r=0;r=u8()|0,t[e>>2]=r,YF(r,n),Q2(t[e>>2]|0)}function PF(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,IF()|0),e|0}function IF(){var e=0;return p[8032]|0||(m8(10820),Ht(64,10820,he|0)|0,e=8032,t[e>>2]=1,t[e+4>>2]=0),rr(10820)|0||m8(10820),10820}function m8(e){e=e|0,jF(e),Wp(e,25)}function BF(e){e=e|0,UF(e+24|0)}function UF(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function jF(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,18,n,WF()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function zF(e,n){e=e|0,n=n|0,qF(e,n)}function qF(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;r=m,m=m+16|0,u=r,l=r+4|0,Of(l,n),t[u>>2]=kf(l,n)|0,HF(e,u),m=r}function HF(e,n){e=e|0,n=n|0,y8(e+4|0,t[n>>2]|0),p[e+8>>0]=1}function y8(e,n){e=e|0,n=n|0,t[e>>2]=n}function WF(){return 1824}function VF(e){return e=e|0,GF(e)|0}function GF(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0;return r=m,m=m+16|0,l=r+4|0,h=r,u=Sa(8)|0,n=u,D=cn(4)|0,Of(l,e),y8(D,kf(l,e)|0),s=n+4|0,t[s>>2]=D,e=cn(8)|0,s=t[s>>2]|0,t[h>>2]=0,t[l>>2]=t[h>>2],o8(e,s,l),t[u>>2]=e,m=r,n|0}function Sa(e){e=e|0;var n=0,r=0;return e=e+7&-8,(e>>>0<=32768?(n=t[2701]|0,e>>>0<=(65536-n|0)>>>0):0)?(r=(t[2702]|0)+n|0,t[2701]=n+e,e=r):(e=H8(e+8|0)|0,t[e>>2]=t[2703],t[2703]=e,e=e+8|0),e|0}function YF(e,n){e=e|0,n=n|0,t[e>>2]=KF()|0,t[e+4>>2]=XF()|0,t[e+12>>2]=n,t[e+8>>2]=QF()|0,t[e+32>>2]=9}function KF(){return 11744}function XF(){return 1832}function QF(){return E_()|0}function JF(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(ZF(r),yt(r)):n|0&&yt(n)}function ZF(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function $F(e){e=e|0,eb(e,5052),tb(e)|0,nb(e,5058,26)|0,rb(e,5069,1)|0,ib(e,5077,10)|0,ub(e,5087,19)|0,ob(e,5094,27)|0}function eb(e,n){e=e|0,n=n|0;var r=0;r=ZP()|0,t[e>>2]=r,$P(r,n),Q2(t[e>>2]|0)}function tb(e){e=e|0;var n=0;return n=t[e>>2]|0,Hp(n,BP()|0),e|0}function nb(e,n,r){return e=e|0,n=n|0,r=r|0,EP(e,Or(n)|0,r,0),e|0}function rb(e,n,r){return e=e|0,n=n|0,r=r|0,uP(e,Or(n)|0,r,0),e|0}function ib(e,n,r){return e=e|0,n=n|0,r=r|0,Ib(e,Or(n)|0,r,0),e|0}function ub(e,n,r){return e=e|0,n=n|0,r=r|0,wb(e,Or(n)|0,r,0),e|0}function g8(e,n){e=e|0,n=n|0;var r=0,u=0;e:for(;;){for(r=t[2703]|0;;){if((r|0)==(n|0))break e;if(u=t[r>>2]|0,t[2703]=u,!r)r=u;else break}yt(r)}t[2701]=e}function ob(e,n,r){return e=e|0,n=n|0,r=r|0,lb(e,Or(n)|0,r,0),e|0}function lb(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=IE()|0,e=sb(r)|0,vi(s,n,l,e,ab(r,u)|0,u)}function IE(){var e=0,n=0;if(p[8040]|0||(E8(10860),Ht(65,10860,he|0)|0,n=8040,t[n>>2]=1,t[n+4>>2]=0),!(rr(10860)|0)){e=10860,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));E8(10860)}return 10860}function sb(e){return e=e|0,e|0}function ab(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=IE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(_8(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(fb(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function _8(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function fb(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=cb(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,db(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,_8(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,pb(e,l),hb(l),m=D;return}}function cb(e){return e=e|0,536870911}function db(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function pb(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function hb(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function E8(e){e=e|0,yb(e)}function vb(e){e=e|0,mb(e+24|0)}function mb(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function yb(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,11,n,gb()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function gb(){return 1840}function _b(e,n,r){e=e|0,n=n|0,r=r|0,Db(t[(Eb(e)|0)>>2]|0,n,r)}function Eb(e){return e=e|0,(t[(IE()|0)+24>>2]|0)+(e<<3)|0}function Db(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;u=m,m=m+16|0,s=u+1|0,l=u,Of(s,n),n=kf(s,n)|0,Of(l,r),r=kf(l,r)|0,N1[e&31](n,r),m=u}function wb(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=BE()|0,e=Sb(r)|0,vi(s,n,l,e,Tb(r,u)|0,u)}function BE(){var e=0,n=0;if(p[8048]|0||(w8(10896),Ht(66,10896,he|0)|0,n=8048,t[n>>2]=1,t[n+4>>2]=0),!(rr(10896)|0)){e=10896,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));w8(10896)}return 10896}function Sb(e){return e=e|0,e|0}function Tb(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=BE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(D8(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(Cb(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function D8(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function Cb(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=xb(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,Ab(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,D8(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,Rb(e,l),Ob(l),m=D;return}}function xb(e){return e=e|0,536870911}function Ab(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function Rb(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Ob(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function w8(e){e=e|0,Nb(e)}function kb(e){e=e|0,Mb(e+24|0)}function Mb(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function Nb(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,11,n,Lb()|0,1),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Lb(){return 1852}function Fb(e,n){return e=e|0,n=n|0,Pb(t[(bb(e)|0)>>2]|0,n)|0}function bb(e){return e=e|0,(t[(BE()|0)+24>>2]|0)+(e<<3)|0}function Pb(e,n){e=e|0,n=n|0;var r=0,u=0;return r=m,m=m+16|0,u=r,Of(u,n),n=kf(u,n)|0,n=z0(Xp[e&31](n)|0)|0,m=r,n|0}function Ib(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=UE()|0,e=Bb(r)|0,vi(s,n,l,e,Ub(r,u)|0,u)}function UE(){var e=0,n=0;if(p[8056]|0||(T8(10932),Ht(67,10932,he|0)|0,n=8056,t[n>>2]=1,t[n+4>>2]=0),!(rr(10932)|0)){e=10932,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));T8(10932)}return 10932}function Bb(e){return e=e|0,e|0}function Ub(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=UE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(S8(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(jb(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function S8(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function jb(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=zb(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,qb(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,S8(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,Hb(e,l),Wb(l),m=D;return}}function zb(e){return e=e|0,536870911}function qb(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function Hb(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function Wb(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function T8(e){e=e|0,Yb(e)}function Vb(e){e=e|0,Gb(e+24|0)}function Gb(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function Yb(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,7,n,Kb()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function Kb(){return 1860}function Xb(e,n,r){return e=e|0,n=n|0,r=r|0,Jb(t[(Qb(e)|0)>>2]|0,n,r)|0}function Qb(e){return e=e|0,(t[(UE()|0)+24>>2]|0)+(e<<3)|0}function Jb(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0;return u=m,m=m+32|0,h=u+12|0,s=u+8|0,D=u,S=u+16|0,l=u+4|0,Zb(S,n),$b(D,S,n),qs(l,r),r=Hs(l,r)|0,t[h>>2]=t[D>>2],jy[e&15](s,h,r),r=eP(s)|0,B0(s),Ws(l),m=u,r|0}function Zb(e,n){e=e|0,n=n|0}function $b(e,n,r){e=e|0,n=n|0,r=r|0,tP(e,r)}function eP(e){return e=e|0,vo(e)|0}function tP(e,n){e=e|0,n=n|0;var r=0,u=0,l=0;l=m,m=m+16|0,r=l,u=n,u&1?(nP(r,0),Yi(u|0,r|0)|0,rP(e,r),iP(r)):t[e>>2]=t[n>>2],m=l}function nP(e,n){e=e|0,n=n|0,l2(e,n),t[e+4>>2]=0,p[e+8>>0]=0}function rP(e,n){e=e|0,n=n|0,t[e>>2]=t[n+4>>2]}function iP(e){e=e|0,p[e+8>>0]=0}function uP(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=jE()|0,e=oP(r)|0,vi(s,n,l,e,lP(r,u)|0,u)}function jE(){var e=0,n=0;if(p[8064]|0||(x8(10968),Ht(68,10968,he|0)|0,n=8064,t[n>>2]=1,t[n+4>>2]=0),!(rr(10968)|0)){e=10968,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));x8(10968)}return 10968}function oP(e){return e=e|0,e|0}function lP(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=jE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(C8(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(sP(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function C8(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function sP(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=aP(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,fP(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,C8(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,cP(e,l),dP(l),m=D;return}}function aP(e){return e=e|0,536870911}function fP(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function cP(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function dP(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function x8(e){e=e|0,vP(e)}function pP(e){e=e|0,hP(e+24|0)}function hP(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function vP(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,1,n,mP()|0,5),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function mP(){return 1872}function yP(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,_P(t[(gP(e)|0)>>2]|0,n,r,u,l,s)}function gP(e){return e=e|0,(t[(jE()|0)+24>>2]|0)+(e<<3)|0}function _P(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0;var h=0,D=0,S=0,M=0,O=0,P=0;h=m,m=m+32|0,D=h+16|0,S=h+12|0,M=h+8|0,O=h+4|0,P=h,qs(D,n),n=Hs(D,n)|0,qs(S,r),r=Hs(S,r)|0,qs(M,u),u=Hs(M,u)|0,qs(O,l),l=Hs(O,l)|0,qs(P,s),s=Hs(P,s)|0,K8[e&1](n,r,u,l,s),Ws(P),Ws(O),Ws(M),Ws(S),Ws(D),m=h}function EP(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;s=t[e>>2]|0,l=zE()|0,e=DP(r)|0,vi(s,n,l,e,wP(r,u)|0,u)}function zE(){var e=0,n=0;if(p[8072]|0||(R8(11004),Ht(69,11004,he|0)|0,n=8072,t[n>>2]=1,t[n+4>>2]=0),!(rr(11004)|0)){e=11004,n=e+36|0;do t[e>>2]=0,e=e+4|0;while((e|0)<(n|0));R8(11004)}return 11004}function DP(e){return e=e|0,e|0}function wP(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0,D=0,S=0;return D=m,m=m+16|0,l=D,s=D+4|0,t[l>>2]=e,S=zE()|0,h=S+24|0,n=dn(n,4)|0,t[s>>2]=n,r=S+28|0,u=t[r>>2]|0,u>>>0<(t[S+32>>2]|0)>>>0?(A8(u,e,n),n=(t[r>>2]|0)+8|0,t[r>>2]=n):(SP(h,l,s),n=t[r>>2]|0),m=D,(n-(t[h>>2]|0)>>3)+-1|0}function A8(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,t[e+4>>2]=r}function SP(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0;if(D=m,m=m+32|0,l=D,s=e+4|0,h=((t[s>>2]|0)-(t[e>>2]|0)>>3)+1|0,u=TP(e)|0,u>>>0>>0)li(e);else{S=t[e>>2]|0,O=(t[e+8>>2]|0)-S|0,M=O>>2,CP(l,O>>3>>>0>>1>>>0?M>>>0>>0?h:M:u,(t[s>>2]|0)-S>>3,e+8|0),h=l+8|0,A8(t[h>>2]|0,t[n>>2]|0,t[r>>2]|0),t[h>>2]=(t[h>>2]|0)+8,xP(e,l),AP(l),m=D;return}}function TP(e){return e=e|0,536870911}function CP(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0;t[e+12>>2]=0,t[e+16>>2]=u;do if(n)if(n>>>0>536870911)Xn();else{l=cn(n<<3)|0;break}else l=0;while(0);t[e>>2]=l,u=l+(r<<3)|0,t[e+8>>2]=u,t[e+4>>2]=u,t[e+12>>2]=l+(n<<3)}function xP(e,n){e=e|0,n=n|0;var r=0,u=0,l=0,s=0,h=0;u=t[e>>2]|0,h=e+4|0,s=n+4|0,l=(t[h>>2]|0)-u|0,r=(t[s>>2]|0)+(0-(l>>3)<<3)|0,t[s>>2]=r,(l|0)>0?(pr(r|0,u|0,l|0)|0,u=s,r=t[s>>2]|0):u=s,s=t[e>>2]|0,t[e>>2]=r,t[u>>2]=s,s=n+8|0,l=t[h>>2]|0,t[h>>2]=t[s>>2],t[s>>2]=l,s=e+8|0,h=n+12|0,e=t[s>>2]|0,t[s>>2]=t[h>>2],t[h>>2]=e,t[n>>2]=t[u>>2]}function AP(e){e=e|0;var n=0,r=0,u=0;n=t[e+4>>2]|0,r=e+8|0,u=t[r>>2]|0,(u|0)!=(n|0)&&(t[r>>2]=u+(~((u+-8-n|0)>>>3)<<3)),e=t[e>>2]|0,e|0&&yt(e)}function R8(e){e=e|0,kP(e)}function RP(e){e=e|0,OP(e+24|0)}function OP(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function kP(e){e=e|0;var n=0;n=dr()|0,Pn(e,1,12,n,MP()|0,2),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function MP(){return 1896}function NP(e,n,r){e=e|0,n=n|0,r=r|0,FP(t[(LP(e)|0)>>2]|0,n,r)}function LP(e){return e=e|0,(t[(zE()|0)+24>>2]|0)+(e<<3)|0}function FP(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;u=m,m=m+16|0,s=u+4|0,l=u,bP(s,n),n=PP(s,n)|0,qs(l,r),r=Hs(l,r)|0,N1[e&31](n,r),Ws(l),m=u}function bP(e,n){e=e|0,n=n|0}function PP(e,n){return e=e|0,n=n|0,IP(n)|0}function IP(e){return e=e|0,e|0}function BP(){var e=0;return p[8080]|0||(O8(11040),Ht(70,11040,he|0)|0,e=8080,t[e>>2]=1,t[e+4>>2]=0),rr(11040)|0||O8(11040),11040}function O8(e){e=e|0,zP(e),Wp(e,71)}function UP(e){e=e|0,jP(e+24|0)}function jP(e){e=e|0;var n=0,r=0,u=0;r=t[e>>2]|0,u=r,r|0&&(e=e+4|0,n=t[e>>2]|0,(n|0)!=(r|0)&&(t[e>>2]=n+(~((n+-8-u|0)>>>3)<<3)),yt(r))}function zP(e){e=e|0;var n=0;n=dr()|0,Pn(e,5,7,n,VP()|0,0),t[e+24>>2]=0,t[e+28>>2]=0,t[e+32>>2]=0}function qP(e){e=e|0,HP(e)}function HP(e){e=e|0,WP(e)}function WP(e){e=e|0,p[e+8>>0]=1}function VP(){return 1936}function GP(){return YP()|0}function YP(){var e=0,n=0,r=0,u=0,l=0,s=0,h=0;return n=m,m=m+16|0,l=n+4|0,h=n,r=Sa(8)|0,e=r,s=e+4|0,t[s>>2]=cn(1)|0,u=cn(8)|0,s=t[s>>2]|0,t[h>>2]=0,t[l>>2]=t[h>>2],KP(u,s,l),t[r>>2]=u,m=n,e|0}function KP(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]=n,r=cn(16)|0,t[r+4>>2]=0,t[r+8>>2]=0,t[r>>2]=1916,t[r+12>>2]=n,t[e+4>>2]=r}function XP(e){e=e|0,Pv(e),yt(e)}function QP(e){e=e|0,e=t[e+12>>2]|0,e|0&&yt(e)}function JP(e){e=e|0,yt(e)}function ZP(){var e=0;return p[8088]|0||(uI(11076),Ht(25,11076,he|0)|0,e=8088,t[e>>2]=1,t[e+4>>2]=0),11076}function $P(e,n){e=e|0,n=n|0,t[e>>2]=eI()|0,t[e+4>>2]=tI()|0,t[e+12>>2]=n,t[e+8>>2]=nI()|0,t[e+32>>2]=10}function eI(){return 11745}function tI(){return 1940}function nI(){return O1()|0}function rI(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,(Pl(u,896)|0)==512?r|0&&(iI(r),yt(r)):n|0&&yt(n)}function iI(e){e=e|0,e=t[e+4>>2]|0,e|0&&J2(e)}function uI(e){e=e|0,Ha(e)}function jf(e,n){e=e|0,n=n|0,t[e>>2]=n}function qE(e){return e=e|0,t[e>>2]|0}function oI(e){return e=e|0,p[t[e>>2]>>0]|0}function lI(e,n){e=e|0,n=n|0;var r=0,u=0;r=m,m=m+16|0,u=r,t[u>>2]=t[e>>2],sI(n,u)|0,m=r}function sI(e,n){e=e|0,n=n|0;var r=0;return r=aI(t[e>>2]|0,n)|0,n=e+4|0,t[(t[n>>2]|0)+8>>2]=r,t[(t[n>>2]|0)+8>>2]|0}function aI(e,n){e=e|0,n=n|0;var r=0,u=0;return r=m,m=m+16|0,u=r,Ta(u),e=vo(e)|0,n=fI(e,t[n>>2]|0)|0,Ca(u),m=r,n|0}function Ta(e){e=e|0,t[e>>2]=t[2701],t[e+4>>2]=t[2703]}function fI(e,n){e=e|0,n=n|0;var r=0;return r=mo(cI()|0)|0,ji(0,r|0,e|0,bE(n)|0)|0}function Ca(e){e=e|0,g8(t[e>>2]|0,t[e+4>>2]|0)}function cI(){var e=0;return p[8096]|0||(dI(11120),e=8096,t[e>>2]=1,t[e+4>>2]=0),11120}function dI(e){e=e|0,nl(e,pI()|0,1)}function pI(){return 1948}function hI(){vI()}function vI(){var e=0,n=0,r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0;if(Ee=m,m=m+16|0,O=Ee+4|0,P=Ee,Ln(65536,10804,t[2702]|0,10812),r=$3()|0,n=t[r>>2]|0,e=t[n>>2]|0,e|0)for(u=t[r+8>>2]|0,r=t[r+4>>2]|0;Wl(e|0,k[r>>0]|0|0,p[u>>0]|0),n=n+4|0,e=t[n>>2]|0,e;)u=u+1|0,r=r+1|0;if(e=e8()|0,n=t[e>>2]|0,n|0)do xo(n|0,t[e+4>>2]|0),e=e+8|0,n=t[e>>2]|0;while((n|0)!=0);xo(mI()|0,5167),M=Mv()|0,e=t[M>>2]|0;e:do if(e|0){do yI(t[e+4>>2]|0),e=t[e>>2]|0;while((e|0)!=0);if(e=t[M>>2]|0,e|0){S=M;do{for(;l=e,e=t[e>>2]|0,l=t[l+4>>2]|0,!!(gI(l)|0);)if(t[P>>2]=S,t[O>>2]=t[P>>2],_I(M,O)|0,!e)break e;if(EI(l),S=t[S>>2]|0,n=k8(l)|0,s=lo()|0,h=m,m=m+((1*(n<<2)|0)+15&-16)|0,D=m,m=m+((1*(n<<2)|0)+15&-16)|0,n=t[(d8(l)|0)>>2]|0,n|0)for(r=h,u=D;t[r>>2]=t[(Nv(t[n+4>>2]|0)|0)>>2],t[u>>2]=t[n+8>>2],n=t[n>>2]|0,n;)r=r+4|0,u=u+4|0;ve=Nv(l)|0,n=DI(l)|0,r=k8(l)|0,u=wI(l)|0,Ao(ve|0,n|0,h|0,D|0,r|0,u|0,OE(l)|0),ci(s|0)}while((e|0)!=0)}}while(0);if(e=t[(kE()|0)>>2]|0,e|0)do ve=e+4|0,M=ME(ve)|0,l=Py(M)|0,s=Fy(M)|0,h=(by(M)|0)+1|0,D=T_(M)|0,S=M8(ve)|0,M=rr(M)|0,O=D_(ve)|0,P=HE(ve)|0,oo(0,l|0,s|0,h|0,D|0,S|0,M|0,O|0,P|0,WE(ve)|0),e=t[e>>2]|0;while((e|0)!=0);e=t[(Mv()|0)>>2]|0;e:do if(e|0){t:for(;;){if(n=t[e+4>>2]|0,n|0?(K=t[(Nv(n)|0)>>2]|0,Pe=t[(p8(n)|0)>>2]|0,Pe|0):0){r=Pe;do{n=r+4|0,u=ME(n)|0;n:do if(u|0)switch(rr(u)|0){case 0:break t;case 4:case 3:case 2:{D=Py(u)|0,S=Fy(u)|0,M=(by(u)|0)+1|0,O=T_(u)|0,P=rr(u)|0,ve=D_(n)|0,oo(K|0,D|0,S|0,M|0,O|0,0,P|0,ve|0,HE(n)|0,WE(n)|0);break n}case 1:{h=Py(u)|0,D=Fy(u)|0,S=(by(u)|0)+1|0,M=T_(u)|0,O=M8(n)|0,P=rr(u)|0,ve=D_(n)|0,oo(K|0,h|0,D|0,S|0,M|0,O|0,P|0,ve|0,HE(n)|0,WE(n)|0);break n}case 5:{M=Py(u)|0,O=Fy(u)|0,P=(by(u)|0)+1|0,ve=T_(u)|0,oo(K|0,M|0,O|0,P|0,ve|0,SI(u)|0,rr(u)|0,0,0,0);break n}default:break n}while(0);r=t[r>>2]|0}while((r|0)!=0)}if(e=t[e>>2]|0,!e)break e}Xn()}while(0);Ms(),m=Ee}function mI(){return 11703}function yI(e){e=e|0,p[e+40>>0]=0}function gI(e){return e=e|0,(p[e+40>>0]|0)!=0|0}function _I(e,n){return e=e|0,n=n|0,n=TI(n)|0,e=t[n>>2]|0,t[n>>2]=t[e>>2],yt(e),t[n>>2]|0}function EI(e){e=e|0,p[e+40>>0]=1}function k8(e){return e=e|0,t[e+20>>2]|0}function DI(e){return e=e|0,t[e+8>>2]|0}function wI(e){return e=e|0,t[e+32>>2]|0}function T_(e){return e=e|0,t[e+4>>2]|0}function M8(e){return e=e|0,t[e+4>>2]|0}function HE(e){return e=e|0,t[e+8>>2]|0}function WE(e){return e=e|0,t[e+16>>2]|0}function SI(e){return e=e|0,t[e+20>>2]|0}function TI(e){return e=e|0,t[e>>2]|0}function C_(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0,Qe=0,We=0,st=0,Re=0,Fe=0,Qt=0;Qt=m,m=m+16|0,K=Qt;do if(e>>>0<245){if(M=e>>>0<11?16:e+11&-8,e=M>>>3,P=t[2783]|0,r=P>>>e,r&3|0)return n=(r&1^1)+e|0,e=11172+(n<<1<<2)|0,r=e+8|0,u=t[r>>2]|0,l=u+8|0,s=t[l>>2]|0,(e|0)==(s|0)?t[2783]=P&~(1<>2]=e,t[r>>2]=s),Fe=n<<3,t[u+4>>2]=Fe|3,Fe=u+Fe+4|0,t[Fe>>2]=t[Fe>>2]|1,Fe=l,m=Qt,Fe|0;if(O=t[2785]|0,M>>>0>O>>>0){if(r|0)return n=2<>>12&16,n=n>>>h,r=n>>>5&8,n=n>>>r,l=n>>>2&4,n=n>>>l,e=n>>>1&2,n=n>>>e,u=n>>>1&1,u=(r|h|l|e|u)+(n>>>u)|0,n=11172+(u<<1<<2)|0,e=n+8|0,l=t[e>>2]|0,h=l+8|0,r=t[h>>2]|0,(n|0)==(r|0)?(e=P&~(1<>2]=n,t[e>>2]=r,e=P),s=(u<<3)-M|0,t[l+4>>2]=M|3,u=l+M|0,t[u+4>>2]=s|1,t[u+s>>2]=s,O|0&&(l=t[2788]|0,n=O>>>3,r=11172+(n<<1<<2)|0,n=1<>2]|0):(t[2783]=e|n,n=r,e=r+8|0),t[e>>2]=l,t[n+12>>2]=l,t[l+8>>2]=n,t[l+12>>2]=r),t[2785]=s,t[2788]=u,Fe=h,m=Qt,Fe|0;if(D=t[2784]|0,D){if(r=(D&0-D)+-1|0,h=r>>>12&16,r=r>>>h,s=r>>>5&8,r=r>>>s,S=r>>>2&4,r=r>>>S,u=r>>>1&2,r=r>>>u,e=r>>>1&1,e=t[11436+((s|h|S|u|e)+(r>>>e)<<2)>>2]|0,r=(t[e+4>>2]&-8)-M|0,u=t[e+16+(((t[e+16>>2]|0)==0&1)<<2)>>2]|0,!u)S=e,s=r;else{do h=(t[u+4>>2]&-8)-M|0,S=h>>>0>>0,r=S?h:r,e=S?u:e,u=t[u+16+(((t[u+16>>2]|0)==0&1)<<2)>>2]|0;while((u|0)!=0);S=e,s=r}if(h=S+M|0,S>>>0>>0){l=t[S+24>>2]|0,n=t[S+12>>2]|0;do if((n|0)==(S|0)){if(e=S+20|0,n=t[e>>2]|0,!n&&(e=S+16|0,n=t[e>>2]|0,!n)){r=0;break}for(;;){if(r=n+20|0,u=t[r>>2]|0,u|0){n=u,e=r;continue}if(r=n+16|0,u=t[r>>2]|0,u)n=u,e=r;else break}t[e>>2]=0,r=n}else r=t[S+8>>2]|0,t[r+12>>2]=n,t[n+8>>2]=r,r=n;while(0);do if(l|0){if(n=t[S+28>>2]|0,e=11436+(n<<2)|0,(S|0)==(t[e>>2]|0)){if(t[e>>2]=r,!r){t[2784]=D&~(1<>2]|0)!=(S|0)&1)<<2)>>2]=r,!r)break;t[r+24>>2]=l,n=t[S+16>>2]|0,n|0&&(t[r+16>>2]=n,t[n+24>>2]=r),n=t[S+20>>2]|0,n|0&&(t[r+20>>2]=n,t[n+24>>2]=r)}while(0);return s>>>0<16?(Fe=s+M|0,t[S+4>>2]=Fe|3,Fe=S+Fe+4|0,t[Fe>>2]=t[Fe>>2]|1):(t[S+4>>2]=M|3,t[h+4>>2]=s|1,t[h+s>>2]=s,O|0&&(u=t[2788]|0,n=O>>>3,r=11172+(n<<1<<2)|0,n=1<>2]|0):(t[2783]=P|n,n=r,e=r+8|0),t[e>>2]=u,t[n+12>>2]=u,t[u+8>>2]=n,t[u+12>>2]=r),t[2785]=s,t[2788]=h),Fe=S+8|0,m=Qt,Fe|0}else P=M}else P=M}else P=M}else if(e>>>0<=4294967231)if(e=e+11|0,M=e&-8,S=t[2784]|0,S){u=0-M|0,e=e>>>8,e?M>>>0>16777215?D=31:(P=(e+1048320|0)>>>16&8,Re=e<>>16&4,Re=Re<>>16&2,D=14-(O|P|D)+(Re<>>15)|0,D=M>>>(D+7|0)&1|D<<1):D=0,r=t[11436+(D<<2)>>2]|0;e:do if(!r)r=0,e=0,Re=57;else for(e=0,h=M<<((D|0)==31?0:25-(D>>>1)|0),s=0;;){if(l=(t[r+4>>2]&-8)-M|0,l>>>0>>0)if(l)e=r,u=l;else{e=r,u=0,l=r,Re=61;break e}if(l=t[r+20>>2]|0,r=t[r+16+(h>>>31<<2)>>2]|0,s=(l|0)==0|(l|0)==(r|0)?s:l,l=(r|0)==0,l){r=s,Re=57;break}else h=h<<((l^1)&1)}while(0);if((Re|0)==57){if((r|0)==0&(e|0)==0){if(e=2<>>12&16,P=P>>>h,s=P>>>5&8,P=P>>>s,D=P>>>2&4,P=P>>>D,O=P>>>1&2,P=P>>>O,r=P>>>1&1,e=0,r=t[11436+((s|h|D|O|r)+(P>>>r)<<2)>>2]|0}r?(l=r,Re=61):(D=e,h=u)}if((Re|0)==61)for(;;)if(Re=0,r=(t[l+4>>2]&-8)-M|0,P=r>>>0>>0,r=P?r:u,e=P?l:e,l=t[l+16+(((t[l+16>>2]|0)==0&1)<<2)>>2]|0,l)u=r,Re=61;else{D=e,h=r;break}if((D|0)!=0?h>>>0<((t[2785]|0)-M|0)>>>0:0){if(s=D+M|0,D>>>0>=s>>>0)return Fe=0,m=Qt,Fe|0;l=t[D+24>>2]|0,n=t[D+12>>2]|0;do if((n|0)==(D|0)){if(e=D+20|0,n=t[e>>2]|0,!n&&(e=D+16|0,n=t[e>>2]|0,!n)){n=0;break}for(;;){if(r=n+20|0,u=t[r>>2]|0,u|0){n=u,e=r;continue}if(r=n+16|0,u=t[r>>2]|0,u)n=u,e=r;else break}t[e>>2]=0}else Fe=t[D+8>>2]|0,t[Fe+12>>2]=n,t[n+8>>2]=Fe;while(0);do if(l){if(e=t[D+28>>2]|0,r=11436+(e<<2)|0,(D|0)==(t[r>>2]|0)){if(t[r>>2]=n,!n){u=S&~(1<>2]|0)!=(D|0)&1)<<2)>>2]=n,!n){u=S;break}t[n+24>>2]=l,e=t[D+16>>2]|0,e|0&&(t[n+16>>2]=e,t[e+24>>2]=n),e=t[D+20>>2]|0,e&&(t[n+20>>2]=e,t[e+24>>2]=n),u=S}else u=S;while(0);do if(h>>>0>=16){if(t[D+4>>2]=M|3,t[s+4>>2]=h|1,t[s+h>>2]=h,n=h>>>3,h>>>0<256){r=11172+(n<<1<<2)|0,e=t[2783]|0,n=1<>2]|0):(t[2783]=e|n,n=r,e=r+8|0),t[e>>2]=s,t[n+12>>2]=s,t[s+8>>2]=n,t[s+12>>2]=r;break}if(n=h>>>8,n?h>>>0>16777215?n=31:(Re=(n+1048320|0)>>>16&8,Fe=n<>>16&4,Fe=Fe<>>16&2,n=14-(st|Re|n)+(Fe<>>15)|0,n=h>>>(n+7|0)&1|n<<1):n=0,r=11436+(n<<2)|0,t[s+28>>2]=n,e=s+16|0,t[e+4>>2]=0,t[e>>2]=0,e=1<>2]=s,t[s+24>>2]=r,t[s+12>>2]=s,t[s+8>>2]=s;break}for(e=h<<((n|0)==31?0:25-(n>>>1)|0),r=t[r>>2]|0;;){if((t[r+4>>2]&-8|0)==(h|0)){Re=97;break}if(u=r+16+(e>>>31<<2)|0,n=t[u>>2]|0,n)e=e<<1,r=n;else{Re=96;break}}if((Re|0)==96){t[u>>2]=s,t[s+24>>2]=r,t[s+12>>2]=s,t[s+8>>2]=s;break}else if((Re|0)==97){Re=r+8|0,Fe=t[Re>>2]|0,t[Fe+12>>2]=s,t[Re>>2]=s,t[s+8>>2]=Fe,t[s+12>>2]=r,t[s+24>>2]=0;break}}else Fe=h+M|0,t[D+4>>2]=Fe|3,Fe=D+Fe+4|0,t[Fe>>2]=t[Fe>>2]|1;while(0);return Fe=D+8|0,m=Qt,Fe|0}else P=M}else P=M;else P=-1;while(0);if(r=t[2785]|0,r>>>0>=P>>>0)return n=r-P|0,e=t[2788]|0,n>>>0>15?(Fe=e+P|0,t[2788]=Fe,t[2785]=n,t[Fe+4>>2]=n|1,t[Fe+n>>2]=n,t[e+4>>2]=P|3):(t[2785]=0,t[2788]=0,t[e+4>>2]=r|3,Fe=e+r+4|0,t[Fe>>2]=t[Fe>>2]|1),Fe=e+8|0,m=Qt,Fe|0;if(h=t[2786]|0,h>>>0>P>>>0)return st=h-P|0,t[2786]=st,Fe=t[2789]|0,Re=Fe+P|0,t[2789]=Re,t[Re+4>>2]=st|1,t[Fe+4>>2]=P|3,Fe=Fe+8|0,m=Qt,Fe|0;if(t[2901]|0?e=t[2903]|0:(t[2903]=4096,t[2902]=4096,t[2904]=-1,t[2905]=-1,t[2906]=0,t[2894]=0,e=K&-16^1431655768,t[K>>2]=e,t[2901]=e,e=4096),D=P+48|0,S=P+47|0,s=e+S|0,l=0-e|0,M=s&l,M>>>0<=P>>>0||(e=t[2893]|0,e|0?(O=t[2891]|0,K=O+M|0,K>>>0<=O>>>0|K>>>0>e>>>0):0))return Fe=0,m=Qt,Fe|0;e:do if(t[2894]&4)n=0,Re=133;else{r=t[2789]|0;t:do if(r){for(u=11580;e=t[u>>2]|0,!(e>>>0<=r>>>0?(ve=u+4|0,(e+(t[ve>>2]|0)|0)>>>0>r>>>0):0);)if(e=t[u+8>>2]|0,e)u=e;else{Re=118;break t}if(n=s-h&l,n>>>0<2147483647)if(e=Z2(n|0)|0,(e|0)==((t[u>>2]|0)+(t[ve>>2]|0)|0)){if((e|0)!=(-1|0)){h=n,s=e,Re=135;break e}}else u=e,Re=126;else n=0}else Re=118;while(0);do if((Re|0)==118)if(r=Z2(0)|0,(r|0)!=(-1|0)?(n=r,Pe=t[2902]|0,Ee=Pe+-1|0,n=((Ee&n|0)==0?0:(Ee+n&0-Pe)-n|0)+M|0,Pe=t[2891]|0,Ee=n+Pe|0,n>>>0>P>>>0&n>>>0<2147483647):0){if(ve=t[2893]|0,ve|0?Ee>>>0<=Pe>>>0|Ee>>>0>ve>>>0:0){n=0;break}if(e=Z2(n|0)|0,(e|0)==(r|0)){h=n,s=r,Re=135;break e}else u=e,Re=126}else n=0;while(0);do if((Re|0)==126){if(r=0-n|0,!(D>>>0>n>>>0&(n>>>0<2147483647&(u|0)!=(-1|0))))if((u|0)==(-1|0)){n=0;break}else{h=n,s=u,Re=135;break e}if(e=t[2903]|0,e=S-n+e&0-e,e>>>0>=2147483647){h=n,s=u,Re=135;break e}if((Z2(e|0)|0)==(-1|0)){Z2(r|0)|0,n=0;break}else{h=e+n|0,s=u,Re=135;break e}}while(0);t[2894]=t[2894]|4,Re=133}while(0);if((((Re|0)==133?M>>>0<2147483647:0)?(st=Z2(M|0)|0,ve=Z2(0)|0,Qe=ve-st|0,We=Qe>>>0>(P+40|0)>>>0,!((st|0)==(-1|0)|We^1|st>>>0>>0&((st|0)!=(-1|0)&(ve|0)!=(-1|0))^1)):0)&&(h=We?Qe:n,s=st,Re=135),(Re|0)==135){n=(t[2891]|0)+h|0,t[2891]=n,n>>>0>(t[2892]|0)>>>0&&(t[2892]=n),S=t[2789]|0;do if(S){for(n=11580;;){if(e=t[n>>2]|0,r=n+4|0,u=t[r>>2]|0,(s|0)==(e+u|0)){Re=145;break}if(l=t[n+8>>2]|0,l)n=l;else break}if(((Re|0)==145?(t[n+12>>2]&8|0)==0:0)?S>>>0>>0&S>>>0>=e>>>0:0){t[r>>2]=u+h,Fe=S+8|0,Fe=(Fe&7|0)==0?0:0-Fe&7,Re=S+Fe|0,Fe=(t[2786]|0)+(h-Fe)|0,t[2789]=Re,t[2786]=Fe,t[Re+4>>2]=Fe|1,t[Re+Fe+4>>2]=40,t[2790]=t[2905];break}for(s>>>0<(t[2787]|0)>>>0&&(t[2787]=s),r=s+h|0,n=11580;;){if((t[n>>2]|0)==(r|0)){Re=153;break}if(e=t[n+8>>2]|0,e)n=e;else break}if((Re|0)==153?(t[n+12>>2]&8|0)==0:0){t[n>>2]=s,O=n+4|0,t[O>>2]=(t[O>>2]|0)+h,O=s+8|0,O=s+((O&7|0)==0?0:0-O&7)|0,n=r+8|0,n=r+((n&7|0)==0?0:0-n&7)|0,M=O+P|0,D=n-O-P|0,t[O+4>>2]=P|3;do if((n|0)!=(S|0)){if((n|0)==(t[2788]|0)){Fe=(t[2785]|0)+D|0,t[2785]=Fe,t[2788]=M,t[M+4>>2]=Fe|1,t[M+Fe>>2]=Fe;break}if(e=t[n+4>>2]|0,(e&3|0)==1){h=e&-8,u=e>>>3;e:do if(e>>>0<256)if(e=t[n+8>>2]|0,r=t[n+12>>2]|0,(r|0)==(e|0)){t[2783]=t[2783]&~(1<>2]=r,t[r+8>>2]=e;break}else{s=t[n+24>>2]|0,e=t[n+12>>2]|0;do if((e|0)==(n|0)){if(u=n+16|0,r=u+4|0,e=t[r>>2]|0,!e)if(e=t[u>>2]|0,e)r=u;else{e=0;break}for(;;){if(u=e+20|0,l=t[u>>2]|0,l|0){e=l,r=u;continue}if(u=e+16|0,l=t[u>>2]|0,l)e=l,r=u;else break}t[r>>2]=0}else Fe=t[n+8>>2]|0,t[Fe+12>>2]=e,t[e+8>>2]=Fe;while(0);if(!s)break;r=t[n+28>>2]|0,u=11436+(r<<2)|0;do if((n|0)!=(t[u>>2]|0)){if(t[s+16+(((t[s+16>>2]|0)!=(n|0)&1)<<2)>>2]=e,!e)break e}else{if(t[u>>2]=e,e|0)break;t[2784]=t[2784]&~(1<>2]=s,r=n+16|0,u=t[r>>2]|0,u|0&&(t[e+16>>2]=u,t[u+24>>2]=e),r=t[r+4>>2]|0,!r)break;t[e+20>>2]=r,t[r+24>>2]=e}while(0);n=n+h|0,l=h+D|0}else l=D;if(n=n+4|0,t[n>>2]=t[n>>2]&-2,t[M+4>>2]=l|1,t[M+l>>2]=l,n=l>>>3,l>>>0<256){r=11172+(n<<1<<2)|0,e=t[2783]|0,n=1<>2]|0):(t[2783]=e|n,n=r,e=r+8|0),t[e>>2]=M,t[n+12>>2]=M,t[M+8>>2]=n,t[M+12>>2]=r;break}n=l>>>8;do if(!n)n=0;else{if(l>>>0>16777215){n=31;break}Re=(n+1048320|0)>>>16&8,Fe=n<>>16&4,Fe=Fe<>>16&2,n=14-(st|Re|n)+(Fe<>>15)|0,n=l>>>(n+7|0)&1|n<<1}while(0);if(u=11436+(n<<2)|0,t[M+28>>2]=n,e=M+16|0,t[e+4>>2]=0,t[e>>2]=0,e=t[2784]|0,r=1<>2]=M,t[M+24>>2]=u,t[M+12>>2]=M,t[M+8>>2]=M;break}for(e=l<<((n|0)==31?0:25-(n>>>1)|0),r=t[u>>2]|0;;){if((t[r+4>>2]&-8|0)==(l|0)){Re=194;break}if(u=r+16+(e>>>31<<2)|0,n=t[u>>2]|0,n)e=e<<1,r=n;else{Re=193;break}}if((Re|0)==193){t[u>>2]=M,t[M+24>>2]=r,t[M+12>>2]=M,t[M+8>>2]=M;break}else if((Re|0)==194){Re=r+8|0,Fe=t[Re>>2]|0,t[Fe+12>>2]=M,t[Re>>2]=M,t[M+8>>2]=Fe,t[M+12>>2]=r,t[M+24>>2]=0;break}}else Fe=(t[2786]|0)+D|0,t[2786]=Fe,t[2789]=M,t[M+4>>2]=Fe|1;while(0);return Fe=O+8|0,m=Qt,Fe|0}for(n=11580;e=t[n>>2]|0,!(e>>>0<=S>>>0?(Fe=e+(t[n+4>>2]|0)|0,Fe>>>0>S>>>0):0);)n=t[n+8>>2]|0;l=Fe+-47|0,e=l+8|0,e=l+((e&7|0)==0?0:0-e&7)|0,l=S+16|0,e=e>>>0>>0?S:e,n=e+8|0,r=s+8|0,r=(r&7|0)==0?0:0-r&7,Re=s+r|0,r=h+-40-r|0,t[2789]=Re,t[2786]=r,t[Re+4>>2]=r|1,t[Re+r+4>>2]=40,t[2790]=t[2905],r=e+4|0,t[r>>2]=27,t[n>>2]=t[2895],t[n+4>>2]=t[2896],t[n+8>>2]=t[2897],t[n+12>>2]=t[2898],t[2895]=s,t[2896]=h,t[2898]=0,t[2897]=n,n=e+24|0;do Re=n,n=n+4|0,t[n>>2]=7;while((Re+8|0)>>>0>>0);if((e|0)!=(S|0)){if(s=e-S|0,t[r>>2]=t[r>>2]&-2,t[S+4>>2]=s|1,t[e>>2]=s,n=s>>>3,s>>>0<256){r=11172+(n<<1<<2)|0,e=t[2783]|0,n=1<>2]|0):(t[2783]=e|n,n=r,e=r+8|0),t[e>>2]=S,t[n+12>>2]=S,t[S+8>>2]=n,t[S+12>>2]=r;break}if(n=s>>>8,n?s>>>0>16777215?r=31:(Re=(n+1048320|0)>>>16&8,Fe=n<>>16&4,Fe=Fe<>>16&2,r=14-(st|Re|r)+(Fe<>>15)|0,r=s>>>(r+7|0)&1|r<<1):r=0,u=11436+(r<<2)|0,t[S+28>>2]=r,t[S+20>>2]=0,t[l>>2]=0,n=t[2784]|0,e=1<>2]=S,t[S+24>>2]=u,t[S+12>>2]=S,t[S+8>>2]=S;break}for(e=s<<((r|0)==31?0:25-(r>>>1)|0),r=t[u>>2]|0;;){if((t[r+4>>2]&-8|0)==(s|0)){Re=216;break}if(u=r+16+(e>>>31<<2)|0,n=t[u>>2]|0,n)e=e<<1,r=n;else{Re=215;break}}if((Re|0)==215){t[u>>2]=S,t[S+24>>2]=r,t[S+12>>2]=S,t[S+8>>2]=S;break}else if((Re|0)==216){Re=r+8|0,Fe=t[Re>>2]|0,t[Fe+12>>2]=S,t[Re>>2]=S,t[S+8>>2]=Fe,t[S+12>>2]=r,t[S+24>>2]=0;break}}}else{Fe=t[2787]|0,(Fe|0)==0|s>>>0>>0&&(t[2787]=s),t[2895]=s,t[2896]=h,t[2898]=0,t[2792]=t[2901],t[2791]=-1,n=0;do Fe=11172+(n<<1<<2)|0,t[Fe+12>>2]=Fe,t[Fe+8>>2]=Fe,n=n+1|0;while((n|0)!=32);Fe=s+8|0,Fe=(Fe&7|0)==0?0:0-Fe&7,Re=s+Fe|0,Fe=h+-40-Fe|0,t[2789]=Re,t[2786]=Fe,t[Re+4>>2]=Fe|1,t[Re+Fe+4>>2]=40,t[2790]=t[2905]}while(0);if(n=t[2786]|0,n>>>0>P>>>0)return st=n-P|0,t[2786]=st,Fe=t[2789]|0,Re=Fe+P|0,t[2789]=Re,t[Re+4>>2]=st|1,t[Fe+4>>2]=P|3,Fe=Fe+8|0,m=Qt,Fe|0}return t[(Fv()|0)>>2]=12,Fe=0,m=Qt,Fe|0}function x_(e){e=e|0;var n=0,r=0,u=0,l=0,s=0,h=0,D=0,S=0;if(!!e){r=e+-8|0,l=t[2787]|0,e=t[e+-4>>2]|0,n=e&-8,S=r+n|0;do if(e&1)D=r,h=r;else{if(u=t[r>>2]|0,!(e&3)||(h=r+(0-u)|0,s=u+n|0,h>>>0>>0))return;if((h|0)==(t[2788]|0)){if(e=S+4|0,n=t[e>>2]|0,(n&3|0)!=3){D=h,n=s;break}t[2785]=s,t[e>>2]=n&-2,t[h+4>>2]=s|1,t[h+s>>2]=s;return}if(r=u>>>3,u>>>0<256)if(e=t[h+8>>2]|0,n=t[h+12>>2]|0,(n|0)==(e|0)){t[2783]=t[2783]&~(1<>2]=n,t[n+8>>2]=e,D=h,n=s;break}l=t[h+24>>2]|0,e=t[h+12>>2]|0;do if((e|0)==(h|0)){if(r=h+16|0,n=r+4|0,e=t[n>>2]|0,!e)if(e=t[r>>2]|0,e)n=r;else{e=0;break}for(;;){if(r=e+20|0,u=t[r>>2]|0,u|0){e=u,n=r;continue}if(r=e+16|0,u=t[r>>2]|0,u)e=u,n=r;else break}t[n>>2]=0}else D=t[h+8>>2]|0,t[D+12>>2]=e,t[e+8>>2]=D;while(0);if(l){if(n=t[h+28>>2]|0,r=11436+(n<<2)|0,(h|0)==(t[r>>2]|0)){if(t[r>>2]=e,!e){t[2784]=t[2784]&~(1<>2]|0)!=(h|0)&1)<<2)>>2]=e,!e){D=h,n=s;break}t[e+24>>2]=l,n=h+16|0,r=t[n>>2]|0,r|0&&(t[e+16>>2]=r,t[r+24>>2]=e),n=t[n+4>>2]|0,n?(t[e+20>>2]=n,t[n+24>>2]=e,D=h,n=s):(D=h,n=s)}else D=h,n=s}while(0);if(!(h>>>0>=S>>>0)&&(e=S+4|0,u=t[e>>2]|0,!!(u&1))){if(u&2)t[e>>2]=u&-2,t[D+4>>2]=n|1,t[h+n>>2]=n,l=n;else{if(e=t[2788]|0,(S|0)==(t[2789]|0)){if(S=(t[2786]|0)+n|0,t[2786]=S,t[2789]=D,t[D+4>>2]=S|1,(D|0)!=(e|0))return;t[2788]=0,t[2785]=0;return}if((S|0)==(e|0)){S=(t[2785]|0)+n|0,t[2785]=S,t[2788]=h,t[D+4>>2]=S|1,t[h+S>>2]=S;return}l=(u&-8)+n|0,r=u>>>3;do if(u>>>0<256)if(n=t[S+8>>2]|0,e=t[S+12>>2]|0,(e|0)==(n|0)){t[2783]=t[2783]&~(1<>2]=e,t[e+8>>2]=n;break}else{s=t[S+24>>2]|0,e=t[S+12>>2]|0;do if((e|0)==(S|0)){if(r=S+16|0,n=r+4|0,e=t[n>>2]|0,!e)if(e=t[r>>2]|0,e)n=r;else{r=0;break}for(;;){if(r=e+20|0,u=t[r>>2]|0,u|0){e=u,n=r;continue}if(r=e+16|0,u=t[r>>2]|0,u)e=u,n=r;else break}t[n>>2]=0,r=e}else r=t[S+8>>2]|0,t[r+12>>2]=e,t[e+8>>2]=r,r=e;while(0);if(s|0){if(e=t[S+28>>2]|0,n=11436+(e<<2)|0,(S|0)==(t[n>>2]|0)){if(t[n>>2]=r,!r){t[2784]=t[2784]&~(1<>2]|0)!=(S|0)&1)<<2)>>2]=r,!r)break;t[r+24>>2]=s,e=S+16|0,n=t[e>>2]|0,n|0&&(t[r+16>>2]=n,t[n+24>>2]=r),e=t[e+4>>2]|0,e|0&&(t[r+20>>2]=e,t[e+24>>2]=r)}}while(0);if(t[D+4>>2]=l|1,t[h+l>>2]=l,(D|0)==(t[2788]|0)){t[2785]=l;return}}if(e=l>>>3,l>>>0<256){r=11172+(e<<1<<2)|0,n=t[2783]|0,e=1<>2]|0):(t[2783]=n|e,e=r,n=r+8|0),t[n>>2]=D,t[e+12>>2]=D,t[D+8>>2]=e,t[D+12>>2]=r;return}e=l>>>8,e?l>>>0>16777215?e=31:(h=(e+1048320|0)>>>16&8,S=e<>>16&4,S=S<>>16&2,e=14-(s|h|e)+(S<>>15)|0,e=l>>>(e+7|0)&1|e<<1):e=0,u=11436+(e<<2)|0,t[D+28>>2]=e,t[D+20>>2]=0,t[D+16>>2]=0,n=t[2784]|0,r=1<>>1)|0),r=t[u>>2]|0;;){if((t[r+4>>2]&-8|0)==(l|0)){e=73;break}if(u=r+16+(n>>>31<<2)|0,e=t[u>>2]|0,e)n=n<<1,r=e;else{e=72;break}}if((e|0)==72){t[u>>2]=D,t[D+24>>2]=r,t[D+12>>2]=D,t[D+8>>2]=D;break}else if((e|0)==73){h=r+8|0,S=t[h>>2]|0,t[S+12>>2]=D,t[h>>2]=D,t[D+8>>2]=S,t[D+12>>2]=r,t[D+24>>2]=0;break}}else t[2784]=n|r,t[u>>2]=D,t[D+24>>2]=u,t[D+12>>2]=D,t[D+8>>2]=D;while(0);if(S=(t[2791]|0)+-1|0,t[2791]=S,!S)e=11588;else return;for(;e=t[e>>2]|0,e;)e=e+8|0;t[2791]=-1}}}function CI(){return 11628}function xI(e){e=e|0;var n=0,r=0;return n=m,m=m+16|0,r=n,t[r>>2]=OI(t[e+60>>2]|0)|0,e=A_(wu(6,r|0)|0)|0,m=n,e|0}function N8(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0;P=m,m=m+48|0,M=P+16|0,s=P,l=P+32|0,D=e+28|0,u=t[D>>2]|0,t[l>>2]=u,S=e+20|0,u=(t[S>>2]|0)-u|0,t[l+4>>2]=u,t[l+8>>2]=n,t[l+12>>2]=r,u=u+r|0,h=e+60|0,t[s>>2]=t[h>>2],t[s+4>>2]=l,t[s+8>>2]=2,s=A_(d0(146,s|0)|0)|0;e:do if((u|0)!=(s|0)){for(n=2;!((s|0)<0);)if(u=u-s|0,Pe=t[l+4>>2]|0,K=s>>>0>Pe>>>0,l=K?l+8|0:l,n=(K<<31>>31)+n|0,Pe=s-(K?Pe:0)|0,t[l>>2]=(t[l>>2]|0)+Pe,K=l+4|0,t[K>>2]=(t[K>>2]|0)-Pe,t[M>>2]=t[h>>2],t[M+4>>2]=l,t[M+8>>2]=n,s=A_(d0(146,M|0)|0)|0,(u|0)==(s|0)){O=3;break e}t[e+16>>2]=0,t[D>>2]=0,t[S>>2]=0,t[e>>2]=t[e>>2]|32,(n|0)==2?r=0:r=r-(t[l+4>>2]|0)|0}else O=3;while(0);return(O|0)==3&&(Pe=t[e+44>>2]|0,t[e+16>>2]=Pe+(t[e+48>>2]|0),t[D>>2]=Pe,t[S>>2]=Pe),m=P,r|0}function AI(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;return l=m,m=m+32|0,s=l,u=l+20|0,t[s>>2]=t[e+60>>2],t[s+4>>2]=0,t[s+8>>2]=n,t[s+12>>2]=u,t[s+16>>2]=r,(A_(Ti(140,s|0)|0)|0)<0?(t[u>>2]=-1,e=-1):e=t[u>>2]|0,m=l,e|0}function A_(e){return e=e|0,e>>>0>4294963200&&(t[(Fv()|0)>>2]=0-e,e=-1),e|0}function Fv(){return(RI()|0)+64|0}function RI(){return VE()|0}function VE(){return 2084}function OI(e){return e=e|0,e|0}function kI(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;return l=m,m=m+32|0,u=l,t[e+36>>2]=1,((t[e>>2]&64|0)==0?(t[u>>2]=t[e+60>>2],t[u+4>>2]=21523,t[u+8>>2]=l+16,b0(54,u|0)|0):0)&&(p[e+75>>0]=-1),u=N8(e,n,r)|0,m=l,u|0}function L8(e,n){e=e|0,n=n|0;var r=0,u=0;if(r=p[e>>0]|0,u=p[n>>0]|0,r<<24>>24==0?1:r<<24>>24!=u<<24>>24)e=u;else{do e=e+1|0,n=n+1|0,r=p[e>>0]|0,u=p[n>>0]|0;while(!(r<<24>>24==0?1:r<<24>>24!=u<<24>>24));e=u}return(r&255)-(e&255)|0}function MI(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0;e:do if(!r)e=0;else{for(;u=p[e>>0]|0,l=p[n>>0]|0,u<<24>>24==l<<24>>24;)if(r=r+-1|0,r)e=e+1|0,n=n+1|0;else{e=0;break e}e=(u&255)-(l&255)|0}while(0);return e|0}function F8(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0;ve=m,m=m+224|0,O=ve+120|0,P=ve+80|0,Pe=ve,Ee=ve+136|0,u=P,l=u+40|0;do t[u>>2]=0,u=u+4|0;while((u|0)<(l|0));return t[O>>2]=t[r>>2],(GE(0,n,O,Pe,P)|0)<0?r=-1:((t[e+76>>2]|0)>-1?K=NI(e)|0:K=0,r=t[e>>2]|0,M=r&32,(p[e+74>>0]|0)<1&&(t[e>>2]=r&-33),u=e+48|0,t[u>>2]|0?r=GE(e,n,O,Pe,P)|0:(l=e+44|0,s=t[l>>2]|0,t[l>>2]=Ee,h=e+28|0,t[h>>2]=Ee,D=e+20|0,t[D>>2]=Ee,t[u>>2]=80,S=e+16|0,t[S>>2]=Ee+80,r=GE(e,n,O,Pe,P)|0,s&&(M_[t[e+36>>2]&7](e,0,0)|0,r=(t[D>>2]|0)==0?-1:r,t[l>>2]=s,t[u>>2]=0,t[S>>2]=0,t[h>>2]=0,t[D>>2]=0)),u=t[e>>2]|0,t[e>>2]=u|M,K|0&&LI(e),r=(u&32|0)==0?r:-1),m=ve,r|0}function GE(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0,Qe=0,We=0,st=0,Re=0,Fe=0,Qt=0,Lr=0,Nn=0,mn=0,hr=0,kr=0,On=0;On=m,m=m+64|0,Nn=On+16|0,mn=On,Qt=On+24|0,hr=On+8|0,kr=On+20|0,t[Nn>>2]=n,st=(e|0)!=0,Re=Qt+40|0,Fe=Re,Qt=Qt+39|0,Lr=hr+4|0,h=0,s=0,O=0;e:for(;;){do if((s|0)>-1)if((h|0)>(2147483647-s|0)){t[(Fv()|0)>>2]=75,s=-1;break}else{s=h+s|0;break}while(0);if(h=p[n>>0]|0,h<<24>>24)D=n;else{We=87;break}t:for(;;){switch(h<<24>>24){case 37:{h=D,We=9;break t}case 0:{h=D;break t}default:}Qe=D+1|0,t[Nn>>2]=Qe,h=p[Qe>>0]|0,D=Qe}t:do if((We|0)==9)for(;;){if(We=0,(p[D+1>>0]|0)!=37)break t;if(h=h+1|0,D=D+2|0,t[Nn>>2]=D,(p[D>>0]|0)==37)We=9;else break}while(0);if(h=h-n|0,st&&qo(e,n,h),h|0){n=D;continue}S=D+1|0,h=(p[S>>0]|0)+-48|0,h>>>0<10?(Qe=(p[D+2>>0]|0)==36,ve=Qe?h:-1,O=Qe?1:O,S=Qe?D+3|0:S):ve=-1,t[Nn>>2]=S,h=p[S>>0]|0,D=(h<<24>>24)+-32|0;t:do if(D>>>0<32)for(M=0,P=h;;){if(h=1<>2]=S,h=p[S>>0]|0,D=(h<<24>>24)+-32|0,D>>>0>=32)break;P=h}else M=0;while(0);if(h<<24>>24==42){if(D=S+1|0,h=(p[D>>0]|0)+-48|0,h>>>0<10?(p[S+2>>0]|0)==36:0)t[l+(h<<2)>>2]=10,h=t[u+((p[D>>0]|0)+-48<<3)>>2]|0,O=1,S=S+3|0;else{if(O|0){s=-1;break}st?(O=(t[r>>2]|0)+(4-1)&~(4-1),h=t[O>>2]|0,t[r>>2]=O+4,O=0,S=D):(h=0,O=0,S=D)}t[Nn>>2]=S,Qe=(h|0)<0,h=Qe?0-h|0:h,M=Qe?M|8192:M}else{if(h=b8(Nn)|0,(h|0)<0){s=-1;break}S=t[Nn>>2]|0}do if((p[S>>0]|0)==46){if((p[S+1>>0]|0)!=42){t[Nn>>2]=S+1,D=b8(Nn)|0,S=t[Nn>>2]|0;break}if(P=S+2|0,D=(p[P>>0]|0)+-48|0,D>>>0<10?(p[S+3>>0]|0)==36:0){t[l+(D<<2)>>2]=10,D=t[u+((p[P>>0]|0)+-48<<3)>>2]|0,S=S+4|0,t[Nn>>2]=S;break}if(O|0){s=-1;break e}st?(Qe=(t[r>>2]|0)+(4-1)&~(4-1),D=t[Qe>>2]|0,t[r>>2]=Qe+4):D=0,t[Nn>>2]=P,S=P}else D=-1;while(0);for(Ee=0;;){if(((p[S>>0]|0)+-65|0)>>>0>57){s=-1;break e}if(Qe=S+1|0,t[Nn>>2]=Qe,P=p[(p[S>>0]|0)+-65+(5178+(Ee*58|0))>>0]|0,K=P&255,(K+-1|0)>>>0<8)Ee=K,S=Qe;else break}if(!(P<<24>>24)){s=-1;break}Pe=(ve|0)>-1;do if(P<<24>>24==19)if(Pe){s=-1;break e}else We=49;else{if(Pe){t[l+(ve<<2)>>2]=K,Pe=u+(ve<<3)|0,ve=t[Pe+4>>2]|0,We=mn,t[We>>2]=t[Pe>>2],t[We+4>>2]=ve,We=49;break}if(!st){s=0;break e}P8(mn,K,r)}while(0);if((We|0)==49?(We=0,!st):0){h=0,n=Qe;continue}S=p[S>>0]|0,S=(Ee|0)!=0&(S&15|0)==3?S&-33:S,Pe=M&-65537,ve=(M&8192|0)==0?M:Pe;t:do switch(S|0){case 110:switch((Ee&255)<<24>>24){case 0:{t[t[mn>>2]>>2]=s,h=0,n=Qe;continue e}case 1:{t[t[mn>>2]>>2]=s,h=0,n=Qe;continue e}case 2:{h=t[mn>>2]|0,t[h>>2]=s,t[h+4>>2]=((s|0)<0)<<31>>31,h=0,n=Qe;continue e}case 3:{E[t[mn>>2]>>1]=s,h=0,n=Qe;continue e}case 4:{p[t[mn>>2]>>0]=s,h=0,n=Qe;continue e}case 6:{t[t[mn>>2]>>2]=s,h=0,n=Qe;continue e}case 7:{h=t[mn>>2]|0,t[h>>2]=s,t[h+4>>2]=((s|0)<0)<<31>>31,h=0,n=Qe;continue e}default:{h=0,n=Qe;continue e}}case 112:{S=120,D=D>>>0>8?D:8,n=ve|8,We=61;break}case 88:case 120:{n=ve,We=61;break}case 111:{S=mn,n=t[S>>2]|0,S=t[S+4>>2]|0,K=bI(n,S,Re)|0,Pe=Fe-K|0,M=0,P=5642,D=(ve&8|0)==0|(D|0)>(Pe|0)?D:Pe+1|0,Pe=ve,We=67;break}case 105:case 100:if(S=mn,n=t[S>>2]|0,S=t[S+4>>2]|0,(S|0)<0){n=R_(0,0,n|0,S|0)|0,S=ft,M=mn,t[M>>2]=n,t[M+4>>2]=S,M=1,P=5642,We=66;break t}else{M=(ve&2049|0)!=0&1,P=(ve&2048|0)==0?(ve&1|0)==0?5642:5644:5643,We=66;break t}case 117:{S=mn,M=0,P=5642,n=t[S>>2]|0,S=t[S+4>>2]|0,We=66;break}case 99:{p[Qt>>0]=t[mn>>2],n=Qt,M=0,P=5642,K=Re,S=1,D=Pe;break}case 109:{S=PI(t[(Fv()|0)>>2]|0)|0,We=71;break}case 115:{S=t[mn>>2]|0,S=S|0?S:5652,We=71;break}case 67:{t[hr>>2]=t[mn>>2],t[Lr>>2]=0,t[mn>>2]=hr,K=-1,S=hr,We=75;break}case 83:{n=t[mn>>2]|0,D?(K=D,S=n,We=75):(hl(e,32,h,0,ve),n=0,We=84);break}case 65:case 71:case 70:case 69:case 97:case 103:case 102:case 101:{h=BI(e,+U[mn>>3],h,D,ve,S)|0,n=Qe;continue e}default:M=0,P=5642,K=Re,S=D,D=ve}while(0);t:do if((We|0)==61)ve=mn,Ee=t[ve>>2]|0,ve=t[ve+4>>2]|0,K=FI(Ee,ve,Re,S&32)|0,P=(n&8|0)==0|(Ee|0)==0&(ve|0)==0,M=P?0:2,P=P?5642:5642+(S>>4)|0,Pe=n,n=Ee,S=ve,We=67;else if((We|0)==66)K=bv(n,S,Re)|0,Pe=ve,We=67;else if((We|0)==71)We=0,ve=II(S,0,D)|0,Ee=(ve|0)==0,n=S,M=0,P=5642,K=Ee?S+D|0:ve,S=Ee?D:ve-S|0,D=Pe;else if((We|0)==75){for(We=0,P=S,n=0,D=0;M=t[P>>2]|0,!(!M||(D=I8(kr,M)|0,(D|0)<0|D>>>0>(K-n|0)>>>0));)if(n=D+n|0,K>>>0>n>>>0)P=P+4|0;else break;if((D|0)<0){s=-1;break e}if(hl(e,32,h,n,ve),!n)n=0,We=84;else for(M=0;;){if(D=t[S>>2]|0,!D){We=84;break t}if(D=I8(kr,D)|0,M=D+M|0,(M|0)>(n|0)){We=84;break t}if(qo(e,kr,D),M>>>0>=n>>>0){We=84;break}else S=S+4|0}}while(0);if((We|0)==67)We=0,S=(n|0)!=0|(S|0)!=0,ve=(D|0)!=0|S,S=((S^1)&1)+(Fe-K)|0,n=ve?K:Re,K=Re,S=ve?(D|0)>(S|0)?D:S:D,D=(D|0)>-1?Pe&-65537:Pe;else if((We|0)==84){We=0,hl(e,32,h,n,ve^8192),h=(h|0)>(n|0)?h:n,n=Qe;continue}Ee=K-n|0,Pe=(S|0)<(Ee|0)?Ee:S,ve=Pe+M|0,h=(h|0)<(ve|0)?ve:h,hl(e,32,h,ve,D),qo(e,P,M),hl(e,48,h,ve,D^65536),hl(e,48,Pe,Ee,0),qo(e,n,Ee),hl(e,32,h,ve,D^8192),n=Qe}e:do if((We|0)==87&&!e)if(!O)s=0;else{for(s=1;n=t[l+(s<<2)>>2]|0,!!n;)if(P8(u+(s<<3)|0,n,r),s=s+1|0,(s|0)>=10){s=1;break e}for(;;){if(t[l+(s<<2)>>2]|0){s=-1;break e}if(s=s+1|0,(s|0)>=10){s=1;break}}}while(0);return m=On,s|0}function NI(e){return e=e|0,0}function LI(e){e=e|0}function qo(e,n,r){e=e|0,n=n|0,r=r|0,t[e>>2]&32||YI(n,r,e)|0}function b8(e){e=e|0;var n=0,r=0,u=0;if(r=t[e>>2]|0,u=(p[r>>0]|0)+-48|0,u>>>0<10){n=0;do n=u+(n*10|0)|0,r=r+1|0,t[e>>2]=r,u=(p[r>>0]|0)+-48|0;while(u>>>0<10)}else n=0;return n|0}function P8(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;e:do if(n>>>0<=20)do switch(n|0){case 9:{u=(t[r>>2]|0)+(4-1)&~(4-1),n=t[u>>2]|0,t[r>>2]=u+4,t[e>>2]=n;break e}case 10:{u=(t[r>>2]|0)+(4-1)&~(4-1),n=t[u>>2]|0,t[r>>2]=u+4,u=e,t[u>>2]=n,t[u+4>>2]=((n|0)<0)<<31>>31;break e}case 11:{u=(t[r>>2]|0)+(4-1)&~(4-1),n=t[u>>2]|0,t[r>>2]=u+4,u=e,t[u>>2]=n,t[u+4>>2]=0;break e}case 12:{u=(t[r>>2]|0)+(8-1)&~(8-1),n=u,l=t[n>>2]|0,n=t[n+4>>2]|0,t[r>>2]=u+8,u=e,t[u>>2]=l,t[u+4>>2]=n;break e}case 13:{l=(t[r>>2]|0)+(4-1)&~(4-1),u=t[l>>2]|0,t[r>>2]=l+4,u=(u&65535)<<16>>16,l=e,t[l>>2]=u,t[l+4>>2]=((u|0)<0)<<31>>31;break e}case 14:{l=(t[r>>2]|0)+(4-1)&~(4-1),u=t[l>>2]|0,t[r>>2]=l+4,l=e,t[l>>2]=u&65535,t[l+4>>2]=0;break e}case 15:{l=(t[r>>2]|0)+(4-1)&~(4-1),u=t[l>>2]|0,t[r>>2]=l+4,u=(u&255)<<24>>24,l=e,t[l>>2]=u,t[l+4>>2]=((u|0)<0)<<31>>31;break e}case 16:{l=(t[r>>2]|0)+(4-1)&~(4-1),u=t[l>>2]|0,t[r>>2]=l+4,l=e,t[l>>2]=u&255,t[l+4>>2]=0;break e}case 17:{l=(t[r>>2]|0)+(8-1)&~(8-1),s=+U[l>>3],t[r>>2]=l+8,U[e>>3]=s;break e}case 18:{l=(t[r>>2]|0)+(8-1)&~(8-1),s=+U[l>>3],t[r>>2]=l+8,U[e>>3]=s;break e}default:break e}while(0);while(0)}function FI(e,n,r,u){if(e=e|0,n=n|0,r=r|0,u=u|0,!((e|0)==0&(n|0)==0))do r=r+-1|0,p[r>>0]=k[5694+(e&15)>>0]|0|u,e=O_(e|0,n|0,4)|0,n=ft;while(!((e|0)==0&(n|0)==0));return r|0}function bI(e,n,r){if(e=e|0,n=n|0,r=r|0,!((e|0)==0&(n|0)==0))do r=r+-1|0,p[r>>0]=e&7|48,e=O_(e|0,n|0,3)|0,n=ft;while(!((e|0)==0&(n|0)==0));return r|0}function bv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;if(n>>>0>0|(n|0)==0&e>>>0>4294967295){for(;u=QE(e|0,n|0,10,0)|0,r=r+-1|0,p[r>>0]=u&255|48,u=e,e=XE(e|0,n|0,10,0)|0,n>>>0>9|(n|0)==9&u>>>0>4294967295;)n=ft;n=e}else n=e;if(n)for(;r=r+-1|0,p[r>>0]=(n>>>0)%10|0|48,!(n>>>0<10);)n=(n>>>0)/10|0;return r|0}function PI(e){return e=e|0,HI(e,t[(qI()|0)+188>>2]|0)|0}function II(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;s=n&255,u=(r|0)!=0;e:do if(u&(e&3|0)!=0)for(l=n&255;;){if((p[e>>0]|0)==l<<24>>24){h=6;break e}if(e=e+1|0,r=r+-1|0,u=(r|0)!=0,!(u&(e&3|0)!=0)){h=5;break}}else h=5;while(0);(h|0)==5&&(u?h=6:r=0);e:do if((h|0)==6&&(l=n&255,(p[e>>0]|0)!=l<<24>>24)){u=nr(s,16843009)|0;t:do if(r>>>0>3){for(;s=t[e>>2]^u,!((s&-2139062144^-2139062144)&s+-16843009|0);)if(e=e+4|0,r=r+-4|0,r>>>0<=3){h=11;break t}}else h=11;while(0);if((h|0)==11&&!r){r=0;break}for(;;){if((p[e>>0]|0)==l<<24>>24)break e;if(e=e+1|0,r=r+-1|0,!r){r=0;break}}}while(0);return(r|0?e:0)|0}function hl(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0;if(h=m,m=m+256|0,s=h,(r|0)>(u|0)&(l&73728|0)==0){if(l=r-u|0,Iv(s|0,n|0,(l>>>0<256?l:256)|0)|0,l>>>0>255){n=r-u|0;do qo(e,s,256),l=l+-256|0;while(l>>>0>255);l=n&255}qo(e,s,l)}m=h}function I8(e,n){return e=e|0,n=n|0,e?e=jI(e,n,0)|0:e=0,e|0}function BI(e,n,r,u,l,s){e=e|0,n=+n,r=r|0,u=u|0,l=l|0,s=s|0;var h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0,ve=0,Qe=0,We=0,st=0,Re=0,Fe=0,Qt=0,Lr=0,Nn=0,mn=0,hr=0,kr=0,On=0,Zi=0;Zi=m,m=m+560|0,S=Zi+8|0,Qe=Zi,On=Zi+524|0,kr=On,M=Zi+512|0,t[Qe>>2]=0,hr=M+12|0,B8(n)|0,(ft|0)<0?(n=-n,Nn=1,Lr=5659):(Nn=(l&2049|0)!=0&1,Lr=(l&2048|0)==0?(l&1|0)==0?5660:5665:5662),B8(n)|0,mn=ft&2146435072;do if(mn>>>0<2146435072|(mn|0)==2146435072&0<0){if(Pe=+UI(n,Qe)*2,h=Pe!=0,h&&(t[Qe>>2]=(t[Qe>>2]|0)+-1),st=s|32,(st|0)==97){Ee=s&32,K=(Ee|0)==0?Lr:Lr+9|0,P=Nn|2,h=12-u|0;do if(u>>>0>11|(h|0)==0)n=Pe;else{n=8;do h=h+-1|0,n=n*16;while((h|0)!=0);if((p[K>>0]|0)==45){n=-(n+(-Pe-n));break}else{n=Pe+n-n;break}}while(0);D=t[Qe>>2]|0,h=(D|0)<0?0-D|0:D,h=bv(h,((h|0)<0)<<31>>31,hr)|0,(h|0)==(hr|0)&&(h=M+11|0,p[h>>0]=48),p[h+-1>>0]=(D>>31&2)+43,O=h+-2|0,p[O>>0]=s+15,M=(u|0)<1,S=(l&8|0)==0,h=On;do mn=~~n,D=h+1|0,p[h>>0]=k[5694+mn>>0]|Ee,n=(n-+(mn|0))*16,((D-kr|0)==1?!(S&(M&n==0)):0)?(p[D>>0]=46,h=h+2|0):h=D;while(n!=0);mn=h-kr|0,kr=hr-O|0,hr=(u|0)!=0&(mn+-2|0)<(u|0)?u+2|0:mn,h=kr+P+hr|0,hl(e,32,r,h,l),qo(e,K,P),hl(e,48,r,h,l^65536),qo(e,On,mn),hl(e,48,hr-mn|0,0,0),qo(e,O,kr),hl(e,32,r,h,l^8192);break}D=(u|0)<0?6:u,h?(h=(t[Qe>>2]|0)+-28|0,t[Qe>>2]=h,n=Pe*268435456):(n=Pe,h=t[Qe>>2]|0),mn=(h|0)<0?S:S+288|0,S=mn;do Fe=~~n>>>0,t[S>>2]=Fe,S=S+4|0,n=(n-+(Fe>>>0))*1e9;while(n!=0);if((h|0)>0)for(M=mn,P=S;;){if(O=(h|0)<29?h:29,h=P+-4|0,h>>>0>=M>>>0){S=0;do Re=W8(t[h>>2]|0,0,O|0)|0,Re=KE(Re|0,ft|0,S|0,0)|0,Fe=ft,We=QE(Re|0,Fe|0,1e9,0)|0,t[h>>2]=We,S=XE(Re|0,Fe|0,1e9,0)|0,h=h+-4|0;while(h>>>0>=M>>>0);S&&(M=M+-4|0,t[M>>2]=S)}for(S=P;!(S>>>0<=M>>>0);)if(h=S+-4|0,!(t[h>>2]|0))S=h;else break;if(h=(t[Qe>>2]|0)-O|0,t[Qe>>2]=h,(h|0)>0)P=S;else break}else M=mn;if((h|0)<0){u=((D+25|0)/9|0)+1|0,ve=(st|0)==102;do{if(Ee=0-h|0,Ee=(Ee|0)<9?Ee:9,M>>>0>>0){O=(1<>>Ee,K=0,h=M;do Fe=t[h>>2]|0,t[h>>2]=(Fe>>>Ee)+K,K=nr(Fe&O,P)|0,h=h+4|0;while(h>>>0>>0);h=(t[M>>2]|0)==0?M+4|0:M,K?(t[S>>2]=K,M=h,h=S+4|0):(M=h,h=S)}else M=(t[M>>2]|0)==0?M+4|0:M,h=S;S=ve?mn:M,S=(h-S>>2|0)>(u|0)?S+(u<<2)|0:h,h=(t[Qe>>2]|0)+Ee|0,t[Qe>>2]=h}while((h|0)<0);h=M,u=S}else h=M,u=S;if(Fe=mn,h>>>0>>0){if(S=(Fe-h>>2)*9|0,O=t[h>>2]|0,O>>>0>=10){M=10;do M=M*10|0,S=S+1|0;while(O>>>0>=M>>>0)}}else S=0;if(ve=(st|0)==103,We=(D|0)!=0,M=D-((st|0)!=102?S:0)+((We&ve)<<31>>31)|0,(M|0)<(((u-Fe>>2)*9|0)+-9|0)){if(M=M+9216|0,Ee=mn+4+(((M|0)/9|0)+-1024<<2)|0,M=((M|0)%9|0)+1|0,(M|0)<9){O=10;do O=O*10|0,M=M+1|0;while((M|0)!=9)}else O=10;if(P=t[Ee>>2]|0,K=(P>>>0)%(O>>>0)|0,M=(Ee+4|0)==(u|0),M&(K|0)==0)M=Ee;else if(Pe=(((P>>>0)/(O>>>0)|0)&1|0)==0?9007199254740992:9007199254740994,Re=(O|0)/2|0,n=K>>>0>>0?.5:M&(K|0)==(Re|0)?1:1.5,Nn&&(Re=(p[Lr>>0]|0)==45,n=Re?-n:n,Pe=Re?-Pe:Pe),M=P-K|0,t[Ee>>2]=M,Pe+n!=Pe){if(Re=M+O|0,t[Ee>>2]=Re,Re>>>0>999999999)for(S=Ee;M=S+-4|0,t[S>>2]=0,M>>>0>>0&&(h=h+-4|0,t[h>>2]=0),Re=(t[M>>2]|0)+1|0,t[M>>2]=Re,Re>>>0>999999999;)S=M;else M=Ee;if(S=(Fe-h>>2)*9|0,P=t[h>>2]|0,P>>>0>=10){O=10;do O=O*10|0,S=S+1|0;while(P>>>0>=O>>>0)}}else M=Ee;M=M+4|0,M=u>>>0>M>>>0?M:u,Re=h}else M=u,Re=h;for(st=M;;){if(st>>>0<=Re>>>0){Qe=0;break}if(h=st+-4|0,!(t[h>>2]|0))st=h;else{Qe=1;break}}u=0-S|0;do if(ve)if(h=((We^1)&1)+D|0,(h|0)>(S|0)&(S|0)>-5?(O=s+-1|0,D=h+-1-S|0):(O=s+-2|0,D=h+-1|0),h=l&8,h)Ee=h;else{if(Qe?(Qt=t[st+-4>>2]|0,(Qt|0)!=0):0)if((Qt>>>0)%10|0)M=0;else{M=0,h=10;do h=h*10|0,M=M+1|0;while(!((Qt>>>0)%(h>>>0)|0|0))}else M=9;if(h=((st-Fe>>2)*9|0)+-9|0,(O|32|0)==102){Ee=h-M|0,Ee=(Ee|0)>0?Ee:0,D=(D|0)<(Ee|0)?D:Ee,Ee=0;break}else{Ee=h+S-M|0,Ee=(Ee|0)>0?Ee:0,D=(D|0)<(Ee|0)?D:Ee,Ee=0;break}}else O=s,Ee=l&8;while(0);if(ve=D|Ee,P=(ve|0)!=0&1,K=(O|32|0)==102,K)We=0,h=(S|0)>0?S:0;else{if(h=(S|0)<0?u:S,h=bv(h,((h|0)<0)<<31>>31,hr)|0,M=hr,(M-h|0)<2)do h=h+-1|0,p[h>>0]=48;while((M-h|0)<2);p[h+-1>>0]=(S>>31&2)+43,h=h+-2|0,p[h>>0]=O,We=h,h=M-h|0}if(h=Nn+1+D+P+h|0,hl(e,32,r,h,l),qo(e,Lr,Nn),hl(e,48,r,h,l^65536),K){O=Re>>>0>mn>>>0?mn:Re,Ee=On+9|0,P=Ee,K=On+8|0,M=O;do{if(S=bv(t[M>>2]|0,0,Ee)|0,(M|0)==(O|0))(S|0)==(Ee|0)&&(p[K>>0]=48,S=K);else if(S>>>0>On>>>0){Iv(On|0,48,S-kr|0)|0;do S=S+-1|0;while(S>>>0>On>>>0)}qo(e,S,P-S|0),M=M+4|0}while(M>>>0<=mn>>>0);if(ve|0&&qo(e,5710,1),M>>>0>>0&(D|0)>0)for(;;){if(S=bv(t[M>>2]|0,0,Ee)|0,S>>>0>On>>>0){Iv(On|0,48,S-kr|0)|0;do S=S+-1|0;while(S>>>0>On>>>0)}if(qo(e,S,(D|0)<9?D:9),M=M+4|0,S=D+-9|0,M>>>0>>0&(D|0)>9)D=S;else{D=S;break}}hl(e,48,D+9|0,9,0)}else{if(ve=Qe?st:Re+4|0,(D|0)>-1){Qe=On+9|0,Ee=(Ee|0)==0,u=Qe,P=0-kr|0,K=On+8|0,O=Re;do{S=bv(t[O>>2]|0,0,Qe)|0,(S|0)==(Qe|0)&&(p[K>>0]=48,S=K);do if((O|0)==(Re|0)){if(M=S+1|0,qo(e,S,1),Ee&(D|0)<1){S=M;break}qo(e,5710,1),S=M}else{if(S>>>0<=On>>>0)break;Iv(On|0,48,S+P|0)|0;do S=S+-1|0;while(S>>>0>On>>>0)}while(0);kr=u-S|0,qo(e,S,(D|0)>(kr|0)?kr:D),D=D-kr|0,O=O+4|0}while(O>>>0>>0&(D|0)>-1)}hl(e,48,D+18|0,18,0),qo(e,We,hr-We|0)}hl(e,32,r,h,l^8192)}else On=(s&32|0)!=0,h=Nn+3|0,hl(e,32,r,h,l&-65537),qo(e,Lr,Nn),qo(e,n!=n|!1?On?5686:5690:On?5678:5682,3),hl(e,32,r,h,l^8192);while(0);return m=Zi,((h|0)<(r|0)?r:h)|0}function B8(e){e=+e;var n=0;return U[W>>3]=e,n=t[W>>2]|0,ft=t[W+4>>2]|0,n|0}function UI(e,n){return e=+e,n=n|0,+ +U8(e,n)}function U8(e,n){e=+e,n=n|0;var r=0,u=0,l=0;switch(U[W>>3]=e,r=t[W>>2]|0,u=t[W+4>>2]|0,l=O_(r|0,u|0,52)|0,l&2047){case 0:{e!=0?(e=+U8(e*18446744073709552e3,n),r=(t[n>>2]|0)+-64|0):r=0,t[n>>2]=r;break}case 2047:break;default:t[n>>2]=(l&2047)+-1022,t[W>>2]=r,t[W+4>>2]=u&-2146435073|1071644672,e=+U[W>>3]}return+e}function jI(e,n,r){e=e|0,n=n|0,r=r|0;do if(e){if(n>>>0<128){p[e>>0]=n,e=1;break}if(!(t[t[(zI()|0)+188>>2]>>2]|0))if((n&-128|0)==57216){p[e>>0]=n,e=1;break}else{t[(Fv()|0)>>2]=84,e=-1;break}if(n>>>0<2048){p[e>>0]=n>>>6|192,p[e+1>>0]=n&63|128,e=2;break}if(n>>>0<55296|(n&-8192|0)==57344){p[e>>0]=n>>>12|224,p[e+1>>0]=n>>>6&63|128,p[e+2>>0]=n&63|128,e=3;break}if((n+-65536|0)>>>0<1048576){p[e>>0]=n>>>18|240,p[e+1>>0]=n>>>12&63|128,p[e+2>>0]=n>>>6&63|128,p[e+3>>0]=n&63|128,e=4;break}else{t[(Fv()|0)>>2]=84,e=-1;break}}else e=1;while(0);return e|0}function zI(){return VE()|0}function qI(){return VE()|0}function HI(e,n){e=e|0,n=n|0;var r=0,u=0;for(u=0;;){if((k[5712+u>>0]|0)==(e|0)){e=2;break}if(r=u+1|0,(r|0)==87){r=5800,u=87,e=5;break}else u=r}if((e|0)==2&&(u?(r=5800,e=5):r=5800),(e|0)==5)for(;;){do e=r,r=r+1|0;while((p[e>>0]|0)!=0);if(u=u+-1|0,u)e=5;else break}return WI(r,t[n+20>>2]|0)|0}function WI(e,n){return e=e|0,n=n|0,VI(e,n)|0}function VI(e,n){return e=e|0,n=n|0,n?n=GI(t[n>>2]|0,t[n+4>>2]|0,e)|0:n=0,(n|0?n:e)|0}function GI(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0;K=(t[e>>2]|0)+1794895138|0,s=Gp(t[e+8>>2]|0,K)|0,u=Gp(t[e+12>>2]|0,K)|0,l=Gp(t[e+16>>2]|0,K)|0;e:do if((s>>>0>>2>>>0?(P=n-(s<<2)|0,u>>>0

>>0&l>>>0

>>0):0)?((l|u)&3|0)==0:0){for(P=u>>>2,O=l>>>2,M=0;;){if(D=s>>>1,S=M+D|0,h=S<<1,l=h+P|0,u=Gp(t[e+(l<<2)>>2]|0,K)|0,l=Gp(t[e+(l+1<<2)>>2]|0,K)|0,!(l>>>0>>0&u>>>0<(n-l|0)>>>0)){u=0;break e}if(p[e+(l+u)>>0]|0){u=0;break e}if(u=L8(r,e+l|0)|0,!u)break;if(u=(u|0)<0,(s|0)==1){u=0;break e}else M=u?M:S,s=u?D:s-D|0}u=h+O|0,l=Gp(t[e+(u<<2)>>2]|0,K)|0,u=Gp(t[e+(u+1<<2)>>2]|0,K)|0,u>>>0>>0&l>>>0<(n-u|0)>>>0?u=(p[e+(u+l)>>0]|0)==0?e+u|0:0:u=0}else u=0;while(0);return u|0}function Gp(e,n){e=e|0,n=n|0;var r=0;return r=Y8(e|0)|0,((n|0)==0?e:r)|0}function YI(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0,D=0;u=r+16|0,l=t[u>>2]|0,l?s=5:KI(r)|0?u=0:(l=t[u>>2]|0,s=5);e:do if((s|0)==5){if(D=r+20|0,h=t[D>>2]|0,u=h,(l-h|0)>>>0>>0){u=M_[t[r+36>>2]&7](r,e,n)|0;break}t:do if((p[r+75>>0]|0)>-1){for(h=n;;){if(!h){s=0,l=e;break t}if(l=h+-1|0,(p[e+l>>0]|0)==10)break;h=l}if(u=M_[t[r+36>>2]&7](r,e,h)|0,u>>>0>>0)break e;s=h,l=e+h|0,n=n-h|0,u=t[D>>2]|0}else s=0,l=e;while(0);pr(u|0,l|0,n|0)|0,t[D>>2]=(t[D>>2]|0)+n,u=s+n|0}while(0);return u|0}function KI(e){e=e|0;var n=0,r=0;return n=e+74|0,r=p[n>>0]|0,p[n>>0]=r+255|r,n=t[e>>2]|0,n&8?(t[e>>2]=n|32,e=-1):(t[e+8>>2]=0,t[e+4>>2]=0,r=t[e+44>>2]|0,t[e+28>>2]=r,t[e+20>>2]=r,t[e+16>>2]=r+(t[e+48>>2]|0),e=0),e|0}function Eu(e,n){e=w(e),n=w(n);var r=0,u=0;r=j8(e)|0;do if((r&2147483647)>>>0<=2139095040){if(u=j8(n)|0,(u&2147483647)>>>0<=2139095040)if((u^r|0)<0){e=(r|0)<0?n:e;break}else{e=e>2]=e,t[W>>2]|0|0}function Yp(e,n){e=w(e),n=w(n);var r=0,u=0;r=z8(e)|0;do if((r&2147483647)>>>0<=2139095040){if(u=z8(n)|0,(u&2147483647)>>>0<=2139095040)if((u^r|0)<0){e=(r|0)<0?e:n;break}else{e=e>2]=e,t[W>>2]|0|0}function YE(e,n){e=w(e),n=w(n);var r=0,u=0,l=0,s=0,h=0,D=0,S=0,M=0;s=(C[W>>2]=e,t[W>>2]|0),D=(C[W>>2]=n,t[W>>2]|0),r=s>>>23&255,h=D>>>23&255,S=s&-2147483648,l=D<<1;e:do if((l|0)!=0?!((r|0)==255|((XI(n)|0)&2147483647)>>>0>2139095040):0){if(u=s<<1,u>>>0<=l>>>0)return n=w(e*w(0)),w((u|0)==(l|0)?n:e);if(r)u=s&8388607|8388608;else{if(r=s<<9,(r|0)>-1){u=r,r=0;do r=r+-1|0,u=u<<1;while((u|0)>-1)}else r=0;u=s<<1-r}if(h)D=D&8388607|8388608;else{if(s=D<<9,(s|0)>-1){l=0;do l=l+-1|0,s=s<<1;while((s|0)>-1)}else l=0;h=l,D=D<<1-l}l=u-D|0,s=(l|0)>-1;t:do if((r|0)>(h|0)){for(;;){if(s)if(l)u=l;else break;if(u=u<<1,r=r+-1|0,l=u-D|0,s=(l|0)>-1,(r|0)<=(h|0))break t}n=w(e*w(0));break e}while(0);if(s)if(l)u=l;else{n=w(e*w(0));break}if(u>>>0<8388608)do u=u<<1,r=r+-1|0;while(u>>>0<8388608);(r|0)>0?r=u+-8388608|r<<23:r=u>>>(1-r|0),n=(t[W>>2]=r|S,w(C[W>>2]))}else M=3;while(0);return(M|0)==3&&(n=w(e*n),n=w(n/n)),w(n)}function XI(e){return e=w(e),C[W>>2]=e,t[W>>2]|0|0}function QI(e,n){return e=e|0,n=n|0,F8(t[582]|0,e,n)|0}function li(e){e=e|0,Xn()}function Pv(e){e=e|0}function JI(e,n){return e=e|0,n=n|0,0}function ZI(e){return e=e|0,(q8(e+4|0)|0)==-1?(M1[t[(t[e>>2]|0)+8>>2]&127](e),e=1):e=0,e|0}function q8(e){e=e|0;var n=0;return n=t[e>>2]|0,t[e>>2]=n+-1,n+-1|0}function J2(e){e=e|0,ZI(e)|0&&$I(e)}function $I(e){e=e|0;var n=0;n=e+8|0,((t[n>>2]|0)!=0?(q8(n)|0)!=-1:0)||M1[t[(t[e>>2]|0)+16>>2]&127](e)}function cn(e){e=e|0;var n=0;for(n=(e|0)==0?1:e;e=C_(n)|0,!(e|0);){if(e=tB()|0,!e){e=0;break}rS[e&0]()}return e|0}function H8(e){return e=e|0,cn(e)|0}function yt(e){e=e|0,x_(e)}function eB(e){e=e|0,(p[e+11>>0]|0)<0&&yt(t[e>>2]|0)}function tB(){var e=0;return e=t[2923]|0,t[2923]=e+0,e|0}function nB(){}function R_(e,n,r,u){return e=e|0,n=n|0,r=r|0,u=u|0,u=n-u-(r>>>0>e>>>0|0)>>>0,ft=u,e-r>>>0|0|0}function KE(e,n,r,u){return e=e|0,n=n|0,r=r|0,u=u|0,r=e+r>>>0,ft=n+u+(r>>>0>>0|0)>>>0,r|0|0}function Iv(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0,h=0;if(s=e+r|0,n=n&255,(r|0)>=67){for(;e&3;)p[e>>0]=n,e=e+1|0;for(u=s&-4|0,l=u-64|0,h=n|n<<8|n<<16|n<<24;(e|0)<=(l|0);)t[e>>2]=h,t[e+4>>2]=h,t[e+8>>2]=h,t[e+12>>2]=h,t[e+16>>2]=h,t[e+20>>2]=h,t[e+24>>2]=h,t[e+28>>2]=h,t[e+32>>2]=h,t[e+36>>2]=h,t[e+40>>2]=h,t[e+44>>2]=h,t[e+48>>2]=h,t[e+52>>2]=h,t[e+56>>2]=h,t[e+60>>2]=h,e=e+64|0;for(;(e|0)<(u|0);)t[e>>2]=h,e=e+4|0}for(;(e|0)<(s|0);)p[e>>0]=n,e=e+1|0;return s-r|0}function W8(e,n,r){return e=e|0,n=n|0,r=r|0,(r|0)<32?(ft=n<>>32-r,e<>>r,e>>>r|(n&(1<>>r-32|0)}function pr(e,n,r){e=e|0,n=n|0,r=r|0;var u=0,l=0,s=0;if((r|0)>=8192)return ni(e|0,n|0,r|0)|0;if(s=e|0,l=e+r|0,(e&3)==(n&3)){for(;e&3;){if(!r)return s|0;p[e>>0]=p[n>>0]|0,e=e+1|0,n=n+1|0,r=r-1|0}for(r=l&-4|0,u=r-64|0;(e|0)<=(u|0);)t[e>>2]=t[n>>2],t[e+4>>2]=t[n+4>>2],t[e+8>>2]=t[n+8>>2],t[e+12>>2]=t[n+12>>2],t[e+16>>2]=t[n+16>>2],t[e+20>>2]=t[n+20>>2],t[e+24>>2]=t[n+24>>2],t[e+28>>2]=t[n+28>>2],t[e+32>>2]=t[n+32>>2],t[e+36>>2]=t[n+36>>2],t[e+40>>2]=t[n+40>>2],t[e+44>>2]=t[n+44>>2],t[e+48>>2]=t[n+48>>2],t[e+52>>2]=t[n+52>>2],t[e+56>>2]=t[n+56>>2],t[e+60>>2]=t[n+60>>2],e=e+64|0,n=n+64|0;for(;(e|0)<(r|0);)t[e>>2]=t[n>>2],e=e+4|0,n=n+4|0}else for(r=l-4|0;(e|0)<(r|0);)p[e>>0]=p[n>>0]|0,p[e+1>>0]=p[n+1>>0]|0,p[e+2>>0]=p[n+2>>0]|0,p[e+3>>0]=p[n+3>>0]|0,e=e+4|0,n=n+4|0;for(;(e|0)<(l|0);)p[e>>0]=p[n>>0]|0,e=e+1|0,n=n+1|0;return s|0}function V8(e){e=e|0;var n=0;return n=p[Se+(e&255)>>0]|0,(n|0)<8?n|0:(n=p[Se+(e>>8&255)>>0]|0,(n|0)<8?n+8|0:(n=p[Se+(e>>16&255)>>0]|0,(n|0)<8?n+16|0:(p[Se+(e>>>24)>>0]|0)+24|0))}function G8(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0;var s=0,h=0,D=0,S=0,M=0,O=0,P=0,K=0,Pe=0,Ee=0;if(O=e,S=n,M=S,h=r,K=u,D=K,!M)return s=(l|0)!=0,D?s?(t[l>>2]=e|0,t[l+4>>2]=n&0,K=0,l=0,ft=K,l|0):(K=0,l=0,ft=K,l|0):(s&&(t[l>>2]=(O>>>0)%(h>>>0),t[l+4>>2]=0),K=0,l=(O>>>0)/(h>>>0)>>>0,ft=K,l|0);s=(D|0)==0;do if(h){if(!s){if(s=(vr(D|0)|0)-(vr(M|0)|0)|0,s>>>0<=31){P=s+1|0,D=31-s|0,n=s-31>>31,h=P,e=O>>>(P>>>0)&n|M<>>(P>>>0)&n,s=0,D=O<>2]=e|0,t[l+4>>2]=S|n&0,K=0,l=0,ft=K,l|0):(K=0,l=0,ft=K,l|0)}if(s=h-1|0,s&h|0){D=(vr(h|0)|0)+33-(vr(M|0)|0)|0,Ee=64-D|0,P=32-D|0,S=P>>31,Pe=D-32|0,n=Pe>>31,h=D,e=P-1>>31&M>>>(Pe>>>0)|(M<>>(D>>>0))&n,n=n&M>>>(D>>>0),s=O<>>(Pe>>>0))&S|O<>31;break}return l|0&&(t[l>>2]=s&O,t[l+4>>2]=0),(h|0)==1?(Pe=S|n&0,Ee=e|0|0,ft=Pe,Ee|0):(Ee=V8(h|0)|0,Pe=M>>>(Ee>>>0)|0,Ee=M<<32-Ee|O>>>(Ee>>>0)|0,ft=Pe,Ee|0)}else{if(s)return l|0&&(t[l>>2]=(M>>>0)%(h>>>0),t[l+4>>2]=0),Pe=0,Ee=(M>>>0)/(h>>>0)>>>0,ft=Pe,Ee|0;if(!O)return l|0&&(t[l>>2]=0,t[l+4>>2]=(M>>>0)%(D>>>0)),Pe=0,Ee=(M>>>0)/(D>>>0)>>>0,ft=Pe,Ee|0;if(s=D-1|0,!(s&D))return l|0&&(t[l>>2]=e|0,t[l+4>>2]=s&M|n&0),Pe=0,Ee=M>>>((V8(D|0)|0)>>>0),ft=Pe,Ee|0;if(s=(vr(D|0)|0)-(vr(M|0)|0)|0,s>>>0<=30){n=s+1|0,D=31-s|0,h=n,e=M<>>(n>>>0),n=M>>>(n>>>0),s=0,D=O<>2]=e|0,t[l+4>>2]=S|n&0,Pe=0,Ee=0,ft=Pe,Ee|0):(Pe=0,Ee=0,ft=Pe,Ee|0)}while(0);if(!h)M=D,S=0,D=0;else{P=r|0|0,O=K|u&0,M=KE(P|0,O|0,-1,-1)|0,r=ft,S=D,D=0;do u=S,S=s>>>31|S<<1,s=D|s<<1,u=e<<1|u>>>31|0,K=e>>>31|n<<1|0,R_(M|0,r|0,u|0,K|0)|0,Ee=ft,Pe=Ee>>31|((Ee|0)<0?-1:0)<<1,D=Pe&1,e=R_(u|0,K|0,Pe&P|0,(((Ee|0)<0?-1:0)>>31|((Ee|0)<0?-1:0)<<1)&O|0)|0,n=ft,h=h-1|0;while((h|0)!=0);M=S,S=0}return h=0,l|0&&(t[l>>2]=e,t[l+4>>2]=n),Pe=(s|0)>>>31|(M|h)<<1|(h<<1|s>>>31)&0|S,Ee=(s<<1|0>>>31)&-2|D,ft=Pe,Ee|0}function XE(e,n,r,u){return e=e|0,n=n|0,r=r|0,u=u|0,G8(e,n,r,u,0)|0}function Z2(e){e=e|0;var n=0,r=0;return r=e+15&-16|0,n=t[q>>2]|0,e=n+r|0,(r|0)>0&(e|0)<(n|0)|(e|0)<0?(ur()|0,Vl(12),-1):(t[q>>2]=e,((e|0)>(Fr()|0)?(fr()|0)==0:0)?(t[q>>2]=n,Vl(12),-1):n|0)}function Iy(e,n,r){e=e|0,n=n|0,r=r|0;var u=0;if((n|0)<(e|0)&(e|0)<(n+r|0)){for(u=e,n=n+r|0,e=e+r|0;(r|0)>0;)e=e-1|0,n=n-1|0,r=r-1|0,p[e>>0]=p[n>>0]|0;e=u}else pr(e,n,r)|0;return e|0}function QE(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0;var l=0,s=0;return s=m,m=m+16|0,l=s|0,G8(e,n,r,u,l)|0,m=s,ft=t[l+4>>2]|0,t[l>>2]|0|0}function Y8(e){return e=e|0,(e&255)<<24|(e>>8&255)<<16|(e>>16&255)<<8|e>>>24|0}function rB(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,K8[e&1](n|0,r|0,u|0,l|0,s|0)}function iB(e,n,r){e=e|0,n=n|0,r=w(r),X8[e&1](n|0,w(r))}function uB(e,n,r){e=e|0,n=n|0,r=+r,Q8[e&31](n|0,+r)}function oB(e,n,r,u){return e=e|0,n=n|0,r=w(r),u=w(u),w(J8[e&0](n|0,w(r),w(u)))}function lB(e,n){e=e|0,n=n|0,M1[e&127](n|0)}function sB(e,n,r){e=e|0,n=n|0,r=r|0,N1[e&31](n|0,r|0)}function aB(e,n){return e=e|0,n=n|0,Xp[e&31](n|0)|0}function fB(e,n,r,u,l){e=e|0,n=n|0,r=+r,u=+u,l=l|0,Z8[e&1](n|0,+r,+u,l|0)}function cB(e,n,r,u){e=e|0,n=n|0,r=+r,u=+u,VB[e&1](n|0,+r,+u)}function dB(e,n,r,u){return e=e|0,n=n|0,r=r|0,u=u|0,M_[e&7](n|0,r|0,u|0)|0}function pB(e,n,r,u){return e=e|0,n=n|0,r=r|0,u=u|0,+GB[e&1](n|0,r|0,u|0)}function hB(e,n){return e=e|0,n=n|0,+$8[e&15](n|0)}function vB(e,n,r){return e=e|0,n=n|0,r=+r,YB[e&1](n|0,+r)|0}function mB(e,n,r){return e=e|0,n=n|0,r=r|0,ZE[e&15](n|0,r|0)|0}function yB(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=+u,l=+l,s=s|0,KB[e&1](n|0,r|0,+u,+l,s|0)}function gB(e,n,r,u,l,s,h){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,h=h|0,XB[e&1](n|0,r|0,u|0,l|0,s|0,h|0)}function _B(e,n,r){return e=e|0,n=n|0,r=r|0,+eS[e&7](n|0,r|0)}function EB(e){return e=e|0,N_[e&7]()|0}function DB(e,n,r,u,l,s){return e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,tS[e&1](n|0,r|0,u|0,l|0,s|0)|0}function wB(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=+l,QB[e&1](n|0,r|0,u|0,+l)}function SB(e,n,r,u,l,s,h){e=e|0,n=n|0,r=r|0,u=w(u),l=l|0,s=w(s),h=h|0,nS[e&1](n|0,r|0,w(u),l|0,w(s),h|0)}function TB(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,jy[e&15](n|0,r|0,u|0)}function CB(e){e=e|0,rS[e&0]()}function xB(e,n,r,u){e=e|0,n=n|0,r=r|0,u=+u,iS[e&15](n|0,r|0,+u)}function AB(e,n,r){return e=e|0,n=+n,r=+r,JB[e&1](+n,+r)|0}function RB(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,$E[e&15](n|0,r|0,u|0,l|0)}function OB(e,n,r,u,l){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,Ut(0)}function kB(e,n){e=e|0,n=w(n),Ut(1)}function Ks(e,n){e=e|0,n=+n,Ut(2)}function MB(e,n,r){return e=e|0,n=w(n),r=w(r),Ut(3),St}function Kn(e){e=e|0,Ut(4)}function By(e,n){e=e|0,n=n|0,Ut(5)}function xa(e){return e=e|0,Ut(6),0}function NB(e,n,r,u){e=e|0,n=+n,r=+r,u=u|0,Ut(7)}function LB(e,n,r){e=e|0,n=+n,r=+r,Ut(8)}function FB(e,n,r){return e=e|0,n=n|0,r=r|0,Ut(9),0}function bB(e,n,r){return e=e|0,n=n|0,r=r|0,Ut(10),0}function Kp(e){return e=e|0,Ut(11),0}function PB(e,n){return e=e|0,n=+n,Ut(12),0}function Uy(e,n){return e=e|0,n=n|0,Ut(13),0}function IB(e,n,r,u,l){e=e|0,n=n|0,r=+r,u=+u,l=l|0,Ut(14)}function BB(e,n,r,u,l,s){e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,s=s|0,Ut(15)}function JE(e,n){return e=e|0,n=n|0,Ut(16),0}function UB(){return Ut(17),0}function jB(e,n,r,u,l){return e=e|0,n=n|0,r=r|0,u=u|0,l=l|0,Ut(18),0}function zB(e,n,r,u){e=e|0,n=n|0,r=r|0,u=+u,Ut(19)}function qB(e,n,r,u,l,s){e=e|0,n=n|0,r=w(r),u=u|0,l=w(l),s=s|0,Ut(20)}function k_(e,n,r){e=e|0,n=n|0,r=r|0,Ut(21)}function HB(){Ut(22)}function Bv(e,n,r){e=e|0,n=n|0,r=+r,Ut(23)}function WB(e,n){return e=+e,n=+n,Ut(24),0}function Uv(e,n,r,u){e=e|0,n=n|0,r=r|0,u=u|0,Ut(25)}var K8=[OB,UL],X8=[kB,Ju],Q8=[Ks,ua,ys,gs,Ql,Io,hf,tl,Ia,Zu,vf,jc,lc,Sl,_s,oa,n2,la,sc,Ks,Ks,Ks,Ks,Ks,Ks,Ks,Ks,Ks,Ks,Ks,Ks,Ks],J8=[MB],M1=[Kn,Pv,an,$l,go,Lf,x1,Fl,hN,vN,mN,xL,AL,RL,XP,QP,JP,Ne,uc,La,ju,U0,hh,yf,$c,Af,pa,Rh,Sm,h1,v1,Xh,pp,M2,Gm,D1,Sc,ry,oy,Sv,Av,rn,Q4,lE,h_,Nt,_u,Qu,RO,WO,ak,Ak,qk,aM,_M,wM,UM,qM,uN,gN,DN,BN,nL,v2,BF,vb,kb,Vb,pP,RP,UP,qP,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn,Kn],N1=[By,gd,$1,Uc,Dl,el,_d,Bs,wl,Fa,ba,Pa,Tl,Be,ut,Jt,jn,ti,tr,Ba,Dd,mh,fE,vE,Mk,zF,fL,g8,By,By,By,By],Xp=[xa,xI,pf,y,J,fe,mt,Ct,Mt,Er,iu,j0,Ua,r2,Vc,Cs,Gk,zN,VF,Sa,xa,xa,xa,xa,xa,xa,xa,xa,xa,xa,xa,xa],Z8=[NB,Td],VB=[LB,sN],M_=[FB,N8,AI,kI,zh,dv,NO,Xb],GB=[bB,lv],$8=[Kp,e0,He,ii,vh,il,sa,Cd,xd,ac,Kp,Kp,Kp,Kp,Kp,Kp],YB=[PB,mM],ZE=[Uy,JI,Ed,ll,zd,Nm,ap,Cp,ly,xr,bo,Fb,Uy,Uy,Uy,Uy],KB=[IB,Sh],XB=[BB,yP],eS=[JE,qi,Ad,a2,Gc,cl,JE,JE],N_=[UB,Yc,to,g0,xM,GM,CN,GP],tS=[jB,ei],QB=[zB,Dy],nS=[qB,i2],jy=[k_,A,$u,jr,gu,d1,k2,ir,Cy,po,aF,_b,NP,k_,k_,k_],rS=[HB],iS=[Bv,e2,ho,t2,Po,zc,bi,g,Ip,KO,dM,Bv,Bv,Bv,Bv,Bv],JB=[WB,dN],$E=[Uv,_p,Rc,pk,tM,NM,ZM,NN,lL,JF,rI,Uv,Uv,Uv,Uv,Uv];return{_llvm_bswap_i32:Y8,dynCall_idd:AB,dynCall_i:EB,_i64Subtract:R_,___udivdi3:XE,dynCall_vif:iB,setThrew:fs,dynCall_viii:TB,_bitshift64Lshr:O_,_bitshift64Shl:W8,dynCall_vi:lB,dynCall_viiddi:yB,dynCall_diii:pB,dynCall_iii:mB,_memset:Iv,_sbrk:Z2,_memcpy:pr,__GLOBAL__sub_I_Yoga_cpp:Qi,dynCall_vii:sB,___uremdi3:QE,dynCall_vid:uB,stackAlloc:so,_nbind_init:hI,getTempRet0:X,dynCall_di:hB,dynCall_iid:vB,setTempRet0:P0,_i64Add:KE,dynCall_fiff:oB,dynCall_iiii:dB,_emscripten_get_global_libc:CI,dynCall_viid:xB,dynCall_viiid:wB,dynCall_viififi:SB,dynCall_ii:aB,__GLOBAL__sub_I_Binding_cc:kF,dynCall_viiii:RB,dynCall_iiiiii:DB,stackSave:Jo,dynCall_viiiii:rB,__GLOBAL__sub_I_nbind_cc:Us,dynCall_vidd:cB,_free:x_,runPostSets:nB,dynCall_viiiiii:gB,establishStackSpace:Fu,_memmove:Iy,stackRestore:Gl,_malloc:C_,__GLOBAL__sub_I_common_cc:XN,dynCall_viddi:fB,dynCall_dii:_B,dynCall_v:CB}}(Module.asmGlobalArg,Module.asmLibraryArg,buffer),_llvm_bswap_i32=Module._llvm_bswap_i32=asm._llvm_bswap_i32,getTempRet0=Module.getTempRet0=asm.getTempRet0,___udivdi3=Module.___udivdi3=asm.___udivdi3,setThrew=Module.setThrew=asm.setThrew,_bitshift64Lshr=Module._bitshift64Lshr=asm._bitshift64Lshr,_bitshift64Shl=Module._bitshift64Shl=asm._bitshift64Shl,_memset=Module._memset=asm._memset,_sbrk=Module._sbrk=asm._sbrk,_memcpy=Module._memcpy=asm._memcpy,stackAlloc=Module.stackAlloc=asm.stackAlloc,___uremdi3=Module.___uremdi3=asm.___uremdi3,_nbind_init=Module._nbind_init=asm._nbind_init,_i64Subtract=Module._i64Subtract=asm._i64Subtract,setTempRet0=Module.setTempRet0=asm.setTempRet0,_i64Add=Module._i64Add=asm._i64Add,_emscripten_get_global_libc=Module._emscripten_get_global_libc=asm._emscripten_get_global_libc,__GLOBAL__sub_I_Yoga_cpp=Module.__GLOBAL__sub_I_Yoga_cpp=asm.__GLOBAL__sub_I_Yoga_cpp,__GLOBAL__sub_I_Binding_cc=Module.__GLOBAL__sub_I_Binding_cc=asm.__GLOBAL__sub_I_Binding_cc,stackSave=Module.stackSave=asm.stackSave,__GLOBAL__sub_I_nbind_cc=Module.__GLOBAL__sub_I_nbind_cc=asm.__GLOBAL__sub_I_nbind_cc,_free=Module._free=asm._free,runPostSets=Module.runPostSets=asm.runPostSets,establishStackSpace=Module.establishStackSpace=asm.establishStackSpace,_memmove=Module._memmove=asm._memmove,stackRestore=Module.stackRestore=asm.stackRestore,_malloc=Module._malloc=asm._malloc,__GLOBAL__sub_I_common_cc=Module.__GLOBAL__sub_I_common_cc=asm.__GLOBAL__sub_I_common_cc,dynCall_viiiii=Module.dynCall_viiiii=asm.dynCall_viiiii,dynCall_vif=Module.dynCall_vif=asm.dynCall_vif,dynCall_vid=Module.dynCall_vid=asm.dynCall_vid,dynCall_fiff=Module.dynCall_fiff=asm.dynCall_fiff,dynCall_vi=Module.dynCall_vi=asm.dynCall_vi,dynCall_vii=Module.dynCall_vii=asm.dynCall_vii,dynCall_ii=Module.dynCall_ii=asm.dynCall_ii,dynCall_viddi=Module.dynCall_viddi=asm.dynCall_viddi,dynCall_vidd=Module.dynCall_vidd=asm.dynCall_vidd,dynCall_iiii=Module.dynCall_iiii=asm.dynCall_iiii,dynCall_diii=Module.dynCall_diii=asm.dynCall_diii,dynCall_di=Module.dynCall_di=asm.dynCall_di,dynCall_iid=Module.dynCall_iid=asm.dynCall_iid,dynCall_iii=Module.dynCall_iii=asm.dynCall_iii,dynCall_viiddi=Module.dynCall_viiddi=asm.dynCall_viiddi,dynCall_viiiiii=Module.dynCall_viiiiii=asm.dynCall_viiiiii,dynCall_dii=Module.dynCall_dii=asm.dynCall_dii,dynCall_i=Module.dynCall_i=asm.dynCall_i,dynCall_iiiiii=Module.dynCall_iiiiii=asm.dynCall_iiiiii,dynCall_viiid=Module.dynCall_viiid=asm.dynCall_viiid,dynCall_viififi=Module.dynCall_viififi=asm.dynCall_viififi,dynCall_viii=Module.dynCall_viii=asm.dynCall_viii,dynCall_v=Module.dynCall_v=asm.dynCall_v,dynCall_viid=Module.dynCall_viid=asm.dynCall_viid,dynCall_idd=Module.dynCall_idd=asm.dynCall_idd,dynCall_viiii=Module.dynCall_viiii=asm.dynCall_viiii;Runtime.stackAlloc=Module.stackAlloc,Runtime.stackSave=Module.stackSave,Runtime.stackRestore=Module.stackRestore,Runtime.establishStackSpace=Module.establishStackSpace,Runtime.setTempRet0=Module.setTempRet0,Runtime.getTempRet0=Module.getTempRet0,Module.asm=asm;function ExitStatus(i){this.name="ExitStatus",this.message="Program terminated with exit("+i+")",this.status=i}ExitStatus.prototype=new Error,ExitStatus.prototype.constructor=ExitStatus;var initialStackTop,preloadStartTime=null,calledMain=!1;dependenciesFulfilled=function i(){Module.calledRun||run(),Module.calledRun||(dependenciesFulfilled=i)},Module.callMain=Module.callMain=function(o){o=o||[],ensureInitRuntime();var f=o.length+1;function p(){for(var N=0;N<4-1;N++)E.push(0)}var E=[allocate(intArrayFromString(Module.thisProgram),"i8",ALLOC_NORMAL)];p();for(var t=0;t0||(preRun(),runDependencies>0)||Module.calledRun)return;function o(){Module.calledRun||(Module.calledRun=!0,!ABORT&&(ensureInitRuntime(),preMain(),Module.onRuntimeInitialized&&Module.onRuntimeInitialized(),Module._main&&shouldRunNow&&Module.callMain(i),postRun()))}Module.setStatus?(Module.setStatus("Running..."),setTimeout(function(){setTimeout(function(){Module.setStatus("")},1),o()},1)):o()}Module.run=Module.run=run;function exit(i,o){o&&Module.noExitRuntime||(Module.noExitRuntime||(ABORT=!0,EXITSTATUS=i,STACKTOP=initialStackTop,exitRuntime(),Module.onExit&&Module.onExit(i)),ENVIRONMENT_IS_NODE&&process.exit(i),Module.quit(i,new ExitStatus(i)))}Module.exit=Module.exit=exit;var abortDecorators=[];function abort(i){Module.onAbort&&Module.onAbort(i),i!==void 0?(Module.print(i),Module.printErr(i),i=JSON.stringify(i)):i="",ABORT=!0,EXITSTATUS=1;var o=` +If this abort() is unexpected, build with -s ASSERTIONS=1 which can give more information.`,f="abort("+i+") at "+stackTrace()+o;throw abortDecorators&&abortDecorators.forEach(function(p){f=p(f,i)}),f}if(Module.abort=Module.abort=abort,Module.preInit)for(typeof Module.preInit=="function"&&(Module.preInit=[Module.preInit]);Module.preInit.length>0;)Module.preInit.pop()();var shouldRunNow=!0;Module.noInitialRun&&(shouldRunNow=!1),run()})});var eh=ce((Wne,O9)=>{"use strict";var tX=A9(),nX=R9(),hw=!1,vw=null;nX({},function(i,o){if(!hw){if(hw=!0,i)throw i;vw=o}});if(!hw)throw new Error("Failed to load the yoga module - it needed to be loaded synchronously, but didn't");O9.exports=tX(vw.bind,vw.lib)});var M9=ce((Vne,k9)=>{"use strict";k9.exports=({onlyFirst:i=!1}={})=>{let o=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(o,i?void 0:"g")}});var mw=ce((Gne,N9)=>{"use strict";var rX=M9();N9.exports=i=>typeof i=="string"?i.replace(rX(),""):i});var gw=ce((Yne,yw)=>{"use strict";var L9=i=>Number.isNaN(i)?!1:i>=4352&&(i<=4447||i===9001||i===9002||11904<=i&&i<=12871&&i!==12351||12880<=i&&i<=19903||19968<=i&&i<=42182||43360<=i&&i<=43388||44032<=i&&i<=55203||63744<=i&&i<=64255||65040<=i&&i<=65049||65072<=i&&i<=65131||65281<=i&&i<=65376||65504<=i&&i<=65510||110592<=i&&i<=110593||127488<=i&&i<=127569||131072<=i&&i<=262141);yw.exports=L9;yw.exports.default=L9});var b9=ce((Kne,F9)=>{"use strict";F9.exports=function(){return/\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F|\uD83D\uDC68(?:\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68\uD83C\uDFFB|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|[\u2695\u2696\u2708]\uFE0F|\uD83D[\uDC66\uDC67]|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708])\uFE0F|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C[\uDFFB-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)\uD83C\uDFFB|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB\uDFFC])|\uD83D\uDC69(?:\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB-\uDFFD])|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|(?:(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)\uFE0F|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\u200D[\u2640\u2642])|\uD83C\uDFF4\u200D\u2620)\uFE0F|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF4\uD83C\uDDF2|\uD83C\uDDF6\uD83C\uDDE6|[#\*0-9]\uFE0F\u20E3|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83D\uDC69(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270A-\u270D]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC70\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDCAA\uDD74\uDD7A\uDD90\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD36\uDDB5\uDDB6\uDDBB\uDDD2-\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5\uDEEB\uDEEC\uDEF4-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g}});var m4=ce((Xne,_w)=>{"use strict";var iX=mw(),uX=gw(),oX=b9(),P9=i=>{if(i=i.replace(oX()," "),typeof i!="string"||i.length===0)return 0;i=iX(i);let o=0;for(let f=0;f=127&&p<=159||p>=768&&p<=879||(p>65535&&f++,o+=uX(p)?2:1)}return o};_w.exports=P9;_w.exports.default=P9});var Dw=ce((Qne,Ew)=>{"use strict";var lX=m4(),I9=i=>{let o=0;for(let f of i.split(` +`))o=Math.max(o,lX(f));return o};Ew.exports=I9;Ew.exports.default=I9});var B9=ce(vg=>{"use strict";var sX=vg&&vg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(vg,"__esModule",{value:!0});var aX=sX(Dw()),ww={};vg.default=i=>{if(i.length===0)return{width:0,height:0};if(ww[i])return ww[i];let o=aX.default(i),f=i.split(` +`).length;return ww[i]={width:o,height:f},{width:o,height:f}}});var U9=ce(mg=>{"use strict";var fX=mg&&mg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(mg,"__esModule",{value:!0});var Ii=fX(eh()),cX=(i,o)=>{"position"in o&&i.setPositionType(o.position==="absolute"?Ii.default.POSITION_TYPE_ABSOLUTE:Ii.default.POSITION_TYPE_RELATIVE)},dX=(i,o)=>{"marginLeft"in o&&i.setMargin(Ii.default.EDGE_START,o.marginLeft||0),"marginRight"in o&&i.setMargin(Ii.default.EDGE_END,o.marginRight||0),"marginTop"in o&&i.setMargin(Ii.default.EDGE_TOP,o.marginTop||0),"marginBottom"in o&&i.setMargin(Ii.default.EDGE_BOTTOM,o.marginBottom||0)},pX=(i,o)=>{"paddingLeft"in o&&i.setPadding(Ii.default.EDGE_LEFT,o.paddingLeft||0),"paddingRight"in o&&i.setPadding(Ii.default.EDGE_RIGHT,o.paddingRight||0),"paddingTop"in o&&i.setPadding(Ii.default.EDGE_TOP,o.paddingTop||0),"paddingBottom"in o&&i.setPadding(Ii.default.EDGE_BOTTOM,o.paddingBottom||0)},hX=(i,o)=>{var f;"flexGrow"in o&&i.setFlexGrow((f=o.flexGrow)!==null&&f!==void 0?f:0),"flexShrink"in o&&i.setFlexShrink(typeof o.flexShrink=="number"?o.flexShrink:1),"flexDirection"in o&&(o.flexDirection==="row"&&i.setFlexDirection(Ii.default.FLEX_DIRECTION_ROW),o.flexDirection==="row-reverse"&&i.setFlexDirection(Ii.default.FLEX_DIRECTION_ROW_REVERSE),o.flexDirection==="column"&&i.setFlexDirection(Ii.default.FLEX_DIRECTION_COLUMN),o.flexDirection==="column-reverse"&&i.setFlexDirection(Ii.default.FLEX_DIRECTION_COLUMN_REVERSE)),"flexBasis"in o&&(typeof o.flexBasis=="number"?i.setFlexBasis(o.flexBasis):typeof o.flexBasis=="string"?i.setFlexBasisPercent(Number.parseInt(o.flexBasis,10)):i.setFlexBasis(NaN)),"alignItems"in o&&((o.alignItems==="stretch"||!o.alignItems)&&i.setAlignItems(Ii.default.ALIGN_STRETCH),o.alignItems==="flex-start"&&i.setAlignItems(Ii.default.ALIGN_FLEX_START),o.alignItems==="center"&&i.setAlignItems(Ii.default.ALIGN_CENTER),o.alignItems==="flex-end"&&i.setAlignItems(Ii.default.ALIGN_FLEX_END)),"alignSelf"in o&&((o.alignSelf==="auto"||!o.alignSelf)&&i.setAlignSelf(Ii.default.ALIGN_AUTO),o.alignSelf==="flex-start"&&i.setAlignSelf(Ii.default.ALIGN_FLEX_START),o.alignSelf==="center"&&i.setAlignSelf(Ii.default.ALIGN_CENTER),o.alignSelf==="flex-end"&&i.setAlignSelf(Ii.default.ALIGN_FLEX_END)),"justifyContent"in o&&((o.justifyContent==="flex-start"||!o.justifyContent)&&i.setJustifyContent(Ii.default.JUSTIFY_FLEX_START),o.justifyContent==="center"&&i.setJustifyContent(Ii.default.JUSTIFY_CENTER),o.justifyContent==="flex-end"&&i.setJustifyContent(Ii.default.JUSTIFY_FLEX_END),o.justifyContent==="space-between"&&i.setJustifyContent(Ii.default.JUSTIFY_SPACE_BETWEEN),o.justifyContent==="space-around"&&i.setJustifyContent(Ii.default.JUSTIFY_SPACE_AROUND))},vX=(i,o)=>{var f,p;"width"in o&&(typeof o.width=="number"?i.setWidth(o.width):typeof o.width=="string"?i.setWidthPercent(Number.parseInt(o.width,10)):i.setWidthAuto()),"height"in o&&(typeof o.height=="number"?i.setHeight(o.height):typeof o.height=="string"?i.setHeightPercent(Number.parseInt(o.height,10)):i.setHeightAuto()),"minWidth"in o&&(typeof o.minWidth=="string"?i.setMinWidthPercent(Number.parseInt(o.minWidth,10)):i.setMinWidth((f=o.minWidth)!==null&&f!==void 0?f:0)),"minHeight"in o&&(typeof o.minHeight=="string"?i.setMinHeightPercent(Number.parseInt(o.minHeight,10)):i.setMinHeight((p=o.minHeight)!==null&&p!==void 0?p:0))},mX=(i,o)=>{"display"in o&&i.setDisplay(o.display==="flex"?Ii.default.DISPLAY_FLEX:Ii.default.DISPLAY_NONE)},yX=(i,o)=>{if("borderStyle"in o){let f=typeof o.borderStyle=="string"?1:0;i.setBorder(Ii.default.EDGE_TOP,f),i.setBorder(Ii.default.EDGE_BOTTOM,f),i.setBorder(Ii.default.EDGE_LEFT,f),i.setBorder(Ii.default.EDGE_RIGHT,f)}};mg.default=(i,o={})=>{cX(i,o),dX(i,o),pX(i,o),hX(i,o),vX(i,o),mX(i,o),yX(i,o)}});var z9=ce(($ne,j9)=>{"use strict";j9.exports={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});var Sw=ce((ere,q9)=>{var yg=z9(),H9={};for(let i of Object.keys(yg))H9[yg[i]]=i;var In={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};q9.exports=In;for(let i of Object.keys(In)){if(!("channels"in In[i]))throw new Error("missing channels property: "+i);if(!("labels"in In[i]))throw new Error("missing channel labels property: "+i);if(In[i].labels.length!==In[i].channels)throw new Error("channel and label counts mismatch: "+i);let{channels:o,labels:f}=In[i];delete In[i].channels,delete In[i].labels,Object.defineProperty(In[i],"channels",{value:o}),Object.defineProperty(In[i],"labels",{value:f})}In.rgb.hsl=function(i){let o=i[0]/255,f=i[1]/255,p=i[2]/255,E=Math.min(o,f,p),t=Math.max(o,f,p),k=t-E,L,N;t===E?L=0:o===t?L=(f-p)/k:f===t?L=2+(p-o)/k:p===t&&(L=4+(o-f)/k),L=Math.min(L*60,360),L<0&&(L+=360);let C=(E+t)/2;return t===E?N=0:C<=.5?N=k/(t+E):N=k/(2-t-E),[L,N*100,C*100]};In.rgb.hsv=function(i){let o,f,p,E,t,k=i[0]/255,L=i[1]/255,N=i[2]/255,C=Math.max(k,L,N),U=C-Math.min(k,L,N),q=function(W){return(C-W)/6/U+1/2};return U===0?(E=0,t=0):(t=U/C,o=q(k),f=q(L),p=q(N),k===C?E=p-f:L===C?E=1/3+o-p:N===C&&(E=2/3+f-o),E<0?E+=1:E>1&&(E-=1)),[E*360,t*100,C*100]};In.rgb.hwb=function(i){let o=i[0],f=i[1],p=i[2],E=In.rgb.hsl(i)[0],t=1/255*Math.min(o,Math.min(f,p));return p=1-1/255*Math.max(o,Math.max(f,p)),[E,t*100,p*100]};In.rgb.cmyk=function(i){let o=i[0]/255,f=i[1]/255,p=i[2]/255,E=Math.min(1-o,1-f,1-p),t=(1-o-E)/(1-E)||0,k=(1-f-E)/(1-E)||0,L=(1-p-E)/(1-E)||0;return[t*100,k*100,L*100,E*100]};function gX(i,o){return(i[0]-o[0])**2+(i[1]-o[1])**2+(i[2]-o[2])**2}In.rgb.keyword=function(i){let o=H9[i];if(o)return o;let f=Infinity,p;for(let E of Object.keys(yg)){let t=yg[E],k=gX(i,t);k.04045?((o+.055)/1.055)**2.4:o/12.92,f=f>.04045?((f+.055)/1.055)**2.4:f/12.92,p=p>.04045?((p+.055)/1.055)**2.4:p/12.92;let E=o*.4124+f*.3576+p*.1805,t=o*.2126+f*.7152+p*.0722,k=o*.0193+f*.1192+p*.9505;return[E*100,t*100,k*100]};In.rgb.lab=function(i){let o=In.rgb.xyz(i),f=o[0],p=o[1],E=o[2];f/=95.047,p/=100,E/=108.883,f=f>.008856?f**(1/3):7.787*f+16/116,p=p>.008856?p**(1/3):7.787*p+16/116,E=E>.008856?E**(1/3):7.787*E+16/116;let t=116*p-16,k=500*(f-p),L=200*(p-E);return[t,k,L]};In.hsl.rgb=function(i){let o=i[0]/360,f=i[1]/100,p=i[2]/100,E,t,k;if(f===0)return k=p*255,[k,k,k];p<.5?E=p*(1+f):E=p+f-p*f;let L=2*p-E,N=[0,0,0];for(let C=0;C<3;C++)t=o+1/3*-(C-1),t<0&&t++,t>1&&t--,6*t<1?k=L+(E-L)*6*t:2*t<1?k=E:3*t<2?k=L+(E-L)*(2/3-t)*6:k=L,N[C]=k*255;return N};In.hsl.hsv=function(i){let o=i[0],f=i[1]/100,p=i[2]/100,E=f,t=Math.max(p,.01);p*=2,f*=p<=1?p:2-p,E*=t<=1?t:2-t;let k=(p+f)/2,L=p===0?2*E/(t+E):2*f/(p+f);return[o,L*100,k*100]};In.hsv.rgb=function(i){let o=i[0]/60,f=i[1]/100,p=i[2]/100,E=Math.floor(o)%6,t=o-Math.floor(o),k=255*p*(1-f),L=255*p*(1-f*t),N=255*p*(1-f*(1-t));switch(p*=255,E){case 0:return[p,N,k];case 1:return[L,p,k];case 2:return[k,p,N];case 3:return[k,L,p];case 4:return[N,k,p];case 5:return[p,k,L]}};In.hsv.hsl=function(i){let o=i[0],f=i[1]/100,p=i[2]/100,E=Math.max(p,.01),t,k;k=(2-f)*p;let L=(2-f)*E;return t=f*E,t/=L<=1?L:2-L,t=t||0,k/=2,[o,t*100,k*100]};In.hwb.rgb=function(i){let o=i[0]/360,f=i[1]/100,p=i[2]/100,E=f+p,t;E>1&&(f/=E,p/=E);let k=Math.floor(6*o),L=1-p;t=6*o-k,(k&1)!=0&&(t=1-t);let N=f+t*(L-f),C,U,q;switch(k){default:case 6:case 0:C=L,U=N,q=f;break;case 1:C=N,U=L,q=f;break;case 2:C=f,U=L,q=N;break;case 3:C=f,U=N,q=L;break;case 4:C=N,U=f,q=L;break;case 5:C=L,U=f,q=N;break}return[C*255,U*255,q*255]};In.cmyk.rgb=function(i){let o=i[0]/100,f=i[1]/100,p=i[2]/100,E=i[3]/100,t=1-Math.min(1,o*(1-E)+E),k=1-Math.min(1,f*(1-E)+E),L=1-Math.min(1,p*(1-E)+E);return[t*255,k*255,L*255]};In.xyz.rgb=function(i){let o=i[0]/100,f=i[1]/100,p=i[2]/100,E,t,k;return E=o*3.2406+f*-1.5372+p*-.4986,t=o*-.9689+f*1.8758+p*.0415,k=o*.0557+f*-.204+p*1.057,E=E>.0031308?1.055*E**(1/2.4)-.055:E*12.92,t=t>.0031308?1.055*t**(1/2.4)-.055:t*12.92,k=k>.0031308?1.055*k**(1/2.4)-.055:k*12.92,E=Math.min(Math.max(0,E),1),t=Math.min(Math.max(0,t),1),k=Math.min(Math.max(0,k),1),[E*255,t*255,k*255]};In.xyz.lab=function(i){let o=i[0],f=i[1],p=i[2];o/=95.047,f/=100,p/=108.883,o=o>.008856?o**(1/3):7.787*o+16/116,f=f>.008856?f**(1/3):7.787*f+16/116,p=p>.008856?p**(1/3):7.787*p+16/116;let E=116*f-16,t=500*(o-f),k=200*(f-p);return[E,t,k]};In.lab.xyz=function(i){let o=i[0],f=i[1],p=i[2],E,t,k;t=(o+16)/116,E=f/500+t,k=t-p/200;let L=t**3,N=E**3,C=k**3;return t=L>.008856?L:(t-16/116)/7.787,E=N>.008856?N:(E-16/116)/7.787,k=C>.008856?C:(k-16/116)/7.787,E*=95.047,t*=100,k*=108.883,[E,t,k]};In.lab.lch=function(i){let o=i[0],f=i[1],p=i[2],E;E=Math.atan2(p,f)*360/2/Math.PI,E<0&&(E+=360);let k=Math.sqrt(f*f+p*p);return[o,k,E]};In.lch.lab=function(i){let o=i[0],f=i[1],E=i[2]/360*2*Math.PI,t=f*Math.cos(E),k=f*Math.sin(E);return[o,t,k]};In.rgb.ansi16=function(i,o=null){let[f,p,E]=i,t=o===null?In.rgb.hsv(i)[2]:o;if(t=Math.round(t/50),t===0)return 30;let k=30+(Math.round(E/255)<<2|Math.round(p/255)<<1|Math.round(f/255));return t===2&&(k+=60),k};In.hsv.ansi16=function(i){return In.rgb.ansi16(In.hsv.rgb(i),i[2])};In.rgb.ansi256=function(i){let o=i[0],f=i[1],p=i[2];return o===f&&f===p?o<8?16:o>248?231:Math.round((o-8)/247*24)+232:16+36*Math.round(o/255*5)+6*Math.round(f/255*5)+Math.round(p/255*5)};In.ansi16.rgb=function(i){let o=i%10;if(o===0||o===7)return i>50&&(o+=3.5),o=o/10.5*255,[o,o,o];let f=(~~(i>50)+1)*.5,p=(o&1)*f*255,E=(o>>1&1)*f*255,t=(o>>2&1)*f*255;return[p,E,t]};In.ansi256.rgb=function(i){if(i>=232){let t=(i-232)*10+8;return[t,t,t]}i-=16;let o,f=Math.floor(i/36)/5*255,p=Math.floor((o=i%36)/6)/5*255,E=o%6/5*255;return[f,p,E]};In.rgb.hex=function(i){let f=(((Math.round(i[0])&255)<<16)+((Math.round(i[1])&255)<<8)+(Math.round(i[2])&255)).toString(16).toUpperCase();return"000000".substring(f.length)+f};In.hex.rgb=function(i){let o=i.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!o)return[0,0,0];let f=o[0];o[0].length===3&&(f=f.split("").map(L=>L+L).join(""));let p=parseInt(f,16),E=p>>16&255,t=p>>8&255,k=p&255;return[E,t,k]};In.rgb.hcg=function(i){let o=i[0]/255,f=i[1]/255,p=i[2]/255,E=Math.max(Math.max(o,f),p),t=Math.min(Math.min(o,f),p),k=E-t,L,N;return k<1?L=t/(1-k):L=0,k<=0?N=0:E===o?N=(f-p)/k%6:E===f?N=2+(p-o)/k:N=4+(o-f)/k,N/=6,N%=1,[N*360,k*100,L*100]};In.hsl.hcg=function(i){let o=i[1]/100,f=i[2]/100,p=f<.5?2*o*f:2*o*(1-f),E=0;return p<1&&(E=(f-.5*p)/(1-p)),[i[0],p*100,E*100]};In.hsv.hcg=function(i){let o=i[1]/100,f=i[2]/100,p=o*f,E=0;return p<1&&(E=(f-p)/(1-p)),[i[0],p*100,E*100]};In.hcg.rgb=function(i){let o=i[0]/360,f=i[1]/100,p=i[2]/100;if(f===0)return[p*255,p*255,p*255];let E=[0,0,0],t=o%1*6,k=t%1,L=1-k,N=0;switch(Math.floor(t)){case 0:E[0]=1,E[1]=k,E[2]=0;break;case 1:E[0]=L,E[1]=1,E[2]=0;break;case 2:E[0]=0,E[1]=1,E[2]=k;break;case 3:E[0]=0,E[1]=L,E[2]=1;break;case 4:E[0]=k,E[1]=0,E[2]=1;break;default:E[0]=1,E[1]=0,E[2]=L}return N=(1-f)*p,[(f*E[0]+N)*255,(f*E[1]+N)*255,(f*E[2]+N)*255]};In.hcg.hsv=function(i){let o=i[1]/100,f=i[2]/100,p=o+f*(1-o),E=0;return p>0&&(E=o/p),[i[0],E*100,p*100]};In.hcg.hsl=function(i){let o=i[1]/100,p=i[2]/100*(1-o)+.5*o,E=0;return p>0&&p<.5?E=o/(2*p):p>=.5&&p<1&&(E=o/(2*(1-p))),[i[0],E*100,p*100]};In.hcg.hwb=function(i){let o=i[1]/100,f=i[2]/100,p=o+f*(1-o);return[i[0],(p-o)*100,(1-p)*100]};In.hwb.hcg=function(i){let o=i[1]/100,f=i[2]/100,p=1-f,E=p-o,t=0;return E<1&&(t=(p-E)/(1-E)),[i[0],E*100,t*100]};In.apple.rgb=function(i){return[i[0]/65535*255,i[1]/65535*255,i[2]/65535*255]};In.rgb.apple=function(i){return[i[0]/255*65535,i[1]/255*65535,i[2]/255*65535]};In.gray.rgb=function(i){return[i[0]/100*255,i[0]/100*255,i[0]/100*255]};In.gray.hsl=function(i){return[0,0,i[0]]};In.gray.hsv=In.gray.hsl;In.gray.hwb=function(i){return[0,100,i[0]]};In.gray.cmyk=function(i){return[0,0,0,i[0]]};In.gray.lab=function(i){return[i[0],0,0]};In.gray.hex=function(i){let o=Math.round(i[0]/100*255)&255,p=((o<<16)+(o<<8)+o).toString(16).toUpperCase();return"000000".substring(p.length)+p};In.rgb.gray=function(i){return[(i[0]+i[1]+i[2])/3/255*100]}});var V9=ce((tre,W9)=>{var y4=Sw();function _X(){let i={},o=Object.keys(y4);for(let f=o.length,p=0;p{var Tw=Sw(),SX=V9(),sm={},TX=Object.keys(Tw);function CX(i){let o=function(...f){let p=f[0];return p==null?p:(p.length>1&&(f=p),i(f))};return"conversion"in i&&(o.conversion=i.conversion),o}function xX(i){let o=function(...f){let p=f[0];if(p==null)return p;p.length>1&&(f=p);let E=i(f);if(typeof E=="object")for(let t=E.length,k=0;k{sm[i]={},Object.defineProperty(sm[i],"channels",{value:Tw[i].channels}),Object.defineProperty(sm[i],"labels",{value:Tw[i].labels});let o=SX(i);Object.keys(o).forEach(p=>{let E=o[p];sm[i][p]=xX(E),sm[i][p].raw=CX(E)})});G9.exports=sm});var _4=ce((rre,K9)=>{"use strict";var X9=(i,o)=>(...f)=>`[${i(...f)+o}m`,Q9=(i,o)=>(...f)=>{let p=i(...f);return`[${38+o};5;${p}m`},J9=(i,o)=>(...f)=>{let p=i(...f);return`[${38+o};2;${p[0]};${p[1]};${p[2]}m`},g4=i=>i,Z9=(i,o,f)=>[i,o,f],am=(i,o,f)=>{Object.defineProperty(i,o,{get:()=>{let p=f();return Object.defineProperty(i,o,{value:p,enumerable:!0,configurable:!0}),p},enumerable:!0,configurable:!0})},Cw,fm=(i,o,f,p)=>{Cw===void 0&&(Cw=Y9());let E=p?10:0,t={};for(let[k,L]of Object.entries(Cw)){let N=k==="ansi16"?"ansi":k;k===o?t[N]=i(f,E):typeof L=="object"&&(t[N]=i(L[o],E))}return t};function AX(){let i=new Map,o={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};o.color.gray=o.color.blackBright,o.bgColor.bgGray=o.bgColor.bgBlackBright,o.color.grey=o.color.blackBright,o.bgColor.bgGrey=o.bgColor.bgBlackBright;for(let[f,p]of Object.entries(o)){for(let[E,t]of Object.entries(p))o[E]={open:`[${t[0]}m`,close:`[${t[1]}m`},p[E]=o[E],i.set(t[0],t[1]);Object.defineProperty(o,f,{value:p,enumerable:!1})}return Object.defineProperty(o,"codes",{value:i,enumerable:!1}),o.color.close="",o.bgColor.close="",am(o.color,"ansi",()=>fm(X9,"ansi16",g4,!1)),am(o.color,"ansi256",()=>fm(Q9,"ansi256",g4,!1)),am(o.color,"ansi16m",()=>fm(J9,"rgb",Z9,!1)),am(o.bgColor,"ansi",()=>fm(X9,"ansi16",g4,!0)),am(o.bgColor,"ansi256",()=>fm(Q9,"ansi256",g4,!0)),am(o.bgColor,"ansi16m",()=>fm(J9,"rgb",Z9,!0)),o}Object.defineProperty(K9,"exports",{enumerable:!0,get:AX})});var tA=ce((ire,$9)=>{"use strict";var gg=m4(),RX=mw(),OX=_4(),xw=new Set(["","\x9B"]),kX=39,eA=i=>`${xw.values().next().value}[${i}m`,MX=i=>i.split(" ").map(o=>gg(o)),Aw=(i,o,f)=>{let p=[...o],E=!1,t=gg(RX(i[i.length-1]));for(let[k,L]of p.entries()){let N=gg(L);if(t+N<=f?i[i.length-1]+=L:(i.push(L),t=0),xw.has(L))E=!0;else if(E&&L==="m"){E=!1;continue}E||(t+=N,t===f&&k0&&i.length>1&&(i[i.length-2]+=i.pop())},NX=i=>{let o=i.split(" "),f=o.length;for(;f>0&&!(gg(o[f-1])>0);)f--;return f===o.length?i:o.slice(0,f).join(" ")+o.slice(f).join("")},LX=(i,o,f={})=>{if(f.trim!==!1&&i.trim()==="")return"";let p="",E="",t,k=MX(i),L=[""];for(let[N,C]of i.split(" ").entries()){f.trim!==!1&&(L[L.length-1]=L[L.length-1].trimLeft());let U=gg(L[L.length-1]);if(N!==0&&(U>=o&&(f.wordWrap===!1||f.trim===!1)&&(L.push(""),U=0),(U>0||f.trim===!1)&&(L[L.length-1]+=" ",U++)),f.hard&&k[N]>o){let q=o-U,W=1+Math.floor((k[N]-q-1)/o);Math.floor((k[N]-1)/o)o&&U>0&&k[N]>0){if(f.wordWrap===!1&&Uo&&f.wordWrap===!1){Aw(L,C,o);continue}L[L.length-1]+=C}f.trim!==!1&&(L=L.map(NX)),p=L.join(` +`);for(let[N,C]of[...p].entries()){if(E+=C,xw.has(C)){let q=parseFloat(/\d[^m]*/.exec(p.slice(N,N+4)));t=q===kX?null:q}let U=OX.codes.get(Number(t));t&&U&&(p[N+1]===` +`?E+=eA(U):C===` +`&&(E+=eA(t)))}return E};$9.exports=(i,o,f)=>String(i).normalize().replace(/\r\n/g,` +`).split(` +`).map(p=>LX(p,o,f)).join(` +`)});var iA=ce((ure,nA)=>{"use strict";var rA="[\uD800-\uDBFF][\uDC00-\uDFFF]",FX=i=>i&&i.exact?new RegExp(`^${rA}$`):new RegExp(rA,"g");nA.exports=FX});var Rw=ce((ore,uA)=>{"use strict";var bX=gw(),PX=iA(),oA=_4(),lA=["","\x9B"],E4=i=>`${lA[0]}[${i}m`,sA=(i,o,f)=>{let p=[];i=[...i];for(let E of i){let t=E;E.match(";")&&(E=E.split(";")[0][0]+"0");let k=oA.codes.get(parseInt(E,10));if(k){let L=i.indexOf(k.toString());L>=0?i.splice(L,1):p.push(E4(o?k:t))}else if(o){p.push(E4(0));break}else p.push(E4(t))}if(o&&(p=p.filter((E,t)=>p.indexOf(E)===t),f!==void 0)){let E=E4(oA.codes.get(parseInt(f,10)));p=p.reduce((t,k)=>k===E?[k,...t]:[...t,k],[])}return p.join("")};uA.exports=(i,o,f)=>{let p=[...i.normalize()],E=[];f=typeof f=="number"?f:p.length;let t=!1,k,L=0,N="";for(let[C,U]of p.entries()){let q=!1;if(lA.includes(U)){let W=/\d[^m]*/.exec(i.slice(C,C+18));k=W&&W.length>0?W[0]:void 0,Lo&&L<=f)N+=U;else if(L===o&&!t&&k!==void 0)N=sA(E);else if(L>=f){N+=sA(E,!0,k);break}}return N}});var fA=ce((lre,aA)=>{"use strict";var pd=Rw(),IX=m4();function D4(i,o,f){if(i.charAt(o)===" ")return o;for(let p=1;p<=3;p++)if(f){if(i.charAt(o+p)===" ")return o+p}else if(i.charAt(o-p)===" ")return o-p;return o}aA.exports=(i,o,f)=>{f=E0({position:"end",preferTruncationOnSpace:!1},f);let{position:p,space:E,preferTruncationOnSpace:t}=f,k="\u2026",L=1;if(typeof i!="string")throw new TypeError(`Expected \`input\` to be a string, got ${typeof i}`);if(typeof o!="number")throw new TypeError(`Expected \`columns\` to be a number, got ${typeof o}`);if(o<1)return"";if(o===1)return k;let N=IX(i);if(N<=o)return i;if(p==="start"){if(t){let C=D4(i,N-o+1,!0);return k+pd(i,C,N).trim()}return E===!0&&(k+=" ",L=2),k+pd(i,N-o+L,N)}if(p==="middle"){E===!0&&(k=" "+k+" ",L=3);let C=Math.floor(o/2);if(t){let U=D4(i,C),q=D4(i,N-(o-C)+1,!0);return pd(i,0,U)+k+pd(i,q,N).trim()}return pd(i,0,C)+k+pd(i,N-(o-C)+L,N)}if(p==="end"){if(t){let C=D4(i,o-1);return pd(i,0,C)+k}return E===!0&&(k=" "+k,L=2),pd(i,0,o-L)+k}throw new Error(`Expected \`options.position\` to be either \`start\`, \`middle\` or \`end\`, got ${p}`)}});var kw=ce(_g=>{"use strict";var cA=_g&&_g.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(_g,"__esModule",{value:!0});var BX=cA(tA()),UX=cA(fA()),Ow={};_g.default=(i,o,f)=>{let p=i+String(o)+String(f);if(Ow[p])return Ow[p];let E=i;if(f==="wrap"&&(E=BX.default(i,o,{trim:!1,hard:!0})),f.startsWith("truncate")){let t="end";f==="truncate-middle"&&(t="middle"),f==="truncate-start"&&(t="start"),E=UX.default(i,o,{position:t})}return Ow[p]=E,E}});var Nw=ce(Mw=>{"use strict";Object.defineProperty(Mw,"__esModule",{value:!0});var dA=i=>{let o="";if(i.childNodes.length>0)for(let f of i.childNodes){let p="";f.nodeName==="#text"?p=f.nodeValue:((f.nodeName==="ink-text"||f.nodeName==="ink-virtual-text")&&(p=dA(f)),p.length>0&&typeof f.internal_transform=="function"&&(p=f.internal_transform(p))),o+=p}return o};Mw.default=dA});var Lw=ce(l0=>{"use strict";var Eg=l0&&l0.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(l0,"__esModule",{value:!0});l0.setTextNodeValue=l0.createTextNode=l0.setStyle=l0.setAttribute=l0.removeChildNode=l0.insertBeforeNode=l0.appendChildNode=l0.createNode=l0.TEXT_NAME=void 0;var jX=Eg(eh()),pA=Eg(B9()),zX=Eg(U9()),qX=Eg(kw()),HX=Eg(Nw());l0.TEXT_NAME="#text";l0.createNode=i=>{var o;let f={nodeName:i,style:{},attributes:{},childNodes:[],parentNode:null,yogaNode:i==="ink-virtual-text"?void 0:jX.default.Node.create()};return i==="ink-text"&&((o=f.yogaNode)===null||o===void 0||o.setMeasureFunc(WX.bind(null,f))),f};l0.appendChildNode=(i,o)=>{var f;o.parentNode&&l0.removeChildNode(o.parentNode,o),o.parentNode=i,i.childNodes.push(o),o.yogaNode&&((f=i.yogaNode)===null||f===void 0||f.insertChild(o.yogaNode,i.yogaNode.getChildCount())),(i.nodeName==="ink-text"||i.nodeName==="ink-virtual-text")&&w4(i)};l0.insertBeforeNode=(i,o,f)=>{var p,E;o.parentNode&&l0.removeChildNode(o.parentNode,o),o.parentNode=i;let t=i.childNodes.indexOf(f);if(t>=0){i.childNodes.splice(t,0,o),o.yogaNode&&((p=i.yogaNode)===null||p===void 0||p.insertChild(o.yogaNode,t));return}i.childNodes.push(o),o.yogaNode&&((E=i.yogaNode)===null||E===void 0||E.insertChild(o.yogaNode,i.yogaNode.getChildCount())),(i.nodeName==="ink-text"||i.nodeName==="ink-virtual-text")&&w4(i)};l0.removeChildNode=(i,o)=>{var f,p;o.yogaNode&&((p=(f=o.parentNode)===null||f===void 0?void 0:f.yogaNode)===null||p===void 0||p.removeChild(o.yogaNode)),o.parentNode=null;let E=i.childNodes.indexOf(o);E>=0&&i.childNodes.splice(E,1),(i.nodeName==="ink-text"||i.nodeName==="ink-virtual-text")&&w4(i)};l0.setAttribute=(i,o,f)=>{i.attributes[o]=f};l0.setStyle=(i,o)=>{i.style=o,i.yogaNode&&zX.default(i.yogaNode,o)};l0.createTextNode=i=>{let o={nodeName:"#text",nodeValue:i,yogaNode:void 0,parentNode:null,style:{}};return l0.setTextNodeValue(o,i),o};var WX=function(i,o){var f,p;let E=i.nodeName==="#text"?i.nodeValue:HX.default(i),t=pA.default(E);if(t.width<=o||t.width>=1&&o>0&&o<1)return t;let k=(p=(f=i.style)===null||f===void 0?void 0:f.textWrap)!==null&&p!==void 0?p:"wrap",L=qX.default(E,o,k);return pA.default(L)},hA=i=>{var o;if(!(!i||!i.parentNode))return(o=i.yogaNode)!==null&&o!==void 0?o:hA(i.parentNode)},w4=i=>{let o=hA(i);o==null||o.markDirty()};l0.setTextNodeValue=(i,o)=>{typeof o!="string"&&(o=String(o)),i.nodeValue=o,w4(i)}});var th=ce((cre,vA)=>{"use strict";vA.exports={BINARY_TYPES:["nodebuffer","arraybuffer","fragments"],GUID:"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",kStatusCode:Symbol("status-code"),kWebSocket:Symbol("websocket"),EMPTY_BUFFER:Buffer.alloc(0),NOOP:()=>{}}});var Dg=ce((dre,Fw)=>{"use strict";var{EMPTY_BUFFER:VX}=th();function mA(i,o){if(i.length===0)return VX;if(i.length===1)return i[0];let f=Buffer.allocUnsafe(o),p=0;for(let E=0;E{"use strict";var DA=Symbol("kDone"),bw=Symbol("kRun"),wA=class{constructor(o){this[DA]=()=>{this.pending--,this[bw]()},this.concurrency=o||Infinity,this.jobs=[],this.pending=0}add(o){this.jobs.push(o),this[bw]()}[bw](){if(this.pending!==this.concurrency&&this.jobs.length){let o=this.jobs.shift();this.pending++,o(this[DA])}}};EA.exports=wA});var Tg=ce((hre,TA)=>{"use strict";var wg=require("zlib"),CA=Dg(),GX=SA(),{kStatusCode:xA,NOOP:YX}=th(),KX=Buffer.from([0,0,255,255]),T4=Symbol("permessage-deflate"),G1=Symbol("total-length"),Sg=Symbol("callback"),hd=Symbol("buffers"),Pw=Symbol("error"),C4,AA=class{constructor(o,f,p){if(this._maxPayload=p|0,this._options=o||{},this._threshold=this._options.threshold!==void 0?this._options.threshold:1024,this._isServer=!!f,this._deflate=null,this._inflate=null,this.params=null,!C4){let E=this._options.concurrencyLimit!==void 0?this._options.concurrencyLimit:10;C4=new GX(E)}}static get extensionName(){return"permessage-deflate"}offer(){let o={};return this._options.serverNoContextTakeover&&(o.server_no_context_takeover=!0),this._options.clientNoContextTakeover&&(o.client_no_context_takeover=!0),this._options.serverMaxWindowBits&&(o.server_max_window_bits=this._options.serverMaxWindowBits),this._options.clientMaxWindowBits?o.client_max_window_bits=this._options.clientMaxWindowBits:this._options.clientMaxWindowBits==null&&(o.client_max_window_bits=!0),o}accept(o){return o=this.normalizeParams(o),this.params=this._isServer?this.acceptAsServer(o):this.acceptAsClient(o),this.params}cleanup(){if(this._inflate&&(this._inflate.close(),this._inflate=null),this._deflate){let o=this._deflate[Sg];this._deflate.close(),this._deflate=null,o&&o(new Error("The deflate stream was closed while data was being processed"))}}acceptAsServer(o){let f=this._options,p=o.find(E=>!(f.serverNoContextTakeover===!1&&E.server_no_context_takeover||E.server_max_window_bits&&(f.serverMaxWindowBits===!1||typeof f.serverMaxWindowBits=="number"&&f.serverMaxWindowBits>E.server_max_window_bits)||typeof f.clientMaxWindowBits=="number"&&!E.client_max_window_bits));if(!p)throw new Error("None of the extension offers can be accepted");return f.serverNoContextTakeover&&(p.server_no_context_takeover=!0),f.clientNoContextTakeover&&(p.client_no_context_takeover=!0),typeof f.serverMaxWindowBits=="number"&&(p.server_max_window_bits=f.serverMaxWindowBits),typeof f.clientMaxWindowBits=="number"?p.client_max_window_bits=f.clientMaxWindowBits:(p.client_max_window_bits===!0||f.clientMaxWindowBits===!1)&&delete p.client_max_window_bits,p}acceptAsClient(o){let f=o[0];if(this._options.clientNoContextTakeover===!1&&f.client_no_context_takeover)throw new Error('Unexpected parameter "client_no_context_takeover"');if(!f.client_max_window_bits)typeof this._options.clientMaxWindowBits=="number"&&(f.client_max_window_bits=this._options.clientMaxWindowBits);else if(this._options.clientMaxWindowBits===!1||typeof this._options.clientMaxWindowBits=="number"&&f.client_max_window_bits>this._options.clientMaxWindowBits)throw new Error('Unexpected or invalid parameter "client_max_window_bits"');return f}normalizeParams(o){return o.forEach(f=>{Object.keys(f).forEach(p=>{let E=f[p];if(E.length>1)throw new Error(`Parameter "${p}" must have only a single value`);if(E=E[0],p==="client_max_window_bits"){if(E!==!0){let t=+E;if(!Number.isInteger(t)||t<8||t>15)throw new TypeError(`Invalid value for parameter "${p}": ${E}`);E=t}else if(!this._isServer)throw new TypeError(`Invalid value for parameter "${p}": ${E}`)}else if(p==="server_max_window_bits"){let t=+E;if(!Number.isInteger(t)||t<8||t>15)throw new TypeError(`Invalid value for parameter "${p}": ${E}`);E=t}else if(p==="client_no_context_takeover"||p==="server_no_context_takeover"){if(E!==!0)throw new TypeError(`Invalid value for parameter "${p}": ${E}`)}else throw new Error(`Unknown parameter "${p}"`);f[p]=E})}),o}decompress(o,f,p){C4.add(E=>{this._decompress(o,f,(t,k)=>{E(),p(t,k)})})}compress(o,f,p){C4.add(E=>{this._compress(o,f,(t,k)=>{E(),p(t,k)})})}_decompress(o,f,p){let E=this._isServer?"client":"server";if(!this._inflate){let t=`${E}_max_window_bits`,k=typeof this.params[t]!="number"?wg.Z_DEFAULT_WINDOWBITS:this.params[t];this._inflate=wg.createInflateRaw(Gf(E0({},this._options.zlibInflateOptions),{windowBits:k})),this._inflate[T4]=this,this._inflate[G1]=0,this._inflate[hd]=[],this._inflate.on("error",QX),this._inflate.on("data",RA)}this._inflate[Sg]=p,this._inflate.write(o),f&&this._inflate.write(KX),this._inflate.flush(()=>{let t=this._inflate[Pw];if(t){this._inflate.close(),this._inflate=null,p(t);return}let k=CA.concat(this._inflate[hd],this._inflate[G1]);this._inflate._readableState.endEmitted?(this._inflate.close(),this._inflate=null):(this._inflate[G1]=0,this._inflate[hd]=[],f&&this.params[`${E}_no_context_takeover`]&&this._inflate.reset()),p(null,k)})}_compress(o,f,p){let E=this._isServer?"server":"client";if(!this._deflate){let t=`${E}_max_window_bits`,k=typeof this.params[t]!="number"?wg.Z_DEFAULT_WINDOWBITS:this.params[t];this._deflate=wg.createDeflateRaw(Gf(E0({},this._options.zlibDeflateOptions),{windowBits:k})),this._deflate[G1]=0,this._deflate[hd]=[],this._deflate.on("error",YX),this._deflate.on("data",XX)}this._deflate[Sg]=p,this._deflate.write(o),this._deflate.flush(wg.Z_SYNC_FLUSH,()=>{if(!this._deflate)return;let t=CA.concat(this._deflate[hd],this._deflate[G1]);f&&(t=t.slice(0,t.length-4)),this._deflate[Sg]=null,this._deflate[G1]=0,this._deflate[hd]=[],f&&this.params[`${E}_no_context_takeover`]&&this._deflate.reset(),p(null,t)})}};TA.exports=AA;function XX(i){this[hd].push(i),this[G1]+=i.length}function RA(i){if(this[G1]+=i.length,this[T4]._maxPayload<1||this[G1]<=this[T4]._maxPayload){this[hd].push(i);return}this[Pw]=new RangeError("Max payload size exceeded"),this[Pw][xA]=1009,this.removeListener("data",RA),this.reset()}function QX(i){this[T4]._inflate=null,i[xA]=1007,this[Sg](i)}});var Bw=ce((vre,Iw)=>{"use strict";function OA(i){return i>=1e3&&i<=1014&&i!==1004&&i!==1005&&i!==1006||i>=3e3&&i<=4999}function kA(i){let o=i.length,f=0;for(;f=o||(i[f+1]&192)!=128||(i[f+2]&192)!=128||i[f]===224&&(i[f+1]&224)==128||i[f]===237&&(i[f+1]&224)==160)return!1;f+=3}else if((i[f]&248)==240){if(f+3>=o||(i[f+1]&192)!=128||(i[f+2]&192)!=128||(i[f+3]&192)!=128||i[f]===240&&(i[f+1]&240)==128||i[f]===244&&i[f+1]>143||i[f]>244)return!1;f+=4}else return!1;return!0}try{let i=require("utf-8-validate");typeof i=="object"&&(i=i.Validation.isValidUTF8),Iw.exports={isValidStatusCode:OA,isValidUTF8(o){return o.length<150?kA(o):i(o)}}}catch(i){Iw.exports={isValidStatusCode:OA,isValidUTF8:kA}}});var zw=ce((mre,MA)=>{"use strict";var{Writable:JX}=require("stream"),NA=Tg(),{BINARY_TYPES:ZX,EMPTY_BUFFER:$X,kStatusCode:eQ,kWebSocket:tQ}=th(),{concat:Uw,toArrayBuffer:nQ,unmask:rQ}=Dg(),{isValidStatusCode:iQ,isValidUTF8:LA}=Bw(),Cg=0,FA=1,bA=2,PA=3,jw=4,uQ=5,IA=class extends JX{constructor(o,f,p,E){super();this._binaryType=o||ZX[0],this[tQ]=void 0,this._extensions=f||{},this._isServer=!!p,this._maxPayload=E|0,this._bufferedBytes=0,this._buffers=[],this._compressed=!1,this._payloadLength=0,this._mask=void 0,this._fragmented=0,this._masked=!1,this._fin=!1,this._opcode=0,this._totalPayloadLength=0,this._messageLength=0,this._fragments=[],this._state=Cg,this._loop=!1}_write(o,f,p){if(this._opcode===8&&this._state==Cg)return p();this._bufferedBytes+=o.length,this._buffers.push(o),this.startLoop(p)}consume(o){if(this._bufferedBytes-=o,o===this._buffers[0].length)return this._buffers.shift();if(o=p.length?f.set(this._buffers.shift(),E):(f.set(new Uint8Array(p.buffer,p.byteOffset,o),E),this._buffers[0]=p.slice(o)),o-=p.length}while(o>0);return f}startLoop(o){let f;this._loop=!0;do switch(this._state){case Cg:f=this.getInfo();break;case FA:f=this.getPayloadLength16();break;case bA:f=this.getPayloadLength64();break;case PA:this.getMask();break;case jw:f=this.getData(o);break;default:this._loop=!1;return}while(this._loop);o(f)}getInfo(){if(this._bufferedBytes<2){this._loop=!1;return}let o=this.consume(2);if((o[0]&48)!=0)return this._loop=!1,Ho(RangeError,"RSV2 and RSV3 must be clear",!0,1002);let f=(o[0]&64)==64;if(f&&!this._extensions[NA.extensionName])return this._loop=!1,Ho(RangeError,"RSV1 must be clear",!0,1002);if(this._fin=(o[0]&128)==128,this._opcode=o[0]&15,this._payloadLength=o[1]&127,this._opcode===0){if(f)return this._loop=!1,Ho(RangeError,"RSV1 must be clear",!0,1002);if(!this._fragmented)return this._loop=!1,Ho(RangeError,"invalid opcode 0",!0,1002);this._opcode=this._fragmented}else if(this._opcode===1||this._opcode===2){if(this._fragmented)return this._loop=!1,Ho(RangeError,`invalid opcode ${this._opcode}`,!0,1002);this._compressed=f}else if(this._opcode>7&&this._opcode<11){if(!this._fin)return this._loop=!1,Ho(RangeError,"FIN must be set",!0,1002);if(f)return this._loop=!1,Ho(RangeError,"RSV1 must be clear",!0,1002);if(this._payloadLength>125)return this._loop=!1,Ho(RangeError,`invalid payload length ${this._payloadLength}`,!0,1002)}else return this._loop=!1,Ho(RangeError,`invalid opcode ${this._opcode}`,!0,1002);if(!this._fin&&!this._fragmented&&(this._fragmented=this._opcode),this._masked=(o[1]&128)==128,this._isServer){if(!this._masked)return this._loop=!1,Ho(RangeError,"MASK must be set",!0,1002)}else if(this._masked)return this._loop=!1,Ho(RangeError,"MASK must be clear",!0,1002);if(this._payloadLength===126)this._state=FA;else if(this._payloadLength===127)this._state=bA;else return this.haveLength()}getPayloadLength16(){if(this._bufferedBytes<2){this._loop=!1;return}return this._payloadLength=this.consume(2).readUInt16BE(0),this.haveLength()}getPayloadLength64(){if(this._bufferedBytes<8){this._loop=!1;return}let o=this.consume(8),f=o.readUInt32BE(0);return f>Math.pow(2,53-32)-1?(this._loop=!1,Ho(RangeError,"Unsupported WebSocket frame: payload length > 2^53 - 1",!1,1009)):(this._payloadLength=f*Math.pow(2,32)+o.readUInt32BE(4),this.haveLength())}haveLength(){if(this._payloadLength&&this._opcode<8&&(this._totalPayloadLength+=this._payloadLength,this._totalPayloadLength>this._maxPayload&&this._maxPayload>0))return this._loop=!1,Ho(RangeError,"Max payload size exceeded",!1,1009);this._masked?this._state=PA:this._state=jw}getMask(){if(this._bufferedBytes<4){this._loop=!1;return}this._mask=this.consume(4),this._state=jw}getData(o){let f=$X;if(this._payloadLength){if(this._bufferedBytes7)return this.controlMessage(f);if(this._compressed){this._state=uQ,this.decompress(f,o);return}return f.length&&(this._messageLength=this._totalPayloadLength,this._fragments.push(f)),this.dataMessage()}decompress(o,f){this._extensions[NA.extensionName].decompress(o,this._fin,(E,t)=>{if(E)return f(E);if(t.length){if(this._messageLength+=t.length,this._messageLength>this._maxPayload&&this._maxPayload>0)return f(Ho(RangeError,"Max payload size exceeded",!1,1009));this._fragments.push(t)}let k=this.dataMessage();if(k)return f(k);this.startLoop(f)})}dataMessage(){if(this._fin){let o=this._messageLength,f=this._fragments;if(this._totalPayloadLength=0,this._messageLength=0,this._fragmented=0,this._fragments=[],this._opcode===2){let p;this._binaryType==="nodebuffer"?p=Uw(f,o):this._binaryType==="arraybuffer"?p=nQ(Uw(f,o)):p=f,this.emit("message",p)}else{let p=Uw(f,o);if(!LA(p))return this._loop=!1,Ho(Error,"invalid UTF-8 sequence",!0,1007);this.emit("message",p.toString())}}this._state=Cg}controlMessage(o){if(this._opcode===8)if(this._loop=!1,o.length===0)this.emit("conclude",1005,""),this.end();else{if(o.length===1)return Ho(RangeError,"invalid payload length 1",!0,1002);{let f=o.readUInt16BE(0);if(!iQ(f))return Ho(RangeError,`invalid status code ${f}`,!0,1002);let p=o.slice(2);if(!LA(p))return Ho(Error,"invalid UTF-8 sequence",!0,1007);this.emit("conclude",f,p.toString()),this.end()}}else this._opcode===9?this.emit("ping",o):this.emit("pong",o);this._state=Cg}};MA.exports=IA;function Ho(i,o,f,p){let E=new i(f?`Invalid WebSocket frame: ${o}`:o);return Error.captureStackTrace(E,Ho),E[eQ]=p,E}});var qw=ce((yre,BA)=>{"use strict";var{randomFillSync:oQ}=require("crypto"),UA=Tg(),{EMPTY_BUFFER:lQ}=th(),{isValidStatusCode:sQ}=Bw(),{mask:jA,toBuffer:Y1}=Dg(),nh=Buffer.alloc(4),K1=class{constructor(o,f){this._extensions=f||{},this._socket=o,this._firstFragment=!0,this._compress=!1,this._bufferedBytes=0,this._deflating=!1,this._queue=[]}static frame(o,f){let p=f.mask&&f.readOnly,E=f.mask?6:2,t=o.length;o.length>=65536?(E+=8,t=127):o.length>125&&(E+=2,t=126);let k=Buffer.allocUnsafe(p?o.length+E:E);return k[0]=f.fin?f.opcode|128:f.opcode,f.rsv1&&(k[0]|=64),k[1]=t,t===126?k.writeUInt16BE(o.length,2):t===127&&(k.writeUInt32BE(0,2),k.writeUInt32BE(o.length,6)),f.mask?(oQ(nh,0,4),k[1]|=128,k[E-4]=nh[0],k[E-3]=nh[1],k[E-2]=nh[2],k[E-1]=nh[3],p?(jA(o,nh,k,E,o.length),[k]):(jA(o,nh,o,0,o.length),[k,o])):[k,o]}close(o,f,p,E){let t;if(o===void 0)t=lQ;else{if(typeof o!="number"||!sQ(o))throw new TypeError("First argument must be a valid error code number");if(f===void 0||f==="")t=Buffer.allocUnsafe(2),t.writeUInt16BE(o,0);else{let k=Buffer.byteLength(f);if(k>123)throw new RangeError("The message must not be greater than 123 bytes");t=Buffer.allocUnsafe(2+k),t.writeUInt16BE(o,0),t.write(f,2)}}this._deflating?this.enqueue([this.doClose,t,p,E]):this.doClose(t,p,E)}doClose(o,f,p){this.sendFrame(K1.frame(o,{fin:!0,rsv1:!1,opcode:8,mask:f,readOnly:!1}),p)}ping(o,f,p){let E=Y1(o);if(E.length>125)throw new RangeError("The data size must not be greater than 125 bytes");this._deflating?this.enqueue([this.doPing,E,f,Y1.readOnly,p]):this.doPing(E,f,Y1.readOnly,p)}doPing(o,f,p,E){this.sendFrame(K1.frame(o,{fin:!0,rsv1:!1,opcode:9,mask:f,readOnly:p}),E)}pong(o,f,p){let E=Y1(o);if(E.length>125)throw new RangeError("The data size must not be greater than 125 bytes");this._deflating?this.enqueue([this.doPong,E,f,Y1.readOnly,p]):this.doPong(E,f,Y1.readOnly,p)}doPong(o,f,p,E){this.sendFrame(K1.frame(o,{fin:!0,rsv1:!1,opcode:10,mask:f,readOnly:p}),E)}send(o,f,p){let E=Y1(o),t=this._extensions[UA.extensionName],k=f.binary?2:1,L=f.compress;if(this._firstFragment?(this._firstFragment=!1,L&&t&&(L=E.length>=t._threshold),this._compress=L):(L=!1,k=0),f.fin&&(this._firstFragment=!0),t){let N={fin:f.fin,rsv1:L,opcode:k,mask:f.mask,readOnly:Y1.readOnly};this._deflating?this.enqueue([this.dispatch,E,this._compress,N,p]):this.dispatch(E,this._compress,N,p)}else this.sendFrame(K1.frame(E,{fin:f.fin,rsv1:!1,opcode:k,mask:f.mask,readOnly:Y1.readOnly}),p)}dispatch(o,f,p,E){if(!f){this.sendFrame(K1.frame(o,p),E);return}let t=this._extensions[UA.extensionName];this._bufferedBytes+=o.length,this._deflating=!0,t.compress(o,p.fin,(k,L)=>{if(this._socket.destroyed){let N=new Error("The socket was closed while data was being compressed");typeof E=="function"&&E(N);for(let C=0;C{"use strict";var xg=class{constructor(o,f){this.target=f,this.type=o}},qA=class extends xg{constructor(o,f){super("message",f);this.data=o}},HA=class extends xg{constructor(o,f,p){super("close",p);this.wasClean=p._closeFrameReceived&&p._closeFrameSent,this.reason=f,this.code=o}},WA=class extends xg{constructor(o){super("open",o)}},VA=class extends xg{constructor(o,f){super("error",f);this.message=o.message,this.error=o}},aQ={addEventListener(i,o,f){if(typeof o!="function")return;function p(N){o.call(this,new qA(N,this))}function E(N,C){o.call(this,new HA(N,C,this))}function t(N){o.call(this,new VA(N,this))}function k(){o.call(this,new WA(this))}let L=f&&f.once?"once":"on";i==="message"?(p._listener=o,this[L](i,p)):i==="close"?(E._listener=o,this[L](i,E)):i==="error"?(t._listener=o,this[L](i,t)):i==="open"?(k._listener=o,this[L](i,k)):this[L](i,o)},removeEventListener(i,o){let f=this.listeners(i);for(let p=0;p{"use strict";var Ag=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,0];function Pc(i,o,f){i[o]===void 0?i[o]=[f]:i[o].push(f)}function fQ(i){let o=Object.create(null);if(i===void 0||i==="")return o;let f=Object.create(null),p=!1,E=!1,t=!1,k,L,N=-1,C=-1,U=0;for(;U{let f=i[o];return Array.isArray(f)||(f=[f]),f.map(p=>[o].concat(Object.keys(p).map(E=>{let t=p[E];return Array.isArray(t)||(t=[t]),t.map(k=>k===!0?E:`${E}=${k}`).join("; ")})).join("; ")).join(", ")}).join(", ")}YA.exports={format:cQ,parse:fQ}});var Kw=ce((Ere,KA)=>{"use strict";var dQ=require("events"),pQ=require("https"),hQ=require("http"),XA=require("net"),vQ=require("tls"),{randomBytes:mQ,createHash:yQ}=require("crypto"),{URL:Ww}=require("url"),vd=Tg(),gQ=zw(),_Q=qw(),{BINARY_TYPES:QA,EMPTY_BUFFER:Vw,GUID:EQ,kStatusCode:DQ,kWebSocket:Qs,NOOP:JA}=th(),{addEventListener:wQ,removeEventListener:SQ}=GA(),{format:TQ,parse:CQ}=Hw(),{toBuffer:xQ}=Dg(),ZA=["CONNECTING","OPEN","CLOSING","CLOSED"],Gw=[8,13],AQ=30*1e3,Bi=class extends dQ{constructor(o,f,p){super();this._binaryType=QA[0],this._closeCode=1006,this._closeFrameReceived=!1,this._closeFrameSent=!1,this._closeMessage="",this._closeTimer=null,this._extensions={},this._protocol="",this._readyState=Bi.CONNECTING,this._receiver=null,this._sender=null,this._socket=null,o!==null?(this._bufferedAmount=0,this._isServer=!1,this._redirects=0,Array.isArray(f)?f=f.join(", "):typeof f=="object"&&f!==null&&(p=f,f=void 0),$A(this,o,f,p)):this._isServer=!0}get binaryType(){return this._binaryType}set binaryType(o){!QA.includes(o)||(this._binaryType=o,this._receiver&&(this._receiver._binaryType=o))}get bufferedAmount(){return this._socket?this._socket._writableState.length+this._sender._bufferedBytes:this._bufferedAmount}get extensions(){return Object.keys(this._extensions).join()}get protocol(){return this._protocol}get readyState(){return this._readyState}get url(){return this._url}setSocket(o,f,p){let E=new gQ(this.binaryType,this._extensions,this._isServer,p);this._sender=new _Q(o,this._extensions),this._receiver=E,this._socket=o,E[Qs]=this,o[Qs]=this,E.on("conclude",RQ),E.on("drain",OQ),E.on("error",kQ),E.on("message",MQ),E.on("ping",NQ),E.on("pong",LQ),o.setTimeout(0),o.setNoDelay(),f.length>0&&o.unshift(f),o.on("close",eR),o.on("data",x4),o.on("end",tR),o.on("error",nR),this._readyState=Bi.OPEN,this.emit("open")}emitClose(){if(!this._socket){this._readyState=Bi.CLOSED,this.emit("close",this._closeCode,this._closeMessage);return}this._extensions[vd.extensionName]&&this._extensions[vd.extensionName].cleanup(),this._receiver.removeAllListeners(),this._readyState=Bi.CLOSED,this.emit("close",this._closeCode,this._closeMessage)}close(o,f){if(this.readyState!==Bi.CLOSED){if(this.readyState===Bi.CONNECTING){let p="WebSocket was closed before the connection was established";return X1(this,this._req,p)}if(this.readyState===Bi.CLOSING){this._closeFrameSent&&this._closeFrameReceived&&this._socket.end();return}this._readyState=Bi.CLOSING,this._sender.close(o,f,!this._isServer,p=>{p||(this._closeFrameSent=!0,this._closeFrameReceived&&this._socket.end())}),this._closeTimer=setTimeout(this._socket.destroy.bind(this._socket),AQ)}}ping(o,f,p){if(this.readyState===Bi.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof o=="function"?(p=o,o=f=void 0):typeof f=="function"&&(p=f,f=void 0),typeof o=="number"&&(o=o.toString()),this.readyState!==Bi.OPEN){Yw(this,o,p);return}f===void 0&&(f=!this._isServer),this._sender.ping(o||Vw,f,p)}pong(o,f,p){if(this.readyState===Bi.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof o=="function"?(p=o,o=f=void 0):typeof f=="function"&&(p=f,f=void 0),typeof o=="number"&&(o=o.toString()),this.readyState!==Bi.OPEN){Yw(this,o,p);return}f===void 0&&(f=!this._isServer),this._sender.pong(o||Vw,f,p)}send(o,f,p){if(this.readyState===Bi.CONNECTING)throw new Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof f=="function"&&(p=f,f={}),typeof o=="number"&&(o=o.toString()),this.readyState!==Bi.OPEN){Yw(this,o,p);return}let E=E0({binary:typeof o!="string",mask:!this._isServer,compress:!0,fin:!0},f);this._extensions[vd.extensionName]||(E.compress=!1),this._sender.send(o||Vw,E,p)}terminate(){if(this.readyState!==Bi.CLOSED){if(this.readyState===Bi.CONNECTING){let o="WebSocket was closed before the connection was established";return X1(this,this._req,o)}this._socket&&(this._readyState=Bi.CLOSING,this._socket.destroy())}}};ZA.forEach((i,o)=>{let f={enumerable:!0,value:o};Object.defineProperty(Bi.prototype,i,f),Object.defineProperty(Bi,i,f)});["binaryType","bufferedAmount","extensions","protocol","readyState","url"].forEach(i=>{Object.defineProperty(Bi.prototype,i,{enumerable:!0})});["open","error","close","message"].forEach(i=>{Object.defineProperty(Bi.prototype,`on${i}`,{configurable:!0,enumerable:!0,get(){let o=this.listeners(i);for(let f=0;f{X1(i,W,"Opening handshake has timed out")}),W.on("error",ne=>{W===null||W.aborted||(W=i._req=null,i._readyState=Bi.CLOSING,i.emit("error",ne),i.emitClose())}),W.on("response",ne=>{let m=ne.headers.location,we=ne.statusCode;if(m&&E.followRedirects&&we>=300&&we<400){if(++i._redirects>E.maxRedirects){X1(i,W,"Maximum redirects exceeded");return}W.abort();let Se=new Ww(m,o);$A(i,Se,f,p)}else i.emit("unexpected-response",W,ne)||X1(i,W,`Unexpected server response: ${ne.statusCode}`)}),W.on("upgrade",(ne,m,we)=>{if(i.emit("upgrade",ne),i.readyState!==Bi.CONNECTING)return;W=i._req=null;let Se=yQ("sha1").update(C+EQ).digest("base64");if(ne.headers["sec-websocket-accept"]!==Se){X1(i,m,"Invalid Sec-WebSocket-Accept header");return}let he=ne.headers["sec-websocket-protocol"],ge=(f||"").split(/, */),ze;if(!f&&he?ze="Server sent a subprotocol but none was requested":f&&!he?ze="Server sent no subprotocol":he&&!ge.includes(he)&&(ze="Server sent an invalid subprotocol"),ze){X1(i,m,ze);return}if(he&&(i._protocol=he),q)try{let pe=CQ(ne.headers["sec-websocket-extensions"]);pe[vd.extensionName]&&(q.accept(pe[vd.extensionName]),i._extensions[vd.extensionName]=q)}catch(pe){X1(i,m,"Invalid Sec-WebSocket-Extensions header");return}i.setSocket(m,we,E.maxPayload)})}function FQ(i){return i.path=i.socketPath,XA.connect(i)}function bQ(i){return i.path=void 0,!i.servername&&i.servername!==""&&(i.servername=XA.isIP(i.host)?"":i.host),vQ.connect(i)}function X1(i,o,f){i._readyState=Bi.CLOSING;let p=new Error(f);Error.captureStackTrace(p,X1),o.setHeader?(o.abort(),o.socket&&!o.socket.destroyed&&o.socket.destroy(),o.once("abort",i.emitClose.bind(i)),i.emit("error",p)):(o.destroy(p),o.once("error",i.emit.bind(i,"error")),o.once("close",i.emitClose.bind(i)))}function Yw(i,o,f){if(o){let p=xQ(o).length;i._socket?i._sender._bufferedBytes+=p:i._bufferedAmount+=p}if(f){let p=new Error(`WebSocket is not open: readyState ${i.readyState} (${ZA[i.readyState]})`);f(p)}}function RQ(i,o){let f=this[Qs];f._socket.removeListener("data",x4),f._socket.resume(),f._closeFrameReceived=!0,f._closeMessage=o,f._closeCode=i,i===1005?f.close():f.close(i,o)}function OQ(){this[Qs]._socket.resume()}function kQ(i){let o=this[Qs];o._socket.removeListener("data",x4),o._readyState=Bi.CLOSING,o._closeCode=i[DQ],o.emit("error",i),o._socket.destroy()}function rR(){this[Qs].emitClose()}function MQ(i){this[Qs].emit("message",i)}function NQ(i){let o=this[Qs];o.pong(i,!o._isServer,JA),o.emit("ping",i)}function LQ(i){this[Qs].emit("pong",i)}function eR(){let i=this[Qs];this.removeListener("close",eR),this.removeListener("end",tR),i._readyState=Bi.CLOSING,i._socket.read(),i._receiver.end(),this.removeListener("data",x4),this[Qs]=void 0,clearTimeout(i._closeTimer),i._receiver._writableState.finished||i._receiver._writableState.errorEmitted?i.emitClose():(i._receiver.on("error",rR),i._receiver.on("finish",rR))}function x4(i){this[Qs]._receiver.write(i)||this.pause()}function tR(){let i=this[Qs];i._readyState=Bi.CLOSING,i._receiver.end(),this.end()}function nR(){let i=this[Qs];this.removeListener("error",nR),this.on("error",JA),i&&(i._readyState=Bi.CLOSING,this.destroy())}});var lR=ce((Dre,iR)=>{"use strict";var{Duplex:PQ}=require("stream");function uR(i){i.emit("close")}function IQ(){!this.destroyed&&this._writableState.finished&&this.destroy()}function oR(i){this.removeListener("error",oR),this.destroy(),this.listenerCount("error")===0&&this.emit("error",i)}function BQ(i,o){let f=!0;function p(){f&&i._socket.resume()}i.readyState===i.CONNECTING?i.once("open",function(){i._receiver.removeAllListeners("drain"),i._receiver.on("drain",p)}):(i._receiver.removeAllListeners("drain"),i._receiver.on("drain",p));let E=new PQ(Gf(E0({},o),{autoDestroy:!1,emitClose:!1,objectMode:!1,writableObjectMode:!1}));return i.on("message",function(k){E.push(k)||(f=!1,i._socket.pause())}),i.once("error",function(k){E.destroyed||E.destroy(k)}),i.once("close",function(){E.destroyed||E.push(null)}),E._destroy=function(t,k){if(i.readyState===i.CLOSED){k(t),process.nextTick(uR,E);return}let L=!1;i.once("error",function(C){L=!0,k(C)}),i.once("close",function(){L||k(t),process.nextTick(uR,E)}),i.terminate()},E._final=function(t){if(i.readyState===i.CONNECTING){i.once("open",function(){E._final(t)});return}i._socket!==null&&(i._socket._writableState.finished?(t(),E._readableState.endEmitted&&E.destroy()):(i._socket.once("finish",function(){t()}),i.close()))},E._read=function(){i.readyState===i.OPEN&&!f&&(f=!0,i._receiver._writableState.needDrain||i._socket.resume())},E._write=function(t,k,L){if(i.readyState===i.CONNECTING){i.once("open",function(){E._write(t,k,L)});return}i.send(t,L)},E.on("end",IQ),E.on("error",oR),E}iR.exports=BQ});var fR=ce((wre,sR)=>{"use strict";var UQ=require("events"),{createHash:jQ}=require("crypto"),{createServer:zQ,STATUS_CODES:Xw}=require("http"),rh=Tg(),qQ=Kw(),{format:HQ,parse:WQ}=Hw(),{GUID:VQ,kWebSocket:GQ}=th(),YQ=/^[+/0-9A-Za-z]{22}==$/,aR=class extends UQ{constructor(o,f){super();if(o=E0({maxPayload:100*1024*1024,perMessageDeflate:!1,handleProtocols:null,clientTracking:!0,verifyClient:null,noServer:!1,backlog:null,server:null,host:null,path:null,port:null},o),o.port==null&&!o.server&&!o.noServer)throw new TypeError('One of the "port", "server", or "noServer" options must be specified');if(o.port!=null?(this._server=zQ((p,E)=>{let t=Xw[426];E.writeHead(426,{"Content-Length":t.length,"Content-Type":"text/plain"}),E.end(t)}),this._server.listen(o.port,o.host,o.backlog,f)):o.server&&(this._server=o.server),this._server){let p=this.emit.bind(this,"connection");this._removeListeners=KQ(this._server,{listening:this.emit.bind(this,"listening"),error:this.emit.bind(this,"error"),upgrade:(E,t,k)=>{this.handleUpgrade(E,t,k,p)}})}o.perMessageDeflate===!0&&(o.perMessageDeflate={}),o.clientTracking&&(this.clients=new Set),this.options=o}address(){if(this.options.noServer)throw new Error('The server is operating in "noServer" mode');return this._server?this._server.address():null}close(o){if(o&&this.once("close",o),this.clients)for(let p of this.clients)p.terminate();let f=this._server;if(f&&(this._removeListeners(),this._removeListeners=this._server=null,this.options.port!=null)){f.close(()=>this.emit("close"));return}process.nextTick(XQ,this)}shouldHandle(o){if(this.options.path){let f=o.url.indexOf("?");if((f!==-1?o.url.slice(0,f):o.url)!==this.options.path)return!1}return!0}handleUpgrade(o,f,p,E){f.on("error",Qw);let t=o.headers["sec-websocket-key"]!==void 0?o.headers["sec-websocket-key"].trim():!1,k=+o.headers["sec-websocket-version"],L={};if(o.method!=="GET"||o.headers.upgrade.toLowerCase()!=="websocket"||!t||!YQ.test(t)||k!==8&&k!==13||!this.shouldHandle(o))return A4(f,400);if(this.options.perMessageDeflate){let N=new rh(this.options.perMessageDeflate,!0,this.options.maxPayload);try{let C=WQ(o.headers["sec-websocket-extensions"]);C[rh.extensionName]&&(N.accept(C[rh.extensionName]),L[rh.extensionName]=N)}catch(C){return A4(f,400)}}if(this.options.verifyClient){let N={origin:o.headers[`${k===8?"sec-websocket-origin":"origin"}`],secure:!!(o.socket.authorized||o.socket.encrypted),req:o};if(this.options.verifyClient.length===2){this.options.verifyClient(N,(C,U,q,W)=>{if(!C)return A4(f,U||401,q,W);this.completeUpgrade(t,L,o,f,p,E)});return}if(!this.options.verifyClient(N))return A4(f,401)}this.completeUpgrade(t,L,o,f,p,E)}completeUpgrade(o,f,p,E,t,k){if(!E.readable||!E.writable)return E.destroy();if(E[GQ])throw new Error("server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration");let L=jQ("sha1").update(o+VQ).digest("base64"),N=["HTTP/1.1 101 Switching Protocols","Upgrade: websocket","Connection: Upgrade",`Sec-WebSocket-Accept: ${L}`],C=new qQ(null),U=p.headers["sec-websocket-protocol"];if(U&&(U=U.split(",").map(QQ),this.options.handleProtocols?U=this.options.handleProtocols(U,p):U=U[0],U&&(N.push(`Sec-WebSocket-Protocol: ${U}`),C._protocol=U)),f[rh.extensionName]){let q=f[rh.extensionName].params,W=HQ({[rh.extensionName]:[q]});N.push(`Sec-WebSocket-Extensions: ${W}`),C._extensions=f}this.emit("headers",N,p),E.write(N.concat(`\r +`).join(`\r +`)),E.removeListener("error",Qw),C.setSocket(E,t,this.options.maxPayload),this.clients&&(this.clients.add(C),C.on("close",()=>this.clients.delete(C))),k(C,p)}};sR.exports=aR;function KQ(i,o){for(let f of Object.keys(o))i.on(f,o[f]);return function(){for(let p of Object.keys(o))i.removeListener(p,o[p])}}function XQ(i){i.emit("close")}function Qw(){this.destroy()}function A4(i,o,f,p){i.writable&&(f=f||Xw[o],p=E0({Connection:"close","Content-Type":"text/html","Content-Length":Buffer.byteLength(f)},p),i.write(`HTTP/1.1 ${o} ${Xw[o]}\r +`+Object.keys(p).map(E=>`${E}: ${p[E]}`).join(`\r +`)+`\r +\r +`+f)),i.removeListener("error",Qw),i.destroy()}function QQ(i){return i.trim()}});var dR=ce((Sre,cR)=>{"use strict";var Rg=Kw();Rg.createWebSocketStream=lR();Rg.Server=fR();Rg.Receiver=zw();Rg.Sender=qw();cR.exports=Rg});var pR=ce(R4=>{"use strict";var JQ=R4&&R4.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(R4,"__esModule",{value:!0});var ZQ=JQ(dR()),Og=global;Og.WebSocket||(Og.WebSocket=ZQ.default);Og.window||(Og.window=global);Og.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__=[{type:1,value:7,isEnabled:!0},{type:2,value:"InternalApp",isEnabled:!0,isValid:!0},{type:2,value:"InternalAppContext",isEnabled:!0,isValid:!0},{type:2,value:"InternalStdoutContext",isEnabled:!0,isValid:!0},{type:2,value:"InternalStderrContext",isEnabled:!0,isValid:!0},{type:2,value:"InternalStdinContext",isEnabled:!0,isValid:!0},{type:2,value:"InternalFocusContext",isEnabled:!0,isValid:!0}]});var hR=ce((O4,Jw)=>{(function(i,o){typeof O4=="object"&&typeof Jw=="object"?Jw.exports=o():typeof define=="function"&&define.amd?define([],o):typeof O4=="object"?O4.ReactDevToolsBackend=o():i.ReactDevToolsBackend=o()})(window,function(){return function(i){var o={};function f(p){if(o[p])return o[p].exports;var E=o[p]={i:p,l:!1,exports:{}};return i[p].call(E.exports,E,E.exports,f),E.l=!0,E.exports}return f.m=i,f.c=o,f.d=function(p,E,t){f.o(p,E)||Object.defineProperty(p,E,{enumerable:!0,get:t})},f.r=function(p){typeof Symbol!="undefined"&&Symbol.toStringTag&&Object.defineProperty(p,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(p,"__esModule",{value:!0})},f.t=function(p,E){if(1&E&&(p=f(p)),8&E||4&E&&typeof p=="object"&&p&&p.__esModule)return p;var t=Object.create(null);if(f.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:p}),2&E&&typeof p!="string")for(var k in p)f.d(t,k,function(L){return p[L]}.bind(null,k));return t},f.n=function(p){var E=p&&p.__esModule?function(){return p.default}:function(){return p};return f.d(E,"a",E),E},f.o=function(p,E){return Object.prototype.hasOwnProperty.call(p,E)},f.p="",f(f.s=20)}([function(i,o,f){"use strict";i.exports=f(12)},function(i,o,f){"use strict";var p=Object.getOwnPropertySymbols,E=Object.prototype.hasOwnProperty,t=Object.prototype.propertyIsEnumerable;function k(L){if(L==null)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(L)}i.exports=function(){try{if(!Object.assign)return!1;var L=new String("abc");if(L[5]="de",Object.getOwnPropertyNames(L)[0]==="5")return!1;for(var N={},C=0;C<10;C++)N["_"+String.fromCharCode(C)]=C;if(Object.getOwnPropertyNames(N).map(function(q){return N[q]}).join("")!=="0123456789")return!1;var U={};return"abcdefghijklmnopqrst".split("").forEach(function(q){U[q]=q}),Object.keys(Object.assign({},U)).join("")==="abcdefghijklmnopqrst"}catch(q){return!1}}()?Object.assign:function(L,N){for(var C,U,q=k(L),W=1;W=le||en<0||$t&&At-Ke>=wt}function ue(){var At=Se();if(Ce(At))return je(At);$e=setTimeout(ue,function(en){var ln=le-(en-ft);return $t?we(ln,wt-(en-Ke)):ln}(At))}function je(At){return $e=void 0,at&&Ge?Q(At):(Ge=rt=void 0,xt)}function ct(){var At=Se(),en=Ce(At);if(Ge=arguments,rt=this,ft=At,en){if($e===void 0)return ae(ft);if($t)return $e=setTimeout(ue,le),Q(ft)}return $e===void 0&&($e=setTimeout(ue,le)),xt}return le=pe(le)||0,ge(Ue)&&(jt=!!Ue.leading,wt=($t="maxWait"in Ue)?m(pe(Ue.maxWait)||0,le):wt,at="trailing"in Ue?!!Ue.trailing:at),ct.cancel=function(){$e!==void 0&&clearTimeout($e),Ke=0,Ge=ft=rt=$e=void 0},ct.flush=function(){return $e===void 0?xt:je(Se())},ct}function ge(Oe){var le=E(Oe);return!!Oe&&(le=="object"||le=="function")}function ze(Oe){return E(Oe)=="symbol"||function(le){return!!le&&E(le)=="object"}(Oe)&&ne.call(Oe)=="[object Symbol]"}function pe(Oe){if(typeof Oe=="number")return Oe;if(ze(Oe))return NaN;if(ge(Oe)){var le=typeof Oe.valueOf=="function"?Oe.valueOf():Oe;Oe=ge(le)?le+"":le}if(typeof Oe!="string")return Oe===0?Oe:+Oe;Oe=Oe.replace(t,"");var Ue=L.test(Oe);return Ue||N.test(Oe)?C(Oe.slice(2),Ue?2:8):k.test(Oe)?NaN:+Oe}i.exports=function(Oe,le,Ue){var Ge=!0,rt=!0;if(typeof Oe!="function")throw new TypeError("Expected a function");return ge(Ue)&&(Ge="leading"in Ue?!!Ue.leading:Ge,rt="trailing"in Ue?!!Ue.trailing:rt),he(Oe,le,{leading:Ge,maxWait:le,trailing:rt})}}).call(this,f(4))},function(i,o,f){(function(p){function E(Q){return(E=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(ae){return typeof ae}:function(ae){return ae&&typeof Symbol=="function"&&ae.constructor===Symbol&&ae!==Symbol.prototype?"symbol":typeof ae})(Q)}var t;o=i.exports=m,t=(p===void 0?"undefined":E(p))==="object"&&p.env&&p.env.NODE_DEBUG&&/\bsemver\b/i.test(p.env.NODE_DEBUG)?function(){var Q=Array.prototype.slice.call(arguments,0);Q.unshift("SEMVER"),console.log.apply(console,Q)}:function(){},o.SEMVER_SPEC_VERSION="2.0.0";var k=Number.MAX_SAFE_INTEGER||9007199254740991,L=o.re=[],N=o.src=[],C=o.tokens={},U=0;function q(Q){C[Q]=U++}q("NUMERICIDENTIFIER"),N[C.NUMERICIDENTIFIER]="0|[1-9]\\d*",q("NUMERICIDENTIFIERLOOSE"),N[C.NUMERICIDENTIFIERLOOSE]="[0-9]+",q("NONNUMERICIDENTIFIER"),N[C.NONNUMERICIDENTIFIER]="\\d*[a-zA-Z-][a-zA-Z0-9-]*",q("MAINVERSION"),N[C.MAINVERSION]="("+N[C.NUMERICIDENTIFIER]+")\\.("+N[C.NUMERICIDENTIFIER]+")\\.("+N[C.NUMERICIDENTIFIER]+")",q("MAINVERSIONLOOSE"),N[C.MAINVERSIONLOOSE]="("+N[C.NUMERICIDENTIFIERLOOSE]+")\\.("+N[C.NUMERICIDENTIFIERLOOSE]+")\\.("+N[C.NUMERICIDENTIFIERLOOSE]+")",q("PRERELEASEIDENTIFIER"),N[C.PRERELEASEIDENTIFIER]="(?:"+N[C.NUMERICIDENTIFIER]+"|"+N[C.NONNUMERICIDENTIFIER]+")",q("PRERELEASEIDENTIFIERLOOSE"),N[C.PRERELEASEIDENTIFIERLOOSE]="(?:"+N[C.NUMERICIDENTIFIERLOOSE]+"|"+N[C.NONNUMERICIDENTIFIER]+")",q("PRERELEASE"),N[C.PRERELEASE]="(?:-("+N[C.PRERELEASEIDENTIFIER]+"(?:\\."+N[C.PRERELEASEIDENTIFIER]+")*))",q("PRERELEASELOOSE"),N[C.PRERELEASELOOSE]="(?:-?("+N[C.PRERELEASEIDENTIFIERLOOSE]+"(?:\\."+N[C.PRERELEASEIDENTIFIERLOOSE]+")*))",q("BUILDIDENTIFIER"),N[C.BUILDIDENTIFIER]="[0-9A-Za-z-]+",q("BUILD"),N[C.BUILD]="(?:\\+("+N[C.BUILDIDENTIFIER]+"(?:\\."+N[C.BUILDIDENTIFIER]+")*))",q("FULL"),q("FULLPLAIN"),N[C.FULLPLAIN]="v?"+N[C.MAINVERSION]+N[C.PRERELEASE]+"?"+N[C.BUILD]+"?",N[C.FULL]="^"+N[C.FULLPLAIN]+"$",q("LOOSEPLAIN"),N[C.LOOSEPLAIN]="[v=\\s]*"+N[C.MAINVERSIONLOOSE]+N[C.PRERELEASELOOSE]+"?"+N[C.BUILD]+"?",q("LOOSE"),N[C.LOOSE]="^"+N[C.LOOSEPLAIN]+"$",q("GTLT"),N[C.GTLT]="((?:<|>)?=?)",q("XRANGEIDENTIFIERLOOSE"),N[C.XRANGEIDENTIFIERLOOSE]=N[C.NUMERICIDENTIFIERLOOSE]+"|x|X|\\*",q("XRANGEIDENTIFIER"),N[C.XRANGEIDENTIFIER]=N[C.NUMERICIDENTIFIER]+"|x|X|\\*",q("XRANGEPLAIN"),N[C.XRANGEPLAIN]="[v=\\s]*("+N[C.XRANGEIDENTIFIER]+")(?:\\.("+N[C.XRANGEIDENTIFIER]+")(?:\\.("+N[C.XRANGEIDENTIFIER]+")(?:"+N[C.PRERELEASE]+")?"+N[C.BUILD]+"?)?)?",q("XRANGEPLAINLOOSE"),N[C.XRANGEPLAINLOOSE]="[v=\\s]*("+N[C.XRANGEIDENTIFIERLOOSE]+")(?:\\.("+N[C.XRANGEIDENTIFIERLOOSE]+")(?:\\.("+N[C.XRANGEIDENTIFIERLOOSE]+")(?:"+N[C.PRERELEASELOOSE]+")?"+N[C.BUILD]+"?)?)?",q("XRANGE"),N[C.XRANGE]="^"+N[C.GTLT]+"\\s*"+N[C.XRANGEPLAIN]+"$",q("XRANGELOOSE"),N[C.XRANGELOOSE]="^"+N[C.GTLT]+"\\s*"+N[C.XRANGEPLAINLOOSE]+"$",q("COERCE"),N[C.COERCE]="(^|[^\\d])(\\d{1,16})(?:\\.(\\d{1,16}))?(?:\\.(\\d{1,16}))?(?:$|[^\\d])",q("COERCERTL"),L[C.COERCERTL]=new RegExp(N[C.COERCE],"g"),q("LONETILDE"),N[C.LONETILDE]="(?:~>?)",q("TILDETRIM"),N[C.TILDETRIM]="(\\s*)"+N[C.LONETILDE]+"\\s+",L[C.TILDETRIM]=new RegExp(N[C.TILDETRIM],"g"),q("TILDE"),N[C.TILDE]="^"+N[C.LONETILDE]+N[C.XRANGEPLAIN]+"$",q("TILDELOOSE"),N[C.TILDELOOSE]="^"+N[C.LONETILDE]+N[C.XRANGEPLAINLOOSE]+"$",q("LONECARET"),N[C.LONECARET]="(?:\\^)",q("CARETTRIM"),N[C.CARETTRIM]="(\\s*)"+N[C.LONECARET]+"\\s+",L[C.CARETTRIM]=new RegExp(N[C.CARETTRIM],"g"),q("CARET"),N[C.CARET]="^"+N[C.LONECARET]+N[C.XRANGEPLAIN]+"$",q("CARETLOOSE"),N[C.CARETLOOSE]="^"+N[C.LONECARET]+N[C.XRANGEPLAINLOOSE]+"$",q("COMPARATORLOOSE"),N[C.COMPARATORLOOSE]="^"+N[C.GTLT]+"\\s*("+N[C.LOOSEPLAIN]+")$|^$",q("COMPARATOR"),N[C.COMPARATOR]="^"+N[C.GTLT]+"\\s*("+N[C.FULLPLAIN]+")$|^$",q("COMPARATORTRIM"),N[C.COMPARATORTRIM]="(\\s*)"+N[C.GTLT]+"\\s*("+N[C.LOOSEPLAIN]+"|"+N[C.XRANGEPLAIN]+")",L[C.COMPARATORTRIM]=new RegExp(N[C.COMPARATORTRIM],"g"),q("HYPHENRANGE"),N[C.HYPHENRANGE]="^\\s*("+N[C.XRANGEPLAIN]+")\\s+-\\s+("+N[C.XRANGEPLAIN]+")\\s*$",q("HYPHENRANGELOOSE"),N[C.HYPHENRANGELOOSE]="^\\s*("+N[C.XRANGEPLAINLOOSE]+")\\s+-\\s+("+N[C.XRANGEPLAINLOOSE]+")\\s*$",q("STAR"),N[C.STAR]="(<|>)?=?\\s*\\*";for(var W=0;W256||!(ae.loose?L[C.LOOSE]:L[C.FULL]).test(Q))return null;try{return new m(Q,ae)}catch(Ce){return null}}function m(Q,ae){if(ae&&E(ae)==="object"||(ae={loose:!!ae,includePrerelease:!1}),Q instanceof m){if(Q.loose===ae.loose)return Q;Q=Q.version}else if(typeof Q!="string")throw new TypeError("Invalid Version: "+Q);if(Q.length>256)throw new TypeError("version is longer than 256 characters");if(!(this instanceof m))return new m(Q,ae);t("SemVer",Q,ae),this.options=ae,this.loose=!!ae.loose;var Ce=Q.trim().match(ae.loose?L[C.LOOSE]:L[C.FULL]);if(!Ce)throw new TypeError("Invalid Version: "+Q);if(this.raw=Q,this.major=+Ce[1],this.minor=+Ce[2],this.patch=+Ce[3],this.major>k||this.major<0)throw new TypeError("Invalid major version");if(this.minor>k||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>k||this.patch<0)throw new TypeError("Invalid patch version");Ce[4]?this.prerelease=Ce[4].split(".").map(function(ue){if(/^[0-9]+$/.test(ue)){var je=+ue;if(je>=0&&je=0;)typeof this.prerelease[Ce]=="number"&&(this.prerelease[Ce]++,Ce=-2);Ce===-1&&this.prerelease.push(0)}ae&&(this.prerelease[0]===ae?isNaN(this.prerelease[1])&&(this.prerelease=[ae,0]):this.prerelease=[ae,0]);break;default:throw new Error("invalid increment argument: "+Q)}return this.format(),this.raw=this.version,this},o.inc=function(Q,ae,Ce,ue){typeof Ce=="string"&&(ue=Ce,Ce=void 0);try{return new m(Q,Ce).inc(ae,ue).version}catch(je){return null}},o.diff=function(Q,ae){if(pe(Q,ae))return null;var Ce=ne(Q),ue=ne(ae),je="";if(Ce.prerelease.length||ue.prerelease.length){je="pre";var ct="prerelease"}for(var At in Ce)if((At==="major"||At==="minor"||At==="patch")&&Ce[At]!==ue[At])return je+At;return ct},o.compareIdentifiers=Se;var we=/^[0-9]+$/;function Se(Q,ae){var Ce=we.test(Q),ue=we.test(ae);return Ce&&ue&&(Q=+Q,ae=+ae),Q===ae?0:Ce&&!ue?-1:ue&&!Ce?1:Q0}function ze(Q,ae,Ce){return he(Q,ae,Ce)<0}function pe(Q,ae,Ce){return he(Q,ae,Ce)===0}function Oe(Q,ae,Ce){return he(Q,ae,Ce)!==0}function le(Q,ae,Ce){return he(Q,ae,Ce)>=0}function Ue(Q,ae,Ce){return he(Q,ae,Ce)<=0}function Ge(Q,ae,Ce,ue){switch(ae){case"===":return E(Q)==="object"&&(Q=Q.version),E(Ce)==="object"&&(Ce=Ce.version),Q===Ce;case"!==":return E(Q)==="object"&&(Q=Q.version),E(Ce)==="object"&&(Ce=Ce.version),Q!==Ce;case"":case"=":case"==":return pe(Q,Ce,ue);case"!=":return Oe(Q,Ce,ue);case">":return ge(Q,Ce,ue);case">=":return le(Q,Ce,ue);case"<":return ze(Q,Ce,ue);case"<=":return Ue(Q,Ce,ue);default:throw new TypeError("Invalid operator: "+ae)}}function rt(Q,ae){if(ae&&E(ae)==="object"||(ae={loose:!!ae,includePrerelease:!1}),Q instanceof rt){if(Q.loose===!!ae.loose)return Q;Q=Q.value}if(!(this instanceof rt))return new rt(Q,ae);t("comparator",Q,ae),this.options=ae,this.loose=!!ae.loose,this.parse(Q),this.semver===wt?this.value="":this.value=this.operator+this.semver.version,t("comp",this)}o.rcompareIdentifiers=function(Q,ae){return Se(ae,Q)},o.major=function(Q,ae){return new m(Q,ae).major},o.minor=function(Q,ae){return new m(Q,ae).minor},o.patch=function(Q,ae){return new m(Q,ae).patch},o.compare=he,o.compareLoose=function(Q,ae){return he(Q,ae,!0)},o.compareBuild=function(Q,ae,Ce){var ue=new m(Q,Ce),je=new m(ae,Ce);return ue.compare(je)||ue.compareBuild(je)},o.rcompare=function(Q,ae,Ce){return he(ae,Q,Ce)},o.sort=function(Q,ae){return Q.sort(function(Ce,ue){return o.compareBuild(Ce,ue,ae)})},o.rsort=function(Q,ae){return Q.sort(function(Ce,ue){return o.compareBuild(ue,Ce,ae)})},o.gt=ge,o.lt=ze,o.eq=pe,o.neq=Oe,o.gte=le,o.lte=Ue,o.cmp=Ge,o.Comparator=rt;var wt={};function xt(Q,ae){if(ae&&E(ae)==="object"||(ae={loose:!!ae,includePrerelease:!1}),Q instanceof xt)return Q.loose===!!ae.loose&&Q.includePrerelease===!!ae.includePrerelease?Q:new xt(Q.raw,ae);if(Q instanceof rt)return new xt(Q.value,ae);if(!(this instanceof xt))return new xt(Q,ae);if(this.options=ae,this.loose=!!ae.loose,this.includePrerelease=!!ae.includePrerelease,this.raw=Q,this.set=Q.split(/\s*\|\|\s*/).map(function(Ce){return this.parseRange(Ce.trim())},this).filter(function(Ce){return Ce.length}),!this.set.length)throw new TypeError("Invalid SemVer Range: "+Q);this.format()}function $e(Q,ae){for(var Ce=!0,ue=Q.slice(),je=ue.pop();Ce&&ue.length;)Ce=ue.every(function(ct){return je.intersects(ct,ae)}),je=ue.pop();return Ce}function ft(Q){return!Q||Q.toLowerCase()==="x"||Q==="*"}function Ke(Q,ae,Ce,ue,je,ct,At,en,ln,An,nr,un,Wt){return((ae=ft(Ce)?"":ft(ue)?">="+Ce+".0.0":ft(je)?">="+Ce+"."+ue+".0":">="+ae)+" "+(en=ft(ln)?"":ft(An)?"<"+(+ln+1)+".0.0":ft(nr)?"<"+ln+"."+(+An+1)+".0":un?"<="+ln+"."+An+"."+nr+"-"+un:"<="+en)).trim()}function jt(Q,ae,Ce){for(var ue=0;ue0){var je=Q[ue].semver;if(je.major===ae.major&&je.minor===ae.minor&&je.patch===ae.patch)return!0}return!1}return!0}function $t(Q,ae,Ce){try{ae=new xt(ae,Ce)}catch(ue){return!1}return ae.test(Q)}function at(Q,ae,Ce,ue){var je,ct,At,en,ln;switch(Q=new m(Q,ue),ae=new xt(ae,ue),Ce){case">":je=ge,ct=Ue,At=ze,en=">",ln=">=";break;case"<":je=ze,ct=le,At=ge,en="<",ln="<=";break;default:throw new TypeError('Must provide a hilo val of "<" or ">"')}if($t(Q,ae,ue))return!1;for(var An=0;An=0.0.0")),un=un||vr,Wt=Wt||vr,je(vr.semver,un.semver,ue)?un=vr:At(vr.semver,Wt.semver,ue)&&(Wt=vr)}),un.operator===en||un.operator===ln||(!Wt.operator||Wt.operator===en)&&ct(Q,Wt.semver)||Wt.operator===ln&&At(Q,Wt.semver))return!1}return!0}rt.prototype.parse=function(Q){var ae=this.options.loose?L[C.COMPARATORLOOSE]:L[C.COMPARATOR],Ce=Q.match(ae);if(!Ce)throw new TypeError("Invalid comparator: "+Q);this.operator=Ce[1]!==void 0?Ce[1]:"",this.operator==="="&&(this.operator=""),Ce[2]?this.semver=new m(Ce[2],this.options.loose):this.semver=wt},rt.prototype.toString=function(){return this.value},rt.prototype.test=function(Q){if(t("Comparator.test",Q,this.options.loose),this.semver===wt||Q===wt)return!0;if(typeof Q=="string")try{Q=new m(Q,this.options)}catch(ae){return!1}return Ge(Q,this.operator,this.semver,this.options)},rt.prototype.intersects=function(Q,ae){if(!(Q instanceof rt))throw new TypeError("a Comparator is required");var Ce;if(ae&&E(ae)==="object"||(ae={loose:!!ae,includePrerelease:!1}),this.operator==="")return this.value===""||(Ce=new xt(Q.value,ae),$t(this.value,Ce,ae));if(Q.operator==="")return Q.value===""||(Ce=new xt(this.value,ae),$t(Q.semver,Ce,ae));var ue=!(this.operator!==">="&&this.operator!==">"||Q.operator!==">="&&Q.operator!==">"),je=!(this.operator!=="<="&&this.operator!=="<"||Q.operator!=="<="&&Q.operator!=="<"),ct=this.semver.version===Q.semver.version,At=!(this.operator!==">="&&this.operator!=="<="||Q.operator!==">="&&Q.operator!=="<="),en=Ge(this.semver,"<",Q.semver,ae)&&(this.operator===">="||this.operator===">")&&(Q.operator==="<="||Q.operator==="<"),ln=Ge(this.semver,">",Q.semver,ae)&&(this.operator==="<="||this.operator==="<")&&(Q.operator===">="||Q.operator===">");return ue||je||ct&&At||en||ln},o.Range=xt,xt.prototype.format=function(){return this.range=this.set.map(function(Q){return Q.join(" ").trim()}).join("||").trim(),this.range},xt.prototype.toString=function(){return this.range},xt.prototype.parseRange=function(Q){var ae=this.options.loose;Q=Q.trim();var Ce=ae?L[C.HYPHENRANGELOOSE]:L[C.HYPHENRANGE];Q=Q.replace(Ce,Ke),t("hyphen replace",Q),Q=Q.replace(L[C.COMPARATORTRIM],"$1$2$3"),t("comparator trim",Q,L[C.COMPARATORTRIM]),Q=(Q=(Q=Q.replace(L[C.TILDETRIM],"$1~")).replace(L[C.CARETTRIM],"$1^")).split(/\s+/).join(" ");var ue=ae?L[C.COMPARATORLOOSE]:L[C.COMPARATOR],je=Q.split(" ").map(function(ct){return function(At,en){return t("comp",At,en),At=function(ln,An){return ln.trim().split(/\s+/).map(function(nr){return function(un,Wt){t("caret",un,Wt);var vr=Wt.loose?L[C.CARETLOOSE]:L[C.CARET];return un.replace(vr,function(w,Ut,Vn,fr,Fr){var ur;return t("caret",un,w,Ut,Vn,fr,Fr),ft(Ut)?ur="":ft(Vn)?ur=">="+Ut+".0.0 <"+(+Ut+1)+".0.0":ft(fr)?ur=Ut==="0"?">="+Ut+"."+Vn+".0 <"+Ut+"."+(+Vn+1)+".0":">="+Ut+"."+Vn+".0 <"+(+Ut+1)+".0.0":Fr?(t("replaceCaret pr",Fr),ur=Ut==="0"?Vn==="0"?">="+Ut+"."+Vn+"."+fr+"-"+Fr+" <"+Ut+"."+Vn+"."+(+fr+1):">="+Ut+"."+Vn+"."+fr+"-"+Fr+" <"+Ut+"."+(+Vn+1)+".0":">="+Ut+"."+Vn+"."+fr+"-"+Fr+" <"+(+Ut+1)+".0.0"):(t("no pr"),ur=Ut==="0"?Vn==="0"?">="+Ut+"."+Vn+"."+fr+" <"+Ut+"."+Vn+"."+(+fr+1):">="+Ut+"."+Vn+"."+fr+" <"+Ut+"."+(+Vn+1)+".0":">="+Ut+"."+Vn+"."+fr+" <"+(+Ut+1)+".0.0"),t("caret return",ur),ur})}(nr,An)}).join(" ")}(At,en),t("caret",At),At=function(ln,An){return ln.trim().split(/\s+/).map(function(nr){return function(un,Wt){var vr=Wt.loose?L[C.TILDELOOSE]:L[C.TILDE];return un.replace(vr,function(w,Ut,Vn,fr,Fr){var ur;return t("tilde",un,w,Ut,Vn,fr,Fr),ft(Ut)?ur="":ft(Vn)?ur=">="+Ut+".0.0 <"+(+Ut+1)+".0.0":ft(fr)?ur=">="+Ut+"."+Vn+".0 <"+Ut+"."+(+Vn+1)+".0":Fr?(t("replaceTilde pr",Fr),ur=">="+Ut+"."+Vn+"."+fr+"-"+Fr+" <"+Ut+"."+(+Vn+1)+".0"):ur=">="+Ut+"."+Vn+"."+fr+" <"+Ut+"."+(+Vn+1)+".0",t("tilde return",ur),ur})}(nr,An)}).join(" ")}(At,en),t("tildes",At),At=function(ln,An){return t("replaceXRanges",ln,An),ln.split(/\s+/).map(function(nr){return function(un,Wt){un=un.trim();var vr=Wt.loose?L[C.XRANGELOOSE]:L[C.XRANGE];return un.replace(vr,function(w,Ut,Vn,fr,Fr,ur){t("xRange",un,w,Ut,Vn,fr,Fr,ur);var br=ft(Vn),Kt=br||ft(fr),vu=Kt||ft(Fr),a0=vu;return Ut==="="&&a0&&(Ut=""),ur=Wt.includePrerelease?"-0":"",br?w=Ut===">"||Ut==="<"?"<0.0.0-0":"*":Ut&&a0?(Kt&&(fr=0),Fr=0,Ut===">"?(Ut=">=",Kt?(Vn=+Vn+1,fr=0,Fr=0):(fr=+fr+1,Fr=0)):Ut==="<="&&(Ut="<",Kt?Vn=+Vn+1:fr=+fr+1),w=Ut+Vn+"."+fr+"."+Fr+ur):Kt?w=">="+Vn+".0.0"+ur+" <"+(+Vn+1)+".0.0"+ur:vu&&(w=">="+Vn+"."+fr+".0"+ur+" <"+Vn+"."+(+fr+1)+".0"+ur),t("xRange return",w),w})}(nr,An)}).join(" ")}(At,en),t("xrange",At),At=function(ln,An){return t("replaceStars",ln,An),ln.trim().replace(L[C.STAR],"")}(At,en),t("stars",At),At}(ct,this.options)},this).join(" ").split(/\s+/);return this.options.loose&&(je=je.filter(function(ct){return!!ct.match(ue)})),je=je.map(function(ct){return new rt(ct,this.options)},this)},xt.prototype.intersects=function(Q,ae){if(!(Q instanceof xt))throw new TypeError("a Range is required");return this.set.some(function(Ce){return $e(Ce,ae)&&Q.set.some(function(ue){return $e(ue,ae)&&Ce.every(function(je){return ue.every(function(ct){return je.intersects(ct,ae)})})})})},o.toComparators=function(Q,ae){return new xt(Q,ae).set.map(function(Ce){return Ce.map(function(ue){return ue.value}).join(" ").trim().split(" ")})},xt.prototype.test=function(Q){if(!Q)return!1;if(typeof Q=="string")try{Q=new m(Q,this.options)}catch(Ce){return!1}for(var ae=0;ae":ct.prerelease.length===0?ct.patch++:ct.prerelease.push(0),ct.raw=ct.format();case"":case">=":Ce&&!ge(Ce,ct)||(Ce=ct);break;case"<":case"<=":break;default:throw new Error("Unexpected operation: "+je.operator)}});return Ce&&Q.test(Ce)?Ce:null},o.validRange=function(Q,ae){try{return new xt(Q,ae).range||"*"}catch(Ce){return null}},o.ltr=function(Q,ae,Ce){return at(Q,ae,"<",Ce)},o.gtr=function(Q,ae,Ce){return at(Q,ae,">",Ce)},o.outside=at,o.prerelease=function(Q,ae){var Ce=ne(Q,ae);return Ce&&Ce.prerelease.length?Ce.prerelease:null},o.intersects=function(Q,ae,Ce){return Q=new xt(Q,Ce),ae=new xt(ae,Ce),Q.intersects(ae)},o.coerce=function(Q,ae){if(Q instanceof m)return Q;if(typeof Q=="number"&&(Q=String(Q)),typeof Q!="string")return null;var Ce=null;if((ae=ae||{}).rtl){for(var ue;(ue=L[C.COERCERTL].exec(Q))&&(!Ce||Ce.index+Ce[0].length!==Q.length);)Ce&&ue.index+ue[0].length===Ce.index+Ce[0].length||(Ce=ue),L[C.COERCERTL].lastIndex=ue.index+ue[1].length+ue[2].length;L[C.COERCERTL].lastIndex=-1}else Ce=Q.match(L[C.COERCE]);return Ce===null?null:ne(Ce[2]+"."+(Ce[3]||"0")+"."+(Ce[4]||"0"),ae)}}).call(this,f(5))},function(i,o){function f(E){return(f=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(t){return typeof t}:function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(E)}var p;p=function(){return this}();try{p=p||new Function("return this")()}catch(E){(typeof window=="undefined"?"undefined":f(window))==="object"&&(p=window)}i.exports=p},function(i,o){var f,p,E=i.exports={};function t(){throw new Error("setTimeout has not been defined")}function k(){throw new Error("clearTimeout has not been defined")}function L(Se){if(f===setTimeout)return setTimeout(Se,0);if((f===t||!f)&&setTimeout)return f=setTimeout,setTimeout(Se,0);try{return f(Se,0)}catch(he){try{return f.call(null,Se,0)}catch(ge){return f.call(this,Se,0)}}}(function(){try{f=typeof setTimeout=="function"?setTimeout:t}catch(Se){f=t}try{p=typeof clearTimeout=="function"?clearTimeout:k}catch(Se){p=k}})();var N,C=[],U=!1,q=-1;function W(){U&&N&&(U=!1,N.length?C=N.concat(C):q=-1,C.length&&ne())}function ne(){if(!U){var Se=L(W);U=!0;for(var he=C.length;he;){for(N=C,C=[];++q1)for(var ge=1;gethis[k])return Oe(this,this[m].get($e)),!1;var at=this[m].get($e).value;return this[q]&&(this[W]||this[q]($e,at.value)),at.now=jt,at.maxAge=Ke,at.value=ft,this[L]+=$t-at.length,at.length=$t,this.get($e),pe(this),!0}var Q=new le($e,ft,$t,jt,Ke);return Q.length>this[k]?(this[q]&&this[q]($e,ft),!1):(this[L]+=Q.length,this[ne].unshift(Q),this[m].set($e,this[ne].head),pe(this),!0)}},{key:"has",value:function($e){if(!this[m].has($e))return!1;var ft=this[m].get($e).value;return!ze(this,ft)}},{key:"get",value:function($e){return ge(this,$e,!0)}},{key:"peek",value:function($e){return ge(this,$e,!1)}},{key:"pop",value:function(){var $e=this[ne].tail;return $e?(Oe(this,$e),$e.value):null}},{key:"del",value:function($e){Oe(this,this[m].get($e))}},{key:"load",value:function($e){this.reset();for(var ft=Date.now(),Ke=$e.length-1;Ke>=0;Ke--){var jt=$e[Ke],$t=jt.e||0;if($t===0)this.set(jt.k,jt.v);else{var at=$t-ft;at>0&&this.set(jt.k,jt.v,at)}}}},{key:"prune",value:function(){var $e=this;this[m].forEach(function(ft,Ke){return ge($e,Ke,!1)})}},{key:"max",set:function($e){if(typeof $e!="number"||$e<0)throw new TypeError("max must be a non-negative number");this[k]=$e||1/0,pe(this)},get:function(){return this[k]}},{key:"allowStale",set:function($e){this[C]=!!$e},get:function(){return this[C]}},{key:"maxAge",set:function($e){if(typeof $e!="number")throw new TypeError("maxAge must be a non-negative number");this[U]=$e,pe(this)},get:function(){return this[U]}},{key:"lengthCalculator",set:function($e){var ft=this;typeof $e!="function"&&($e=Se),$e!==this[N]&&(this[N]=$e,this[L]=0,this[ne].forEach(function(Ke){Ke.length=ft[N](Ke.value,Ke.key),ft[L]+=Ke.length})),pe(this)},get:function(){return this[N]}},{key:"length",get:function(){return this[L]}},{key:"itemCount",get:function(){return this[ne].length}}])&&E(rt.prototype,wt),xt&&E(rt,xt),Ge}(),ge=function(Ge,rt,wt){var xt=Ge[m].get(rt);if(xt){var $e=xt.value;if(ze(Ge,$e)){if(Oe(Ge,xt),!Ge[C])return}else wt&&(Ge[we]&&(xt.value.now=Date.now()),Ge[ne].unshiftNode(xt));return $e.value}},ze=function(Ge,rt){if(!rt||!rt.maxAge&&!Ge[U])return!1;var wt=Date.now()-rt.now;return rt.maxAge?wt>rt.maxAge:Ge[U]&&wt>Ge[U]},pe=function(Ge){if(Ge[L]>Ge[k])for(var rt=Ge[ne].tail;Ge[L]>Ge[k]&&rt!==null;){var wt=rt.prev;Oe(Ge,rt),rt=wt}},Oe=function(Ge,rt){if(rt){var wt=rt.value;Ge[q]&&Ge[q](wt.key,wt.value),Ge[L]-=wt.length,Ge[m].delete(wt.key),Ge[ne].removeNode(rt)}},le=function Ge(rt,wt,xt,$e,ft){p(this,Ge),this.key=rt,this.value=wt,this.length=xt,this.now=$e,this.maxAge=ft||0},Ue=function(Ge,rt,wt,xt){var $e=wt.value;ze(Ge,$e)&&(Oe(Ge,wt),Ge[C]||($e=void 0)),$e&&rt.call(xt,$e.value,$e.key,Ge)};i.exports=he},function(i,o,f){(function(p){function E(t){return(E=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(k){return typeof k}:function(k){return k&&typeof Symbol=="function"&&k.constructor===Symbol&&k!==Symbol.prototype?"symbol":typeof k})(t)}i.exports=function(){if(typeof document=="undefined"||!document.addEventListener)return null;var t,k,L,N={};return N.copy=function(){var C=!1,U=null,q=!1;function W(){C=!1,U=null,q&&window.getSelection().removeAllRanges(),q=!1}return document.addEventListener("copy",function(ne){if(C){for(var m in U)ne.clipboardData.setData(m,U[m]);ne.preventDefault()}}),function(ne){return new Promise(function(m,we){C=!0,typeof ne=="string"?U={"text/plain":ne}:ne instanceof Node?U={"text/html":new XMLSerializer().serializeToString(ne)}:ne instanceof Object?U=ne:we("Invalid data type. Must be string, DOM node, or an object mapping MIME types to strings."),function Se(he){try{if(document.execCommand("copy"))W(),m();else{if(he)throw W(),new Error("Unable to copy. Perhaps it's not available in your browser?");(function(){var ge=document.getSelection();if(!document.queryCommandEnabled("copy")&&ge.isCollapsed){var ze=document.createRange();ze.selectNodeContents(document.body),ge.removeAllRanges(),ge.addRange(ze),q=!0}})(),Se(!0)}}catch(ge){W(),we(ge)}}(!1)})}}(),N.paste=(L=!1,document.addEventListener("paste",function(C){if(L){L=!1,C.preventDefault();var U=t;t=null,U(C.clipboardData.getData(k))}}),function(C){return new Promise(function(U,q){L=!0,t=U,k=C||"text/plain";try{document.execCommand("paste")||(L=!1,q(new Error("Unable to paste. Pasting only works in Internet Explorer at the moment.")))}catch(W){L=!1,q(new Error(W))}})}),typeof ClipboardEvent=="undefined"&&window.clipboardData!==void 0&&window.clipboardData.setData!==void 0&&(function(C){function U(pe,Oe){return function(){pe.apply(Oe,arguments)}}function q(pe){if(E(this)!="object")throw new TypeError("Promises must be constructed via new");if(typeof pe!="function")throw new TypeError("not a function");this._state=null,this._value=null,this._deferreds=[],he(pe,U(ne,this),U(m,this))}function W(pe){var Oe=this;return this._state===null?void this._deferreds.push(pe):void ge(function(){var le=Oe._state?pe.onFulfilled:pe.onRejected;if(le!==null){var Ue;try{Ue=le(Oe._value)}catch(Ge){return void pe.reject(Ge)}pe.resolve(Ue)}else(Oe._state?pe.resolve:pe.reject)(Oe._value)})}function ne(pe){try{if(pe===this)throw new TypeError("A promise cannot be resolved with itself.");if(pe&&(E(pe)=="object"||typeof pe=="function")){var Oe=pe.then;if(typeof Oe=="function")return void he(U(Oe,pe),U(ne,this),U(m,this))}this._state=!0,this._value=pe,we.call(this)}catch(le){m.call(this,le)}}function m(pe){this._state=!1,this._value=pe,we.call(this)}function we(){for(var pe=0,Oe=this._deferreds.length;Oe>pe;pe++)W.call(this,this._deferreds[pe]);this._deferreds=null}function Se(pe,Oe,le,Ue){this.onFulfilled=typeof pe=="function"?pe:null,this.onRejected=typeof Oe=="function"?Oe:null,this.resolve=le,this.reject=Ue}function he(pe,Oe,le){var Ue=!1;try{pe(function(Ge){Ue||(Ue=!0,Oe(Ge))},function(Ge){Ue||(Ue=!0,le(Ge))})}catch(Ge){if(Ue)return;Ue=!0,le(Ge)}}var ge=q.immediateFn||typeof p=="function"&&p||function(pe){setTimeout(pe,1)},ze=Array.isArray||function(pe){return Object.prototype.toString.call(pe)==="[object Array]"};q.prototype.catch=function(pe){return this.then(null,pe)},q.prototype.then=function(pe,Oe){var le=this;return new q(function(Ue,Ge){W.call(le,new Se(pe,Oe,Ue,Ge))})},q.all=function(){var pe=Array.prototype.slice.call(arguments.length===1&&ze(arguments[0])?arguments[0]:arguments);return new q(function(Oe,le){function Ue(wt,xt){try{if(xt&&(E(xt)=="object"||typeof xt=="function")){var $e=xt.then;if(typeof $e=="function")return void $e.call(xt,function(ft){Ue(wt,ft)},le)}pe[wt]=xt,--Ge==0&&Oe(pe)}catch(ft){le(ft)}}if(pe.length===0)return Oe([]);for(var Ge=pe.length,rt=0;rtUe;Ue++)pe[Ue].then(Oe,le)})},i.exports?i.exports=q:C.Promise||(C.Promise=q)}(this),N.copy=function(C){return new Promise(function(U,q){if(typeof C!="string"&&!("text/plain"in C))throw new Error("You must provide a text/plain type.");var W=typeof C=="string"?C:C["text/plain"];window.clipboardData.setData("Text",W)?U():q(new Error("Copying was rejected."))})},N.paste=function(){return new Promise(function(C,U){var q=window.clipboardData.getData("Text");q?C(q):U(new Error("Pasting was rejected."))})}),N}()}).call(this,f(13).setImmediate)},function(i,o,f){"use strict";i.exports=f(15)},function(i,o,f){"use strict";f.r(o),o.default=`:root { + /** + * IMPORTANT: When new theme variables are added below\u2013 also add them to SettingsContext updateThemeVariables() + */ + + /* Light theme */ + --light-color-attribute-name: #ef6632; + --light-color-attribute-name-not-editable: #23272f; + --light-color-attribute-name-inverted: rgba(255, 255, 255, 0.7); + --light-color-attribute-value: #1a1aa6; + --light-color-attribute-value-inverted: #ffffff; + --light-color-attribute-editable-value: #1a1aa6; + --light-color-background: #ffffff; + --light-color-background-hover: rgba(0, 136, 250, 0.1); + --light-color-background-inactive: #e5e5e5; + --light-color-background-invalid: #fff0f0; + --light-color-background-selected: #0088fa; + --light-color-button-background: #ffffff; + --light-color-button-background-focus: #ededed; + --light-color-button: #5f6673; + --light-color-button-disabled: #cfd1d5; + --light-color-button-active: #0088fa; + --light-color-button-focus: #23272f; + --light-color-button-hover: #23272f; + --light-color-border: #eeeeee; + --light-color-commit-did-not-render-fill: #cfd1d5; + --light-color-commit-did-not-render-fill-text: #000000; + --light-color-commit-did-not-render-pattern: #cfd1d5; + --light-color-commit-did-not-render-pattern-text: #333333; + --light-color-commit-gradient-0: #37afa9; + --light-color-commit-gradient-1: #63b19e; + --light-color-commit-gradient-2: #80b393; + --light-color-commit-gradient-3: #97b488; + --light-color-commit-gradient-4: #abb67d; + --light-color-commit-gradient-5: #beb771; + --light-color-commit-gradient-6: #cfb965; + --light-color-commit-gradient-7: #dfba57; + --light-color-commit-gradient-8: #efbb49; + --light-color-commit-gradient-9: #febc38; + --light-color-commit-gradient-text: #000000; + --light-color-component-name: #6a51b2; + --light-color-component-name-inverted: #ffffff; + --light-color-component-badge-background: rgba(0, 0, 0, 0.1); + --light-color-component-badge-background-inverted: rgba(255, 255, 255, 0.25); + --light-color-component-badge-count: #777d88; + --light-color-component-badge-count-inverted: rgba(255, 255, 255, 0.7); + --light-color-context-background: rgba(0,0,0,.9); + --light-color-context-background-hover: rgba(255, 255, 255, 0.1); + --light-color-context-background-selected: #178fb9; + --light-color-context-border: #3d424a; + --light-color-context-text: #ffffff; + --light-color-context-text-selected: #ffffff; + --light-color-dim: #777d88; + --light-color-dimmer: #cfd1d5; + --light-color-dimmest: #eff0f1; + --light-color-error-background: hsl(0, 100%, 97%); + --light-color-error-border: hsl(0, 100%, 92%); + --light-color-error-text: #ff0000; + --light-color-expand-collapse-toggle: #777d88; + --light-color-link: #0000ff; + --light-color-modal-background: rgba(255, 255, 255, 0.75); + --light-color-record-active: #fc3a4b; + --light-color-record-hover: #3578e5; + --light-color-record-inactive: #0088fa; + --light-color-scroll-thumb: #c2c2c2; + --light-color-scroll-track: #fafafa; + --light-color-search-match: yellow; + --light-color-search-match-current: #f7923b; + --light-color-selected-tree-highlight-active: rgba(0, 136, 250, 0.1); + --light-color-selected-tree-highlight-inactive: rgba(0, 0, 0, 0.05); + --light-color-shadow: rgba(0, 0, 0, 0.25); + --light-color-tab-selected-border: #0088fa; + --light-color-text: #000000; + --light-color-text-invalid: #ff0000; + --light-color-text-selected: #ffffff; + --light-color-toggle-background-invalid: #fc3a4b; + --light-color-toggle-background-on: #0088fa; + --light-color-toggle-background-off: #cfd1d5; + --light-color-toggle-text: #ffffff; + --light-color-tooltip-background: rgba(0, 0, 0, 0.9); + --light-color-tooltip-text: #ffffff; + + /* Dark theme */ + --dark-color-attribute-name: #9d87d2; + --dark-color-attribute-name-not-editable: #ededed; + --dark-color-attribute-name-inverted: #282828; + --dark-color-attribute-value: #cedae0; + --dark-color-attribute-value-inverted: #ffffff; + --dark-color-attribute-editable-value: yellow; + --dark-color-background: #282c34; + --dark-color-background-hover: rgba(255, 255, 255, 0.1); + --dark-color-background-inactive: #3d424a; + --dark-color-background-invalid: #5c0000; + --dark-color-background-selected: #178fb9; + --dark-color-button-background: #282c34; + --dark-color-button-background-focus: #3d424a; + --dark-color-button: #afb3b9; + --dark-color-button-active: #61dafb; + --dark-color-button-disabled: #4f5766; + --dark-color-button-focus: #a2e9fc; + --dark-color-button-hover: #ededed; + --dark-color-border: #3d424a; + --dark-color-commit-did-not-render-fill: #777d88; + --dark-color-commit-did-not-render-fill-text: #000000; + --dark-color-commit-did-not-render-pattern: #666c77; + --dark-color-commit-did-not-render-pattern-text: #ffffff; + --dark-color-commit-gradient-0: #37afa9; + --dark-color-commit-gradient-1: #63b19e; + --dark-color-commit-gradient-2: #80b393; + --dark-color-commit-gradient-3: #97b488; + --dark-color-commit-gradient-4: #abb67d; + --dark-color-commit-gradient-5: #beb771; + --dark-color-commit-gradient-6: #cfb965; + --dark-color-commit-gradient-7: #dfba57; + --dark-color-commit-gradient-8: #efbb49; + --dark-color-commit-gradient-9: #febc38; + --dark-color-commit-gradient-text: #000000; + --dark-color-component-name: #61dafb; + --dark-color-component-name-inverted: #282828; + --dark-color-component-badge-background: rgba(255, 255, 255, 0.25); + --dark-color-component-badge-background-inverted: rgba(0, 0, 0, 0.25); + --dark-color-component-badge-count: #8f949d; + --dark-color-component-badge-count-inverted: rgba(255, 255, 255, 0.7); + --dark-color-context-background: rgba(255,255,255,.9); + --dark-color-context-background-hover: rgba(0, 136, 250, 0.1); + --dark-color-context-background-selected: #0088fa; + --dark-color-context-border: #eeeeee; + --dark-color-context-text: #000000; + --dark-color-context-text-selected: #ffffff; + --dark-color-dim: #8f949d; + --dark-color-dimmer: #777d88; + --dark-color-dimmest: #4f5766; + --dark-color-error-background: #200; + --dark-color-error-border: #900; + --dark-color-error-text: #f55; + --dark-color-expand-collapse-toggle: #8f949d; + --dark-color-link: #61dafb; + --dark-color-modal-background: rgba(0, 0, 0, 0.75); + --dark-color-record-active: #fc3a4b; + --dark-color-record-hover: #a2e9fc; + --dark-color-record-inactive: #61dafb; + --dark-color-scroll-thumb: #afb3b9; + --dark-color-scroll-track: #313640; + --dark-color-search-match: yellow; + --dark-color-search-match-current: #f7923b; + --dark-color-selected-tree-highlight-active: rgba(23, 143, 185, 0.15); + --dark-color-selected-tree-highlight-inactive: rgba(255, 255, 255, 0.05); + --dark-color-shadow: rgba(0, 0, 0, 0.5); + --dark-color-tab-selected-border: #178fb9; + --dark-color-text: #ffffff; + --dark-color-text-invalid: #ff8080; + --dark-color-text-selected: #ffffff; + --dark-color-toggle-background-invalid: #fc3a4b; + --dark-color-toggle-background-on: #178fb9; + --dark-color-toggle-background-off: #777d88; + --dark-color-toggle-text: #ffffff; + --dark-color-tooltip-background: rgba(255, 255, 255, 0.9); + --dark-color-tooltip-text: #000000; + + /* Font smoothing */ + --light-font-smoothing: auto; + --dark-font-smoothing: antialiased; + --font-smoothing: auto; + + /* Compact density */ + --compact-font-size-monospace-small: 9px; + --compact-font-size-monospace-normal: 11px; + --compact-font-size-monospace-large: 15px; + --compact-font-size-sans-small: 10px; + --compact-font-size-sans-normal: 12px; + --compact-font-size-sans-large: 14px; + --compact-line-height-data: 18px; + --compact-root-font-size: 16px; + + /* Comfortable density */ + --comfortable-font-size-monospace-small: 10px; + --comfortable-font-size-monospace-normal: 13px; + --comfortable-font-size-monospace-large: 17px; + --comfortable-font-size-sans-small: 12px; + --comfortable-font-size-sans-normal: 14px; + --comfortable-font-size-sans-large: 16px; + --comfortable-line-height-data: 22px; + --comfortable-root-font-size: 20px; + + /* GitHub.com system fonts */ + --font-family-monospace: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, + Courier, monospace; + --font-family-sans: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, + Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol; + + /* Constant values shared between JS and CSS */ + --interaction-commit-size: 10px; + --interaction-label-width: 200px; +} +`},function(i,o,f){"use strict";function p(N){var C=this;if(C instanceof p||(C=new p),C.tail=null,C.head=null,C.length=0,N&&typeof N.forEach=="function")N.forEach(function(W){C.push(W)});else if(arguments.length>0)for(var U=0,q=arguments.length;U1)U=C;else{if(!this.head)throw new TypeError("Reduce of empty list with no initial value");q=this.head.next,U=this.head.value}for(var W=0;q!==null;W++)U=N(U,q.value,W),q=q.next;return U},p.prototype.reduceReverse=function(N,C){var U,q=this.tail;if(arguments.length>1)U=C;else{if(!this.tail)throw new TypeError("Reduce of empty list with no initial value");q=this.tail.prev,U=this.tail.value}for(var W=this.length-1;q!==null;W--)U=N(U,q.value,W),q=q.prev;return U},p.prototype.toArray=function(){for(var N=new Array(this.length),C=0,U=this.head;U!==null;C++)N[C]=U.value,U=U.next;return N},p.prototype.toArrayReverse=function(){for(var N=new Array(this.length),C=0,U=this.tail;U!==null;C++)N[C]=U.value,U=U.prev;return N},p.prototype.slice=function(N,C){(C=C||this.length)<0&&(C+=this.length),(N=N||0)<0&&(N+=this.length);var U=new p;if(Cthis.length&&(C=this.length);for(var q=0,W=this.head;W!==null&&qthis.length&&(C=this.length);for(var q=this.length,W=this.tail;W!==null&&q>C;q--)W=W.prev;for(;W!==null&&q>N;q--,W=W.prev)U.push(W.value);return U},p.prototype.splice=function(N,C){N>this.length&&(N=this.length-1),N<0&&(N=this.length+N);for(var U=0,q=this.head;q!==null&&U=0&&(L._idleTimeoutId=setTimeout(function(){L._onTimeout&&L._onTimeout()},N))},f(14),o.setImmediate=typeof self!="undefined"&&self.setImmediate||p!==void 0&&p.setImmediate||this&&this.setImmediate,o.clearImmediate=typeof self!="undefined"&&self.clearImmediate||p!==void 0&&p.clearImmediate||this&&this.clearImmediate}).call(this,f(4))},function(i,o,f){(function(p,E){(function(t,k){"use strict";if(!t.setImmediate){var L,N,C,U,q,W=1,ne={},m=!1,we=t.document,Se=Object.getPrototypeOf&&Object.getPrototypeOf(t);Se=Se&&Se.setTimeout?Se:t,{}.toString.call(t.process)==="[object process]"?L=function(ze){E.nextTick(function(){ge(ze)})}:function(){if(t.postMessage&&!t.importScripts){var ze=!0,pe=t.onmessage;return t.onmessage=function(){ze=!1},t.postMessage("","*"),t.onmessage=pe,ze}}()?(U="setImmediate$"+Math.random()+"$",q=function(ze){ze.source===t&&typeof ze.data=="string"&&ze.data.indexOf(U)===0&&ge(+ze.data.slice(U.length))},t.addEventListener?t.addEventListener("message",q,!1):t.attachEvent("onmessage",q),L=function(ze){t.postMessage(U+ze,"*")}):t.MessageChannel?((C=new MessageChannel).port1.onmessage=function(ze){ge(ze.data)},L=function(ze){C.port2.postMessage(ze)}):we&&"onreadystatechange"in we.createElement("script")?(N=we.documentElement,L=function(ze){var pe=we.createElement("script");pe.onreadystatechange=function(){ge(ze),pe.onreadystatechange=null,N.removeChild(pe),pe=null},N.appendChild(pe)}):L=function(ze){setTimeout(ge,0,ze)},Se.setImmediate=function(ze){typeof ze!="function"&&(ze=new Function(""+ze));for(var pe=new Array(arguments.length-1),Oe=0;Oeae;ae++)if((Q=he(at,jt,ae))!==-1){Se=ae,jt=Q;break e}jt=-1}}e:{if(at=$t,(Q=W().get(Ke.primitive))!==void 0){for(ae=0;aejt-at?null:$t.slice(at,jt-1))!==null){if(jt=0,rt!==null){for(;jt<$t.length&&jtjt;rt--)wt=$e.pop()}for(rt=$t.length-jt-1;1<=rt;rt--)jt=[],wt.push({id:null,isStateEditable:!1,name:ze($t[rt-1].functionName),value:void 0,subHooks:jt}),$e.push(wt),wt=jt;rt=$t}jt=($t=Ke.primitive)==="Context"||$t==="DebugValue"?null:xt++,wt.push({id:jt,isStateEditable:$t==="Reducer"||$t==="State",name:$t,value:Ke.value,subHooks:[]})}return function Ce(ue,je){for(var ct=[],At=0;At-1&&(ne=ne.replace(/eval code/g,"eval").replace(/(\(eval at [^()]*)|(\),.*$)/g,""));var m=ne.replace(/^\s+/,"").replace(/\(eval code/g,"("),we=m.match(/ (\((.+):(\d+):(\d+)\)$)/),Se=(m=we?m.replace(we[0],""):m).split(/\s+/).slice(1),he=this.extractLocation(we?we[1]:Se.pop()),ge=Se.join(" ")||void 0,ze=["eval",""].indexOf(he[0])>-1?void 0:he[0];return new N({functionName:ge,fileName:ze,lineNumber:he[1],columnNumber:he[2],source:ne})},this)},parseFFOrSafari:function(W){return W.stack.split(` +`).filter(function(ne){return!ne.match(q)},this).map(function(ne){if(ne.indexOf(" > eval")>-1&&(ne=ne.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,":$1")),ne.indexOf("@")===-1&&ne.indexOf(":")===-1)return new N({functionName:ne});var m=/((.*".+"[^@]*)?[^@]*)(?:@)/,we=ne.match(m),Se=we&&we[1]?we[1]:void 0,he=this.extractLocation(ne.replace(m,""));return new N({functionName:Se,fileName:he[0],lineNumber:he[1],columnNumber:he[2],source:ne})},this)},parseOpera:function(W){return!W.stacktrace||W.message.indexOf(` +`)>-1&&W.message.split(` +`).length>W.stacktrace.split(` +`).length?this.parseOpera9(W):W.stack?this.parseOpera11(W):this.parseOpera10(W)},parseOpera9:function(W){for(var ne=/Line (\d+).*script (?:in )?(\S+)/i,m=W.message.split(` +`),we=[],Se=2,he=m.length;Se/,"$2").replace(/\([^)]*\)/g,"")||void 0;he.match(/\(([^)]*)\)/)&&(m=he.replace(/^[^(]+\(([^)]*)\)$/,"$1"));var ze=m===void 0||m==="[arguments not available]"?void 0:m.split(",");return new N({functionName:ge,args:ze,fileName:Se[0],lineNumber:Se[1],columnNumber:Se[2],source:ne})},this)}}})=="function"?p.apply(o,E):p)===void 0||(i.exports=t)})()},function(i,o,f){var p,E,t;(function(k,L){"use strict";E=[],(t=typeof(p=function(){function N(ge){return ge.charAt(0).toUpperCase()+ge.substring(1)}function C(ge){return function(){return this[ge]}}var U=["isConstructor","isEval","isNative","isToplevel"],q=["columnNumber","lineNumber"],W=["fileName","functionName","source"],ne=U.concat(q,W,["args"]);function m(ge){if(ge)for(var ze=0;ze1?xe-1:0),ke=1;ke=0&&xe.splice(Z,1)}}}])&&p(z.prototype,G),$&&p(z,$),B}(),t=f(2),k=f.n(t);try{var L=f(9).default,N=function(B){var z=new RegExp("".concat(B,": ([0-9]+)")),G=L.match(z);return parseInt(G[1],10)};N("comfortable-line-height-data"),N("compact-line-height-data")}catch(B){}function C(B){try{return sessionStorage.getItem(B)}catch(z){return null}}function U(B){try{sessionStorage.removeItem(B)}catch(z){}}function q(B,z){try{return sessionStorage.setItem(B,z)}catch(G){}}var W=function(B,z){return B===z},ne=f(1),m=f.n(ne);function we(B){return B.ownerDocument?B.ownerDocument.defaultView:null}function Se(B){var z=we(B);return z?z.frameElement:null}function he(B){var z=pe(B);return ge([B.getBoundingClientRect(),{top:z.borderTop,left:z.borderLeft,bottom:z.borderBottom,right:z.borderRight,width:0,height:0}])}function ge(B){return B.reduce(function(z,G){return z==null?G:{top:z.top+G.top,left:z.left+G.left,width:z.width,height:z.height,bottom:z.bottom+G.bottom,right:z.right+G.right}})}function ze(B,z){var G=Se(B);if(G&&G!==z){for(var $=[B.getBoundingClientRect()],De=G,me=!1;De;){var xe=he(De);if($.push(xe),De=Se(De),me)break;De&&we(De)===z&&(me=!0)}return ge($)}return B.getBoundingClientRect()}function pe(B){var z=window.getComputedStyle(B);return{borderLeft:parseInt(z.borderLeftWidth,10),borderRight:parseInt(z.borderRightWidth,10),borderTop:parseInt(z.borderTopWidth,10),borderBottom:parseInt(z.borderBottomWidth,10),marginLeft:parseInt(z.marginLeft,10),marginRight:parseInt(z.marginRight,10),marginTop:parseInt(z.marginTop,10),marginBottom:parseInt(z.marginBottom,10),paddingLeft:parseInt(z.paddingLeft,10),paddingRight:parseInt(z.paddingRight,10),paddingTop:parseInt(z.paddingTop,10),paddingBottom:parseInt(z.paddingBottom,10)}}function Oe(B,z){var G;if(typeof Symbol=="undefined"||B[Symbol.iterator]==null){if(Array.isArray(B)||(G=function(ke,Xe){if(!!ke){if(typeof ke=="string")return le(ke,Xe);var ht=Object.prototype.toString.call(ke).slice(8,-1);if(ht==="Object"&&ke.constructor&&(ht=ke.constructor.name),ht==="Map"||ht==="Set")return Array.from(ke);if(ht==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(ht))return le(ke,Xe)}}(B))||z&&B&&typeof B.length=="number"){G&&(B=G);var $=0,De=function(){};return{s:De,n:function(){return $>=B.length?{done:!0}:{done:!1,value:B[$++]}},e:function(ke){throw ke},f:De}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var me,xe=!0,Z=!1;return{s:function(){G=B[Symbol.iterator]()},n:function(){var ke=G.next();return xe=ke.done,ke},e:function(ke){Z=!0,me=ke},f:function(){try{xe||G.return==null||G.return()}finally{if(Z)throw me}}}}function le(B,z){(z==null||z>B.length)&&(z=B.length);for(var G=0,$=new Array(z);Gxe.left+xe.width&&(ie=xe.left+xe.width-ht-5),{style:{top:ke+="px",left:ie+="px"}}}(z,G,{width:$.width,height:$.height});m()(this.tip.style,De.style)}}]),B}(),$e=function(){function B(){Ue(this,B);var z=window.__REACT_DEVTOOLS_TARGET_WINDOW__||window;this.window=z;var G=window.__REACT_DEVTOOLS_TARGET_WINDOW__||window;this.tipBoundsWindow=G;var $=z.document;this.container=$.createElement("div"),this.container.style.zIndex="10000000",this.tip=new xt($,this.container),this.rects=[],$.body.appendChild(this.container)}return rt(B,[{key:"remove",value:function(){this.tip.remove(),this.rects.forEach(function(z){z.remove()}),this.rects.length=0,this.container.parentNode&&this.container.parentNode.removeChild(this.container)}},{key:"inspect",value:function(z,G){for(var $=this,De=z.filter(function(Tt){return Tt.nodeType===Node.ELEMENT_NODE});this.rects.length>De.length;)this.rects.pop().remove();if(De.length!==0){for(;this.rects.length1&&arguments[1]!==void 0?arguments[1]:W,tt=void 0,Tt=[],kt=void 0,bt=!1,on=function(Lt,gn){return qe(Lt,Tt[gn])},tn=function(){for(var Lt=arguments.length,gn=Array(Lt),lr=0;lr5&&arguments[5]!==void 0?arguments[5]:0,Z=Co(B);switch(Z){case"html_element":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:B.tagName,type:Z};case"function":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:typeof B.name!="function"&&B.name?B.name:"function",type:Z};case"string":return B.length<=500?B:B.slice(0,500)+"...";case"bigint":case"symbol":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:B.toString(),type:Z};case"react_element":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:L0(B)||"Unknown",type:Z};case"array_buffer":case"data_view":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:Z==="data_view"?"DataView":"ArrayBuffer",size:B.byteLength,type:Z};case"array":return me=De($),xe>=2&&!me?a0(Z,!0,B,z,$):B.map(function(ht,ie){return So(ht,z,G,$.concat([ie]),De,me?1:xe+1)});case"html_all_collection":case"typed_array":case"iterator":if(me=De($),xe>=2&&!me)return a0(Z,!0,B,z,$);var ke={unserializable:!0,type:Z,readonly:!0,size:Z==="typed_array"?B.length:void 0,preview_short:Si(B,!1),preview_long:Si(B,!0),name:B.constructor&&B.constructor.name!=="Object"?B.constructor.name:""};return Kt(B[Symbol.iterator])&&Array.from(B).forEach(function(ht,ie){return ke[ie]=So(ht,z,G,$.concat([ie]),De,me?1:xe+1)}),G.push($),ke;case"opaque_iterator":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:B[Symbol.toStringTag],type:Z};case"date":case"regexp":return z.push($),{inspectable:!1,preview_short:Si(B,!1),preview_long:Si(B,!0),name:B.toString(),type:Z};case"object":if(me=De($),xe>=2&&!me)return a0(Z,!0,B,z,$);var Xe={};return eu(B).forEach(function(ht){var ie=ht.toString();Xe[ie]=So(B[ht],z,G,$.concat([ie]),De,me?1:xe+1)}),Xe;case"infinity":case"nan":case"undefined":return z.push($),{type:Z};default:return B}}function Go(B){return(Go=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(z){return typeof z}:function(z){return z&&typeof Symbol=="function"&&z.constructor===Symbol&&z!==Symbol.prototype?"symbol":typeof z})(B)}function Os(B){return function(z){if(Array.isArray(z))return Yo(z)}(B)||function(z){if(typeof Symbol!="undefined"&&Symbol.iterator in Object(z))return Array.from(z)}(B)||function(z,G){if(!!z){if(typeof z=="string")return Yo(z,G);var $=Object.prototype.toString.call(z).slice(8,-1);if($==="Object"&&z.constructor&&($=z.constructor.name),$==="Map"||$==="Set")return Array.from(z);if($==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test($))return Yo(z,G)}}(B)||function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}()}function Yo(B,z){(z==null||z>B.length)&&(z=B.length);for(var G=0,$=new Array(z);Gz.toString()?1:z.toString()>B.toString()?-1:0}function eu(B){for(var z=[],G=B,$=function(){var De=[].concat(Os(Object.keys(G)),Os(Object.getOwnPropertySymbols(G))),me=Object.getOwnPropertyDescriptors(G);De.forEach(function(xe){me[xe].enumerable&&z.push(xe)}),G=Object.getPrototypeOf(G)};G!=null;)$();return z}function ai(B){var z=arguments.length>1&&arguments[1]!==void 0?arguments[1]:"Anonymous",G=Ko.get(B);if(G!=null)return G;var $=z;return typeof B.displayName=="string"?$=B.displayName:typeof B.name=="string"&&B.name!==""&&($=B.name),Ko.set(B,$),$}var mr=0;function Xo(){return++mr}function W0(B){var z=qt.get(B);if(z!==void 0)return z;for(var G=new Array(B.length),$=0;$1&&arguments[1]!==void 0?arguments[1]:50;return B.length>z?B.substr(0,z)+"\u2026":B}function Si(B,z){if(B!=null&&hasOwnProperty.call(B,vu.type))return z?B[vu.preview_long]:B[vu.preview_short];switch(Co(B)){case"html_element":return"<".concat(tu(B.tagName.toLowerCase())," />");case"function":return tu("\u0192 ".concat(typeof B.name=="function"?"":B.name,"() {}"));case"string":return'"'.concat(B,'"');case"bigint":return tu(B.toString()+"n");case"regexp":case"symbol":return tu(B.toString());case"react_element":return"<".concat(tu(L0(B)||"Unknown")," />");case"array_buffer":return"ArrayBuffer(".concat(B.byteLength,")");case"data_view":return"DataView(".concat(B.buffer.byteLength,")");case"array":if(z){for(var G="",$=0;$0&&(G+=", "),!((G+=Si(B[$],!1)).length>50));$++);return"[".concat(tu(G),"]")}var De=hasOwnProperty.call(B,vu.size)?B[vu.size]:B.length;return"Array(".concat(De,")");case"typed_array":var me="".concat(B.constructor.name,"(").concat(B.length,")");if(z){for(var xe="",Z=0;Z0&&(xe+=", "),!((xe+=B[Z]).length>50));Z++);return"".concat(me," [").concat(tu(xe),"]")}return me;case"iterator":var ke=B.constructor.name;if(z){for(var Xe=Array.from(B),ht="",ie=0;ie0&&(ht+=", "),Array.isArray(qe)){var tt=Si(qe[0],!0),Tt=Si(qe[1],!1);ht+="".concat(tt," => ").concat(Tt)}else ht+=Si(qe,!1);if(ht.length>50)break}return"".concat(ke,"(").concat(B.size,") {").concat(tu(ht),"}")}return"".concat(ke,"(").concat(B.size,")");case"opaque_iterator":return B[Symbol.toStringTag];case"date":return B.toString();case"object":if(z){for(var kt=eu(B).sort(_i),bt="",on=0;on0&&(bt+=", "),(bt+="".concat(tn.toString(),": ").concat(Si(B[tn],!1))).length>50)break}return"{".concat(tu(bt),"}")}return"{\u2026}";case"boolean":case"number":case"infinity":case"nan":case"null":case"undefined":return B;default:try{return tu(""+B)}catch(Lt){return"unserializable"}}}var ks=f(7);function Hl(B){return(Hl=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(z){return typeof z}:function(z){return z&&typeof Symbol=="function"&&z.constructor===Symbol&&z!==Symbol.prototype?"symbol":typeof z})(B)}function F0(B,z){var G=Object.keys(B);if(Object.getOwnPropertySymbols){var $=Object.getOwnPropertySymbols(B);z&&($=$.filter(function(De){return Object.getOwnPropertyDescriptor(B,De).enumerable})),G.push.apply(G,$)}return G}function f0(B){for(var z=1;z2&&arguments[2]!==void 0?arguments[2]:[];if(B!==null){var $=[],De=[],me=So(B,$,De,G,z);return{data:me,cleaned:$,unserializable:De}}return null}function G0(B){var z,G,$=(z=B,G=new Set,JSON.stringify(z,function(xe,Z){if(Hl(Z)==="object"&&Z!==null){if(G.has(Z))return;G.add(Z)}return typeof Z=="bigint"?Z.toString()+"n":Z})),De=$===void 0?"undefined":$,me=window.__REACT_DEVTOOLS_GLOBAL_HOOK__.clipboardCopyText;typeof me=="function"?me(De).catch(function(xe){}):Object(ks.copy)(De)}function fi(B,z){var G=arguments.length>2&&arguments[2]!==void 0?arguments[2]:0,$=z[G],De=Array.isArray(B)?B.slice():f0({},B);return G+1===z.length?Array.isArray(De)?De.splice($,1):delete De[$]:De[$]=fi(B[$],z,G+1),De}function Zt(B,z,G){var $=arguments.length>3&&arguments[3]!==void 0?arguments[3]:0,De=z[$],me=Array.isArray(B)?B.slice():f0({},B);if($+1===z.length){var xe=G[$];me[xe]=me[De],Array.isArray(me)?me.splice(De,1):delete me[De]}else me[De]=Zt(B[De],z,G,$+1);return me}function Ln(B,z,G){var $=arguments.length>3&&arguments[3]!==void 0?arguments[3]:0;if($>=z.length)return G;var De=z[$],me=Array.isArray(B)?B.slice():f0({},B);return me[De]=Ln(B[De],z,G,$+1),me}var Di=f(8);function ci(B,z){var G=Object.keys(B);if(Object.getOwnPropertySymbols){var $=Object.getOwnPropertySymbols(B);z&&($=$.filter(function(De){return Object.getOwnPropertyDescriptor(B,De).enumerable})),G.push.apply(G,$)}return G}function Ht(B){for(var z=1;z=B.length?{done:!0}:{done:!1,value:B[$++]}},e:function(ke){throw ke},f:De}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var me,xe=!0,Z=!1;return{s:function(){G=B[Symbol.iterator]()},n:function(){var ke=G.next();return xe=ke.done,ke},e:function(ke){Z=!0,me=ke},f:function(){try{xe||G.return==null||G.return()}finally{if(Z)throw me}}}}function Wl(B,z){if(B){if(typeof B=="string")return xo(B,z);var G=Object.prototype.toString.call(B).slice(8,-1);return G==="Object"&&B.constructor&&(G=B.constructor.name),G==="Map"||G==="Set"?Array.from(B):G==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(G)?xo(B,z):void 0}}function xo(B,z){(z==null||z>B.length)&&(z=B.length);for(var G=0,$=new Array(z);G0){var vt=me(se);if(vt!=null){var Xt,xn=Ui(Mo);try{for(xn.s();!(Xt=xn.n()).done;)if(Xt.value.test(vt))return!0}catch(er){xn.e(er)}finally{xn.f()}}}if(re!=null&&ds.size>0){var _n,yn=re.fileName,En=Ui(ds);try{for(En.s();!(_n=En.n()).done;)if(_n.value.test(yn))return!0}catch(er){En.e(er)}finally{En.f()}}return!1}function yu(se){var re=se.type;switch(se.tag){case Tt:case _r:return 1;case tt:case Cn:return 5;case tn:return 6;case Lt:return 11;case lr:return 7;case gn:case Qn:case on:return 9;case Ar:case Rr:return 8;case nt:return 12;case _t:return 13;default:switch(xe(re)){case 60111:case"Symbol(react.concurrent_mode)":case"Symbol(react.async_mode)":return 9;case 60109:case"Symbol(react.provider)":return 2;case 60110:case"Symbol(react.context)":return 2;case 60108:case"Symbol(react.strict_mode)":return 9;case 60114:case"Symbol(react.profiler)":return 10;default:return 9}}}function pi(se){if(Fo.has(se))return se;var re=se.alternate;return re!=null&&Fo.has(re)?re:(Fo.add(se),se)}window.__REACT_DEVTOOLS_COMPONENT_FILTERS__!=null?ps(window.__REACT_DEVTOOLS_COMPONENT_FILTERS__):ps([{type:1,value:7,isEnabled:!0}]);var T0=new Map,Q0=new Map,Fo=new Set,ta=new Map,Kl=new Map,Ki=-1;function Yr(se){if(!T0.has(se)){var re=Xo();T0.set(se,re),Q0.set(re,se)}return T0.get(se)}function fo(se){switch(yu(se)){case 1:if(I0!==null){var re=Yr(pi(se)),Le=gi(se);Le!==null&&I0.set(re,Le)}}}var Oi={};function gi(se){switch(yu(se)){case 1:var re=se.stateNode,Le=Oi,Ae=Oi;return re!=null&&(re.constructor&&re.constructor.contextType!=null?Ae=re.context:(Le=re.context)&&Object.keys(Le).length===0&&(Le=Oi)),[Le,Ae];default:return null}}function ff(se){switch(yu(se)){case 1:if(I0!==null){var re=Yr(pi(se)),Le=I0.has(re)?I0.get(re):null,Ae=gi(se);if(Le==null||Ae==null)return null;var ot=Y0(Le,2),vt=ot[0],Xt=ot[1],xn=Y0(Ae,2),_n=xn[0],yn=xn[1];if(_n!==Oi)return J0(vt,_n);if(yn!==Oi)return Xt!==yn}}return null}function cf(se,re){if(se==null||re==null)return!1;if(re.hasOwnProperty("baseState")&&re.hasOwnProperty("memoizedState")&&re.hasOwnProperty("next")&&re.hasOwnProperty("queue"))for(;re!==null;){if(re.memoizedState!==se.memoizedState)return!0;re=re.next,se=se.next}return!1}function J0(se,re){if(se==null||re==null||re.hasOwnProperty("baseState")&&re.hasOwnProperty("memoizedState")&&re.hasOwnProperty("next")&&re.hasOwnProperty("queue"))return null;var Le,Ae=[],ot=Ui(new Set([].concat(Yi(Object.keys(se)),Yi(Object.keys(re)))));try{for(ot.s();!(Le=ot.n()).done;){var vt=Le.value;se[vt]!==re[vt]&&Ae.push(vt)}}catch(Xt){ot.e(Xt)}finally{ot.f()}return Ae}function Z0(se,re){switch(re.tag){case Tt:case tt:case kt:case Ar:case Rr:return(oo(re)&ie)===ie;default:return se.memoizedProps!==re.memoizedProps||se.memoizedState!==re.memoizedState||se.ref!==re.ref}}var Te=[],et=[],Ve=[],Gt=[],Yt=new Map,sr=0,Br=null;function wn(se){Te.push(se)}function fu(se){if(Te.length!==0||et.length!==0||Ve.length!==0||Br!==null||Ru){var re=et.length+Ve.length+(Br===null?0:1),Le=new Array(3+sr+(re>0?2+re:0)+Te.length),Ae=0;if(Le[Ae++]=z,Le[Ae++]=Ki,Le[Ae++]=sr,Yt.forEach(function(xn,_n){Le[Ae++]=_n.length;for(var yn=W0(_n),En=0;En0){Le[Ae++]=2,Le[Ae++]=re;for(var ot=et.length-1;ot>=0;ot--)Le[Ae++]=et[ot];for(var vt=0;vt0?se.forEach(function(re){B.emit("operations",re)}):(wr!==null&&(ru=!0),B.getFiberRoots(z).forEach(function(re){Xu(Ki=Yr(pi(re.current)),re.current),Ru&&re.memoizedInteractions!=null&&($o={changeDescriptions:Xl?new Map:null,durations:[],commitTime:Vl()-Yu,interactions:Array.from(re.memoizedInteractions).map(function(Le){return Ht(Ht({},Le),{},{timestamp:Le.timestamp-Yu})}),maxActualDuration:0,priorityLevel:null}),Vr(re.current,null,!1,!1),fu(),Ki=-1}))},getBestMatchForTrackedPath:function(){if(wr===null||$0===null)return null;for(var se=$0;se!==null&&Vu(se);)se=se.return;return se===null?null:{id:Yr(pi(se)),isFullMatch:Xi===wr.length-1}},getDisplayNameForFiberID:function(se){var re=Q0.get(se);return re!=null?me(re):null},getFiberIDForNative:function(se){var re=arguments.length>1&&arguments[1]!==void 0&&arguments[1],Le=G.findFiberByHostInstance(se);if(Le!=null){if(re)for(;Le!==null&&Vu(Le);)Le=Le.return;return Yr(pi(Le))}return null},getInstanceAndStyle:function(se){var re=null,Le=null,Ae=Uu(se);return Ae!==null&&(re=Ae.stateNode,Ae.memoizedProps!==null&&(Le=Ae.memoizedProps.style)),{instance:re,style:Le}},getOwnersList:function(se){var re=Uu(se);if(re==null)return null;var Le=re._debugOwner,Ae=[{displayName:me(re)||"Anonymous",id:se,type:yu(re)}];if(Le)for(var ot=Le;ot!==null;)Ae.unshift({displayName:me(ot)||"Anonymous",id:Yr(pi(ot)),type:yu(ot)}),ot=ot._debugOwner||null;return Ae},getPathForElement:function(se){var re=Q0.get(se);if(re==null)return null;for(var Le=[];re!==null;)Le.push(y0(re)),re=re.return;return Le.reverse(),Le},getProfilingData:function(){var se=[];if(hs===null)throw Error("getProfilingData() called before any profiling data was recorded");return hs.forEach(function(re,Le){var Ae=[],ot=[],vt=new Map,Xt=new Map,xn=El!==null&&El.get(Le)||"Unknown";R0!=null&&R0.forEach(function(_n,yn){co!=null&&co.get(yn)===Le&&ot.push([yn,_n])}),re.forEach(function(_n,yn){var En=_n.changeDescriptions,er=_n.durations,It=_n.interactions,xi=_n.maxActualDuration,Sr=_n.priorityLevel,cr=_n.commitTime,Y=[];It.forEach(function(hi){vt.has(hi.id)||vt.set(hi.id,hi),Y.push(hi.id);var Qi=Xt.get(hi.id);Qi!=null?Qi.push(yn):Xt.set(hi.id,[yn])});for(var Qr=[],Jr=[],Ur=0;Ur1?Wn.set(En,er-1):Wn.delete(En),Xr.delete(_n)}(Ki),Kr(Le,!1))}else Xu(Ki,Le),Vr(Le,null,!1,!1);if(Ru&&ot){var xn=hs.get(Ki);xn!=null?xn.push($o):hs.set(Ki,[$o])}fu(),No&&B.emit("traceUpdates",Lo),Ki=-1},handleCommitFiberUnmount:function(se){Kr(se,!1)},inspectElement:function(se,re){if(Li(se)){if(re!=null){A0(re);var Le=null;return re[0]==="hooks"&&(Le="hooks"),{id:se,type:"hydrated-path",path:re,value:Ei(Lu(zi,re),Fi(null,Le),re)}}return{id:se,type:"no-change"}}if(Is=!1,zi!==null&&zi.id===se||(x0={}),(zi=na(se))===null)return{id:se,type:"not-found"};re!=null&&A0(re),function(ot){var vt=ot.hooks,Xt=ot.id,xn=ot.props,_n=Q0.get(Xt);if(_n!=null){var yn=_n.elementType,En=_n.stateNode,er=_n.tag,It=_n.type;switch(er){case Tt:case _r:case Cn:$.$r=En;break;case tt:$.$r={hooks:vt,props:xn,type:It};break;case tn:$.$r={props:xn,type:It.render};break;case Ar:case Rr:$.$r={props:xn,type:yn!=null&&yn.type!=null?yn.type:It};break;default:$.$r=null}}else console.warn('Could not find Fiber with id "'.concat(Xt,'"'))}(zi);var Ae=Ht({},zi);return Ae.context=Ei(Ae.context,Fi("context",null)),Ae.hooks=Ei(Ae.hooks,Fi("hooks","hooks")),Ae.props=Ei(Ae.props,Fi("props",null)),Ae.state=Ei(Ae.state,Fi("state",null)),{id:se,type:"full-data",value:Ae}},logElementToConsole:function(se){var re=Li(se)?zi:na(se);if(re!==null){var Le=typeof console.groupCollapsed=="function";Le&&console.groupCollapsed("[Click to expand] %c<".concat(re.displayName||"Component"," />"),"color: var(--dom-tag-name-color); font-weight: normal;"),re.props!==null&&console.log("Props:",re.props),re.state!==null&&console.log("State:",re.state),re.hooks!==null&&console.log("Hooks:",re.hooks);var Ae=_l(se);Ae!==null&&console.log("Nodes:",Ae),re.source!==null&&console.log("Location:",re.source),(window.chrome||/firefox/i.test(navigator.userAgent))&&console.log("Right-click any value to save it as a global variable for further inspection."),Le&&console.groupEnd()}else console.warn('Could not find Fiber with id "'.concat(se,'"'))},prepareViewAttributeSource:function(se,re){Li(se)&&(window.$attribute=Lu(zi,re))},prepareViewElementSource:function(se){var re=Q0.get(se);if(re!=null){var Le=re.elementType,Ae=re.tag,ot=re.type;switch(Ae){case Tt:case _r:case Cn:case tt:$.$type=ot;break;case tn:$.$type=ot.render;break;case Ar:case Rr:$.$type=Le!=null&&Le.type!=null?Le.type:ot;break;default:$.$type=null}}else console.warn('Could not find Fiber with id "'.concat(se,'"'))},overrideSuspense:function(se,re){if(typeof ko!="function"||typeof Zo!="function")throw new Error("Expected overrideSuspense() to not get called for earlier React versions.");re?(Ku.add(se),Ku.size===1&&ko(vs)):(Ku.delete(se),Ku.size===0&&ko(df));var Le=Q0.get(se);Le!=null&&Zo(Le)},overrideValueAtPath:function(se,re,Le,Ae,ot){var vt=Uu(re);if(vt!==null){var Xt=vt.stateNode;switch(se){case"context":switch(Ae=Ae.slice(1),vt.tag){case Tt:Ae.length===0?Xt.context=ot:To(Xt.context,Ae,ot),Xt.forceUpdate()}break;case"hooks":typeof nu=="function"&&nu(vt,Le,Ae,ot);break;case"props":switch(vt.tag){case Tt:vt.pendingProps=Ln(Xt.props,Ae,ot),Xt.forceUpdate();break;default:typeof X0=="function"&&X0(vt,Ae,ot)}break;case"state":switch(vt.tag){case Tt:To(Xt.state,Ae,ot),Xt.forceUpdate()}}}},renamePath:function(se,re,Le,Ae,ot){var vt=Uu(re);if(vt!==null){var Xt=vt.stateNode;switch(se){case"context":switch(Ae=Ae.slice(1),ot=ot.slice(1),vt.tag){case Tt:Ae.length===0||Hr(Xt.context,Ae,ot),Xt.forceUpdate()}break;case"hooks":typeof S0=="function"&&S0(vt,Le,Ae,ot);break;case"props":Xt===null?typeof di=="function"&&di(vt,Ae,ot):(vt.pendingProps=Zt(Xt.props,Ae,ot),Xt.forceUpdate());break;case"state":Hr(Xt.state,Ae,ot),Xt.forceUpdate()}}},renderer:G,setTraceUpdatesEnabled:function(se){No=se},setTrackedPath:Ci,startProfiling:ra,stopProfiling:function(){Ru=!1,Xl=!1},storeAsGlobal:function(se,re,Le){if(Li(se)){var Ae=Lu(zi,re),ot="$reactTemp".concat(Le);window[ot]=Ae,console.log(ot),console.log(Ae)}},updateComponentFilters:function(se){if(Ru)throw Error("Cannot modify filter preferences while profiling");B.getFiberRoots(z).forEach(function(re){Ki=Yr(pi(re.current)),Bu(re.current),Kr(re.current,!1),Ki=-1}),ps(se),Wn.clear(),B.getFiberRoots(z).forEach(function(re){Xu(Ki=Yr(pi(re.current)),re.current),Vr(re.current,null,!1,!1),fu(re),Ki=-1})}}}var Xn;function Qo(B){return(Qo=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(z){return typeof z}:function(z){return z&&typeof Symbol=="function"&&z.constructor===Symbol&&z!==Symbol.prototype?"symbol":typeof z})(B)}function lo(B,z,G){if(Xn===void 0)try{throw Error()}catch(De){var $=De.stack.trim().match(/\n( *(at )?)/);Xn=$&&$[1]||""}return` +`+Xn+B}var b0=!1;function yl(B,z,G){if(!B||b0)return"";var $,De=Error.prepareStackTrace;Error.prepareStackTrace=void 0,b0=!0;var me=G.current;G.current=null;try{if(z){var xe=function(){throw Error()};if(Object.defineProperty(xe.prototype,"props",{set:function(){throw Error()}}),(typeof Reflect=="undefined"?"undefined":Qo(Reflect))==="object"&&Reflect.construct){try{Reflect.construct(xe,[])}catch(qe){$=qe}Reflect.construct(B,[],xe)}else{try{xe.call()}catch(qe){$=qe}B.call(xe.prototype)}}else{try{throw Error()}catch(qe){$=qe}B()}}catch(qe){if(qe&&$&&typeof qe.stack=="string"){for(var Z=qe.stack.split(` +`),ke=$.stack.split(` +`),Xe=Z.length-1,ht=ke.length-1;Xe>=1&&ht>=0&&Z[Xe]!==ke[ht];)ht--;for(;Xe>=1&&ht>=0;Xe--,ht--)if(Z[Xe]!==ke[ht]){if(Xe!==1||ht!==1)do if(Xe--,--ht<0||Z[Xe]!==ke[ht])return` +`+Z[Xe].replace(" at new "," at ");while(Xe>=1&&ht>=0);break}}}finally{b0=!1,Error.prepareStackTrace=De,G.current=me}var ie=B?B.displayName||B.name:"";return ie?lo(ie):""}function Ro(B,z,G,$){return yl(B,!1,$)}function Et(B,z,G){var $=B.HostComponent,De=B.LazyComponent,me=B.SuspenseComponent,xe=B.SuspenseListComponent,Z=B.FunctionComponent,ke=B.IndeterminateComponent,Xe=B.SimpleMemoComponent,ht=B.ForwardRef,ie=B.Block,qe=B.ClassComponent;switch(z.tag){case $:return lo(z.type);case De:return lo("Lazy");case me:return lo("Suspense");case xe:return lo("SuspenseList");case Z:case ke:case Xe:return Ro(z.type,0,0,G);case ht:return Ro(z.type.render,0,0,G);case ie:return Ro(z.type._render,0,0,G);case qe:return function(tt,Tt,kt,bt){return yl(tt,!0,bt)}(z.type,0,0,G);default:return""}}function Pt(B,z,G){try{var $="",De=z;do $+=Et(B,De,G),De=De.return;while(De);return $}catch(me){return` +Error generating stack: `+me.message+` +`+me.stack}}function Bn(B,z){var G;if(typeof Symbol=="undefined"||B[Symbol.iterator]==null){if(Array.isArray(B)||(G=function(ke,Xe){if(!!ke){if(typeof ke=="string")return Ir(ke,Xe);var ht=Object.prototype.toString.call(ke).slice(8,-1);if(ht==="Object"&&ke.constructor&&(ht=ke.constructor.name),ht==="Map"||ht==="Set")return Array.from(ke);if(ht==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(ht))return Ir(ke,Xe)}}(B))||z&&B&&typeof B.length=="number"){G&&(B=G);var $=0,De=function(){};return{s:De,n:function(){return $>=B.length?{done:!0}:{done:!1,value:B[$++]}},e:function(ke){throw ke},f:De}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var me,xe=!0,Z=!1;return{s:function(){G=B[Symbol.iterator]()},n:function(){var ke=G.next();return xe=ke.done,ke},e:function(ke){Z=!0,me=ke},f:function(){try{xe||G.return==null||G.return()}finally{if(Z)throw me}}}}function Ir(B,z){(z==null||z>B.length)&&(z=B.length);for(var G=0,$=new Array(z);G0?Xe[Xe.length-1]:null,qe=ie!==null&&(Wr.test(ie)||wu.test(ie));if(!qe){var tt,Tt=Bn(c0.values());try{for(Tt.s();!(tt=Tt.n()).done;){var kt=tt.value,bt=kt.currentDispatcherRef,on=kt.getCurrentFiber,tn=kt.workTagMap,Lt=on();if(Lt!=null){var gn=Pt(tn,Lt,bt);gn!==""&&Xe.push(gn);break}}}catch(lr){Tt.e(lr)}finally{Tt.f()}}}catch(lr){}me.apply(void 0,Xe)};xe.__REACT_DEVTOOLS_ORIGINAL_METHOD__=me,Ti[De]=xe}catch(Z){}})}}function Fu(B){return(Fu=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(z){return typeof z}:function(z){return z&&typeof Symbol=="function"&&z.constructor===Symbol&&z!==Symbol.prototype?"symbol":typeof z})(B)}function fs(B,z){for(var G=0;GB.length)&&(z=B.length);for(var G=0,$=new Array(z);G1?Z-1:0),Xe=1;Xe0?ie[ie.length-1]:0),ie.push(nn),Z.set(Ze,Xe(Ft._topLevelWrapper));try{var sn=nt.apply(this,_t);return ie.pop(),sn}catch(yr){throw ie=[],yr}finally{if(ie.length===0){var Yn=Z.get(Ze);if(Yn===void 0)throw new Error("Expected to find root ID.");lr(Yn)}}},performUpdateIfNecessary:function(nt,_t){var Ze=_t[0];if(w0(Ze)===9)return nt.apply(this,_t);var Ft=Xe(Ze);ie.push(Ft);var nn=Gn(Ze);try{var sn=nt.apply(this,_t),Yn=Gn(Ze);return ht(nn,Yn)||Tt(Ze,Ft,Yn),ie.pop(),sn}catch(nu){throw ie=[],nu}finally{if(ie.length===0){var yr=Z.get(Ze);if(yr===void 0)throw new Error("Expected to find root ID.");lr(yr)}}},receiveComponent:function(nt,_t){var Ze=_t[0];if(w0(Ze)===9)return nt.apply(this,_t);var Ft=Xe(Ze);ie.push(Ft);var nn=Gn(Ze);try{var sn=nt.apply(this,_t),Yn=Gn(Ze);return ht(nn,Yn)||Tt(Ze,Ft,Yn),ie.pop(),sn}catch(nu){throw ie=[],nu}finally{if(ie.length===0){var yr=Z.get(Ze);if(yr===void 0)throw new Error("Expected to find root ID.");lr(yr)}}},unmountComponent:function(nt,_t){var Ze=_t[0];if(w0(Ze)===9)return nt.apply(this,_t);var Ft=Xe(Ze);ie.push(Ft);try{var nn=nt.apply(this,_t);return ie.pop(),function(Yn,yr){tn.push(yr),me.delete(yr)}(0,Ft),nn}catch(Yn){throw ie=[],Yn}finally{if(ie.length===0){var sn=Z.get(Ze);if(sn===void 0)throw new Error("Expected to find root ID.");lr(sn)}}}}));var bt=[],on=new Map,tn=[],Lt=0,gn=null;function lr(nt){if(bt.length!==0||tn.length!==0||gn!==null){var _t=tn.length+(gn===null?0:1),Ze=new Array(3+Lt+(_t>0?2+_t:0)+bt.length),Ft=0;if(Ze[Ft++]=z,Ze[Ft++]=nt,Ze[Ft++]=Lt,on.forEach(function(Yn,yr){Ze[Ft++]=yr.length;for(var nu=W0(yr),Cu=0;Cu0){Ze[Ft++]=2,Ze[Ft++]=_t;for(var nn=0;nn"),"color: var(--dom-tag-name-color); font-weight: normal;"),_t.props!==null&&console.log("Props:",_t.props),_t.state!==null&&console.log("State:",_t.state),_t.context!==null&&console.log("Context:",_t.context);var Ft=De(nt);Ft!==null&&console.log("Node:",Ft),(window.chrome||/firefox/i.test(navigator.userAgent))&&console.log("Right-click any value to save it as a global variable for further inspection."),Ze&&console.groupEnd()}else console.warn('Could not find element with id "'.concat(nt,'"'))},overrideSuspense:function(){throw new Error("overrideSuspense not supported by this renderer")},overrideValueAtPath:function(nt,_t,Ze,Ft,nn){var sn=me.get(_t);if(sn!=null){var Yn=sn._instance;if(Yn!=null)switch(nt){case"context":To(Yn.context,Ft,nn),p0(Yn);break;case"hooks":throw new Error("Hooks not supported by this renderer");case"props":var yr=sn._currentElement;sn._currentElement=K0(K0({},yr),{},{props:Ln(yr.props,Ft,nn)}),p0(Yn);break;case"state":To(Yn.state,Ft,nn),p0(Yn)}}},renamePath:function(nt,_t,Ze,Ft,nn){var sn=me.get(_t);if(sn!=null){var Yn=sn._instance;if(Yn!=null)switch(nt){case"context":Hr(Yn.context,Ft,nn),p0(Yn);break;case"hooks":throw new Error("Hooks not supported by this renderer");case"props":var yr=sn._currentElement;sn._currentElement=K0(K0({},yr),{},{props:Zt(yr.props,Ft,nn)}),p0(Yn);break;case"state":Hr(Yn.state,Ft,nn),p0(Yn)}}},prepareViewAttributeSource:function(nt,_t){var Ze=Rr(nt);Ze!==null&&(window.$attribute=Lu(Ze,_t))},prepareViewElementSource:function(nt){var _t=me.get(nt);if(_t!=null){var Ze=_t._currentElement;Ze!=null?$.$type=Ze.type:console.warn('Could not find element with id "'.concat(nt,'"'))}else console.warn('Could not find instance with id "'.concat(nt,'"'))},renderer:G,setTraceUpdatesEnabled:function(nt){},setTrackedPath:function(nt){},startProfiling:function(){},stopProfiling:function(){},storeAsGlobal:function(nt,_t,Ze){var Ft=Rr(nt);if(Ft!==null){var nn=Lu(Ft,_t),sn="$reactTemp".concat(Ze);window[sn]=nn,console.log(sn),console.log(nn)}},updateComponentFilters:function(nt){}}}function ri(B,z){var G=!1,$={bottom:0,left:0,right:0,top:0},De=z[B];if(De!=null){for(var me=0,xe=Object.keys($);me0?"development":"production";var bt=Function.prototype.toString;if(kt.Mount&&kt.Mount._renderNewRootComponent){var on=bt.call(kt.Mount._renderNewRootComponent);return on.indexOf("function")!==0?"production":on.indexOf("storedMeasure")!==-1?"development":on.indexOf("should be a pure function")!==-1?on.indexOf("NODE_ENV")!==-1||on.indexOf("development")!==-1||on.indexOf("true")!==-1?"development":on.indexOf("nextElement")!==-1||on.indexOf("nextComponent")!==-1?"unminified":"development":on.indexOf("nextElement")!==-1||on.indexOf("nextComponent")!==-1?"unminified":"outdated"}}catch(tn){}return"production"}(ke);try{var ie=window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__!==!1,qe=window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__===!0;(ie||qe)&&(so(ke),Gl({appendComponentStack:ie,breakOnConsoleErrors:qe}))}catch(kt){}var tt=B.__REACT_DEVTOOLS_ATTACH__;if(typeof tt=="function"){var Tt=tt(Z,Xe,ke,B);Z.rendererInterfaces.set(Xe,Tt)}return Z.emit("renderer",{id:Xe,renderer:ke,reactBuildType:ht}),Xe},on:function(ke,Xe){me[ke]||(me[ke]=[]),me[ke].push(Xe)},off:function(ke,Xe){if(me[ke]){var ht=me[ke].indexOf(Xe);ht!==-1&&me[ke].splice(ht,1),me[ke].length||delete me[ke]}},sub:function(ke,Xe){return Z.on(ke,Xe),function(){return Z.off(ke,Xe)}},supportsFiber:!0,checkDCE:function(ke){try{Function.prototype.toString.call(ke).indexOf("^_^")>-1&&(G=!0,setTimeout(function(){throw new Error("React is running in production mode, but dead code elimination has not been applied. Read how to correctly configure React for production: https://reactjs.org/link/perf-use-production-build")}))}catch(Xe){}},onCommitFiberUnmount:function(ke,Xe){var ht=De.get(ke);ht!=null&&ht.handleCommitFiberUnmount(Xe)},onCommitFiberRoot:function(ke,Xe,ht){var ie=Z.getFiberRoots(ke),qe=Xe.current,tt=ie.has(Xe),Tt=qe.memoizedState==null||qe.memoizedState.element==null;tt||Tt?tt&&Tt&&ie.delete(Xe):ie.add(Xe);var kt=De.get(ke);kt!=null&&kt.handleCommitFiberRoot(Xe,ht)}};Object.defineProperty(B,"__REACT_DEVTOOLS_GLOBAL_HOOK__",{configurable:!1,enumerable:!1,get:function(){return Z}})})(window);var h0=window.__REACT_DEVTOOLS_GLOBAL_HOOK__,Fs=[{type:1,value:7,isEnabled:!0}];function Ni(B){if(h0!=null){var z=B||{},G=z.host,$=G===void 0?"localhost":G,De=z.nativeStyleEditorValidAttributes,me=z.useHttps,xe=me!==void 0&&me,Z=z.port,ke=Z===void 0?8097:Z,Xe=z.websocket,ht=z.resolveRNStyle,ie=ht===void 0?null:ht,qe=z.isAppActive,tt=xe?"wss":"ws",Tt=null;if((qe===void 0?function(){return!0}:qe)()){var kt=null,bt=[],on=tt+"://"+$+":"+ke,tn=Xe||new window.WebSocket(on);tn.onclose=function(){kt!==null&&kt.emit("shutdown"),Lt()},tn.onerror=function(){Lt()},tn.onmessage=function(gn){var lr;try{if(typeof gn.data!="string")throw Error();lr=JSON.parse(gn.data)}catch(Qn){return void console.error("[React DevTools] Failed to parse JSON: "+gn.data)}bt.forEach(function(Qn){try{Qn(lr)}catch(_r){throw console.log("[React DevTools] Error calling listener",lr),console.log("error:",_r),_r}})},tn.onopen=function(){(kt=new ao({listen:function(Cn){return bt.push(Cn),function(){var Ar=bt.indexOf(Cn);Ar>=0&&bt.splice(Ar,1)}},send:function(Cn,Ar,v0){tn.readyState===tn.OPEN?tn.send(JSON.stringify({event:Cn,payload:Ar})):(kt!==null&&kt.shutdown(),Lt())}})).addListener("inspectElement",function(Cn){var Ar=Cn.id,v0=Cn.rendererID,Rr=gn.rendererInterfaces[v0];if(Rr!=null){var nt=Rr.findNativeNodesForFiberID(Ar);nt!=null&&nt[0]!=null&&gn.emit("showNativeHighlight",nt[0])}}),kt.addListener("updateComponentFilters",function(Cn){Fs=Cn}),window.__REACT_DEVTOOLS_COMPONENT_FILTERS__==null&&kt.send("overrideComponentFilters",Fs);var gn=new Hn(kt);if(gn.addListener("shutdown",function(){h0.emit("shutdown")}),function(Cn,Ar,v0){if(Cn==null)return function(){};var Rr=[Cn.sub("renderer-attached",function(Ze){var Ft=Ze.id,nn=(Ze.renderer,Ze.rendererInterface);Ar.setRendererInterface(Ft,nn),nn.flushInitialOperations()}),Cn.sub("unsupported-renderer-version",function(Ze){Ar.onUnsupportedRenderer(Ze)}),Cn.sub("operations",Ar.onHookOperations),Cn.sub("traceUpdates",Ar.onTraceUpdates)],nt=function(Ze,Ft){var nn=Cn.rendererInterfaces.get(Ze);nn==null&&(typeof Ft.findFiberByHostInstance=="function"?nn=Ms(Cn,Ze,Ft,v0):Ft.ComponentTree&&(nn=ic(Cn,Ze,Ft,v0)),nn!=null&&Cn.rendererInterfaces.set(Ze,nn)),nn!=null?Cn.emit("renderer-attached",{id:Ze,renderer:Ft,rendererInterface:nn}):Cn.emit("unsupported-renderer-version",Ze)};Cn.renderers.forEach(function(Ze,Ft){nt(Ft,Ze)}),Rr.push(Cn.sub("renderer",function(Ze){var Ft=Ze.id,nn=Ze.renderer;nt(Ft,nn)})),Cn.emit("react-devtools",Ar),Cn.reactDevtoolsAgent=Ar;var _t=function(){Rr.forEach(function(Ze){return Ze()}),Cn.rendererInterfaces.forEach(function(Ze){Ze.cleanup()}),Cn.reactDevtoolsAgent=null};Ar.addListener("shutdown",_t),Rr.push(function(){Ar.removeListener("shutdown",_t)})}(h0,gn,window),ie!=null||h0.resolveRNStyle!=null)ea(kt,gn,ie||h0.resolveRNStyle,De||h0.nativeStyleEditorValidAttributes||null);else{var lr,Qn,_r=function(){kt!==null&&ea(kt,gn,lr,Qn)};h0.hasOwnProperty("resolveRNStyle")||Object.defineProperty(h0,"resolveRNStyle",{enumerable:!1,get:function(){return lr},set:function(Cn){lr=Cn,_r()}}),h0.hasOwnProperty("nativeStyleEditorValidAttributes")||Object.defineProperty(h0,"nativeStyleEditorValidAttributes",{enumerable:!1,get:function(){return Qn},set:function(Cn){Qn=Cn,_r()}})}}}else Lt()}function Lt(){Tt===null&&(Tt=setTimeout(function(){return Ni(B)},2e3))}}}])})});var mR=ce(vR=>{"use strict";Object.defineProperty(vR,"__esModule",{value:!0});pR();var $Q=hR();$Q.connectToDevTools()});var DR=ce(kg=>{"use strict";var yR=kg&&kg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(kg,"__esModule",{value:!0});var gR=h4(),eJ=yR(D9()),_R=yR(eh()),ss=Lw();process.env.DEV==="true"&&mR();var ER=i=>{i==null||i.unsetMeasureFunc(),i==null||i.freeRecursive()};kg.default=eJ.default({schedulePassiveEffects:gR.unstable_scheduleCallback,cancelPassiveEffects:gR.unstable_cancelCallback,now:Date.now,getRootHostContext:()=>({isInsideText:!1}),prepareForCommit:()=>{},resetAfterCommit:i=>{if(i.isStaticDirty){i.isStaticDirty=!1,typeof i.onImmediateRender=="function"&&i.onImmediateRender();return}typeof i.onRender=="function"&&i.onRender()},getChildHostContext:(i,o)=>{let f=i.isInsideText,p=o==="ink-text"||o==="ink-virtual-text";return f===p?i:{isInsideText:p}},shouldSetTextContent:()=>!1,createInstance:(i,o,f,p)=>{if(p.isInsideText&&i==="ink-box")throw new Error(" can\u2019t be nested inside component");let E=i==="ink-text"&&p.isInsideText?"ink-virtual-text":i,t=ss.createNode(E);for(let[k,L]of Object.entries(o))k!=="children"&&(k==="style"?ss.setStyle(t,L):k==="internal_transform"?t.internal_transform=L:k==="internal_static"?t.internal_static=!0:ss.setAttribute(t,k,L));return t},createTextInstance:(i,o,f)=>{if(!f.isInsideText)throw new Error(`Text string "${i}" must be rendered inside component`);return ss.createTextNode(i)},resetTextContent:()=>{},hideTextInstance:i=>{ss.setTextNodeValue(i,"")},unhideTextInstance:(i,o)=>{ss.setTextNodeValue(i,o)},getPublicInstance:i=>i,hideInstance:i=>{var o;(o=i.yogaNode)===null||o===void 0||o.setDisplay(_R.default.DISPLAY_NONE)},unhideInstance:i=>{var o;(o=i.yogaNode)===null||o===void 0||o.setDisplay(_R.default.DISPLAY_FLEX)},appendInitialChild:ss.appendChildNode,appendChild:ss.appendChildNode,insertBefore:ss.insertBeforeNode,finalizeInitialChildren:(i,o,f,p)=>(i.internal_static&&(p.isStaticDirty=!0,p.staticNode=i),!1),supportsMutation:!0,appendChildToContainer:ss.appendChildNode,insertInContainerBefore:ss.insertBeforeNode,removeChildFromContainer:(i,o)=>{ss.removeChildNode(i,o),ER(o.yogaNode)},prepareUpdate:(i,o,f,p,E)=>{i.internal_static&&(E.isStaticDirty=!0);let t={},k=Object.keys(p);for(let L of k)if(p[L]!==f[L]){if(L==="style"&&typeof p.style=="object"&&typeof f.style=="object"){let C=p.style,U=f.style,q=Object.keys(C);for(let W of q){if(W==="borderStyle"||W==="borderColor"){if(typeof t.style!="object"){let ne={};t.style=ne}t.style.borderStyle=C.borderStyle,t.style.borderColor=C.borderColor}if(C[W]!==U[W]){if(typeof t.style!="object"){let ne={};t.style=ne}t.style[W]=C[W]}}continue}t[L]=p[L]}return t},commitUpdate:(i,o)=>{for(let[f,p]of Object.entries(o))f!=="children"&&(f==="style"?ss.setStyle(i,p):f==="internal_transform"?i.internal_transform=p:f==="internal_static"?i.internal_static=!0:ss.setAttribute(i,f,p))},commitTextUpdate:(i,o,f)=>{ss.setTextNodeValue(i,f)},removeChild:(i,o)=>{ss.removeChildNode(i,o),ER(o.yogaNode)}})});var SR=ce((Are,wR)=>{"use strict";wR.exports=(i,o=1,f)=>{if(f=E0({indent:" ",includeEmptyLines:!1},f),typeof i!="string")throw new TypeError(`Expected \`input\` to be a \`string\`, got \`${typeof i}\``);if(typeof o!="number")throw new TypeError(`Expected \`count\` to be a \`number\`, got \`${typeof o}\``);if(typeof f.indent!="string")throw new TypeError(`Expected \`options.indent\` to be a \`string\`, got \`${typeof f.indent}\``);if(o===0)return i;let p=f.includeEmptyLines?/^/gm:/^(?!\s*$)/gm;return i.replace(p,f.indent.repeat(o))}});var TR=ce(Mg=>{"use strict";var tJ=Mg&&Mg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Mg,"__esModule",{value:!0});var k4=tJ(eh());Mg.default=i=>i.getComputedWidth()-i.getComputedPadding(k4.default.EDGE_LEFT)-i.getComputedPadding(k4.default.EDGE_RIGHT)-i.getComputedBorder(k4.default.EDGE_LEFT)-i.getComputedBorder(k4.default.EDGE_RIGHT)});var xR=ce((Ore,CR)=>{CR.exports={single:{topLeft:"\u250C",topRight:"\u2510",bottomRight:"\u2518",bottomLeft:"\u2514",vertical:"\u2502",horizontal:"\u2500"},double:{topLeft:"\u2554",topRight:"\u2557",bottomRight:"\u255D",bottomLeft:"\u255A",vertical:"\u2551",horizontal:"\u2550"},round:{topLeft:"\u256D",topRight:"\u256E",bottomRight:"\u256F",bottomLeft:"\u2570",vertical:"\u2502",horizontal:"\u2500"},bold:{topLeft:"\u250F",topRight:"\u2513",bottomRight:"\u251B",bottomLeft:"\u2517",vertical:"\u2503",horizontal:"\u2501"},singleDouble:{topLeft:"\u2553",topRight:"\u2556",bottomRight:"\u255C",bottomLeft:"\u2559",vertical:"\u2551",horizontal:"\u2500"},doubleSingle:{topLeft:"\u2552",topRight:"\u2555",bottomRight:"\u255B",bottomLeft:"\u2558",vertical:"\u2502",horizontal:"\u2550"},classic:{topLeft:"+",topRight:"+",bottomRight:"+",bottomLeft:"+",vertical:"|",horizontal:"-"}}});var RR=ce((kre,Zw)=>{"use strict";var AR=xR();Zw.exports=AR;Zw.exports.default=AR});var kR=ce((Mre,OR)=>{"use strict";OR.exports=(i,o=process.argv)=>{let f=i.startsWith("-")?"":i.length===1?"-":"--",p=o.indexOf(f+i),E=o.indexOf("--");return p!==-1&&(E===-1||p{"use strict";var nJ=require("os"),NR=require("tty"),of=kR(),{env:Wo}=process,md;of("no-color")||of("no-colors")||of("color=false")||of("color=never")?md=0:(of("color")||of("colors")||of("color=true")||of("color=always"))&&(md=1);"FORCE_COLOR"in Wo&&(Wo.FORCE_COLOR==="true"?md=1:Wo.FORCE_COLOR==="false"?md=0:md=Wo.FORCE_COLOR.length===0?1:Math.min(parseInt(Wo.FORCE_COLOR,10),3));function $w(i){return i===0?!1:{level:i,hasBasic:!0,has256:i>=2,has16m:i>=3}}function e3(i,o){if(md===0)return 0;if(of("color=16m")||of("color=full")||of("color=truecolor"))return 3;if(of("color=256"))return 2;if(i&&!o&&md===void 0)return 0;let f=md||0;if(Wo.TERM==="dumb")return f;if(process.platform==="win32"){let p=nJ.release().split(".");return Number(p[0])>=10&&Number(p[2])>=10586?Number(p[2])>=14931?3:2:1}if("CI"in Wo)return["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI"].some(p=>p in Wo)||Wo.CI_NAME==="codeship"?1:f;if("TEAMCITY_VERSION"in Wo)return/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(Wo.TEAMCITY_VERSION)?1:0;if("GITHUB_ACTIONS"in Wo)return 1;if(Wo.COLORTERM==="truecolor")return 3;if("TERM_PROGRAM"in Wo){let p=parseInt((Wo.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(Wo.TERM_PROGRAM){case"iTerm.app":return p>=3?3:2;case"Apple_Terminal":return 2}}return/-256(color)?$/i.test(Wo.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(Wo.TERM)||"COLORTERM"in Wo?1:f}function rJ(i){let o=e3(i,i&&i.isTTY);return $w(o)}MR.exports={supportsColor:rJ,stdout:$w(e3(!0,NR.isatty(1))),stderr:$w(e3(!0,NR.isatty(2)))}});var bR=ce((Lre,FR)=>{"use strict";var iJ=(i,o,f)=>{let p=i.indexOf(o);if(p===-1)return i;let E=o.length,t=0,k="";do k+=i.substr(t,p-t)+o+f,t=p+E,p=i.indexOf(o,t);while(p!==-1);return k+=i.substr(t),k},uJ=(i,o,f,p)=>{let E=0,t="";do{let k=i[p-1]==="\r";t+=i.substr(E,(k?p-1:p)-E)+o+(k?`\r +`:` +`)+f,E=p+1,p=i.indexOf(` +`,E)}while(p!==-1);return t+=i.substr(E),t};FR.exports={stringReplaceAll:iJ,stringEncaseCRLFWithFirstIndex:uJ}});var jR=ce((Fre,PR)=>{"use strict";var oJ=/(?:\\(u(?:[a-f\d]{4}|\{[a-f\d]{1,6}\})|x[a-f\d]{2}|.))|(?:\{(~)?(\w+(?:\([^)]*\))?(?:\.\w+(?:\([^)]*\))?)*)(?:[ \t]|(?=\r?\n)))|(\})|((?:.|[\r\n\f])+?)/gi,IR=/(?:^|\.)(\w+)(?:\(([^)]*)\))?/g,lJ=/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/,sJ=/\\(u(?:[a-f\d]{4}|{[a-f\d]{1,6}})|x[a-f\d]{2}|.)|([^\\])/gi,aJ=new Map([["n",` +`],["r","\r"],["t"," "],["b","\b"],["f","\f"],["v","\v"],["0","\0"],["\\","\\"],["e",""],["a","\x07"]]);function BR(i){let o=i[0]==="u",f=i[1]==="{";return o&&!f&&i.length===5||i[0]==="x"&&i.length===3?String.fromCharCode(parseInt(i.slice(1),16)):o&&f?String.fromCodePoint(parseInt(i.slice(2,-1),16)):aJ.get(i)||i}function fJ(i,o){let f=[],p=o.trim().split(/\s*,\s*/g),E;for(let t of p){let k=Number(t);if(!Number.isNaN(k))f.push(k);else if(E=t.match(lJ))f.push(E[2].replace(sJ,(L,N,C)=>N?BR(N):C));else throw new Error(`Invalid Chalk template style argument: ${t} (in style '${i}')`)}return f}function cJ(i){IR.lastIndex=0;let o=[],f;for(;(f=IR.exec(i))!==null;){let p=f[1];if(f[2]){let E=fJ(p,f[2]);o.push([p].concat(E))}else o.push([p])}return o}function UR(i,o){let f={};for(let E of o)for(let t of E.styles)f[t[0]]=E.inverse?null:t.slice(1);let p=i;for(let[E,t]of Object.entries(f))if(!!Array.isArray(t)){if(!(E in p))throw new Error(`Unknown Chalk style: ${E}`);p=t.length>0?p[E](...t):p[E]}return p}PR.exports=(i,o)=>{let f=[],p=[],E=[];if(o.replace(oJ,(t,k,L,N,C,U)=>{if(k)E.push(BR(k));else if(N){let q=E.join("");E=[],p.push(f.length===0?q:UR(i,f)(q)),f.push({inverse:L,styles:cJ(N)})}else if(C){if(f.length===0)throw new Error("Found extraneous } in Chalk template literal");p.push(UR(i,f)(E.join(""))),E=[],f.pop()}else E.push(U)}),p.push(E.join("")),f.length>0){let t=`Chalk template literal is missing ${f.length} closing bracket${f.length===1?"":"s"} (\`}\`)`;throw new Error(t)}return p.join("")}});var u3=ce((bre,zR)=>{"use strict";var Ng=_4(),{stdout:t3,stderr:n3}=LR(),{stringReplaceAll:dJ,stringEncaseCRLFWithFirstIndex:pJ}=bR(),{isArray:M4}=Array,qR=["ansi","ansi","ansi256","ansi16m"],cm=Object.create(null),hJ=(i,o={})=>{if(o.level&&!(Number.isInteger(o.level)&&o.level>=0&&o.level<=3))throw new Error("The `level` option should be an integer from 0 to 3");let f=t3?t3.level:0;i.level=o.level===void 0?f:o.level},HR=class{constructor(o){return WR(o)}},WR=i=>{let o={};return hJ(o,i),o.template=(...f)=>VR(o.template,...f),Object.setPrototypeOf(o,N4.prototype),Object.setPrototypeOf(o.template,o),o.template.constructor=()=>{throw new Error("`chalk.constructor()` is deprecated. Use `new chalk.Instance()` instead.")},o.template.Instance=HR,o.template};function N4(i){return WR(i)}for(let[i,o]of Object.entries(Ng))cm[i]={get(){let f=L4(this,r3(o.open,o.close,this._styler),this._isEmpty);return Object.defineProperty(this,i,{value:f}),f}};cm.visible={get(){let i=L4(this,this._styler,!0);return Object.defineProperty(this,"visible",{value:i}),i}};var GR=["rgb","hex","keyword","hsl","hsv","hwb","ansi","ansi256"];for(let i of GR)cm[i]={get(){let{level:o}=this;return function(...f){let p=r3(Ng.color[qR[o]][i](...f),Ng.color.close,this._styler);return L4(this,p,this._isEmpty)}}};for(let i of GR){let o="bg"+i[0].toUpperCase()+i.slice(1);cm[o]={get(){let{level:f}=this;return function(...p){let E=r3(Ng.bgColor[qR[f]][i](...p),Ng.bgColor.close,this._styler);return L4(this,E,this._isEmpty)}}}}var vJ=Object.defineProperties(()=>{},Gf(E0({},cm),{level:{enumerable:!0,get(){return this._generator.level},set(i){this._generator.level=i}}})),r3=(i,o,f)=>{let p,E;return f===void 0?(p=i,E=o):(p=f.openAll+i,E=o+f.closeAll),{open:i,close:o,openAll:p,closeAll:E,parent:f}},L4=(i,o,f)=>{let p=(...E)=>M4(E[0])&&M4(E[0].raw)?YR(p,VR(p,...E)):YR(p,E.length===1?""+E[0]:E.join(" "));return Object.setPrototypeOf(p,vJ),p._generator=i,p._styler=o,p._isEmpty=f,p},YR=(i,o)=>{if(i.level<=0||!o)return i._isEmpty?"":o;let f=i._styler;if(f===void 0)return o;let{openAll:p,closeAll:E}=f;if(o.indexOf("")!==-1)for(;f!==void 0;)o=dJ(o,f.close,f.open),f=f.parent;let t=o.indexOf(` +`);return t!==-1&&(o=pJ(o,E,p,t)),p+o+E},i3,VR=(i,...o)=>{let[f]=o;if(!M4(f)||!M4(f.raw))return o.join(" ");let p=o.slice(1),E=[f.raw[0]];for(let t=1;t{"use strict";var mJ=Lg&&Lg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Lg,"__esModule",{value:!0});var Fg=mJ(u3()),yJ=/^(rgb|hsl|hsv|hwb)\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/,gJ=/^(ansi|ansi256)\(\s?(\d+)\s?\)$/,b4=(i,o)=>o==="foreground"?i:"bg"+i[0].toUpperCase()+i.slice(1);Lg.default=(i,o,f)=>{if(!o)return i;if(o in Fg.default){let E=b4(o,f);return Fg.default[E](i)}if(o.startsWith("#")){let E=b4("hex",f);return Fg.default[E](o)(i)}if(o.startsWith("ansi")){let E=gJ.exec(o);if(!E)return i;let t=b4(E[1],f),k=Number(E[2]);return Fg.default[t](k)(i)}if(o.startsWith("rgb")||o.startsWith("hsl")||o.startsWith("hsv")||o.startsWith("hwb")){let E=yJ.exec(o);if(!E)return i;let t=b4(E[1],f),k=Number(E[2]),L=Number(E[3]),N=Number(E[4]);return Fg.default[t](k,L,N)(i)}return i}});var XR=ce(bg=>{"use strict";var KR=bg&&bg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(bg,"__esModule",{value:!0});var _J=KR(RR()),l3=KR(o3());bg.default=(i,o,f,p)=>{if(typeof f.style.borderStyle=="string"){let E=f.yogaNode.getComputedWidth(),t=f.yogaNode.getComputedHeight(),k=f.style.borderColor,L=_J.default[f.style.borderStyle],N=l3.default(L.topLeft+L.horizontal.repeat(E-2)+L.topRight,k,"foreground"),C=(l3.default(L.vertical,k,"foreground")+` +`).repeat(t-2),U=l3.default(L.bottomLeft+L.horizontal.repeat(E-2)+L.bottomRight,k,"foreground");p.write(i,o,N,{transformers:[]}),p.write(i,o+1,C,{transformers:[]}),p.write(i+E-1,o+1,C,{transformers:[]}),p.write(i,o+t-1,U,{transformers:[]})}}});var JR=ce(Pg=>{"use strict";var ih=Pg&&Pg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Pg,"__esModule",{value:!0});var EJ=ih(eh()),DJ=ih(Dw()),wJ=ih(SR()),SJ=ih(kw()),TJ=ih(TR()),CJ=ih(Nw()),xJ=ih(XR()),AJ=(i,o)=>{var f;let p=(f=i.childNodes[0])===null||f===void 0?void 0:f.yogaNode;if(p){let E=p.getComputedLeft(),t=p.getComputedTop();o=` +`.repeat(t)+wJ.default(o,E)}return o},QR=(i,o,f)=>{var p;let{offsetX:E=0,offsetY:t=0,transformers:k=[],skipStaticElements:L}=f;if(L&&i.internal_static)return;let{yogaNode:N}=i;if(N){if(N.getDisplay()===EJ.default.DISPLAY_NONE)return;let C=E+N.getComputedLeft(),U=t+N.getComputedTop(),q=k;if(typeof i.internal_transform=="function"&&(q=[i.internal_transform,...k]),i.nodeName==="ink-text"){let W=CJ.default(i);if(W.length>0){let ne=DJ.default(W),m=TJ.default(N);if(ne>m){let we=(p=i.style.textWrap)!==null&&p!==void 0?p:"wrap";W=SJ.default(W,m,we)}W=AJ(i,W),o.write(C,U,W,{transformers:q})}return}if(i.nodeName==="ink-box"&&xJ.default(C,U,i,o),i.nodeName==="ink-root"||i.nodeName==="ink-box")for(let W of i.childNodes)QR(W,o,{offsetX:C,offsetY:U,transformers:q,skipStaticElements:L})}};Pg.default=QR});var $R=ce((Ure,ZR)=>{"use strict";ZR.exports=i=>{i=Object.assign({onlyFirst:!1},i);let o=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(o,i.onlyFirst?void 0:"g")}});var t7=ce((jre,s3)=>{"use strict";var RJ=$R(),e7=i=>typeof i=="string"?i.replace(RJ(),""):i;s3.exports=e7;s3.exports.default=e7});var i7=ce((zre,n7)=>{"use strict";var r7="[\uD800-\uDBFF][\uDC00-\uDFFF]";n7.exports=i=>i&&i.exact?new RegExp(`^${r7}$`):new RegExp(r7,"g")});var o7=ce((qre,a3)=>{"use strict";var OJ=t7(),kJ=i7(),u7=i=>OJ(i).replace(kJ()," ").length;a3.exports=u7;a3.exports.default=u7});var f7=ce(Ig=>{"use strict";var l7=Ig&&Ig.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Ig,"__esModule",{value:!0});var s7=l7(Rw()),MJ=l7(o7()),a7=class{constructor(o){this.writes=[];let{width:f,height:p}=o;this.width=f,this.height=p}write(o,f,p,E){let{transformers:t}=E;!p||this.writes.push({x:o,y:f,text:p,transformers:t})}get(){let o=[];for(let p=0;pp.trimRight()).join(` +`),height:o.length}}};Ig.default=a7});var p7=ce(Bg=>{"use strict";var f3=Bg&&Bg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Bg,"__esModule",{value:!0});var NJ=f3(eh()),c7=f3(JR()),d7=f3(f7());Bg.default=(i,o)=>{var f;if(i.yogaNode.setWidth(o),i.yogaNode){i.yogaNode.calculateLayout(void 0,void 0,NJ.default.DIRECTION_LTR);let p=new d7.default({width:i.yogaNode.getComputedWidth(),height:i.yogaNode.getComputedHeight()});c7.default(i,p,{skipStaticElements:!0});let E;((f=i.staticNode)===null||f===void 0?void 0:f.yogaNode)&&(E=new d7.default({width:i.staticNode.yogaNode.getComputedWidth(),height:i.staticNode.yogaNode.getComputedHeight()}),c7.default(i.staticNode,E,{skipStaticElements:!1}));let{output:t,height:k}=p.get();return{output:t,outputHeight:k,staticOutput:E?`${E.get().output} +`:""}}return{output:"",outputHeight:0,staticOutput:""}}});var y7=ce((Vre,h7)=>{"use strict";var v7=require("stream"),m7=["assert","count","countReset","debug","dir","dirxml","error","group","groupCollapsed","groupEnd","info","log","table","time","timeEnd","timeLog","trace","warn"],c3={},LJ=i=>{let o=new v7.PassThrough,f=new v7.PassThrough;o.write=E=>i("stdout",E),f.write=E=>i("stderr",E);let p=new console.Console(o,f);for(let E of m7)c3[E]=console[E],console[E]=p[E];return()=>{for(let E of m7)console[E]=c3[E];c3={}}};h7.exports=LJ});var p3=ce(d3=>{"use strict";Object.defineProperty(d3,"__esModule",{value:!0});d3.default=new WeakMap});var v3=ce(h3=>{"use strict";Object.defineProperty(h3,"__esModule",{value:!0});var FJ=su(),g7=FJ.createContext({exit:()=>{}});g7.displayName="InternalAppContext";h3.default=g7});var y3=ce(m3=>{"use strict";Object.defineProperty(m3,"__esModule",{value:!0});var bJ=su(),_7=bJ.createContext({stdin:void 0,setRawMode:()=>{},isRawModeSupported:!1,internal_exitOnCtrlC:!0});_7.displayName="InternalStdinContext";m3.default=_7});var _3=ce(g3=>{"use strict";Object.defineProperty(g3,"__esModule",{value:!0});var PJ=su(),E7=PJ.createContext({stdout:void 0,write:()=>{}});E7.displayName="InternalStdoutContext";g3.default=E7});var D3=ce(E3=>{"use strict";Object.defineProperty(E3,"__esModule",{value:!0});var IJ=su(),D7=IJ.createContext({stderr:void 0,write:()=>{}});D7.displayName="InternalStderrContext";E3.default=D7});var P4=ce(w3=>{"use strict";Object.defineProperty(w3,"__esModule",{value:!0});var BJ=su(),w7=BJ.createContext({activeId:void 0,add:()=>{},remove:()=>{},activate:()=>{},deactivate:()=>{},enableFocus:()=>{},disableFocus:()=>{},focusNext:()=>{},focusPrevious:()=>{}});w7.displayName="InternalFocusContext";w3.default=w7});var T7=ce((Zre,S7)=>{"use strict";var UJ=/[|\\{}()[\]^$+*?.-]/g;S7.exports=i=>{if(typeof i!="string")throw new TypeError("Expected a string");return i.replace(UJ,"\\$&")}});var R7=ce(($re,C7)=>{"use strict";var jJ=T7(),x7=[].concat(require("module").builtinModules,"bootstrap_node","node").map(i=>new RegExp(`(?:\\(${i}\\.js:\\d+:\\d+\\)$|^\\s*at ${i}\\.js:\\d+:\\d+$)`));x7.push(/\(internal\/[^:]+:\d+:\d+\)$/,/\s*at internal\/[^:]+:\d+:\d+$/,/\/\.node-spawn-wrap-\w+-\w+\/node:\d+:\d+\)?$/);var I4=class{constructor(o){o=E0({ignoredPackages:[]},o),"internals"in o||(o.internals=I4.nodeInternals()),"cwd"in o||(o.cwd=process.cwd()),this._cwd=o.cwd.replace(/\\/g,"/"),this._internals=[].concat(o.internals,zJ(o.ignoredPackages)),this._wrapCallSite=o.wrapCallSite||!1}static nodeInternals(){return[...x7]}clean(o,f=0){f=" ".repeat(f),Array.isArray(o)||(o=o.split(` +`)),!/^\s*at /.test(o[0])&&/^\s*at /.test(o[1])&&(o=o.slice(1));let p=!1,E=null,t=[];return o.forEach(k=>{if(k=k.replace(/\\/g,"/"),this._internals.some(N=>N.test(k)))return;let L=/^\s*at /.test(k);p?k=k.trimEnd().replace(/^(\s+)at /,"$1"):(k=k.trim(),L&&(k=k.slice(3))),k=k.replace(`${this._cwd}/`,""),k&&(L?(E&&(t.push(E),E=null),t.push(k)):(p=!0,E=k))}),t.map(k=>`${f}${k} +`).join("")}captureString(o,f=this.captureString){typeof o=="function"&&(f=o,o=Infinity);let{stackTraceLimit:p}=Error;o&&(Error.stackTraceLimit=o);let E={};Error.captureStackTrace(E,f);let{stack:t}=E;return Error.stackTraceLimit=p,this.clean(t)}capture(o,f=this.capture){typeof o=="function"&&(f=o,o=Infinity);let{prepareStackTrace:p,stackTraceLimit:E}=Error;Error.prepareStackTrace=(L,N)=>this._wrapCallSite?N.map(this._wrapCallSite):N,o&&(Error.stackTraceLimit=o);let t={};Error.captureStackTrace(t,f);let{stack:k}=t;return Object.assign(Error,{prepareStackTrace:p,stackTraceLimit:E}),k}at(o=this.at){let[f]=this.capture(1,o);if(!f)return{};let p={line:f.getLineNumber(),column:f.getColumnNumber()};A7(p,f.getFileName(),this._cwd),f.isConstructor()&&(p.constructor=!0),f.isEval()&&(p.evalOrigin=f.getEvalOrigin()),f.isNative()&&(p.native=!0);let E;try{E=f.getTypeName()}catch(L){}E&&E!=="Object"&&E!=="[object Object]"&&(p.type=E);let t=f.getFunctionName();t&&(p.function=t);let k=f.getMethodName();return k&&t!==k&&(p.method=k),p}parseLine(o){let f=o&&o.match(qJ);if(!f)return null;let p=f[1]==="new",E=f[2],t=f[3],k=f[4],L=Number(f[5]),N=Number(f[6]),C=f[7],U=f[8],q=f[9],W=f[10]==="native",ne=f[11]===")",m,we={};if(U&&(we.line=Number(U)),q&&(we.column=Number(q)),ne&&C){let Se=0;for(let he=C.length-1;he>0;he--)if(C.charAt(he)===")")Se++;else if(C.charAt(he)==="("&&C.charAt(he-1)===" "&&(Se--,Se===-1&&C.charAt(he-1)===" ")){let ge=C.slice(0,he-1);C=C.slice(he+1),E+=` (${ge}`;break}}if(E){let Se=E.match(HJ);Se&&(E=Se[1],m=Se[2])}return A7(we,C,this._cwd),p&&(we.constructor=!0),t&&(we.evalOrigin=t,we.evalLine=L,we.evalColumn=N,we.evalFile=k&&k.replace(/\\/g,"/")),W&&(we.native=!0),E&&(we.function=E),m&&E!==m&&(we.method=m),we}};function A7(i,o,f){o&&(o=o.replace(/\\/g,"/"),o.startsWith(`${f}/`)&&(o=o.slice(f.length+1)),i.file=o)}function zJ(i){if(i.length===0)return[];let o=i.map(f=>jJ(f));return new RegExp(`[/\\\\]node_modules[/\\\\](?:${o.join("|")})[/\\\\][^:]+:\\d+:\\d+`)}var qJ=new RegExp("^(?:\\s*at )?(?:(new) )?(?:(.*?) \\()?(?:eval at ([^ ]+) \\((.+?):(\\d+):(\\d+)\\), )?(?:(.+?):(\\d+):(\\d+)|(native))(\\)?)$"),HJ=/^(.*?) \[as (.*?)\]$/;C7.exports=I4});var k7=ce((eie,O7)=>{"use strict";O7.exports=(i,o)=>i.replace(/^\t+/gm,f=>" ".repeat(f.length*(o||2)))});var N7=ce((tie,M7)=>{"use strict";var WJ=k7(),VJ=(i,o)=>{let f=[],p=i-o,E=i+o;for(let t=p;t<=E;t++)f.push(t);return f};M7.exports=(i,o,f)=>{if(typeof i!="string")throw new TypeError("Source code is missing.");if(!o||o<1)throw new TypeError("Line number must start from `1`.");if(i=WJ(i).split(/\r?\n/),!(o>i.length))return f=E0({around:3},f),VJ(o,f.around).filter(p=>i[p-1]!==void 0).map(p=>({line:p,value:i[p-1]}))}});var B4=ce(Zf=>{"use strict";var GJ=Zf&&Zf.__createBinding||(Object.create?function(i,o,f,p){p===void 0&&(p=f),Object.defineProperty(i,p,{enumerable:!0,get:function(){return o[f]}})}:function(i,o,f,p){p===void 0&&(p=f),i[p]=o[f]}),YJ=Zf&&Zf.__setModuleDefault||(Object.create?function(i,o){Object.defineProperty(i,"default",{enumerable:!0,value:o})}:function(i,o){i.default=o}),KJ=Zf&&Zf.__importStar||function(i){if(i&&i.__esModule)return i;var o={};if(i!=null)for(var f in i)f!=="default"&&Object.hasOwnProperty.call(i,f)&&GJ(o,i,f);return YJ(o,i),o},XJ=Zf&&Zf.__rest||function(i,o){var f={};for(var p in i)Object.prototype.hasOwnProperty.call(i,p)&&o.indexOf(p)<0&&(f[p]=i[p]);if(i!=null&&typeof Object.getOwnPropertySymbols=="function")for(var E=0,p=Object.getOwnPropertySymbols(i);E{var{children:f}=i,p=XJ(i,["children"]);let E=Object.assign(Object.assign({},p),{marginLeft:p.marginLeft||p.marginX||p.margin||0,marginRight:p.marginRight||p.marginX||p.margin||0,marginTop:p.marginTop||p.marginY||p.margin||0,marginBottom:p.marginBottom||p.marginY||p.margin||0,paddingLeft:p.paddingLeft||p.paddingX||p.padding||0,paddingRight:p.paddingRight||p.paddingX||p.padding||0,paddingTop:p.paddingTop||p.paddingY||p.padding||0,paddingBottom:p.paddingBottom||p.paddingY||p.padding||0});return L7.default.createElement("ink-box",{ref:o,style:E},f)});S3.displayName="Box";S3.defaultProps={flexDirection:"row",flexGrow:0,flexShrink:1};Zf.default=S3});var x3=ce(Ug=>{"use strict";var T3=Ug&&Ug.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Ug,"__esModule",{value:!0});var QJ=T3(su()),dm=T3(u3()),F7=T3(o3()),C3=({color:i,backgroundColor:o,dimColor:f,bold:p,italic:E,underline:t,strikethrough:k,inverse:L,wrap:N,children:C})=>{if(C==null)return null;let U=q=>(f&&(q=dm.default.dim(q)),i&&(q=F7.default(q,i,"foreground")),o&&(q=F7.default(q,o,"background")),p&&(q=dm.default.bold(q)),E&&(q=dm.default.italic(q)),t&&(q=dm.default.underline(q)),k&&(q=dm.default.strikethrough(q)),L&&(q=dm.default.inverse(q)),q);return QJ.default.createElement("ink-text",{style:{flexGrow:0,flexShrink:1,flexDirection:"row",textWrap:N},internal_transform:U},C)};C3.displayName="Text";C3.defaultProps={dimColor:!1,bold:!1,italic:!1,underline:!1,strikethrough:!1,wrap:"wrap"};Ug.default=C3});var B7=ce($f=>{"use strict";var JJ=$f&&$f.__createBinding||(Object.create?function(i,o,f,p){p===void 0&&(p=f),Object.defineProperty(i,p,{enumerable:!0,get:function(){return o[f]}})}:function(i,o,f,p){p===void 0&&(p=f),i[p]=o[f]}),ZJ=$f&&$f.__setModuleDefault||(Object.create?function(i,o){Object.defineProperty(i,"default",{enumerable:!0,value:o})}:function(i,o){i.default=o}),$J=$f&&$f.__importStar||function(i){if(i&&i.__esModule)return i;var o={};if(i!=null)for(var f in i)f!=="default"&&Object.hasOwnProperty.call(i,f)&&JJ(o,i,f);return ZJ(o,i),o},jg=$f&&$f.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty($f,"__esModule",{value:!0});var b7=$J(require("fs")),Vo=jg(su()),P7=jg(R7()),eZ=jg(N7()),Q1=jg(B4()),Ic=jg(x3()),I7=new P7.default({cwd:process.cwd(),internals:P7.default.nodeInternals()}),tZ=({error:i})=>{let o=i.stack?i.stack.split(` +`).slice(1):void 0,f=o?I7.parseLine(o[0]):void 0,p,E=0;if((f==null?void 0:f.file)&&(f==null?void 0:f.line)&&b7.existsSync(f.file)){let t=b7.readFileSync(f.file,"utf8");if(p=eZ.default(t,f.line),p)for(let{line:k}of p)E=Math.max(E,String(k).length)}return Vo.default.createElement(Q1.default,{flexDirection:"column",padding:1},Vo.default.createElement(Q1.default,null,Vo.default.createElement(Ic.default,{backgroundColor:"red",color:"white"}," ","ERROR"," "),Vo.default.createElement(Ic.default,null," ",i.message)),f&&Vo.default.createElement(Q1.default,{marginTop:1},Vo.default.createElement(Ic.default,{dimColor:!0},f.file,":",f.line,":",f.column)),f&&p&&Vo.default.createElement(Q1.default,{marginTop:1,flexDirection:"column"},p.map(({line:t,value:k})=>Vo.default.createElement(Q1.default,{key:t},Vo.default.createElement(Q1.default,{width:E+1},Vo.default.createElement(Ic.default,{dimColor:t!==f.line,backgroundColor:t===f.line?"red":void 0,color:t===f.line?"white":void 0},String(t).padStart(E," "),":")),Vo.default.createElement(Ic.default,{key:t,backgroundColor:t===f.line?"red":void 0,color:t===f.line?"white":void 0}," "+k)))),i.stack&&Vo.default.createElement(Q1.default,{marginTop:1,flexDirection:"column"},i.stack.split(` +`).slice(1).map(t=>{let k=I7.parseLine(t);return k?Vo.default.createElement(Q1.default,{key:t},Vo.default.createElement(Ic.default,{dimColor:!0},"- "),Vo.default.createElement(Ic.default,{dimColor:!0,bold:!0},k.function),Vo.default.createElement(Ic.default,{dimColor:!0,color:"gray"}," ","(",k.file,":",k.line,":",k.column,")")):Vo.default.createElement(Q1.default,{key:t},Vo.default.createElement(Ic.default,{dimColor:!0},"- "),Vo.default.createElement(Ic.default,{dimColor:!0,bold:!0},t))})))};$f.default=tZ});var j7=ce(ec=>{"use strict";var nZ=ec&&ec.__createBinding||(Object.create?function(i,o,f,p){p===void 0&&(p=f),Object.defineProperty(i,p,{enumerable:!0,get:function(){return o[f]}})}:function(i,o,f,p){p===void 0&&(p=f),i[p]=o[f]}),rZ=ec&&ec.__setModuleDefault||(Object.create?function(i,o){Object.defineProperty(i,"default",{enumerable:!0,value:o})}:function(i,o){i.default=o}),iZ=ec&&ec.__importStar||function(i){if(i&&i.__esModule)return i;var o={};if(i!=null)for(var f in i)f!=="default"&&Object.hasOwnProperty.call(i,f)&&nZ(o,i,f);return rZ(o,i),o},uh=ec&&ec.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(ec,"__esModule",{value:!0});var oh=iZ(su()),U7=uh(ZD()),uZ=uh(v3()),oZ=uh(y3()),lZ=uh(_3()),sZ=uh(D3()),aZ=uh(P4()),fZ=uh(B7()),cZ=" ",dZ="",pZ="",A3=class extends oh.PureComponent{constructor(){super(...arguments);this.state={isFocusEnabled:!0,activeFocusId:void 0,focusables:[],error:void 0},this.rawModeEnabledCount=0,this.handleSetRawMode=o=>{let{stdin:f}=this.props;if(!this.isRawModeSupported())throw f===process.stdin?new Error(`Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default. +Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported`):new Error(`Raw mode is not supported on the stdin provided to Ink. +Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported`);if(f.setEncoding("utf8"),o){this.rawModeEnabledCount===0&&(f.addListener("data",this.handleInput),f.resume(),f.setRawMode(!0)),this.rawModeEnabledCount++;return}--this.rawModeEnabledCount==0&&(f.setRawMode(!1),f.removeListener("data",this.handleInput),f.pause())},this.handleInput=o=>{o===""&&this.props.exitOnCtrlC&&this.handleExit(),o===pZ&&this.state.activeFocusId&&this.setState({activeFocusId:void 0}),this.state.isFocusEnabled&&this.state.focusables.length>0&&(o===cZ&&this.focusNext(),o===dZ&&this.focusPrevious())},this.handleExit=o=>{this.isRawModeSupported()&&this.handleSetRawMode(!1),this.props.onExit(o)},this.enableFocus=()=>{this.setState({isFocusEnabled:!0})},this.disableFocus=()=>{this.setState({isFocusEnabled:!1})},this.focusNext=()=>{this.setState(o=>{let f=o.focusables[0].id;return{activeFocusId:this.findNextFocusable(o)||f}})},this.focusPrevious=()=>{this.setState(o=>{let f=o.focusables[o.focusables.length-1].id;return{activeFocusId:this.findPreviousFocusable(o)||f}})},this.addFocusable=(o,{autoFocus:f})=>{this.setState(p=>{let E=p.activeFocusId;return!E&&f&&(E=o),{activeFocusId:E,focusables:[...p.focusables,{id:o,isActive:!0}]}})},this.removeFocusable=o=>{this.setState(f=>({activeFocusId:f.activeFocusId===o?void 0:f.activeFocusId,focusables:f.focusables.filter(p=>p.id!==o)}))},this.activateFocusable=o=>{this.setState(f=>({focusables:f.focusables.map(p=>p.id!==o?p:{id:o,isActive:!0})}))},this.deactivateFocusable=o=>{this.setState(f=>({activeFocusId:f.activeFocusId===o?void 0:f.activeFocusId,focusables:f.focusables.map(p=>p.id!==o?p:{id:o,isActive:!1})}))},this.findNextFocusable=o=>{let f=o.focusables.findIndex(p=>p.id===o.activeFocusId);for(let p=f+1;p{let f=o.focusables.findIndex(p=>p.id===o.activeFocusId);for(let p=f-1;p>=0;p--)if(o.focusables[p].isActive)return o.focusables[p].id}}static getDerivedStateFromError(o){return{error:o}}isRawModeSupported(){return this.props.stdin.isTTY}render(){return oh.default.createElement(uZ.default.Provider,{value:{exit:this.handleExit}},oh.default.createElement(oZ.default.Provider,{value:{stdin:this.props.stdin,setRawMode:this.handleSetRawMode,isRawModeSupported:this.isRawModeSupported(),internal_exitOnCtrlC:this.props.exitOnCtrlC}},oh.default.createElement(lZ.default.Provider,{value:{stdout:this.props.stdout,write:this.props.writeToStdout}},oh.default.createElement(sZ.default.Provider,{value:{stderr:this.props.stderr,write:this.props.writeToStderr}},oh.default.createElement(aZ.default.Provider,{value:{activeId:this.state.activeFocusId,add:this.addFocusable,remove:this.removeFocusable,activate:this.activateFocusable,deactivate:this.deactivateFocusable,enableFocus:this.enableFocus,disableFocus:this.disableFocus,focusNext:this.focusNext,focusPrevious:this.focusPrevious}},this.state.error?oh.default.createElement(fZ.default,{error:this.state.error}):this.props.children)))))}componentDidMount(){U7.default.hide(this.props.stdout)}componentWillUnmount(){U7.default.show(this.props.stdout),this.isRawModeSupported()&&this.handleSetRawMode(!1)}componentDidCatch(o){this.handleExit(o)}};ec.default=A3;A3.displayName="InternalApp"});var W7=ce(tc=>{"use strict";var hZ=tc&&tc.__createBinding||(Object.create?function(i,o,f,p){p===void 0&&(p=f),Object.defineProperty(i,p,{enumerable:!0,get:function(){return o[f]}})}:function(i,o,f,p){p===void 0&&(p=f),i[p]=o[f]}),vZ=tc&&tc.__setModuleDefault||(Object.create?function(i,o){Object.defineProperty(i,"default",{enumerable:!0,value:o})}:function(i,o){i.default=o}),mZ=tc&&tc.__importStar||function(i){if(i&&i.__esModule)return i;var o={};if(i!=null)for(var f in i)f!=="default"&&Object.hasOwnProperty.call(i,f)&&hZ(o,i,f);return vZ(o,i),o},nc=tc&&tc.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(tc,"__esModule",{value:!0});var yZ=nc(su()),z7=B5(),gZ=nc(Z5()),_Z=nc(GD()),EZ=nc(u9()),DZ=nc(l9()),U4=nc(DR()),wZ=nc(p7()),SZ=nc(JD()),TZ=nc(y7()),CZ=mZ(Lw()),xZ=nc(p3()),AZ=nc(j7()),pm=process.env.CI==="false"?!1:EZ.default,q7=()=>{},H7=class{constructor(o){this.resolveExitPromise=()=>{},this.rejectExitPromise=()=>{},this.unsubscribeExit=()=>{},this.onRender=()=>{if(this.isUnmounted)return;let{output:f,outputHeight:p,staticOutput:E}=wZ.default(this.rootNode,this.options.stdout.columns||80),t=E&&E!==` +`;if(this.options.debug){t&&(this.fullStaticOutput+=E),this.options.stdout.write(this.fullStaticOutput+f);return}if(pm){t&&this.options.stdout.write(E),this.lastOutput=f;return}if(t&&(this.fullStaticOutput+=E),p>=this.options.stdout.rows){this.options.stdout.write(_Z.default.clearTerminal+this.fullStaticOutput+f),this.lastOutput=f;return}t&&(this.log.clear(),this.options.stdout.write(E),this.log(f)),!t&&f!==this.lastOutput&&this.throttledLog(f),this.lastOutput=f},DZ.default(this),this.options=o,this.rootNode=CZ.createNode("ink-root"),this.rootNode.onRender=o.debug?this.onRender:z7.throttle(this.onRender,32,{leading:!0,trailing:!0}),this.rootNode.onImmediateRender=this.onRender,this.log=gZ.default.create(o.stdout),this.throttledLog=o.debug?this.log:z7.throttle(this.log,void 0,{leading:!0,trailing:!0}),this.isUnmounted=!1,this.lastOutput="",this.fullStaticOutput="",this.container=U4.default.createContainer(this.rootNode,!1,!1),this.unsubscribeExit=SZ.default(this.unmount,{alwaysLast:!1}),process.env.DEV==="true"&&U4.default.injectIntoDevTools({bundleType:0,version:"16.13.1",rendererPackageName:"ink"}),o.patchConsole&&this.patchConsole(),pm||(o.stdout.on("resize",this.onRender),this.unsubscribeResize=()=>{o.stdout.off("resize",this.onRender)})}render(o){let f=yZ.default.createElement(AZ.default,{stdin:this.options.stdin,stdout:this.options.stdout,stderr:this.options.stderr,writeToStdout:this.writeToStdout,writeToStderr:this.writeToStderr,exitOnCtrlC:this.options.exitOnCtrlC,onExit:this.unmount},o);U4.default.updateContainer(f,this.container,null,q7)}writeToStdout(o){if(!this.isUnmounted){if(this.options.debug){this.options.stdout.write(o+this.fullStaticOutput+this.lastOutput);return}if(pm){this.options.stdout.write(o);return}this.log.clear(),this.options.stdout.write(o),this.log(this.lastOutput)}}writeToStderr(o){if(!this.isUnmounted){if(this.options.debug){this.options.stderr.write(o),this.options.stdout.write(this.fullStaticOutput+this.lastOutput);return}if(pm){this.options.stderr.write(o);return}this.log.clear(),this.options.stderr.write(o),this.log(this.lastOutput)}}unmount(o){this.isUnmounted||(this.onRender(),this.unsubscribeExit(),typeof this.restoreConsole=="function"&&this.restoreConsole(),typeof this.unsubscribeResize=="function"&&this.unsubscribeResize(),pm?this.options.stdout.write(this.lastOutput+` +`):this.options.debug||this.log.done(),this.isUnmounted=!0,U4.default.updateContainer(null,this.container,null,q7),xZ.default.delete(this.options.stdout),o instanceof Error?this.rejectExitPromise(o):this.resolveExitPromise())}waitUntilExit(){return this.exitPromise||(this.exitPromise=new Promise((o,f)=>{this.resolveExitPromise=o,this.rejectExitPromise=f})),this.exitPromise}clear(){!pm&&!this.options.debug&&this.log.clear()}patchConsole(){this.options.debug||(this.restoreConsole=TZ.default((o,f)=>{o==="stdout"&&this.writeToStdout(f),o==="stderr"&&(f.startsWith("The above error occurred")||this.writeToStderr(f))}))}};tc.default=H7});var G7=ce(zg=>{"use strict";var V7=zg&&zg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(zg,"__esModule",{value:!0});var RZ=V7(W7()),j4=V7(p3()),OZ=require("stream"),NZ=(i,o)=>{let f=Object.assign({stdout:process.stdout,stdin:process.stdin,stderr:process.stderr,debug:!1,exitOnCtrlC:!0,patchConsole:!0},kZ(o)),p=MZ(f.stdout,()=>new RZ.default(f));return p.render(i),{rerender:p.render,unmount:()=>p.unmount(),waitUntilExit:p.waitUntilExit,cleanup:()=>j4.default.delete(f.stdout),clear:p.clear}};zg.default=NZ;var kZ=(i={})=>i instanceof OZ.Stream?{stdout:i,stdin:process.stdin}:i,MZ=(i,o)=>{let f;return j4.default.has(i)?f=j4.default.get(i):(f=o(),j4.default.set(i,f)),f}});var K7=ce(J1=>{"use strict";var LZ=J1&&J1.__createBinding||(Object.create?function(i,o,f,p){p===void 0&&(p=f),Object.defineProperty(i,p,{enumerable:!0,get:function(){return o[f]}})}:function(i,o,f,p){p===void 0&&(p=f),i[p]=o[f]}),FZ=J1&&J1.__setModuleDefault||(Object.create?function(i,o){Object.defineProperty(i,"default",{enumerable:!0,value:o})}:function(i,o){i.default=o}),bZ=J1&&J1.__importStar||function(i){if(i&&i.__esModule)return i;var o={};if(i!=null)for(var f in i)f!=="default"&&Object.hasOwnProperty.call(i,f)&&LZ(o,i,f);return FZ(o,i),o};Object.defineProperty(J1,"__esModule",{value:!0});var qg=bZ(su()),Y7=i=>{let{items:o,children:f,style:p}=i,[E,t]=qg.useState(0),k=qg.useMemo(()=>o.slice(E),[o,E]);qg.useLayoutEffect(()=>{t(o.length)},[o.length]);let L=k.map((C,U)=>f(C,E+U)),N=qg.useMemo(()=>Object.assign({position:"absolute",flexDirection:"column"},p),[p]);return qg.default.createElement("ink-box",{internal_static:!0,style:N},L)};Y7.displayName="Static";J1.default=Y7});var Q7=ce(Hg=>{"use strict";var PZ=Hg&&Hg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Hg,"__esModule",{value:!0});var IZ=PZ(su()),X7=({children:i,transform:o})=>i==null?null:IZ.default.createElement("ink-text",{style:{flexGrow:0,flexShrink:1,flexDirection:"row"},internal_transform:o},i);X7.displayName="Transform";Hg.default=X7});var Z7=ce(Wg=>{"use strict";var BZ=Wg&&Wg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Wg,"__esModule",{value:!0});var UZ=BZ(su()),J7=({count:i=1})=>UZ.default.createElement("ink-text",null,` +`.repeat(i));J7.displayName="Newline";Wg.default=J7});var tO=ce(Vg=>{"use strict";var $7=Vg&&Vg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Vg,"__esModule",{value:!0});var jZ=$7(su()),zZ=$7(B4()),eO=()=>jZ.default.createElement(zZ.default,{flexGrow:1});eO.displayName="Spacer";Vg.default=eO});var z4=ce(Gg=>{"use strict";var qZ=Gg&&Gg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Gg,"__esModule",{value:!0});var HZ=su(),WZ=qZ(y3()),VZ=()=>HZ.useContext(WZ.default);Gg.default=VZ});var rO=ce(Yg=>{"use strict";var GZ=Yg&&Yg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Yg,"__esModule",{value:!0});var nO=su(),YZ=GZ(z4()),KZ=(i,o={})=>{let{stdin:f,setRawMode:p,internal_exitOnCtrlC:E}=YZ.default();nO.useEffect(()=>{if(o.isActive!==!1)return p(!0),()=>{p(!1)}},[o.isActive,p]),nO.useEffect(()=>{if(o.isActive===!1)return;let t=k=>{let L=String(k),N={upArrow:L==="",downArrow:L==="",leftArrow:L==="",rightArrow:L==="",pageDown:L==="[6~",pageUp:L==="[5~",return:L==="\r",escape:L==="",ctrl:!1,shift:!1,tab:L===" "||L==="",backspace:L==="\b",delete:L==="\x7F"||L==="[3~",meta:!1};L<=""&&!N.return&&(L=String.fromCharCode(L.charCodeAt(0)+"a".charCodeAt(0)-1),N.ctrl=!0),L.startsWith("")&&(L=L.slice(1),N.meta=!0);let C=L>="A"&&L<="Z",U=L>="\u0410"&&L<="\u042F";L.length===1&&(C||U)&&(N.shift=!0),N.tab&&L==="[Z"&&(N.shift=!0),(N.tab||N.backspace||N.delete)&&(L=""),(!(L==="c"&&N.ctrl)||!E)&&i(L,N)};return f==null||f.on("data",t),()=>{f==null||f.off("data",t)}},[o.isActive,f,E,i])};Yg.default=KZ});var iO=ce(Kg=>{"use strict";var XZ=Kg&&Kg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Kg,"__esModule",{value:!0});var QZ=su(),JZ=XZ(v3()),ZZ=()=>QZ.useContext(JZ.default);Kg.default=ZZ});var uO=ce(Xg=>{"use strict";var $Z=Xg&&Xg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Xg,"__esModule",{value:!0});var e$=su(),t$=$Z(_3()),n$=()=>e$.useContext(t$.default);Xg.default=n$});var oO=ce(Qg=>{"use strict";var r$=Qg&&Qg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Qg,"__esModule",{value:!0});var i$=su(),u$=r$(D3()),o$=()=>i$.useContext(u$.default);Qg.default=o$});var sO=ce(Jg=>{"use strict";var lO=Jg&&Jg.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty(Jg,"__esModule",{value:!0});var Zg=su(),l$=lO(P4()),s$=lO(z4()),a$=({isActive:i=!0,autoFocus:o=!1}={})=>{let{isRawModeSupported:f,setRawMode:p}=s$.default(),{activeId:E,add:t,remove:k,activate:L,deactivate:N}=Zg.useContext(l$.default),C=Zg.useMemo(()=>Math.random().toString().slice(2,7),[]);return Zg.useEffect(()=>(t(C,{autoFocus:o}),()=>{k(C)}),[C,o]),Zg.useEffect(()=>{i?L(C):N(C)},[i,C]),Zg.useEffect(()=>{if(!(!f||!i))return p(!0),()=>{p(!1)}},[i]),{isFocused:Boolean(C)&&E===C}};Jg.default=a$});var aO=ce($g=>{"use strict";var f$=$g&&$g.__importDefault||function(i){return i&&i.__esModule?i:{default:i}};Object.defineProperty($g,"__esModule",{value:!0});var c$=su(),d$=f$(P4()),p$=()=>{let i=c$.useContext(d$.default);return{enableFocus:i.enableFocus,disableFocus:i.disableFocus,focusNext:i.focusNext,focusPrevious:i.focusPrevious}};$g.default=p$});var fO=ce(R3=>{"use strict";Object.defineProperty(R3,"__esModule",{value:!0});R3.default=i=>{var o,f,p,E;return{width:(f=(o=i.yogaNode)===null||o===void 0?void 0:o.getComputedWidth())!==null&&f!==void 0?f:0,height:(E=(p=i.yogaNode)===null||p===void 0?void 0:p.getComputedHeight())!==null&&E!==void 0?E:0}}});var lh=ce(ql=>{"use strict";Object.defineProperty(ql,"__esModule",{value:!0});var h$=G7();Object.defineProperty(ql,"render",{enumerable:!0,get:function(){return h$.default}});var v$=B4();Object.defineProperty(ql,"Box",{enumerable:!0,get:function(){return v$.default}});var m$=x3();Object.defineProperty(ql,"Text",{enumerable:!0,get:function(){return m$.default}});var y$=K7();Object.defineProperty(ql,"Static",{enumerable:!0,get:function(){return y$.default}});var g$=Q7();Object.defineProperty(ql,"Transform",{enumerable:!0,get:function(){return g$.default}});var _$=Z7();Object.defineProperty(ql,"Newline",{enumerable:!0,get:function(){return _$.default}});var E$=tO();Object.defineProperty(ql,"Spacer",{enumerable:!0,get:function(){return E$.default}});var D$=rO();Object.defineProperty(ql,"useInput",{enumerable:!0,get:function(){return D$.default}});var w$=iO();Object.defineProperty(ql,"useApp",{enumerable:!0,get:function(){return w$.default}});var S$=z4();Object.defineProperty(ql,"useStdin",{enumerable:!0,get:function(){return S$.default}});var T$=uO();Object.defineProperty(ql,"useStdout",{enumerable:!0,get:function(){return T$.default}});var C$=oO();Object.defineProperty(ql,"useStderr",{enumerable:!0,get:function(){return C$.default}});var x$=sO();Object.defineProperty(ql,"useFocus",{enumerable:!0,get:function(){return x$.default}});var A$=aO();Object.defineProperty(ql,"useFocusManager",{enumerable:!0,get:function(){return A$.default}});var R$=fO();Object.defineProperty(ql,"measureElement",{enumerable:!0,get:function(){return R$.default}})});var k$={};sS(k$,{default:()=>N$,versionUtils:()=>RD});var M3=Mi(require("@yarnpkg/core"));var X_=Mi(require("@yarnpkg/cli")),em=Mi(require("@yarnpkg/core")),Q_=Mi(require("@yarnpkg/core")),cd=Mi(require("clipanion"));var RD={};sS(RD,{Decision:()=>Nu,applyPrerelease:()=>v5,applyReleases:()=>ND,applyStrategy:()=>Y_,clearVersionFiles:()=>OD,fetchBase:()=>pK,fetchChangedFiles:()=>vK,fetchRoot:()=>hK,getUndecidedDependentWorkspaces:()=>Zy,getUndecidedWorkspaces:()=>K_,openVersionFile:()=>$v,requireMoreDecisions:()=>yK,resolveVersionFiles:()=>Jy,suggestStrategy:()=>MD,updateVersionFiles:()=>kD,validateReleaseDecision:()=>Zv});var Gi=Mi(require("@yarnpkg/core")),D0=Mi(require("@yarnpkg/fslib")),W1=Mi(require("@yarnpkg/parsers")),Zp=Mi(require("@yarnpkg/plugin-git")),Jv=Mi(require("clipanion")),h5=Mi(p5()),Fc=Mi(require("semver")),pK=Zp.gitUtils.fetchBase,hK=Zp.gitUtils.fetchRoot,vK=Zp.gitUtils.fetchChangedFiles,mK=/^(>=|[~^]|)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/,Nu;(function(k){k.UNDECIDED="undecided",k.DECLINE="decline",k.MAJOR="major",k.MINOR="minor",k.PATCH="patch",k.PRERELEASE="prerelease"})(Nu||(Nu={}));function Zv(i){let o=Fc.default.valid(i);return o||Gi.miscUtils.validateEnum((0,h5.default)(Nu,"UNDECIDED"),i)}async function Jy(i,{prerelease:o=null}={}){var t;let f=new Map,p=i.configuration.get("deferredVersionFolder");if(!D0.xfs.existsSync(p))return new Map;let E=await D0.xfs.readdirPromise(p);for(let k of E){if(!k.endsWith(".yml"))continue;let L=D0.ppath.join(p,k),N=await D0.xfs.readFilePromise(L,"utf8"),C=(0,W1.parseSyml)(N);for(let[U,q]of Object.entries(C.releases||{})){if(q===Nu.DECLINE)continue;let W=Gi.structUtils.parseIdent(U),ne=i.tryWorkspaceByIdent(W);if(ne===null)throw new Error(`Assertion failed: Expected a release definition file to only reference existing workspaces (${D0.ppath.basename(L)} references ${U})`);if(ne.manifest.version===null)throw new Error(`Assertion failed: Expected the workspace to have a version (${Gi.structUtils.prettyLocator(i.configuration,ne.anchoredLocator)})`);let m=(t=ne.manifest.raw.stableVersion)!=null?t:ne.manifest.version,we=f.get(ne),Se=Y_(m,Zv(q));if(Se===null)throw new Error(`Assertion failed: Expected ${m} to support being bumped via strategy ${q}`);let he=typeof we!="undefined"?Fc.default.gt(Se,we)?Se:we:Se;f.set(ne,he)}}return o&&(f=new Map([...f].map(([k,L])=>[k,v5(L,{current:k.manifest.version,prerelease:o})]))),f}async function OD(i){let o=i.configuration.get("deferredVersionFolder");!D0.xfs.existsSync(o)||await D0.xfs.removePromise(o)}async function kD(i){let o=i.configuration.get("deferredVersionFolder");if(!D0.xfs.existsSync(o))return;let f=await D0.xfs.readdirPromise(o);for(let p of f){if(!p.endsWith(".yml"))continue;let E=D0.ppath.join(o,p),t=await D0.xfs.readFilePromise(E,"utf8"),k=(0,W1.parseSyml)(t),L=k==null?void 0:k.releases;if(!!L){for(let N of Object.keys(L)){let C=Gi.structUtils.parseLocator(N);i.tryWorkspaceByLocator(C)===null&&delete k.releases[N]}await D0.xfs.changeFilePromise(E,(0,W1.stringifySyml)(new W1.stringifySyml.PreserveOrdering(k)))}}}async function $v(i,{allowEmpty:o=!1}={}){let f=i.configuration;if(f.projectCwd===null)throw new Jv.UsageError("This command can only be run from within a Yarn project");let p=await Zp.gitUtils.fetchRoot(f.projectCwd),E=p!==null?await Zp.gitUtils.fetchBase(p,{baseRefs:f.get("changesetBaseRefs")}):null,t=p!==null?await Zp.gitUtils.fetchChangedFiles(p,{base:E.hash,project:i}):[],k=f.get("deferredVersionFolder"),L=t.filter(ne=>D0.ppath.contains(k,ne)!==null);if(L.length>1)throw new Jv.UsageError(`Your current branch contains multiple versioning files; this isn't supported: +- ${L.map(ne=>D0.npath.fromPortablePath(ne)).join(` +- `)}`);let N=new Set(Gi.miscUtils.mapAndFilter(t,ne=>{let m=i.tryWorkspaceByFilePath(ne);return m===null?Gi.miscUtils.mapAndFilter.skip:m}));if(L.length===0&&N.size===0&&!o)return null;let C=L.length===1?L[0]:D0.ppath.join(k,`${Gi.hashUtils.makeHash(Math.random().toString()).slice(0,8)}.yml`),U=D0.xfs.existsSync(C)?await D0.xfs.readFilePromise(C,"utf8"):"{}",q=(0,W1.parseSyml)(U),W=new Map;for(let ne of q.declined||[]){let m=Gi.structUtils.parseIdent(ne),we=i.getWorkspaceByIdent(m);W.set(we,Nu.DECLINE)}for(let[ne,m]of Object.entries(q.releases||{})){let we=Gi.structUtils.parseIdent(ne),Se=i.getWorkspaceByIdent(we);W.set(Se,Zv(m))}return{project:i,root:p,baseHash:E!==null?E.hash:null,baseTitle:E!==null?E.title:null,changedFiles:new Set(t),changedWorkspaces:N,releaseRoots:new Set([...N].filter(ne=>ne.manifest.version!==null)),releases:W,async saveAll(){let ne={},m=[],we=[];for(let Se of i.workspaces){if(Se.manifest.version===null)continue;let he=Gi.structUtils.stringifyIdent(Se.locator),ge=W.get(Se);ge===Nu.DECLINE?m.push(he):typeof ge!="undefined"?ne[he]=Zv(ge):N.has(Se)&&we.push(he)}await D0.xfs.mkdirPromise(D0.ppath.dirname(C),{recursive:!0}),await D0.xfs.changeFilePromise(C,(0,W1.stringifySyml)(new W1.stringifySyml.PreserveOrdering({releases:Object.keys(ne).length>0?ne:void 0,declined:m.length>0?m:void 0,undecided:we.length>0?we:void 0})))}}}function yK(i){return K_(i).size>0||Zy(i).length>0}function K_(i){let o=new Set;for(let f of i.changedWorkspaces)f.manifest.version!==null&&(i.releases.has(f)||o.add(f));return o}function Zy(i,{include:o=new Set}={}){let f=[],p=new Map(Gi.miscUtils.mapAndFilter([...i.releases],([t,k])=>k===Nu.DECLINE?Gi.miscUtils.mapAndFilter.skip:[t.anchoredLocator.locatorHash,t])),E=new Map(Gi.miscUtils.mapAndFilter([...i.releases],([t,k])=>k!==Nu.DECLINE?Gi.miscUtils.mapAndFilter.skip:[t.anchoredLocator.locatorHash,t]));for(let t of i.project.workspaces)if(!(!o.has(t)&&(E.has(t.anchoredLocator.locatorHash)||p.has(t.anchoredLocator.locatorHash)))&&t.manifest.version!==null)for(let k of Gi.Manifest.hardDependencies)for(let L of t.manifest.getForScope(k).values()){let N=i.project.tryWorkspaceByDescriptor(L);N!==null&&p.has(N.anchoredLocator.locatorHash)&&f.push([t,N])}return f}function MD(i,o){let f=Fc.default.clean(o);for(let p of Object.values(Nu))if(p!==Nu.UNDECIDED&&p!==Nu.DECLINE&&Fc.default.inc(i,p)===f)return p;return null}function Y_(i,o){if(Fc.default.valid(o))return o;if(i===null)throw new Jv.UsageError(`Cannot apply the release strategy "${o}" unless the workspace already has a valid version`);if(!Fc.default.valid(i))throw new Jv.UsageError(`Cannot apply the release strategy "${o}" on a non-semver version (${i})`);let f=Fc.default.inc(i,o);if(f===null)throw new Jv.UsageError(`Cannot apply the release strategy "${o}" on the specified version (${i})`);return f}function ND(i,o,{report:f}){let p=new Map;for(let E of i.workspaces)for(let t of Gi.Manifest.allDependencies)for(let k of E.manifest[t].values()){let L=i.tryWorkspaceByDescriptor(k);if(L===null||!o.has(L))continue;Gi.miscUtils.getArrayWithDefault(p,L).push([E,t,k.identHash])}for(let[E,t]of o){let k=E.manifest.version;E.manifest.version=t,Fc.default.prerelease(t)===null?delete E.manifest.raw.stableVersion:E.manifest.raw.stableVersion||(E.manifest.raw.stableVersion=k);let L=E.manifest.name!==null?Gi.structUtils.stringifyIdent(E.manifest.name):null;f.reportInfo(Gi.MessageName.UNNAMED,`${Gi.structUtils.prettyLocator(i.configuration,E.anchoredLocator)}: Bumped to ${t}`),f.reportJson({cwd:D0.npath.fromPortablePath(E.cwd),ident:L,oldVersion:k,newVersion:t});let N=p.get(E);if(typeof N!="undefined")for(let[C,U,q]of N){let W=C.manifest[U].get(q);if(typeof W=="undefined")throw new Error("Assertion failed: The dependency should have existed");let ne=W.range,m=!1;if(ne.startsWith(Gi.WorkspaceResolver.protocol)&&(ne=ne.slice(Gi.WorkspaceResolver.protocol.length),m=!0,ne===E.relativeCwd))continue;let we=ne.match(mK);if(!we){f.reportWarning(Gi.MessageName.UNNAMED,`Couldn't auto-upgrade range ${ne} (in ${Gi.structUtils.prettyLocator(i.configuration,C.anchoredLocator)})`);continue}let Se=`${we[1]}${t}`;m&&(Se=`${Gi.WorkspaceResolver.protocol}${Se}`);let he=Gi.structUtils.makeDescriptor(W,Se);C.manifest[U].set(q,he)}}}var gK=new Map([["%n",{extract:i=>i.length>=1?[i[0],i.slice(1)]:null,generate:(i=0)=>`${i+1}`}]]);function v5(i,{current:o,prerelease:f}){let p=new Fc.default.SemVer(o),E=p.prerelease.slice(),t=[];p.prerelease=[],p.format()!==i&&(E.length=0);let k=!0,L=f.split(/\./g);for(let N of L){let C=gK.get(N);if(typeof C=="undefined")t.push(N),E[0]===N?E.shift():k=!1;else{let U=k?C.extract(E):null;U!==null&&typeof U[0]=="number"?(t.push(C.generate(U[0])),E=U[1]):(t.push(C.generate()),k=!1)}}return p.prerelease&&(p.prerelease=[]),`${i}-${t.join(".")}`}var $y=class extends X_.BaseCommand{constructor(){super(...arguments);this.all=cd.Option.Boolean("--all",!1,{description:"Apply the deferred version changes on all workspaces"});this.dryRun=cd.Option.Boolean("--dry-run",!1,{description:"Print the versions without actually generating the package archive"});this.prerelease=cd.Option.String("--prerelease",{description:"Add a prerelease identifier to new versions",tolerateBoolean:!0});this.recursive=cd.Option.Boolean("-R,--recursive",{description:"Release the transitive workspaces as well"});this.json=cd.Option.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let o=await em.Configuration.find(this.context.cwd,this.context.plugins),{project:f,workspace:p}=await Q_.Project.find(o,this.context.cwd),E=await em.Cache.find(o);if(!p)throw new X_.WorkspaceRequiredError(f.cwd,this.context.cwd);return await f.restoreInstallState({restoreResolutions:!1}),(await Q_.StreamReport.start({configuration:o,json:this.json,stdout:this.context.stdout},async k=>{let L=this.prerelease?typeof this.prerelease!="boolean"?this.prerelease:"rc.%n":null,N=await Jy(f,{prerelease:L}),C=new Map;if(this.all)C=N;else{let U=this.recursive?p.getRecursiveWorkspaceDependencies():[p];for(let q of U){let W=N.get(q);typeof W!="undefined"&&C.set(q,W)}}if(C.size===0){let U=N.size>0?" Did you want to add --all?":"";k.reportWarning(em.MessageName.UNNAMED,`The current workspace doesn't seem to require a version bump.${U}`);return}ND(f,C,{report:k}),this.dryRun||(L||(this.all?await OD(f):await kD(f)),k.reportSeparator(),await f.install({cache:E,report:k}))})).exitCode()}};$y.paths=[["version","apply"]],$y.usage=cd.Command.Usage({category:"Release-related commands",description:"apply all the deferred version bumps at once",details:` + This command will apply the deferred version changes and remove their definitions from the repository. + + Note that if \`--prerelease\` is set, the given prerelease identifier (by default \`rc.%d\`) will be used on all new versions and the version definitions will be kept as-is. + + By default only the current workspace will be bumped, but you can configure this behavior by using one of: + + - \`--recursive\` to also apply the version bump on its dependencies + - \`--all\` to apply the version bump on all packages in the repository + + Note that this command will also update the \`workspace:\` references across all your local workspaces, thus ensuring that they keep referring to the same workspaces even after the version bump. + `,examples:[["Apply the version change to the local workspace","yarn version apply"],["Apply the version change to all the workspaces in the local workspace","yarn version apply --all"]]});var m5=$y;var e_=Mi(require("@yarnpkg/cli")),s0=Mi(require("@yarnpkg/core")),rc=Mi(require("@yarnpkg/fslib"));var cO=Mi(lh()),sh=Mi(su()),dO=(0,sh.memo)(({active:i})=>{let o=(0,sh.useMemo)(()=>i?"\u25C9":"\u25EF",[i]),f=(0,sh.useMemo)(()=>i?"green":"yellow",[i]);return sh.default.createElement(cO.Text,{color:f},o)});var yd=Mi(lh()),Js=Mi(su());var pO=Mi(lh()),q4=Mi(su());function hm({active:i},o,f){let{stdin:p}=(0,pO.useStdin)(),E=(0,q4.useCallback)((t,k)=>o(t,k),f);(0,q4.useEffect)(()=>{if(!(!i||!p))return p.on("keypress",E),()=>{p.off("keypress",E)}},[i,E,p])}var ah;(function(f){f.BEFORE="before",f.AFTER="after"})(ah||(ah={}));var hO=function({active:i},o,f){hm({active:i},(p,E)=>{E.name==="tab"&&(E.shift?o(ah.BEFORE):o(ah.AFTER))},f)};var H4=function(i,o,{active:f,minus:p,plus:E,set:t,loop:k=!0}){hm({active:f},(L,N)=>{let C=o.indexOf(i);switch(N.name){case p:{let U=C-1;if(k){t(o[(o.length+U)%o.length]);return}if(U<0)return;t(o[U])}break;case E:{let U=C+1;if(k){t(o[U%o.length]);return}if(U>=o.length)return;t(o[U])}break}},[o,i,E,t,k])};var O3=({active:i=!0,children:o=[],radius:f=10,size:p=1,loop:E=!0,onFocusRequest:t,willReachEnd:k})=>{let L=Se=>{if(Se.key===null)throw new Error("Expected all children to have a key");return Se.key},N=Js.default.Children.map(o,Se=>L(Se)),C=N[0],[U,q]=(0,Js.useState)(C),W=N.indexOf(U);(0,Js.useEffect)(()=>{N.includes(U)||q(C)},[o]),(0,Js.useEffect)(()=>{k&&W>=N.length-2&&k()},[W]),hO({active:i&&!!t},Se=>{t==null||t(Se)},[t]),H4(U,N,{active:i,minus:"up",plus:"down",set:q,loop:E});let ne=W-f,m=W+f;m>N.length&&(ne-=m-N.length,m=N.length),ne<0&&(m+=-ne,ne=0),m>=N.length&&(m=N.length-1);let we=[];for(let Se=ne;Se<=m;++Se){let he=N[Se],ge=i&&he===U;we.push(Js.default.createElement(yd.Box,{key:he,height:p},Js.default.createElement(yd.Box,{marginLeft:1,marginRight:1},Js.default.createElement(yd.Text,null,ge?Js.default.createElement(yd.Text,{color:"cyan",bold:!0},">"):" ")),Js.default.createElement(yd.Box,null,Js.default.cloneElement(o[Se],{active:ge}))))}return Js.default.createElement(yd.Box,{flexDirection:"column",width:"100%"},we)};var W4=Mi(lh()),k3=Mi(su());var vO=Mi(lh()),Z1=Mi(su()),mO=Mi(require("readline")),O$=Z1.default.createContext(null),yO=({children:i})=>{let{stdin:o,setRawMode:f}=(0,vO.useStdin)();(0,Z1.useEffect)(()=>{f&&f(!0),o&&(0,mO.emitKeypressEvents)(o)},[o,f]);let[p,E]=(0,Z1.useState)(new Map),t=(0,Z1.useMemo)(()=>({getAll:()=>p,get:k=>p.get(k),set:(k,L)=>E(new Map([...p,[k,L]]))}),[p,E]);return Z1.default.createElement(O$.Provider,{value:t,children:i})};async function gO(i,o,{stdin:f,stdout:p,stderr:E}={}){let t,k=N=>{let{exit:C}=(0,W4.useApp)();hm({active:!0},(U,q)=>{q.name==="return"&&(t=N,C())},[C,N])},{waitUntilExit:L}=(0,W4.render)(k3.default.createElement(yO,null,k3.default.createElement(i,Gf(E0({},o),{useSubmit:k}))),{stdin:f,stdout:p,stderr:E});return await L(),t}var fh=Mi(require("clipanion")),Dr=Mi(lh()),Tn=Mi(su()),V4=Mi(require("semver"));var t_=class extends e_.BaseCommand{constructor(){super(...arguments);this.interactive=fh.Option.Boolean("-i,--interactive",{description:"Open an interactive interface used to set version bumps"})}async execute(){return this.interactive?await this.executeInteractive():await this.executeStandard()}async executeInteractive(){let o=await s0.Configuration.find(this.context.cwd,this.context.plugins),{project:f,workspace:p}=await s0.Project.find(o,this.context.cwd);if(!p)throw new e_.WorkspaceRequiredError(f.cwd,this.context.cwd);await f.restoreInstallState();let E=await $v(f);if(E===null||E.releaseRoots.size===0)return 0;if(E.root===null)throw new fh.UsageError("This command can only be run on Git repositories");let t=()=>Tn.default.createElement(Dr.Box,{flexDirection:"row",paddingBottom:1},Tn.default.createElement(Dr.Box,{flexDirection:"column",width:60},Tn.default.createElement(Dr.Box,null,Tn.default.createElement(Dr.Text,null,"Press ",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},""),"/",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},"")," to select workspaces.")),Tn.default.createElement(Dr.Box,null,Tn.default.createElement(Dr.Text,null,"Press ",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},""),"/",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},"")," to select release strategies."))),Tn.default.createElement(Dr.Box,{flexDirection:"column"},Tn.default.createElement(Dr.Box,{marginLeft:1},Tn.default.createElement(Dr.Text,null,"Press ",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},"")," to save.")),Tn.default.createElement(Dr.Box,{marginLeft:1},Tn.default.createElement(Dr.Text,null,"Press ",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},"")," to abort.")))),k=({workspace:W,active:ne,decision:m,setDecision:we})=>{var ze;let Se=(ze=W.manifest.raw.stableVersion)!=null?ze:W.manifest.version;if(Se===null)throw new Error(`Assertion failed: The version should have been set (${s0.structUtils.prettyLocator(o,W.anchoredLocator)})`);if(V4.default.prerelease(Se)!==null)throw new Error(`Assertion failed: Prerelease identifiers shouldn't be found (${Se})`);let he=[Nu.UNDECIDED,Nu.DECLINE,Nu.PATCH,Nu.MINOR,Nu.MAJOR];H4(m,he,{active:ne,minus:"left",plus:"right",set:we});let ge=m===Nu.UNDECIDED?Tn.default.createElement(Dr.Text,{color:"yellow"},Se):m===Nu.DECLINE?Tn.default.createElement(Dr.Text,{color:"green"},Se):Tn.default.createElement(Dr.Text,null,Tn.default.createElement(Dr.Text,{color:"magenta"},Se)," \u2192 ",Tn.default.createElement(Dr.Text,{color:"green"},V4.default.valid(m)?m:V4.default.inc(Se,m)));return Tn.default.createElement(Dr.Box,{flexDirection:"column"},Tn.default.createElement(Dr.Box,null,Tn.default.createElement(Dr.Text,null,s0.structUtils.prettyLocator(o,W.anchoredLocator)," - ",ge)),Tn.default.createElement(Dr.Box,null,he.map(pe=>Tn.default.createElement(Dr.Box,{key:pe,paddingLeft:2},Tn.default.createElement(Dr.Text,null,Tn.default.createElement(dO,{active:pe===m})," ",pe)))))},L=W=>{let ne=new Set(E.releaseRoots),m=new Map([...W].filter(([we])=>ne.has(we)));for(;;){let we=Zy({project:E.project,releases:m}),Se=!1;if(we.length>0){for(let[he]of we)if(!ne.has(he)){ne.add(he),Se=!0;let ge=W.get(he);typeof ge!="undefined"&&m.set(he,ge)}}if(!Se)break}return{relevantWorkspaces:ne,relevantReleases:m}},N=()=>{let[W,ne]=(0,Tn.useState)(()=>new Map(E.releases)),m=(0,Tn.useCallback)((we,Se)=>{let he=new Map(W);Se!==Nu.UNDECIDED?he.set(we,Se):he.delete(we);let{relevantReleases:ge}=L(he);ne(ge)},[W,ne]);return[W,m]},C=({workspaces:W,releases:ne})=>{let m=[];m.push(`${W.size} total`);let we=0,Se=0;for(let he of W){let ge=ne.get(he);typeof ge=="undefined"?Se+=1:ge!==Nu.DECLINE&&(we+=1)}return m.push(`${we} release${we===1?"":"s"}`),m.push(`${Se} remaining`),Tn.default.createElement(Dr.Text,{color:"yellow"},m.join(", "))},q=await gO(({useSubmit:W})=>{let[ne,m]=N();W(ne);let{relevantWorkspaces:we}=L(ne),Se=new Set([...we].filter(pe=>!E.releaseRoots.has(pe))),[he,ge]=(0,Tn.useState)(0),ze=(0,Tn.useCallback)(pe=>{switch(pe){case ah.BEFORE:ge(he-1);break;case ah.AFTER:ge(he+1);break}},[he,ge]);return Tn.default.createElement(Dr.Box,{flexDirection:"column"},Tn.default.createElement(t,null),Tn.default.createElement(Dr.Box,null,Tn.default.createElement(Dr.Text,{wrap:"wrap"},"The following files have been modified in your local checkout.")),Tn.default.createElement(Dr.Box,{flexDirection:"column",marginTop:1,paddingLeft:2},[...E.changedFiles].map(pe=>Tn.default.createElement(Dr.Box,{key:pe},Tn.default.createElement(Dr.Text,null,Tn.default.createElement(Dr.Text,{color:"grey"},rc.npath.fromPortablePath(E.root)),rc.npath.sep,rc.npath.relative(rc.npath.fromPortablePath(E.root),rc.npath.fromPortablePath(pe)))))),E.releaseRoots.size>0&&Tn.default.createElement(Tn.default.Fragment,null,Tn.default.createElement(Dr.Box,{marginTop:1},Tn.default.createElement(Dr.Text,{wrap:"wrap"},"Because of those files having been modified, the following workspaces may need to be released again (note that private workspaces are also shown here, because even though they won't be published, releasing them will allow us to flag their dependents for potential re-release):")),Se.size>3?Tn.default.createElement(Dr.Box,{marginTop:1},Tn.default.createElement(C,{workspaces:E.releaseRoots,releases:ne})):null,Tn.default.createElement(Dr.Box,{marginTop:1,flexDirection:"column"},Tn.default.createElement(O3,{active:he%2==0,radius:1,size:2,onFocusRequest:ze},[...E.releaseRoots].map(pe=>Tn.default.createElement(k,{key:pe.cwd,workspace:pe,decision:ne.get(pe)||Nu.UNDECIDED,setDecision:Oe=>m(pe,Oe)}))))),Se.size>0?Tn.default.createElement(Tn.default.Fragment,null,Tn.default.createElement(Dr.Box,{marginTop:1},Tn.default.createElement(Dr.Text,{wrap:"wrap"},"The following workspaces depend on other workspaces that have been marked for release, and thus may need to be released as well:")),Tn.default.createElement(Dr.Box,null,Tn.default.createElement(Dr.Text,null,"(Press ",Tn.default.createElement(Dr.Text,{bold:!0,color:"cyanBright"},"")," to move the focus between the workspace groups.)")),Se.size>5?Tn.default.createElement(Dr.Box,{marginTop:1},Tn.default.createElement(C,{workspaces:Se,releases:ne})):null,Tn.default.createElement(Dr.Box,{marginTop:1,flexDirection:"column"},Tn.default.createElement(O3,{active:he%2==1,radius:2,size:2,onFocusRequest:ze},[...Se].map(pe=>Tn.default.createElement(k,{key:pe.cwd,workspace:pe,decision:ne.get(pe)||Nu.UNDECIDED,setDecision:Oe=>m(pe,Oe)}))))):null)},{versionFile:E},{stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr});if(typeof q=="undefined")return 1;E.releases.clear();for(let[W,ne]of q)E.releases.set(W,ne);await E.saveAll()}async executeStandard(){let o=await s0.Configuration.find(this.context.cwd,this.context.plugins),{project:f,workspace:p}=await s0.Project.find(o,this.context.cwd);if(!p)throw new e_.WorkspaceRequiredError(f.cwd,this.context.cwd);return await f.restoreInstallState(),(await s0.StreamReport.start({configuration:o,stdout:this.context.stdout},async t=>{let k=await $v(f);if(k===null||k.releaseRoots.size===0)return;if(k.root===null)throw new fh.UsageError("This command can only be run on Git repositories");if(t.reportInfo(s0.MessageName.UNNAMED,`Your PR was started right after ${s0.formatUtils.pretty(o,k.baseHash.slice(0,7),"yellow")} ${s0.formatUtils.pretty(o,k.baseTitle,"magenta")}`),k.changedFiles.size>0){t.reportInfo(s0.MessageName.UNNAMED,"You have changed the following files since then:"),t.reportSeparator();for(let q of k.changedFiles)t.reportInfo(null,`${s0.formatUtils.pretty(o,rc.npath.fromPortablePath(k.root),"gray")}${rc.npath.sep}${rc.npath.relative(rc.npath.fromPortablePath(k.root),rc.npath.fromPortablePath(q))}`)}let L=!1,N=!1,C=K_(k);if(C.size>0){L||t.reportSeparator();for(let q of C)t.reportError(s0.MessageName.UNNAMED,`${s0.structUtils.prettyLocator(o,q.anchoredLocator)} has been modified but doesn't have a release strategy attached`);L=!0}let U=Zy(k);for(let[q,W]of U)N||t.reportSeparator(),t.reportError(s0.MessageName.UNNAMED,`${s0.structUtils.prettyLocator(o,q.anchoredLocator)} doesn't have a release strategy attached, but depends on ${s0.structUtils.prettyWorkspace(o,W)} which is planned for release.`),N=!0;(L||N)&&(t.reportSeparator(),t.reportInfo(s0.MessageName.UNNAMED,"This command detected that at least some workspaces have received modifications without explicit instructions as to how they had to be released (if needed)."),t.reportInfo(s0.MessageName.UNNAMED,"To correct these errors, run `yarn version check --interactive` then follow the instructions."))})).exitCode()}};t_.paths=[["version","check"]],t_.usage=fh.Command.Usage({category:"Release-related commands",description:"check that all the relevant packages have been bumped",details:"\n **Warning:** This command currently requires Git.\n\n This command will check that all the packages covered by the files listed in argument have been properly bumped or declined to bump.\n\n In the case of a bump, the check will also cover transitive packages - meaning that should `Foo` be bumped, a package `Bar` depending on `Foo` will require a decision as to whether `Bar` will need to be bumped. This check doesn't cross packages that have declined to bump.\n\n In case no arguments are passed to the function, the list of modified files will be generated by comparing the HEAD against `master`.\n ",examples:[["Check whether the modified packages need a bump","yarn version check"]]});var _O=t_;var G4=Mi(require("@yarnpkg/cli")),Y4=Mi(require("@yarnpkg/core")),Bc=Mi(require("clipanion")),K4=Mi(require("semver"));var n_=class extends G4.BaseCommand{constructor(){super(...arguments);this.deferred=Bc.Option.Boolean("-d,--deferred",{description:"Prepare the version to be bumped during the next release cycle"});this.immediate=Bc.Option.Boolean("-i,--immediate",{description:"Bump the version immediately"});this.strategy=Bc.Option.String()}async execute(){let o=await Y4.Configuration.find(this.context.cwd,this.context.plugins),{project:f,workspace:p}=await Y4.Project.find(o,this.context.cwd);if(!p)throw new G4.WorkspaceRequiredError(f.cwd,this.context.cwd);let E=o.get("preferDeferredVersions");this.deferred&&(E=!0),this.immediate&&(E=!1);let t=K4.default.valid(this.strategy),k=this.strategy===Nu.DECLINE,L;if(t)if(p.manifest.version!==null){let C=MD(p.manifest.version,this.strategy);C!==null?L=C:L=this.strategy}else L=this.strategy;else{let C=p.manifest.version;if(!k){if(C===null)throw new Bc.UsageError("Can't bump the version if there wasn't a version to begin with - use 0.0.0 as initial version then run the command again.");if(typeof C!="string"||!K4.default.valid(C))throw new Bc.UsageError(`Can't bump the version (${C}) if it's not valid semver`)}L=Zv(this.strategy)}if(!E){let U=(await Jy(f)).get(p);if(typeof U!="undefined"&&L!==Nu.DECLINE){let q=Y_(p.manifest.version,L);if(K4.default.lt(q,U))throw new Bc.UsageError(`Can't bump the version to one that would be lower than the current deferred one (${U})`)}}let N=await $v(f,{allowEmpty:!0});return N.releases.set(p,L),await N.saveAll(),E?0:await this.cli.run(["version","apply"])}};n_.paths=[["version"]],n_.usage=Bc.Command.Usage({category:"Release-related commands",description:"apply a new version to the current package",details:"\n This command will bump the version number for the given package, following the specified strategy:\n\n - If `major`, the first number from the semver range will be increased (`X.0.0`).\n - If `minor`, the second number from the semver range will be increased (`0.X.0`).\n - If `patch`, the third number from the semver range will be increased (`0.0.X`).\n - If prefixed by `pre` (`premajor`, ...), a `-0` suffix will be set (`0.0.0-0`).\n - If `prerelease`, the suffix will be increased (`0.0.0-X`); the third number from the semver range will also be increased if there was no suffix in the previous version.\n - If `decline`, the nonce will be increased for `yarn version check` to pass without version bump.\n - If a valid semver range, it will be used as new version.\n - If unspecified, Yarn will ask you for guidance.\n\n For more information about the `--deferred` flag, consult our documentation (https://yarnpkg.com/features/release-workflow#deferred-versioning).\n ",examples:[["Immediately bump the version to the next major","yarn version major"],["Prepare the version to be bumped to the next major","yarn version major --deferred"]]});var EO=n_;var M$={configuration:{deferredVersionFolder:{description:"Folder where are stored the versioning files",type:M3.SettingsType.ABSOLUTE_PATH,default:"./.yarn/versions"},preferDeferredVersions:{description:"If true, running `yarn version` will assume the `--deferred` flag unless `--immediate` is set",type:M3.SettingsType.BOOLEAN,default:!1}},commands:[m5,_O,EO]},N$=M$;return k$;})(); +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ +/** + * @license + * Lodash + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + */ +/** @license React v0.0.0-experimental-51a3aa6af + * react-debug-tools.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.0.0-experimental-51a3aa6af + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.0.0-experimental-51a3aa6af + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.18.0 + * scheduler-tracing.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.18.0 + * scheduler-tracing.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.18.0 + * scheduler.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.18.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.24.0 + * react-reconciler.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v0.24.0 + * react-reconciler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v16.13.1 + * react.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +return plugin; +} +}; diff --git a/.yarn/versions/0f63a127.yml b/.yarn/versions/0f63a127.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/0f63a127.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/apps/api/.gitkeep b/.yarn/versions/147baf84.yml similarity index 100% rename from apps/api/.gitkeep rename to .yarn/versions/147baf84.yml diff --git a/.yarn/versions/14a3a33b.yml b/.yarn/versions/14a3a33b.yml new file mode 100644 index 00000000000000..99406120b83d78 --- /dev/null +++ b/.yarn/versions/14a3a33b.yml @@ -0,0 +1,6 @@ +undecided: + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" diff --git a/.yarn/versions/1ab55dcd.yml b/.yarn/versions/1ab55dcd.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/1b1ef70b.yml b/.yarn/versions/1b1ef70b.yml new file mode 100644 index 00000000000000..99406120b83d78 --- /dev/null +++ b/.yarn/versions/1b1ef70b.yml @@ -0,0 +1,6 @@ +undecided: + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" diff --git a/.yarn/versions/1e31ccad.yml b/.yarn/versions/1e31ccad.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/21cf7a53.yml b/.yarn/versions/21cf7a53.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/2c96dd9e.yml b/.yarn/versions/2c96dd9e.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/2e3d771e.yml b/.yarn/versions/2e3d771e.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/2e3d771e.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/306bf815.yml b/.yarn/versions/306bf815.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/306bf815.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/307fc3c8.yml b/.yarn/versions/307fc3c8.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/354f3ba3.yml b/.yarn/versions/354f3ba3.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/354f3ba3.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/4a56dcaa.yml b/.yarn/versions/4a56dcaa.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/4a56dcaa.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/52e3b28d.yml b/.yarn/versions/52e3b28d.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/52e3b28d.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/66c88679.yml b/.yarn/versions/66c88679.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/6768b38c.yml b/.yarn/versions/6768b38c.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/6e890e70.yml b/.yarn/versions/6e890e70.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/727b22e1.yml b/.yarn/versions/727b22e1.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/727b22e1.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/74098419.yml b/.yarn/versions/74098419.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/74098419.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/838ac9b8.yml b/.yarn/versions/838ac9b8.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/8864696c.yml b/.yarn/versions/8864696c.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/8864696c.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/8e04affb.yml b/.yarn/versions/8e04affb.yml new file mode 100644 index 00000000000000..dd2ce8f8224c20 --- /dev/null +++ b/.yarn/versions/8e04affb.yml @@ -0,0 +1,3 @@ +undecided: + - calcom-monorepo + - "@calcom/prisma" diff --git a/.yarn/versions/8f86d7b6.yml b/.yarn/versions/8f86d7b6.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/8f86d7b6.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/94d54193.yml b/.yarn/versions/94d54193.yml new file mode 100644 index 00000000000000..8eb32dd031656d --- /dev/null +++ b/.yarn/versions/94d54193.yml @@ -0,0 +1,7 @@ +undecided: + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/95d946a0.yml b/.yarn/versions/95d946a0.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/9973477a.yml b/.yarn/versions/9973477a.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/a1fc426b.yml b/.yarn/versions/a1fc426b.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/a1fc426b.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/a33020ce.yml b/.yarn/versions/a33020ce.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/a33020ce.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/aa332bc0.yml b/.yarn/versions/aa332bc0.yml new file mode 100644 index 00000000000000..ccddf60ece96de --- /dev/null +++ b/.yarn/versions/aa332bc0.yml @@ -0,0 +1,2 @@ +undecided: + - calcom-monorepo diff --git a/.yarn/versions/ab66eddc.yml b/.yarn/versions/ab66eddc.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/ab66eddc.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/aecb4352.yml b/.yarn/versions/aecb4352.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/aecb4352.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/b6b7b5dc.yml b/.yarn/versions/b6b7b5dc.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/be9e9950.yml b/.yarn/versions/be9e9950.yml new file mode 100644 index 00000000000000..8eb32dd031656d --- /dev/null +++ b/.yarn/versions/be9e9950.yml @@ -0,0 +1,7 @@ +undecided: + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/c041b45d.yml b/.yarn/versions/c041b45d.yml new file mode 100644 index 00000000000000..dd2ce8f8224c20 --- /dev/null +++ b/.yarn/versions/c041b45d.yml @@ -0,0 +1,3 @@ +undecided: + - calcom-monorepo + - "@calcom/prisma" diff --git a/.yarn/versions/c2e72c83.yml b/.yarn/versions/c2e72c83.yml new file mode 100644 index 00000000000000..8eb32dd031656d --- /dev/null +++ b/.yarn/versions/c2e72c83.yml @@ -0,0 +1,7 @@ +undecided: + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/c8975f9d.yml b/.yarn/versions/c8975f9d.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/c8975f9d.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/c92a78d0.yml b/.yarn/versions/c92a78d0.yml new file mode 100644 index 00000000000000..ccddf60ece96de --- /dev/null +++ b/.yarn/versions/c92a78d0.yml @@ -0,0 +1,2 @@ +undecided: + - calcom-monorepo diff --git a/.yarn/versions/c950a729.yml b/.yarn/versions/c950a729.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/cceb2606.yml b/.yarn/versions/cceb2606.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/cceb2606.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/d05ae1e0.yml b/.yarn/versions/d05ae1e0.yml new file mode 100644 index 00000000000000..dd2ce8f8224c20 --- /dev/null +++ b/.yarn/versions/d05ae1e0.yml @@ -0,0 +1,3 @@ +undecided: + - calcom-monorepo + - "@calcom/prisma" diff --git a/.yarn/versions/d1e07a23.yml b/.yarn/versions/d1e07a23.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/e376493f.yml b/.yarn/versions/e376493f.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/e376493f.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/ecf390c8.yml b/.yarn/versions/ecf390c8.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/ecf390c8.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/ef07ed23.yml b/.yarn/versions/ef07ed23.yml new file mode 100644 index 00000000000000..9164d55660135e --- /dev/null +++ b/.yarn/versions/ef07ed23.yml @@ -0,0 +1,8 @@ +undecided: + - calcom-monorepo + - "@calcom/app-store-cli" + - "@calcom/platform-constants" + - "@calcom/platform-enums" + - "@calcom/platform-types" + - "@calcom/platform-utils" + - "@calcom/prisma" diff --git a/.yarn/versions/f4499d35.yml b/.yarn/versions/f4499d35.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarn/versions/f5a004b3.yml b/.yarn/versions/f5a004b3.yml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/.yarnrc.yml b/.yarnrc.yml index 5f26ff096f7d85..9887a03e00781a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,5 +5,7 @@ plugins: spec: "@yarnpkg/plugin-interactive-tools" - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs spec: "@yarnpkg/plugin-workspace-tools" + - path: .yarn/plugins/@yarnpkg/plugin-version.cjs + spec: "@yarnpkg/plugin-version" yarnPath: .yarn/releases/yarn-3.4.1.cjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1fa0b03e1f2e4..b09839e929a783 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,17 @@ # Contributing to Cal.com -Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. +Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. -- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission. +## House rules + +- Before submitting a new issue or PR, check if it already exists in [issues](https://github.com/calcom/cal.com/issues) or [PRs](https://github.com/calcom/cal.com/pulls). +- GitHub issues: take note of the `🚨 needs approval` label. + - **For Contributors**: + - Feature Requests: Wait for a core member to approve and remove the `🚨 needs approval` label before you start coding or submit a PR. + - Bugs, Security, Performance, Documentation, etc.: You can start coding immediately, even if the `🚨 needs approval` label is present. This label mainly concerns feature requests. + - **Our Process**: + - Issues from non-core members automatically receive the `🚨 needs approval` label. + - We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval. ## Priorities @@ -37,7 +46,7 @@ Contributions are what make the open source community such an amazing place to l - Core Features (Booking page, availabilty, timezone calculation) + Core Features (Booking page, availability, timezone calculation) @@ -65,7 +74,7 @@ branch are tagged into a release monthly. To develop locally: -1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your +1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. 2. Create a new branch: @@ -88,10 +97,26 @@ To develop locally: 5. Set up your `.env` file: + - Duplicate `.env.example` to `.env`. - Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file. - - Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file. + - Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file. + +6. Setup Node + If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project: + + ```sh + nvm use + ``` -6. Start developing and watch for code changes: + You first might need to install the specific version: + + ```sh + nvm install + ``` + + You can install nvm from [here](https://github.com/nvm-sh/nvm). + +7. Start developing and watch for code changes: ```sh yarn dev @@ -119,6 +144,16 @@ This will run and test all flows in multiple Chromium windows to verify that no yarn test-e2e ``` +#### Resolving issues + +##### E2E test browsers not installed + +Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`: + +``` +Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium +``` + ## Linting To check the formatting of your code: @@ -131,7 +166,68 @@ If you get errors, be sure to fix them before committing. ## Making a Pull Request -- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR. -- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue - ](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). +- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR. +- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). - Be sure to fill the PR Template accordingly. +- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations + +## Guidelines for committing yarn lockfile + +Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo: + +If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`: + +```bash +git checkout HEAD~1 yarn.lock +git commit -m "Revert yarn.lock changes" +``` + +_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit +If you've pushed the commit with the `yarn.lock`: + +1. Correct the commit locally using the above method. +2. Carefully force push: + +```bash +git push origin --force +``` + +If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes: + +1. **Checkout a Previous Version**: + + - Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log: + + ```bash + git log yarn.lock + ``` + + - Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`: + + ```bash + git checkout yarn.lock + ``` + +2. **Commit the Reverted Version**: + + - After checking out the previous version of the `yarn.lock`, commit this change: + + ```bash + git commit -m "Revert yarn.lock to its state before unintended changes" + ``` + +3. **Proceed with Caution**: + + - If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes: + + ```bash + git pull origin + ``` + + - Then push the updated branch: + + ```bash + git push origin + ``` + +Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR). diff --git a/LICENSE b/LICENSE index c68dd776c5dafa..6520d20afc0bc1 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,8 @@ Copyright (c) 2020-present Cal.com, Inc. Portions of this software are licensed as follows: -* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE". +* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee and +https://github.com/calcom/cal.com/tree/main/apps/api/v2/src/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE". * All third party components incorporated into the Cal.com Software are licensed under the original license provided by the owner of the applicable component. * Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below. diff --git a/README.md b/README.md index e87c7d513eace0..6f481c5414ae2a 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@

Cal.com (formerly Calendso)

- The open-source Calendly alternative. + The open-source Calendly successor.
Learn more »

- Slack + Discussions · Website · @@ -23,8 +23,7 @@

- Join Cal.com Slack - Product Hunt + Product Hunt Uptime Github Stars Hacker News @@ -33,11 +32,10 @@ Pricing Jitsu Tracked Checkly Availability - + -

@@ -46,14 +44,14 @@ ## About the Project -booking-screen +booking-screen # Scheduling infrastructure for absolutely everyone -The open source Calendly alternative. You are in charge -of your own data, workflow and appearance. +The open source Calendly successor. You are in charge +of your own data, workflow, and appearance. -Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customizations. +Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes, and even calls with our families. However, most tools are very limited in terms of control and customization. That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data. @@ -90,9 +88,15 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig - [Prisma.io](https://prisma.io/?ref=cal.com) - [Daily.co](https://go.cal.com/daily) +## Contact us + +Meet our sales team for any commercial inquiries. + +Book us with Cal.com + ## Stay Up-to-Date -Cal.com officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates: +Cal.com officially launched as v.1.0 on the 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates: ![cal-star-github](https://user-images.githubusercontent.com/8019099/154853944-a9e3c999-3da3-4048-b149-b4f73893c6fb.gif) @@ -107,7 +111,7 @@ To get a local copy up and running, please follow these simple steps. Here is what you need to be able to run Cal.com. - Node.js (Version: >=18.x) -- PostgreSQL +- PostgreSQL (Version: >=13.x) - Yarn _(recommended)_ > If you want to enable any of the available integrations, you may want to obtain additional credentials for each one. More details on this can be found below under the [integrations section](#integrations). @@ -116,31 +120,47 @@ Here is what you need to be able to run Cal.com. ### Setup -1. Clone the repo into a public GitHub repository (or fork https://github.com/calcom/cal.com/fork). If you plan to distribute the code, keep the source code public to comply with [AGPLv3](https://github.com/calcom/cal.com/blob/main/LICENSE). To clone in a private repository, [acquire a commercial license](https://cal.com/sales)) +1. Clone the repo into a public GitHub repository (or fork https://github.com/calcom/cal.com/fork). If you plan to distribute the code, keep the source code public to comply with [AGPLv3](https://github.com/calcom/cal.com/blob/main/LICENSE). To clone in a private repository, [acquire a commercial license](https://cal.com/sales) ```sh git clone https://github.com/calcom/cal.com.git ``` - > If you are on windows, run the following command on `gitbash` with admin privileges:
- ```git clone -c core.symlinks=true https://github.com/calcom/cal.com.git```
- See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details. -1. Go to the project folder + > If you are on Windows, run the following command on `gitbash` with admin privileges:
> `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git`
+ > See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details. + +2. Go to the project folder ```sh cd cal.com ``` -1. Install packages with yarn +3. Install packages with yarn ```sh yarn ``` -1. Set up your `.env` file +4. Set up your `.env` file + - Duplicate `.env.example` to `.env` - Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file. - - Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file. + - Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file. + +5. Setup Node + If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project: + + ```sh + nvm use + ``` + + You first might need to install the specific version and then use it: + + ```sh + nvm install && nvm use + ``` + + You can install nvm from [here](https://github.com/nvm-sh/nvm). #### Quick start with `yarn dx` @@ -153,11 +173,30 @@ yarn dx #### Development tip -> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **tRPC**. +Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\ +Where {level} can be one of the following: + +`0` for silly \ +`1` for trace \ +`2` for debug \ +`3` for info \ +`4` for warn \ +`5` for error \ +`6` for fatal + +When you set `NEXT_PUBLIC_LOGGER_LEVEL={level}` in your .env file, it enables logging at that level and higher. Here's how it works: + +The logger will include all logs that are at the specified level or higher. For example: \ + +- If you set `NEXT_PUBLIC_LOGGER_LEVEL=2`, it will log from level 2 (debug) upwards, meaning levels 2 (debug), 3 (info), 4 (warn), 5 (error), and (fatal) will be logged. \ +- If you set `NEXT_PUBLIC_LOGGER_LEVEL=3`, it will log from level 3 (info) upwards, meaning levels 3 (info), 4 (warn), 5 (error), and 6 (fatal) will be logged, but level 2 (debug) and level 1 (trace) will be ignored. \ ```sh -echo 'NEXT_PUBLIC_DEBUG=1' >> .env +echo 'NEXT_PUBLIC_LOGGER_LEVEL=3' >> .env ``` + +for Logger level to be set at info, for example. + #### Gitpod Setup 1. Click the button below to open this project in Gitpod. @@ -166,8 +205,6 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/calcom/cal.com) - - #### Manual setup 1. Configure environment variables in the `.env` file. Replace ``, ``, ``, and `` with their applicable values @@ -175,37 +212,49 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env ``` DATABASE_URL='postgresql://:@:' ``` +
If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick local DB 1. [Download](https://www.postgresql.org/download/) and install postgres in your local (if you don't have it already). - 2. Create your own local db by executing `createDB ` + 2. Create your own local db by executing `createDB ` 3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d ` - 4. Inside the psql shell execute `\conninfo`. And you will get the following info. + 4. Inside the psql shell execute `\conninfo`. And you will get the following info. ![image](https://user-images.githubusercontent.com/39329182/236612291-51d87f69-6dc1-4a23-bf4d-1ca1754e0a35.png) - 5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this - `postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. + 5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this + `postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. The port is configurable and does not have to be 5432.
If you don't want to create a local DB. Then you can also consider using services like railway.app or render. - - [Setup postgres DB with railway.app](https://arctype.com/postgres/setup/railway-postgres) + + - [Setup postgres DB with railway.app](https://docs.railway.app/guides/postgresql) - [Setup postgres DB with render](https://render.com/docs/databases) 1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`. -1. Set a 32 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one). 1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`) + In a development environment, run: + + ```sh + yarn workspace @calcom/prisma db-migrate + ``` + + In a production environment, run: + ```sh yarn workspace @calcom/prisma db-deploy ``` + 1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development + > **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1" + ```sh docker pull mailhog/mailhog docker run -d -p 8025:8025 -p 1025:1025 mailhog/mailhog @@ -219,6 +268,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env #### Setting up your first user +##### Approach 1 + 1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content: ```sh @@ -230,6 +281,17 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env > New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file. 1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user. +##### Approach 2 + +Seed the local db by running + +```sh +cd packages/prisma +yarn db-seed +``` + +The above command will populate the local db with dummy users. + ### E2E-Testing Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`. @@ -238,10 +300,20 @@ Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If # In a terminal just run: yarn test-e2e -# To open last HTML report run: +# To open the last HTML report run: yarn playwright show-report test-results/reports/playwright-html-report ``` +#### Resolving issues + +##### E2E test browsers not installed + +Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`: + +``` +Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium +``` + ### Upgrading from earlier versions 1. Pull the current version: @@ -308,12 +380,6 @@ Issues with Docker? Find your answer or open a new discussion [here](https://git Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk. -### Heroku - - - Deploy - - ### Railway [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/cal) @@ -330,6 +396,10 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/calcom/docker) +### Elestio + +[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/cal.com) + ## Roadmap @@ -338,7 +408,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases. - + + +## License + +Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open. + +> [!NOTE] +> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license. + +| | AGPLv3 | EE | +| --------------------------------- | ------ | --- | +| Self-host for commercial purposes | ✅ | ✅ | +| Clone privately | ✅ | ✅ | +| Fork publicly | ✅ | ✅ | +| Requires CLA | ✅ | ✅ | +|  Official Support | ❌  | ✅ | +| Derivative work privately | ❌ | ✅ | +|  SSO | ❌ | ✅ | +| Admin Panel | ❌ | ✅ | +| Impersonation | ❌ | ✅ | +| Managed Event Types | ❌ | ✅ | +| Organizations | ❌ | ✅ | +| Payments | ❌ | ✅ | +| Platform | ❌ | ✅ | +| Teams | ❌ | ✅ | +| Users | ❌ | ✅ | +| Video | ❌ | ✅ | +| Workflows | ❌ | ✅ | + +> [!TIP] +> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong. ## Repo Activity @@ -377,7 +477,7 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is ### Translations -Don't code but still want to contribute? Join our [slack](https://cal.com/slack) and join the [#i18n channel](https://calendso.slack.com/archives/C02BY67GMMW) and let us know what language you want to translate. +Don't code but still want to contribute? Join our [Discussions](https://github.com/calcom/cal.com/discussions) and join the [#Translate channel](https://github.com/calcom/cal.com/discussions/categories/translations) and let us know what language you want to translate. ![ar translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ar&style=flat&logo=crowdin&query=%24.progress.0.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![bg translation](https://img.shields.io/badge/dynamic/json?color=blue&label=bg&style=flat&logo=crowdin&query=%24.progress.1.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![cs translation](https://img.shields.io/badge/dynamic/json?color=blue&label=cs&style=flat&logo=crowdin&query=%24.progress.2.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![de translation](https://img.shields.io/badge/dynamic/json?color=blue&label=de&style=flat&logo=crowdin&query=%24.progress.3.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![el translation](https://img.shields.io/badge/dynamic/json?color=blue&label=el&style=flat&logo=crowdin&query=%24.progress.4.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![en translation](https://img.shields.io/badge/dynamic/json?color=blue&label=en&style=flat&logo=crowdin&query=%24.progress.5.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![es translation](https://img.shields.io/badge/dynamic/json?color=blue&label=es&style=flat&logo=crowdin&query=%24.progress.6.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![es-419 translation](https://img.shields.io/badge/dynamic/json?color=blue&label=es-419&style=flat&logo=crowdin&query=%24.progress.7.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![fr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=fr&style=flat&logo=crowdin&query=%24.progress.8.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![he translation](https://img.shields.io/badge/dynamic/json?color=blue&label=he&style=flat&logo=crowdin&query=%24.progress.9.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![hu translation](https://img.shields.io/badge/dynamic/json?color=blue&label=hu&style=flat&logo=crowdin&query=%24.progress.10.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![it translation](https://img.shields.io/badge/dynamic/json?color=blue&label=it&style=flat&logo=crowdin&query=%24.progress.11.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ja translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ja&style=flat&logo=crowdin&query=%24.progress.12.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ko translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ko&style=flat&logo=crowdin&query=%24.progress.13.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![nl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=nl&style=flat&logo=crowdin&query=%24.progress.14.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![no translation](https://img.shields.io/badge/dynamic/json?color=blue&label=no&style=flat&logo=crowdin&query=%24.progress.15.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pl&style=flat&logo=crowdin&query=%24.progress.16.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![pt translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pt&style=flat&logo=crowdin&query=%24.progress.17.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![pt-BR translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pt-BR&style=flat&logo=crowdin&query=%24.progress.18.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ro translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ro&style=flat&logo=crowdin&query=%24.progress.19.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ru&style=flat&logo=crowdin&query=%24.progress.20.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![sr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=sr&style=flat&logo=crowdin&query=%24.progress.21.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![sv translation](https://img.shields.io/badge/dynamic/json?color=blue&label=sv&style=flat&logo=crowdin&query=%24.progress.22.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![tr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=tr&style=flat&logo=crowdin&query=%24.progress.23.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![uk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=uk&style=flat&logo=crowdin&query=%24.progress.24.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![vi translation](https://img.shields.io/badge/dynamic/json?color=blue&label=vi&style=flat&logo=crowdin&query=%24.progress.25.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=zh-CN&style=flat&logo=crowdin&query=%24.progress.26.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![zh-TW translation](https://img.shields.io/badge/dynamic/json?color=blue&label=zh-TW&style=flat&logo=crowdin&query=%24.progress.27.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) @@ -385,6 +485,15 @@ Don't code but still want to contribute? Join our [slack](https://cal.com/slack) - Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported. +## Single Org Mode +If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min). +- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker` +- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`. +- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`. +- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`. + +Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization. + ## Integrations ### Obtaining the Google API Credentials @@ -394,7 +503,7 @@ Don't code but still want to contribute? Join our [slack](https://cal.com/slack) 3. Enable the selected API. 4. Next, go to the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) from the side pane. Select the app type (Internal or External) and enter the basic app details on the first page. 5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly` and select Update. -6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured. +6. In the third page (Test Users), add the Google account(s) you'll be using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured. 7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option. 8. Select Web Application as the Application Type. 9. Under Authorized redirect URI's, select Add URI and then add the URI `/api/integrations/googlecalendar/callback` and `/api/auth/callback/google` replacing Cal.com URL with the URI at which your application runs. @@ -412,11 +521,11 @@ yarn seed-app-store ``` You will need to complete a few more steps to activate Google Calendar App. -Make sure to complete section "Obtaining the Google API Credentials". After the do the +Make sure to complete section "Obtaining the Google API Credentials". After that do the following 1. Add extra redirect URL `/api/auth/callback/google` -1. Under 'OAuth concent screen', click "PUBLISH APP" +1. Under 'OAuth consent screen', click "PUBLISH APP" ### Obtaining Microsoft Graph Client ID and Secret @@ -431,17 +540,18 @@ following 1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account. 2. On the upper right, click "Develop" => "Build App". -3. On "OAuth", select "Create". +3. Select "General App" , click "Create". 4. Name your App. -5. Choose "User-managed app" as the app type. -6. De-select the option to publish the app on the Zoom App Marketplace. -7. Click "Create". -8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields. -9. Set the Redirect URL for OAuth `/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs. -10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form. -11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`. -12. Click "Done". -13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings. +5. Choose "User-managed app" for "Select how the app is managed". +6. De-select the option to publish the app on the Zoom App Marketplace, if asked. +7. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields. +8. Set the "OAuth Redirect URL" under "OAuth Information" as `/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs. +9. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form. +10. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, + 1. click the category "Meeting" and check the scope `meeting:write:meeting`. + 2. click the category "User" and check the scope `user:read:settings`. +11. Click "Done". +12. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings. ### Obtaining Daily API Credentials @@ -451,6 +561,17 @@ following 4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file. 5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording. +### Obtaining Basecamp Client ID and Secret + +1. Visit the [37 Signals Integrations Dashboard](launchpad.37signals.com/integrations) and sign in. +2. Register a new application by clicking the Register one now link. +3. Fill in your company details. +4. Select Basecamp 4 as the product to integrate with. +5. Set the Redirect URL for OAuth `/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs. +6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields. +7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`. + For example, `Cal.com (support@cal.com)`. + ### Obtaining HubSpot Client ID and Secret 1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one. @@ -481,8 +602,18 @@ following 9. Click the "Save"/ "UPDATE" button at the bottom footer. 10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings. +### Obtaining Zoho Calendar Client ID and Secret + +[Follow these steps](./packages/app-store/zohocalendar/) + ### Obtaining Zoho Bigin Client ID and Secret + [Follow these steps](./packages/app-store/zoho-bigin/) + +### Obtaining Pipedrive Client ID and Secret + +[Follow these steps](./packages/app-store/pipedrive-crm/) + ## Workflows ### Setting up SendGrid for Email reminders @@ -501,7 +632,7 @@ following 3. Copy Account SID to your `.env` file into the `TWILIO_SID` field 4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field 5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field -6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com) +6. Add your own sender ID to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com) 7. Create a messaging service (Develop -> Messaging -> Services) 8. Choose any name for the messaging service 9. Click 'Add Senders' @@ -525,14 +656,10 @@ Distributed under the [AGPLv3 License](https://github.com/calcom/cal.com/blob/ma Special thanks to these amazing projects which help power Cal.com: -[](https://vercel.com/?utm_source=calend-so&utm_campaign=oss) - - [Vercel](https://vercel.com/?utm_source=calend-so&utm_campaign=oss) - [Next.js](https://nextjs.org/) - [Day.js](https://day.js.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Prisma](https://prisma.io/) -Jitsu.com - Cal.com is an [open startup](https://cal.com/open) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics. diff --git a/SECURITY.md b/SECURITY.md index 734a6f4e6d7aab..8dead5a02798e4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,39 +1,57 @@ # Security -Contact: security@cal.com +Contact: [security@cal.com](mailto:security@cal.com) Based on [https://supabase.com/.well-known/security.txt](https://supabase.com/.well-known/security.txt) -At Cal.com, we consider the security of our systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present. +At Cal.com, we consider the security of our systems a top priority. But no +matter how much effort we put into system security, there can still be +vulnerabilities present. -If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our clients and our systems. +If you discover a vulnerability, we would like to know about it so we can take +steps to address it as quickly as possible. We would like to ask you to help us +better protect our clients and our systems. -## Out of scope vulnerabilities: +## Out of scope vulnerabilities - Clickjacking on pages with no sensitive actions. - Unauthenticated/logout/login CSRF. - Attacks requiring MITM or physical access to a user's device. - Any activity that could lead to the disruption of our service (DoS). -- Content spoofing and text injection issues without showing an attack vector/without being able to modify HTML/CSS. +- Content spoofing and text injection issues without showing an attack + vector/without being able to modify HTML/CSS. - Email spoofing - Missing DNSSEC, CAA, CSP headers - Lack of Secure or HTTP only flag on non-sensitive cookies - Deadlinks -## Please do the following: +## Please do the following - E-mail your findings to [security@cal.com](mailto:security@cal.com). -- Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you. -- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data, +- Do not run automated scanners on our infrastructure or dashboard. If you wish + to do this, contact us and we will set up a sandbox for you. +- Do not take advantage of the vulnerability or problem you have discovered, + for example by downloading more data than necessary to demonstrate the + vulnerability or deleting or modifying other people's data, - Do not reveal the problem to others until it has been resolved, -- Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties, -- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation. - -## What we promise: - -- We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date, -- If you have followed the instructions above, we will not take any legal action against you in regard to the report, -- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission, +- Do not use attacks on physical security, social engineering, distributed + denial of service, spam or applications of third parties, +- Do provide sufficient information to reproduce the problem, so we will be + able to resolve it as quickly as possible. Usually, the IP address or the URL + of the affected system and a description of the vulnerability will be + sufficient, but complex vulnerabilities may require further explanation. + +## What we promise + +- We will respond to your report within 3 business days with our evaluation of + the report and an expected resolution date, +- If you have followed the instructions above, we will not take any legal + action against you in regard to the report, +- We will handle your report with strict confidentiality, and not pass on your + personal details to third parties without your permission, - We will keep you informed of the progress towards resolving the problem, -- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise), and -- We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved. +- In the public information concerning the problem reported, we will give your + name as the discoverer of the problem (unless you desire otherwise), and +- We strive to resolve all problems as quickly as possible, and we would like + to play an active role in the ultimate publication on the problem after it + is resolved. diff --git a/__checks__/README.md b/__checks__/README.md new file mode 100644 index 00000000000000..7063c1b63a5c30 --- /dev/null +++ b/__checks__/README.md @@ -0,0 +1,4 @@ +# Checkly Tests + +Run as `yarn checkly test` +Deploy the tests as `yarn checkly deploy` diff --git a/__checks__/organization.spec.ts b/__checks__/organization.spec.ts new file mode 100644 index 00000000000000..65255cffdcd217 --- /dev/null +++ b/__checks__/organization.spec.ts @@ -0,0 +1,105 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; + +test.describe("Org", () => { + // Because these pages involve next.config.js rewrites, it's better to test them on production + test.describe("Embeds - i.cal.com", () => { + test("Org Profile Page should be embeddable", async ({ page }) => { + const response = await page.goto("https://i.cal.com/embed"); + expect(response?.status()).toBe(200); + await page.screenshot({ path: "screenshot.jpg" }); + await expectPageToBeRenderedWithEmbedSsr(page); + }); + + test("Org User(Rick) Page should be embeddable", async ({ page }) => { + const response = await page.goto("https://i.cal.com/team-rick/embed"); + expect(response?.status()).toBe(200); + await expect(page.locator("text=Used by Checkly")).toBeVisible(); + await expectPageToBeRenderedWithEmbedSsr(page); + }); + + test("Org User Event(/team-rick/test-event) Page should be embeddable", async ({ page }) => { + const response = await page.goto("https://i.cal.com/team-rick/test-event/embed"); + expect(response?.status()).toBe(200); + await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible(); + await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible(); + await expectPageToBeRenderedWithEmbedSsr(page); + }); + + test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => { + const response = await page.goto("https://i.cal.com/sales/embed"); + expect(response?.status()).toBe(200); + await expect(page.locator("text=Cal.com Sales")).toBeVisible(); + await expectPageToBeRenderedWithEmbedSsr(page); + }); + + test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => { + const response = await page.goto("https://i.cal.com/sales/hipaa/embed"); + expect(response?.status()).toBe(200); + await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible(); + await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible(); + await expectPageToBeRenderedWithEmbedSsr(page); + }); + }); + + test.describe("Dynamic Group Booking", () => { + test("Dynamic Group booking link should load", async ({ page }) => { + const users = [ + { + username: "peer", + name: "Peer Richelsen", + }, + { + username: "bailey", + name: "Bailey Pumfleet", + }, + ]; + const response = await page.goto(`http://i.cal.com/${users[0].username}+${users[1].username}`); + expect(response?.status()).toBe(200); + expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Group Meeting"); + + expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain( + "Join us for a meeting with multiple people" + ); + expect((await page.locator('[data-testid="event-meta"] [data-testid="avatar"]').all()).length).toBe(2); + }); + }); + + test("Organization Homepage - Has Engineering and Marketing Teams", async ({ page }) => { + const response = await page.goto("https://i.cal.com"); + expect(response?.status()).toBe(200); + await expect(page.locator("text=Cal.com")).toBeVisible(); + await expect(page.locator("text=Engineering")).toBeVisible(); + await expect(page.locator("text=Marketing")).toBeVisible(); + }); + + test.describe("Browse the Engineering Team", async () => { + test("By User Navigation", async ({ page }) => { + const response = await page.goto("https://i.cal.com"); + await page.waitForLoadState("networkidle"); + expect(response?.status()).toBe(200); + await page.click('text="Engineering"'); + await expect(page.locator("text=Cal.com Engineering")).toBeVisible(); + }); + + test("By /team/engineering", async ({ page }) => { + await page.goto("https://i.cal.com/team/engineering"); + await expect(page.locator("text=Cal.com Engineering")).toBeVisible(); + }); + + test("By /engineering", async ({ page }) => { + await page.goto("https://i.cal.com/engineering"); + await expect(page.locator("text=Cal.com Engineering")).toBeVisible(); + }); + }); +}); + +// This ensures that the route is actually mapped to a page that is using withEmbedSsr +async function expectPageToBeRenderedWithEmbedSsr(page: Page) { + expect( + await page.evaluate(() => { + //@ts-expect-error - __NEXT_DATA__ is a global variable defined by Next.js + return window.__NEXT_DATA__.props.pageProps.isEmbed; + }) + ).toBe(true); +} diff --git a/app.json b/app.json index 5180797014156a..ff45e67a923b1c 100644 --- a/app.json +++ b/app.json @@ -37,6 +37,10 @@ "description": "ApiKey for cronjobs", "value": "" }, + "CRON_ENABLE_APP_SYNC": { + "description": "Whether to automatically keep app metadata in the database in sync with the metadata/config files. When disabled, the sync runs in a reporting-only dry-run mode.", + "value": "false" + }, "SEND_FEEDBACK_EMAIL": { "description": "Send feedback email", "value": "" @@ -79,6 +83,10 @@ "NEXT_PUBLIC_TEAM_IMPERSONATION": { "description": "Set the following value to true if you wish to enable Team Impersonation", "value": "false" + }, + "NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL": { + "description": "Control time intervals on a user's Schedule availability", + "value": "15" } }, "scripts": { diff --git a/apps/api/.env.example b/apps/api/.env.example deleted file mode 100644 index c5354bfd7da9ce..00000000000000 --- a/apps/api/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -API_KEY_PREFIX=cal_ -DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" -NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 - -# Get it in console.cal.com -CALCOM_LICENSE_KEY="" diff --git a/apps/api/README.md b/apps/api/README.md deleted file mode 100644 index bd9217ba09aa00..00000000000000 --- a/apps/api/README.md +++ /dev/null @@ -1,225 +0,0 @@ - - - -# Cal.com Public API - -Welcome to the Public API ("/apps/api") of the Cal.com. - -This is the public REST api for cal.com. -It exposes CRUD Endpoints of all our most important resources. -And it makes it easy for anyone to integrate with Cal.com at the application programming level. - -## Stack - -- NextJS -- TypeScript -- Prisma - -## Development - -### Setup - -1. Clone the main repo (NOT THIS ONE) - - ```sh - git clone --recurse-submodules -j8 https://github.com/calcom/cal.com.git - ``` - -1. Go to the project folder - - ```sh - cd cal.com - ``` - -1. Copy `apps/api/.env.example` to `apps/api/.env` - - ```sh - cp apps/api/.env.example apps/api/.env - cp .env.example .env - ``` - -1. Install packages with yarn - - ```sh - yarn - ``` - -1. Start developing - - ```sh - yarn workspace @calcom/api dev - ``` - -1. Open [http://localhost:3002](http://localhost:3002) with your browser to see the result. - -## API Authentication (API Keys) - -The API requires a valid apiKey query param to be passed: -You can generate them at - -For example: - -```sh -GET https://api.cal.com/v1/users?apiKey={INSERT_YOUR_CAL.COM_API_KEY_HERE} -``` - -API Keys optionally may have expiry dates, if they are expired they won't work. If you create an apiKey without a userId relation, it won't work either for now as it relies on it to establish the current authenticated user. - -In the future we might add support for header Bearer Auth if we need to or if our customers require it. - -## Middlewares - -We don't use the new NextJS 12 Beta Middlewares, mainly because they run on the edge, and are not able to call prisma from api endpoints. We use instead a very nifty library called next-api-middleware that let's us use a similar approach building our own middlewares and applying them as we see fit. - -- withMiddleware() requires some default middlewares (verifyApiKey, etc...) - -## Next.config.js - -### Redirects - -Since this is an API only project, we don't want to have to type /api/ in all the routes, and so redirect all traffic to api, so a call to `api.cal.com/v1` will resolve to `api.cal.com/api/v1` - -Likewise, v1 is added as param query called version to final /api call so we don't duplicate endpoints in the future for versioning if needed. - -### Transpiling locally shared monorepo modules - -We're calling several packages from monorepo, this need to be transpiled before building since are not available as regular npm packages. That's what withTM does. - -```js - "@calcom/app-store", - "@calcom/prisma", - "@calcom/lib", - "@calcom/features", -``` - -## API Endpoint Validation - -We validate that only the supported methods are accepted at each endpoint, so in - -- **/endpoint**: you can only [GET] (all) and [POST] (create new) -- **/endpoint/id**: you can read create and edit [GET, PATCH, DELETE] - -### Zod Validations - -The API uses `zod` library like our main web repo. It validates that either GET query parameters or POST body content's are valid and up to our spec. It gives errors when parsing result's with schemas and failing validation. - -We use it in several ways, but mainly, we first import the auto-generated schema from @calcom/prisma for each model, which lives in `lib/validations/` - -We have some shared validations which several resources require, like baseApiParams which parses apiKey in all requests, or querIdAsString or TransformParseInt which deal with the id's coming from req.query. - -- **[*]BaseBodyParams** that omits any values from the model that are too sensitive or we don't want to pick when creating a new resource like id, userId, etc.. (those are gotten from context or elswhere) - -- **[*]Public** that also omits any values that we don't want to expose when returning the model as a response, which we parse against before returning all resources. - -- **[*]BodyParams** which merges both `[*]BaseBodyParams.merge([*]RequiredParams);` - -### Next Validations - -[Next-Validations Docs](https://next-validations.productsway.com/) -[Next-Validations Repo](https://github.com/jellydn/next-validations) -We also use this useful helper library that let us wrap our endpoints in a validate HOC that checks the req against our validation schema built out with zod for either query and / or body's requests. - -## Testing with Jest + node-mocks-http - -We aim to provide a fully tested API for our peace of mind, this is accomplished by using jest + node-mocks-http - -## Endpoints matrix - -| resource | get [id] | get all | create | edit | delete | -| --------------------- | -------- | ------- | ------ | ---- | ------ | -| attendees | ✅ | ✅ | ✅ | ✅ | ✅ | -| availabilities | ✅ | ✅ | ✅ | ✅ | ✅ | -| booking-references | ✅ | ✅ | ✅ | ✅ | ✅ | -| event-references | ✅ | ✅ | ✅ | ✅ | ✅ | -| destination-calendars | ✅ | ✅ | ✅ | ✅ | ✅ | -| custom-inputs | ✅ | ✅ | ✅ | ✅ | ✅ | -| event-types | ✅ | ✅ | ✅ | ✅ | ✅ | -| memberships | ✅ | ✅ | ✅ | ✅ | ✅ | -| payments | ✅ | ✅ | ❌ | ❌ | ❌ | -| schedules | ✅ | ✅ | ✅ | ✅ | ✅ | -| selected-calendars | ✅ | ✅ | ✅ | ✅ | ✅ | -| teams | ✅ | ✅ | ✅ | ✅ | ✅ | -| users | ✅ | 👤[1] | ✅ | ✅ | ✅ | - -## Models from database that are not exposed - -mostly because they're deemed too sensitive can be revisited if needed. Also they are expected to be used via cal's webapp. - -- [ ] Api Keys -- [ ] Credentials -- [ ] Webhooks -- [ ] ResetPasswordRequest -- [ ] VerificationToken -- [ ] ReminderMail - -## Documentation (OpenAPI) - -You will see that each endpoint has a comment at the top with the annotation `@swagger` with the documentation of the endpoint, **please update it if you change the code!** This is what auto-generates the OpenAPI spec by collecting the YAML in each endpoint and parsing it in /docs alongside the json-schema (auto-generated from prisma package, not added to code but manually for now, need to fix later) - -### @calcom/apps/swagger - -The documentation of the API lives inside the code, and it's auto-generated, the only endpoints that return without a valid apiKey are the homepage, with a JSON message redirecting you to the docs. and the /docs endpoint, which returns the OpenAPI 3.0 JSON Spec. Which SwaggerUi then consumes and generates the docs on. - -## Deployment - -`scripts/vercel-deploy.sh` -The API is deployed to vercel.com, it uses a similar deployment script to website or webapp, and requires transpilation of several shared packages that are part of our turborepo ["app-store", "prisma", "lib", "ee"] -in order to build and deploy properly. - -## Envirorment variables - -### Required - -DATABASE_URL=DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" - -## Optional - -API*KEY_PREFIX=cal*# This can be changed per envirorment so cal*test* for staging for example. - -> If you're self-hosting under our commercial license, you can use any prefix you want for api keys. either leave the default cal\_ (not providing any envirorment variable) or modify it - -**Ensure that while testing swagger, API project should be run in production mode** -We make sure of this by not using next in dev, but next build && next start, if you want hot module reloading and such when developing, please use yarn run next directly on apps/api. - -See . Here in dev mode OPTIONS method is hardcoded to return only GET and OPTIONS as allowed method. Running in Production mode would cause this file to be not used. This is hot-reloading logic only. -To remove this limitation, we need to ensure that on local endpoints are requested by swagger at /api/v1 and not /v1 - -## Hosted api through cal.com - -> _❗ WARNING: This is still experimental and not fully implemented yet❗_ - -Go to console.cal.com -Add a deployment or go to an existing one. -Activate API or Admin addon -Provide your `DATABASE_URL` -Now you can call api.cal.com?key=CALCOM_LICENSE_KEY, which will connect to your own databaseUrl. - -## How to deploy - -We recommend deploying API in vercel. - -There's some settings that you'll need to setup. - -Under Vercel > Your API Project > Settings - -In General > Build & Development Settings -BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps` -OUTPUT DIRECTORY: `apps/api/.next` - -In Git > Ignored Build Step - -Add this command: `./scripts/vercel-deploy.sh` - -See `scripts/vercel-deploy.sh` for more info on how the deployment is done. - -> _❗ IMORTANT: If you're forking the API repo you will need to update the URLs in both the main repo [`.gitmodules`](https://github.com/calcom/cal.com/blob/main/.gitmodules#L7) and this repo [`./scripts/vercel-deploy.sh`](https://github.com/calcom/api/blob/main/scripts/vercel-deploy.sh#L3) ❗_ - -## Environment variables - -Lastly API requires an env var for `DATABASE_URL` and `CALCOM_LICENSE_KEY` diff --git a/apps/api/index.js b/apps/api/index.js new file mode 100644 index 00000000000000..a7e4c44ce3e501 --- /dev/null +++ b/apps/api/index.js @@ -0,0 +1,18 @@ +const http = require("http"); +const connect = require("connect"); +const { createProxyMiddleware } = require("http-proxy-middleware"); + +const apiProxyV1 = createProxyMiddleware({ + target: "http://localhost:3003", +}); + +const apiProxyV2 = createProxyMiddleware({ + target: "http://localhost:3004", +}); + +const app = connect(); +app.use("/", apiProxyV1); + +app.use("/v2", apiProxyV2); + +http.createServer(app).listen(3002); diff --git a/apps/api/lib/helpers/captureErrors.ts b/apps/api/lib/helpers/captureErrors.ts deleted file mode 100644 index 0d850b052b563a..00000000000000 --- a/apps/api/lib/helpers/captureErrors.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; -import type { NextMiddleware } from "next-api-middleware"; - -export const captureErrors: NextMiddleware = async (_req, res, next) => { - try { - // Catch any errors that are thrown in remaining - // middleware and the API route handler - await next(); - } catch (error) { - Sentry.captureException(error); - console.log(error); - res.status(400).json({ message: "Something went wrong", error }); - } -}; diff --git a/apps/api/lib/helpers/customPrisma.ts b/apps/api/lib/helpers/customPrisma.ts deleted file mode 100644 index a30cb6bcb6d044..00000000000000 --- a/apps/api/lib/helpers/customPrisma.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import type { NextMiddleware } from "next-api-middleware"; - -import { CONSOLE_URL } from "@calcom/lib/constants"; - -const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL; - -// This replaces the prisma client for the custom one if the key is valid -export const customPrismaClient: NextMiddleware = async (req, res, next) => { - const { - query: { key }, - } = req; - // If no custom api Id is provided, attach to request the regular cal.com prisma client. - if (!key) { - req.prisma = new PrismaClient(); - await next(); - return; - } - - // If we have a key, we check if the deployment matching the key, has a databaseUrl value set. - const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`) - .then((res) => res.json()) - .then((res) => res.databaseUrl); - - if (!databaseUrl) { - res.status(400).json({ error: "no databaseUrl set up at your instance yet" }); - return; - } - req.prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }); - /* @note: - In order to skip verifyApiKey for customPrisma requests, - we pass isAdmin true, and userId 0, if we detect them later, - we skip verifyApiKey logic and pass onto next middleware instead. - */ - req.isAdmin = true; - req.isCustomPrisma = true; - // We don't need the key from here and on. Prevents unrecognized key errors. - delete req.query.key; - await next(); - await req.prisma.$disconnect(); - // @ts-expect-error testing - delete req.prisma; -}; diff --git a/apps/api/lib/helpers/verifyApiKey.ts b/apps/api/lib/helpers/verifyApiKey.ts deleted file mode 100644 index 45df30e5ef6fab..00000000000000 --- a/apps/api/lib/helpers/verifyApiKey.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { NextMiddleware } from "next-api-middleware"; - -import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; -import checkLicense from "@calcom/features/ee/common/server/checkLicense"; - -import { isAdminGuard } from "~/lib/utils/isAdmin"; - -// Used to check if the apiKey is not expired, could be extracted if reused. but not for now. -export const dateNotInPast = function (date: Date) { - const now = new Date(); - if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) { - return true; - } -}; - -// This verifies the apiKey and sets the user if it is valid. -export const verifyApiKey: NextMiddleware = async (req, res, next) => { - const { prisma, isCustomPrisma, isAdmin } = req; - const hasValidLicense = await checkLicense(prisma); - if (!hasValidLicense) - return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" }); - // If the user is an admin and using a license key (from customPrisma), skip the apiKey check. - if (isCustomPrisma && isAdmin) { - await next(); - return; - } - // Check if the apiKey query param is provided. - if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); - // remove the prefix from the user provided api_key. If no env set default to "cal_" - const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", ""); - // Hash the key again before matching against the database records. - const hashedKey = hashAPIKey(strippedApiKey); - // Check if the hashed api key exists in database. - const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } }); - // If cannot find any api key. Throw a 401 Unauthorized. - if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" }); - if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) { - return res.status(401).json({ error: "This apiKey is expired" }); - } - if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" }); - // save the user id in the request for later use - req.userId = apiKey.userId; - // save the isAdmin boolean here for later use - req.isAdmin = await isAdminGuard(req); - req.isCustomPrisma = false; - await next(); -}; diff --git a/apps/api/lib/helpers/withMiddleware.ts b/apps/api/lib/helpers/withMiddleware.ts deleted file mode 100644 index 6f7d7927c0b210..00000000000000 --- a/apps/api/lib/helpers/withMiddleware.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { label } from "next-api-middleware"; - -import { addRequestId } from "./addRequestid"; -import { captureErrors } from "./captureErrors"; -import { customPrismaClient } from "./customPrisma"; -import { extendRequest } from "./extendRequest"; -import { - HTTP_POST, - HTTP_DELETE, - HTTP_PATCH, - HTTP_GET, - HTTP_GET_OR_POST, - HTTP_GET_DELETE_PATCH, -} from "./httpMethods"; -import { verifyApiKey } from "./verifyApiKey"; -import { withPagination } from "./withPagination"; - -const withMiddleware = label( - { - HTTP_GET_OR_POST, - HTTP_GET_DELETE_PATCH, - HTTP_GET, - HTTP_PATCH, - HTTP_POST, - HTTP_DELETE, - addRequestId, - verifyApiKey, - customPrismaClient, - extendRequest, - pagination: withPagination, - sentry: captureErrors, - }, - // The order here, determines the order of execution, put customPrismaClient before verifyApiKey always. - ["extendRequest", "sentry", "customPrismaClient", "verifyApiKey", "addRequestId"] // <-- Provide a list of middleware to call automatically -); - -export { withMiddleware }; diff --git a/apps/api/lib/utils/isAdmin.ts b/apps/api/lib/utils/isAdmin.ts deleted file mode 100644 index 9b9e6f53425d59..00000000000000 --- a/apps/api/lib/utils/isAdmin.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { UserPermissionRole } from "@calcom/prisma/enums"; - -export const isAdminGuard = async (req: NextApiRequest) => { - const { userId, prisma } = req; - const user = await prisma.user.findUnique({ where: { id: userId } }); - return user?.role === UserPermissionRole.ADMIN; -}; diff --git a/apps/api/lib/validations/booking.ts b/apps/api/lib/validations/booking.ts deleted file mode 100644 index 8298f3cce20574..00000000000000 --- a/apps/api/lib/validations/booking.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from "zod"; - -import { _BookingModel as Booking, _AttendeeModel, _UserModel, _PaymentModel } from "@calcom/prisma/zod"; -import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils"; - -import { schemaQueryUserId } from "./shared/queryUserId"; - -const schemaBookingBaseBodyParams = Booking.pick({ - uid: true, - userId: true, - eventTypeId: true, - title: true, - description: true, - startTime: true, - endTime: true, - status: true, -}).partial(); - -export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial()); - -const schemaBookingEditParams = z - .object({ - title: z.string().optional(), - startTime: iso8601.optional(), - endTime: iso8601.optional(), - // Not supporting responses in edit as that might require re-triggering emails - // responses - }) - .strict(); - -export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams.merge(schemaBookingEditParams); - -export const schemaBookingReadPublic = Booking.extend({ - attendees: z - .array( - _AttendeeModel.pick({ - email: true, - name: true, - timeZone: true, - locale: true, - }) - ) - .optional(), - user: _UserModel - .pick({ - email: true, - name: true, - timeZone: true, - locale: true, - }) - .optional(), - payment: z - .array( - _PaymentModel.pick({ - id: true, - success: true, - paymentOption: true, - }) - ) - .optional(), -}).pick({ - id: true, - userId: true, - description: true, - eventTypeId: true, - uid: true, - title: true, - startTime: true, - endTime: true, - timeZone: true, - attendees: true, - user: true, - payment: true, - metadata: true, - status: true, - responses: true, -}); diff --git a/apps/api/lib/validations/event-type.ts b/apps/api/lib/validations/event-type.ts deleted file mode 100644 index 2b7768a656fa28..00000000000000 --- a/apps/api/lib/validations/event-type.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { z } from "zod"; - -import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod"; -import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils"; - -import { Frequency } from "~/lib/types"; - -import { jsonSchema } from "./shared/jsonSchema"; -import { schemaQueryUserId } from "./shared/queryUserId"; -import { timeZone } from "./shared/timeZone"; - -const recurringEventInputSchema = z.object({ - dtstart: z.string().optional(), - interval: z.number().int().optional(), - count: z.number().int().optional(), - freq: z.nativeEnum(Frequency).optional(), - until: z.string().optional(), - tzid: timeZone.optional(), -}); - -const hostSchema = _HostModel.pick({ - isFixed: true, - userId: true, -}); - -export const schemaEventTypeBaseBodyParams = EventType.pick({ - title: true, - description: true, - slug: true, - length: true, - hidden: true, - position: true, - eventName: true, - timeZone: true, - periodType: true, - periodStartDate: true, - schedulingType: true, - periodEndDate: true, - periodDays: true, - periodCountCalendarDays: true, - requiresConfirmation: true, - disableGuests: true, - hideCalendarNotes: true, - minimumBookingNotice: true, - beforeEventBuffer: true, - afterEventBuffer: true, - teamId: true, - price: true, - currency: true, - slotInterval: true, - successRedirectUrl: true, - locations: true, -}) - .merge(z.object({ hosts: z.array(hostSchema).optional().default([]) })) - .partial() - .strict(); - -const schemaEventTypeCreateParams = z - .object({ - title: z.string(), - slug: z.string(), - description: z.string().optional().nullable(), - length: z.number().int(), - metadata: z.any().optional(), - recurringEvent: recurringEventInputSchema.optional(), - seatsPerTimeSlot: z.number().optional(), - seatsShowAttendees: z.boolean().optional(), - bookingFields: eventTypeBookingFields.optional(), - }) - .strict(); - -export const schemaEventTypeCreateBodyParams = schemaEventTypeBaseBodyParams - .merge(schemaEventTypeCreateParams) - .merge(schemaQueryUserId.partial()); - -const schemaEventTypeEditParams = z - .object({ - title: z.string().optional(), - slug: z.string().optional(), - length: z.number().int().optional(), - seatsPerTimeSlot: z.number().optional(), - seatsShowAttendees: z.boolean().optional(), - bookingFields: eventTypeBookingFields.optional(), - }) - .strict(); - -export const schemaEventTypeEditBodyParams = schemaEventTypeBaseBodyParams.merge(schemaEventTypeEditParams); -export const schemaEventTypeReadPublic = EventType.pick({ - id: true, - title: true, - slug: true, - length: true, - hidden: true, - position: true, - userId: true, - teamId: true, - eventName: true, - timeZone: true, - periodType: true, - periodStartDate: true, - periodEndDate: true, - periodDays: true, - periodCountCalendarDays: true, - requiresConfirmation: true, - recurringEvent: true, - disableGuests: true, - hideCalendarNotes: true, - minimumBookingNotice: true, - beforeEventBuffer: true, - afterEventBuffer: true, - schedulingType: true, - price: true, - currency: true, - slotInterval: true, - successRedirectUrl: true, - description: true, - locations: true, - metadata: true, - seatsPerTimeSlot: true, - seatsShowAttendees: true, - bookingFields: true, -}).merge( - z.object({ - locations: z - .array( - z.object({ - link: z.string().optional(), - address: z.string().optional(), - hostPhoneNumber: z.string().optional(), - type: z.any().optional(), - }) - ) - .nullable(), - metadata: jsonSchema.nullable(), - customInputs: customInputSchema.array().optional(), - link: z.string().optional(), - bookingFields: eventTypeBookingFields.optional().nullable(), - }) -); diff --git a/apps/api/lib/validations/membership.ts b/apps/api/lib/validations/membership.ts deleted file mode 100644 index 24740eaa52fb42..00000000000000 --- a/apps/api/lib/validations/membership.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from "zod"; - -import { MembershipRole } from "@calcom/prisma/enums"; -import { _MembershipModel as Membership, _TeamModel } from "@calcom/prisma/zod"; -import { stringOrNumber } from "@calcom/prisma/zod-utils"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -export const schemaMembershipBaseBodyParams = Membership.omit({}); - -const schemaMembershipRequiredParams = z.object({ - teamId: z.number(), -}); - -export const membershipCreateBodySchema = Membership.partial({ - accepted: true, - role: true, - disableImpersonation: true, -}).transform((v) => ({ - accepted: false, - role: MembershipRole.MEMBER, - disableImpersonation: false, - ...v, -})); - -export const membershipEditBodySchema = Membership.omit({ - /** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */ - teamId: true, - userId: true, -}) - .partial({ - accepted: true, - role: true, - disableImpersonation: true, - }) - .strict(); - -export const schemaMembershipBodyParams = schemaMembershipBaseBodyParams.merge( - schemaMembershipRequiredParams -); - -export const schemaMembershipPublic = Membership.merge(z.object({ team: _TeamModel }).partial()); - -/** We extract userId and teamId from compound ID string */ -export const membershipIdSchema = schemaQueryIdAsString - // So we can query additional team data in memberships - .merge(z.object({ teamId: z.union([stringOrNumber, z.array(stringOrNumber)]) }).partial()) - .transform((v, ctx) => { - const [userIdStr, teamIdStr] = v.id.split("_"); - const userIdInt = schemaQueryIdParseInt.safeParse({ id: userIdStr }); - const teamIdInt = schemaQueryIdParseInt.safeParse({ id: teamIdStr }); - if (!userIdInt.success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "userId is not a number" }); - return z.NEVER; - } - if (!teamIdInt.success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "teamId is not a number " }); - return z.NEVER; - } - return { - userId: userIdInt.data.id, - teamId: teamIdInt.data.id, - }; - }); diff --git a/apps/api/lib/validations/payment.ts b/apps/api/lib/validations/payment.ts deleted file mode 100644 index 32c5d8d641219b..00000000000000 --- a/apps/api/lib/validations/payment.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { _PaymentModel as Payment } from "@calcom/prisma/zod"; - -// FIXME: Payment seems a delicate endpoint, do we need to remove anything here? -export const schemaPaymentBodyParams = Payment.omit({ id: true }); -export const schemaPaymentPublic = Payment.omit({ externalId: true }); diff --git a/apps/api/lib/validations/schedule.ts b/apps/api/lib/validations/schedule.ts deleted file mode 100644 index 51a3fbe1afca7f..00000000000000 --- a/apps/api/lib/validations/schedule.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from "zod"; - -import dayjs from "@calcom/dayjs"; -import { _ScheduleModel as Schedule, _AvailabilityModel as Availability } from "@calcom/prisma/zod"; - -import { timeZone } from "./shared/timeZone"; - -const schemaScheduleBaseBodyParams = Schedule.omit({ id: true, timeZone: true }).partial(); - -export const schemaSingleScheduleBodyParams = schemaScheduleBaseBodyParams.merge( - z.object({ userId: z.number().optional(), timeZone: timeZone.optional() }) -); - -export const schemaCreateScheduleBodyParams = schemaScheduleBaseBodyParams.merge( - z.object({ userId: z.number().optional(), name: z.string(), timeZone }) -); - -export const schemaSchedulePublic = z - .object({ id: z.number() }) - .merge(Schedule) - .merge( - z.object({ - availability: z - .array(Availability.pick({ id: true, eventTypeId: true, days: true, startTime: true, endTime: true })) - .transform((v) => - v.map((item) => ({ - ...item, - startTime: dayjs.utc(item.startTime).format("HH:mm:ss"), - endTime: dayjs.utc(item.endTime).format("HH:mm:ss"), - })) - ) - .optional(), - }) - ); diff --git a/apps/api/lib/validations/shared/jsonSchema.ts b/apps/api/lib/validations/shared/jsonSchema.ts deleted file mode 100644 index bfc7ae1f2b9feb..00000000000000 --- a/apps/api/lib/validations/shared/jsonSchema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod"; - -// Helper schema for JSON fields -type Literal = boolean | number | string; -type Json = Literal | { [key: string]: Json } | Json[]; -const literalSchema = z.union([z.string(), z.number(), z.boolean()]); -export const jsonSchema: z.ZodSchema = z.lazy(() => - z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) -); diff --git a/apps/api/lib/validations/team.ts b/apps/api/lib/validations/team.ts deleted file mode 100644 index e5f8e3cbfa2204..00000000000000 --- a/apps/api/lib/validations/team.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; - -import { _TeamModel as Team } from "@calcom/prisma/zod"; - -export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }).partial({ - hideBranding: true, - metadata: true, -}); - -const schemaTeamRequiredParams = z.object({}); - -export const schemaTeamBodyParams = schemaTeamBaseBodyParams.merge(schemaTeamRequiredParams).strict(); - -export const schemaTeamUpdateBodyParams = schemaTeamBodyParams.partial(); - -export const schemaTeamReadPublic = Team.omit({}); - -export const schemaTeamsReadPublic = z.array(schemaTeamReadPublic); diff --git a/apps/api/lib/validations/user.ts b/apps/api/lib/validations/user.ts deleted file mode 100644 index 25810f27c66c19..00000000000000 --- a/apps/api/lib/validations/user.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { z } from "zod"; - -import { checkUsername } from "@calcom/lib/server/checkUsername"; -import { _UserModel as User } from "@calcom/prisma/zod"; -import { iso8601 } from "@calcom/prisma/zod-utils"; - -import { timeZone } from "~/lib/validations/shared/timeZone"; - -// @note: These are the ONLY values allowed as weekStart. So user don't introduce bad data. -enum weekdays { - MONDAY = "Monday", - TUESDAY = "Tuesday", - WEDNESDAY = "Wednesday", - THURSDAY = "Thursday", - FRIDAY = "Friday", - SATURDAY = "Saturday", - SUNDAY = "Sunday", -} - -// @note: extracted from apps/web/next-i18next.config.js, update if new locales. -enum locales { - EN = "en", - FR = "fr", - IT = "it", - RU = "ru", - ES = "es", - DE = "de", - PT = "pt", - RO = "ro", - NL = "nl", - PT_BR = "pt-BR", - ES_419 = "es-419", - KO = "ko", - JA = "ja", - PL = "pl", - AR = "ar", - IW = "iw", - ZH_CN = "zh-CN", - ZH_TW = "zh-TW", - CS = "cs", - SR = "sr", - SV = "sv", - VI = "vi", -} -enum theme { - DARK = "dark", - LIGHT = "light", -} - -enum timeFormat { - TWELVE = 12, - TWENTY_FOUR = 24, -} - -const usernameSchema = z - .string() - .transform((v) => v.toLowerCase()) - // .refine(() => {}) - .superRefine(async (val, ctx) => { - if (val) { - const result = await checkUsername(val); - if (!result.available) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "already_in_use_error" }); - if (result.premium) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "premium_username" }); - } - }); - -// @note: These are the values that are editable via PATCH method on the user Model -export const schemaUserBaseBodyParams = User.pick({ - name: true, - email: true, - username: true, - bio: true, - timeZone: true, - weekStart: true, - theme: true, - defaultScheduleId: true, - locale: true, - timeFormat: true, - brandColor: true, - darkBrandColor: true, - allowDynamicBooking: true, - away: true, - role: true, - // @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI. - // avatar: true, -}).partial(); -// @note: partial() is used to allow for the user to edit only the fields they want to edit making all optional, -// if want to make any required do it in the schemaRequiredParams - -// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value -// for example making weekStart only accept weekdays as input -const schemaUserEditParams = z.object({ - email: z.string().email(), - username: usernameSchema, - weekStart: z.nativeEnum(weekdays).optional(), - brandColor: z.string().min(4).max(9).regex(/^#/).optional(), - darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), - timeZone: timeZone.optional(), - theme: z.nativeEnum(theme).optional().nullable(), - timeFormat: z.nativeEnum(timeFormat).optional(), - defaultScheduleId: z - .number() - .refine((id: number) => id > 0) - .optional() - .nullable(), - locale: z.nativeEnum(locales).optional().nullable(), -}); - -// @note: These are the values that are editable via PATCH method on the user Model, -// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. - -const schemaUserCreateParams = z.object({ - email: z.string().email(), - username: usernameSchema, - weekStart: z.nativeEnum(weekdays).optional(), - brandColor: z.string().min(4).max(9).regex(/^#/).optional(), - darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), - timeZone: timeZone.optional(), - theme: z.nativeEnum(theme).optional().nullable(), - timeFormat: z.nativeEnum(timeFormat).optional(), - defaultScheduleId: z - .number() - .refine((id: number) => id > 0) - .optional() - .nullable(), - locale: z.nativeEnum(locales).optional(), - createdDate: iso8601.optional(), -}); - -// @note: These are the values that are editable via PATCH method on the user Model, -// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. -export const schemaUserEditBodyParams = schemaUserBaseBodyParams - .merge(schemaUserEditParams) - .omit({}) - .partial() - .strict(); - -export const schemaUserCreateBodyParams = schemaUserBaseBodyParams - .merge(schemaUserCreateParams) - .omit({}) - .strict(); - -// @note: These are the values that are always returned when reading a user -export const schemaUserReadPublic = User.pick({ - id: true, - username: true, - name: true, - email: true, - emailVerified: true, - bio: true, - avatar: true, - timeZone: true, - weekStart: true, - endTime: true, - bufferTime: true, - theme: true, - defaultScheduleId: true, - locale: true, - timeFormat: true, - brandColor: true, - darkBrandColor: true, - allowDynamicBooking: true, - away: true, - createdDate: true, - verified: true, - invitedTo: true, - role: true, -}); - -export const schemaUsersReadPublic = z.array(schemaUserReadPublic); diff --git a/apps/api/lib/validations/webhook.ts b/apps/api/lib/validations/webhook.ts deleted file mode 100644 index 91d8560195d9df..00000000000000 --- a/apps/api/lib/validations/webhook.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { z } from "zod"; - -import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; -import { _WebhookModel as Webhook } from "@calcom/prisma/zod"; - -const schemaWebhookBaseBodyParams = Webhook.pick({ - userId: true, - eventTypeId: true, - eventTriggers: true, - active: true, - subscriberUrl: true, - payloadTemplate: true, -}); - -export const schemaWebhookCreateParams = z - .object({ - // subscriberUrl: z.string().url(), - // eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), - // active: z.boolean(), - payloadTemplate: z.string().optional().nullable(), - eventTypeId: z.number().optional(), - userId: z.number().optional(), - // API shouldn't mess with Apps webhooks yet (ie. Zapier) - // appId: z.string().optional().nullable(), - }) - .strict(); - -export const schemaWebhookCreateBodyParams = schemaWebhookBaseBodyParams.merge(schemaWebhookCreateParams); - -export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams - .merge( - z.object({ - eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), - }) - ) - .partial() - .strict(); - -export const schemaWebhookReadPublic = Webhook.pick({ - id: true, - userId: true, - eventTypeId: true, - payloadTemplate: true, - eventTriggers: true, - // FIXME: We have some invalid urls saved in the DB - // subscriberUrl: true, - /** @todo: find out how to properly add back and validate those. */ - // eventType: true, - // app: true, - appId: true, -}).merge( - z.object({ - subscriberUrl: z.string(), - }) -); diff --git a/apps/api/next-i18next.config.js b/apps/api/next-i18next.config.js deleted file mode 100644 index 402b72363cf401..00000000000000 --- a/apps/api/next-i18next.config.js +++ /dev/null @@ -1,10 +0,0 @@ -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; diff --git a/apps/api/next.config.js b/apps/api/next.config.js deleted file mode 100644 index ba2dcbf89b6b2a..00000000000000 --- a/apps/api/next.config.js +++ /dev/null @@ -1,69 +0,0 @@ -const { withAxiom } = require("next-axiom"); - -module.exports = withAxiom({ - transpilePackages: [ - "@calcom/app-store", - "@calcom/core", - "@calcom/dayjs", - "@calcom/emails", - "@calcom/features", - "@calcom/lib", - "@calcom/prisma", - "@calcom/trpc", - ], - async headers() { - return [ - { - source: "/docs", - headers: [ - { - key: "Access-Control-Allow-Credentials", - value: "true", - }, - { - key: "Access-Control-Allow-Origin", - value: "*", - }, - { - key: "Access-Control-Allow-Methods", - value: "GET, OPTIONS, PATCH, DELETE, POST, PUT", - }, - { - key: "Access-Control-Allow-Headers", - value: - "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, api_key, Authorization", - }, - ], - }, - ]; - }, - async rewrites() { - return { - afterFiles: [ - // This redirects requests recieved at / the root to the /api/ folder. - { - source: "/v:version/:rest*", - destination: "/api/v:version/:rest*", - }, - // This redirects requests to api/v*/ to /api/ passing version as a query parameter. - { - source: "/api/v:version/:rest*", - destination: "/api/:rest*?version=:version", - }, - // Keeps backwards compatibility with old webhook URLs - { - source: "/api/hooks/:rest*", - destination: "/api/webhooks/:rest*", - }, - ], - fallback: [ - // These rewrites are checked after both pages/public files - // and dynamic routes are checked - { - source: "/:path*", - destination: `/api/:path*`, - }, - ], - }; - }, -}); diff --git a/apps/api/package.json b/apps/api/package.json index 1a975d25fa2cf6..252bd4f1772b8f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,45 +1,16 @@ { - "name": "@calcom/api", + "name": "@calcom/api-proxy", "version": "1.0.0", - "description": "Public API for Cal.com", - "main": "index.ts", - "repository": "git@github.com:calcom/api.git", - "author": "Cal.com Inc.", - "private": true, + "description": "", + "main": "index.js", "scripts": { - "build": "next build", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", - "dev": "PORT=3002 next dev", - "lint": "eslint . --ignore-path .gitignore", - "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", - "start": "PORT=3002 next start", - "type-check": "tsc --pretty --noEmit" - }, - "devDependencies": { - "@calcom/tsconfig": "*", - "@calcom/types": "*", - "node-mocks-http": "^1.11.0" + "dev": "node ./index.js" }, + "author": "", + "license": "ISC", "dependencies": { - "@calcom/app-store": "*", - "@calcom/core": "*", - "@calcom/dayjs": "*", - "@calcom/emails": "*", - "@calcom/features": "*", - "@calcom/lib": "*", - "@calcom/prisma": "*", - "@calcom/trpc": "*", - "@sentry/nextjs": "^7.20.0", - "bcryptjs": "^2.4.3", - "memory-cache": "^0.2.0", - "next": "~13.2.1", - "next-api-middleware": "^1.0.1", - "next-axiom": "^0.16.0", - "next-swagger-doc": "^0.3.6", - "next-validations": "^0.2.0", - "typescript": "^4.9.4", - "tzdata": "^1.0.30", - "uuid": "^8.3.2", - "zod": "^3.20.2" + "connect": "^3.7.0", + "http": "^0.0.1-security", + "http-proxy-middleware": "^2.0.6" } } diff --git a/apps/api/pages/api/api-keys/[id]/_auth-middleware.ts b/apps/api/pages/api/api-keys/[id]/_auth-middleware.ts deleted file mode 100644 index fb1c9174859ebe..00000000000000 --- a/apps/api/pages/api/api-keys/[id]/_auth-middleware.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; - -export async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdAsString.parse(req.query); - // Admin can check any api key - if (isAdmin) return; - // Check if user can access the api key - const apiKey = await prisma.apiKey.findFirst({ - where: { id, userId }, - }); - if (!apiKey) throw new HttpError({ statusCode: 404, message: "API key not found" }); -} diff --git a/apps/api/pages/api/api-keys/[id]/_delete.ts b/apps/api/pages/api/api-keys/[id]/_delete.ts deleted file mode 100644 index 099c6ba7b13d24..00000000000000 --- a/apps/api/pages/api/api-keys/[id]/_delete.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; - -async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdAsString.parse(query); - await prisma.apiKey.delete({ where: { id } }); - return { message: `ApiKey with id: ${id} deleted` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/api-keys/[id]/_get.ts b/apps/api/pages/api/api-keys/[id]/_get.ts deleted file mode 100644 index 99b1188507fcbb..00000000000000 --- a/apps/api/pages/api/api-keys/[id]/_get.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { apiKeyPublicSchema } from "~/lib/validations/api-key"; -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; - -async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdAsString.parse(query); - const api_key = await prisma.apiKey.findUniqueOrThrow({ where: { id } }); - return { api_key: apiKeyPublicSchema.parse(api_key) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/api-keys/[id]/_patch.ts b/apps/api/pages/api/api-keys/[id]/_patch.ts deleted file mode 100644 index 673a081f5418f4..00000000000000 --- a/apps/api/pages/api/api-keys/[id]/_patch.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { apiKeyEditBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key"; -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; - -async function patchHandler(req: NextApiRequest) { - const { prisma, body } = req; - const { id } = schemaQueryIdAsString.parse(req.query); - const data = apiKeyEditBodySchema.parse(body); - const api_key = await prisma.apiKey.update({ where: { id }, data }); - return { api_key: apiKeyPublicSchema.parse(api_key) }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/api-keys/_get.ts b/apps/api/pages/api/api-keys/_get.ts deleted file mode 100644 index 8c23c6fe9ed580..00000000000000 --- a/apps/api/pages/api/api-keys/_get.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; -import type { Ensure } from "@calcom/types/utils"; - -import { apiKeyPublicSchema } from "~/lib/validations/api-key"; -import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; - -type CustomNextApiRequest = NextApiRequest & { - args?: Prisma.ApiKeyFindManyArgs; -}; - -/** Admins can query other users' API keys */ -function handleAdminRequests(req: CustomNextApiRequest) { - // To match type safety with runtime - if (!hasReqArgs(req)) throw Error("Missing req.args"); - const { userId, isAdmin } = req; - if (isAdmin && req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - req.args.where = { userId: { in: userIds } }; - if (Array.isArray(query.userId)) req.args.orderBy = { userId: "asc" }; - } -} - -function hasReqArgs(req: CustomNextApiRequest): req is Ensure { - return "args" in req; -} - -async function getHandler(req: CustomNextApiRequest) { - const { userId, isAdmin, prisma } = req; - req.args = isAdmin ? {} : { where: { userId } }; - // Proof of concept: allowing mutation in exchange of composability - handleAdminRequests(req); - const data = await prisma.apiKey.findMany(req.args); - return { api_keys: data.map((v) => apiKeyPublicSchema.parse(v)) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/api-keys/_post.ts b/apps/api/pages/api/api-keys/_post.ts deleted file mode 100644 index 8329ac0df5a10b..00000000000000 --- a/apps/api/pages/api/api-keys/_post.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; -import { v4 } from "uuid"; - -import { generateUniqueAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { apiKeyCreateBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key"; - -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { neverExpires, userId: bodyUserId, ...input } = apiKeyCreateBodySchema.parse(req.body); - const [hashedKey, apiKey] = generateUniqueAPIKey(); - const args: Prisma.ApiKeyCreateArgs = { - data: { - id: v4(), - userId, - ...input, - // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input - expiresAt: neverExpires ? null : input.expiresAt, - hashedKey, - }, - }; - - if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); - - if (isAdmin && bodyUserId) { - const where: Prisma.UserWhereInput = { id: bodyUserId }; - await prisma.user.findFirstOrThrow({ where }); - args.data.userId = bodyUserId; - } - - const result = await prisma.apiKey.create(args); - return { - api_key: { - ...apiKeyPublicSchema.parse(result), - key: `${process.env.API_KEY_PREFIX ?? "cal_"}${apiKey}`, - }, - message: "API key created successfully. Save the `key` value as it won't be displayed again.", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/attendees/[id]/_auth-middleware.ts b/apps/api/pages/api/attendees/[id]/_auth-middleware.ts deleted file mode 100644 index 439758305708a7..00000000000000 --- a/apps/api/pages/api/attendees/[id]/_auth-middleware.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const query = schemaQueryIdParseInt.parse(req.query); - // @note: Here we make sure to only return attendee's of the user's own bookings if the user is not an admin. - if (isAdmin) return; - // Find all user bookings, including attendees - const attendee = await prisma.attendee.findFirst({ - where: { id: query.id, booking: { userId } }, - }); - // Flatten and merge all the attendees in one array - if (!attendee) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/attendees/[id]/_delete.ts b/apps/api/pages/api/attendees/[id]/_delete.ts deleted file mode 100644 index 4d0475b864fcb7..00000000000000 --- a/apps/api/pages/api/attendees/[id]/_delete.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /attendees/{id}: - * delete: - * operationId: removeAttendeeById - * summary: Remove an existing attendee - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the attendee to delete - * tags: - * - attendees - * responses: - * 201: - * description: OK, attendee removed successfully - * 400: - * description: Bad request. Attendee id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - await prisma.attendee.delete({ where: { id } }); - return { message: `Attendee with id: ${id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/attendees/[id]/_get.ts b/apps/api/pages/api/attendees/[id]/_get.ts deleted file mode 100644 index bf1680de29e44c..00000000000000 --- a/apps/api/pages/api/attendees/[id]/_get.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaAttendeeReadPublic } from "~/lib/validations/attendee"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /attendees/{id}: - * get: - * operationId: getAttendeeById - * summary: Find an attendee - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the attendee to get - * tags: - * - attendees - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Attendee was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const attendee = await prisma.attendee.findUnique({ where: { id } }); - return { attendee: schemaAttendeeReadPublic.parse(attendee) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/attendees/[id]/_patch.ts b/apps/api/pages/api/attendees/[id]/_patch.ts deleted file mode 100644 index 67269c7197c8e5..00000000000000 --- a/apps/api/pages/api/attendees/[id]/_patch.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { NextApiRequest } from "next"; -import type { z } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaAttendeeEditBodyParams, schemaAttendeeReadPublic } from "~/lib/validations/attendee"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /attendees/{id}: - * patch: - * operationId: editAttendeeById - * summary: Edit an existing attendee - * requestBody: - * description: Edit an existing attendee related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * format: email - * name: - * type: string - * timeZone: - * type: string - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the attendee to get - * tags: - * - attendees - * responses: - * 201: - * description: OK, attendee edited successfully - * 400: - * description: Bad request. Attendee body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = schemaAttendeeEditBodyParams.parse(body); - await checkPermissions(req, data); - const attendee = await prisma.attendee.update({ where: { id }, data }); - return { attendee: schemaAttendeeReadPublic.parse(attendee) }; -} - -async function checkPermissions(req: NextApiRequest, body: z.infer) { - const { isAdmin, prisma } = req; - if (isAdmin) return; - const { userId } = req; - const { bookingId } = body; - if (bookingId) { - // Ensure that the booking the attendee is being added to belongs to the user - const booking = await prisma.booking.findFirst({ where: { id: bookingId, userId } }); - if (!booking) throw new HttpError({ statusCode: 403, message: "You don't have access to the booking" }); - } -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/attendees/_get.ts b/apps/api/pages/api/attendees/_get.ts deleted file mode 100644 index d6662d897cd84a..00000000000000 --- a/apps/api/pages/api/attendees/_get.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaAttendeeReadPublic } from "~/lib/validations/attendee"; - -/** - * @swagger - * /attendees: - * get: - * operationId: listAttendees - * summary: Find all attendees - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - attendees - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No attendees were found - */ -async function handler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const args: Prisma.AttendeeFindManyArgs = isAdmin ? {} : { where: { booking: { userId } } }; - const data = await prisma.attendee.findMany(args); - const attendees = data.map((attendee) => schemaAttendeeReadPublic.parse(attendee)); - if (!attendees) throw new HttpError({ statusCode: 404, message: "No attendees were found" }); - return { attendees }; -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/attendees/_post.ts b/apps/api/pages/api/attendees/_post.ts deleted file mode 100644 index 8570376c555a9e..00000000000000 --- a/apps/api/pages/api/attendees/_post.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "~/lib/validations/attendee"; - -/** - * @swagger - * /attendees: - * post: - * operationId: addAttendee - * summary: Creates a new attendee - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new attendee related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - bookingId - * - name - * - email - * - timeZone - * properties: - * bookingId: - * type: number - * email: - * type: string - * format: email - * name: - * type: string - * timeZone: - * type: string - * tags: - * - attendees - * responses: - * 201: - * description: OK, attendee created - * 400: - * description: Bad request. Attendee body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const body = schemaAttendeeCreateBodyParams.parse(req.body); - - if (!isAdmin) { - const userBooking = await prisma.booking.findFirst({ - where: { userId, id: body.bookingId }, - select: { id: true }, - }); - // Here we make sure to only return attendee's of the user's own bookings. - if (!userBooking) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - } - - const data = await prisma.attendee.create({ - data: { - email: body.email, - name: body.name, - timeZone: body.timeZone, - booking: { connect: { id: body.bookingId } }, - }, - }); - - return { - attendee: schemaAttendeeReadPublic.parse(data), - message: "Attendee created successfully", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/availabilities/[id]/_auth-middleware.ts b/apps/api/pages/api/availabilities/[id]/_auth-middleware.ts deleted file mode 100644 index 63ad3a3c326371..00000000000000 --- a/apps/api/pages/api/availabilities/[id]/_auth-middleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, prisma, isAdmin, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - /** Admins can skip the ownership verification */ - if (isAdmin) return; - /** - * There's a caveat here. If the availability exists but the user doesn't own it, - * the user will see a 404 error which may or not be the desired behavior. - */ - await prisma.availability.findFirstOrThrow({ - where: { id, Schedule: { userId } }, - }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/availabilities/[id]/_delete.ts b/apps/api/pages/api/availabilities/[id]/_delete.ts deleted file mode 100644 index d480ad4a932cd8..00000000000000 --- a/apps/api/pages/api/availabilities/[id]/_delete.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /availabilities/{id}: - * delete: - * operationId: removeAvailabilityById - * summary: Remove an existing availability - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the availability to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: integer - * description: Your API key - * tags: - * - availabilities - * externalDocs: - * url: https://docs.cal.com/availability - * responses: - * 201: - * description: OK, availability removed successfully - * 400: - * description: Bad request. Availability id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - await prisma.availability.delete({ where: { id } }); - return { message: `Availability with id: ${id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/availabilities/[id]/_get.ts b/apps/api/pages/api/availabilities/[id]/_get.ts deleted file mode 100644 index a481ac1f8953e8..00000000000000 --- a/apps/api/pages/api/availabilities/[id]/_get.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaAvailabilityReadPublic } from "~/lib/validations/availability"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /availabilities/{id}: - * get: - * operationId: getAvailabilityById - * summary: Find an availability - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the availability to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: integer - * description: Your API key - * tags: - * - availabilities - * externalDocs: - * url: https://docs.cal.com/availability - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid - * 404: - * description: Availability not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const availability = await prisma.availability.findUnique({ - where: { id }, - include: { Schedule: { select: { userId: true } } }, - }); - return { availability: schemaAvailabilityReadPublic.parse(availability) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/availabilities/[id]/_patch.ts b/apps/api/pages/api/availabilities/[id]/_patch.ts deleted file mode 100644 index 2b97d01461f06d..00000000000000 --- a/apps/api/pages/api/availabilities/[id]/_patch.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaAvailabilityEditBodyParams, - schemaAvailabilityReadPublic, -} from "~/lib/validations/availability"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /availabilities/{id}: - * patch: - * operationId: editAvailabilityById - * summary: Edit an existing availability - * parameters: - * - in: query - * name: apiKey - * required: true - * description: Your API key - * schema: - * type: integer - * - in: path - * name: id - * required: true - * schema: - * type: integer - * description: ID of the availability to edit - * requestBody: - * description: Edit an existing availability related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * days: - * type: array - * description: Array of integers depicting weekdays - * items: - * type: integer - * enum: [0, 1, 2, 3, 4, 5] - * scheduleId: - * type: integer - * description: ID of schedule this availability is associated with - * startTime: - * type: string - * description: Start time of the availability - * endTime: - * type: string - * description: End time of the availability - * examples: - * availability: - * summary: An example of availability - * value: - * scheduleId: 123 - * days: [1,2,3,5] - * startTime: 1970-01-01T17:00:00.000Z - * endTime: 1970-01-01T17:00:00.000Z - * - * tags: - * - availabilities - * externalDocs: - * url: https://docs.cal.com/availability - * responses: - * 201: - * description: OK, availability edited successfully - * 400: - * description: Bad request. Availability body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = schemaAvailabilityEditBodyParams.parse(body); - const availability = await prisma.availability.update({ - where: { id }, - data, - include: { Schedule: { select: { userId: true } } }, - }); - return { availability: schemaAvailabilityReadPublic.parse(availability) }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/availabilities/_post.ts b/apps/api/pages/api/availabilities/_post.ts deleted file mode 100644 index 19c76f26cb0233..00000000000000 --- a/apps/api/pages/api/availabilities/_post.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaAvailabilityCreateBodyParams, - schemaAvailabilityReadPublic, -} from "~/lib/validations/availability"; - -/** - * @swagger - * /availabilities: - * post: - * operationId: addAvailability - * summary: Creates a new availability - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Edit an existing availability related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - scheduleId - * - startTime - * - endTime - * properties: - * days: - * type: array - * description: Array of integers depicting weekdays - * items: - * type: integer - * enum: [0, 1, 2, 3, 4, 5] - * scheduleId: - * type: integer - * description: ID of schedule this availability is associated with - * startTime: - * type: string - * description: Start time of the availability - * endTime: - * type: string - * description: End time of the availability - * examples: - * availability: - * summary: An example of availability - * value: - * scheduleId: 123 - * days: [1,2,3,5] - * startTime: 1970-01-01T17:00:00.000Z - * endTime: 1970-01-01T17:00:00.000Z - * - * - * tags: - * - availabilities - * externalDocs: - * url: https://docs.cal.com/availability - * responses: - * 201: - * description: OK, availability created - * 400: - * description: Bad request. Availability body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { prisma } = req; - const data = schemaAvailabilityCreateBodyParams.parse(req.body); - await checkPermissions(req); - const availability = await prisma.availability.create({ - data, - include: { Schedule: { select: { userId: true } } }, - }); - req.statusCode = 201; - return { - availability: schemaAvailabilityReadPublic.parse(availability), - message: "Availability created successfully", - }; -} - -async function checkPermissions(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; - if (isAdmin) return; - const data = schemaAvailabilityCreateBodyParams.parse(req.body); - const schedule = await prisma.schedule.findFirst({ - where: { userId, id: data.scheduleId }, - }); - if (!schedule) - throw new HttpError({ statusCode: 401, message: "You can't add availabilities to this schedule" }); -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/availability/_get.ts b/apps/api/pages/api/availability/_get.ts deleted file mode 100644 index f641d22c1835d2..00000000000000 --- a/apps/api/pages/api/availability/_get.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { NextApiRequest } from "next"; -import { z } from "zod"; - -import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; -import { availabilityUserSelect } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; -import { stringOrNumber } from "@calcom/prisma/zod-utils"; - -/** - * @swagger - * /availability: - * get: - * summary: Find user or team availability - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * example: "1234abcd5678efgh" - * description: Your API key - * - in: query - * name: userId - * schema: - * type: integer - * example: 101 - * description: ID of the user to fetch the availability for - * - in: query - * name: teamId - * schema: - * type: integer - * example: 123 - * description: ID of the team to fetch the availability for - * - in: query - * name: username - * schema: - * type: string - * example: "alice" - * description: username of the user to fetch the availability for - * - in: query - * name: dateFrom - * schema: - * type: string - * format: date - * example: "2023-05-14 00:00:00" - * description: Start Date of the availability query - * - in: query - * name: dateTo - * schema: - * type: string - * format: date - * example: "2023-05-20 00:00:00" - * description: End Date of the availability query - * - in: query - * name: eventTypeId - * schema: - * type: integer - * example: 123 - * description: Event Type ID of the event type to fetch the availability for - * operationId: availability - * tags: - * - availability - * responses: - * 200: - * description: OK - * content: - * application/json: - * schema: - * type: object - * example: - * busy: - * - start: "2023-05-14T10:00:00.000Z" - * end: "2023-05-14T11:00:00.000Z" - * title: "Team meeting between Alice and Bob" - * - start: "2023-05-15T14:00:00.000Z" - * end: "2023-05-15T15:00:00.000Z" - * title: "Project review between Carol and Dave" - * - start: "2023-05-16T09:00:00.000Z" - * end: "2023-05-16T10:00:00.000Z" - * - start: "2023-05-17T13:00:00.000Z" - * end: "2023-05-17T14:00:00.000Z" - * timeZone: "America/New_York" - * workingHours: - * - days: [1, 2, 3, 4, 5] - * startTime: 540 - * endTime: 1020 - * userId: 101 - * dateOverrides: - * - date: "2023-05-15" - * startTime: 600 - * endTime: 960 - * userId: 101 - * currentSeats: 4 - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: User not found | Team not found | Team has no members - */ -interface MemberRoles { - [userId: number | string]: MembershipRole; -} - -const availabilitySchema = z - .object({ - userId: stringOrNumber.optional(), - teamId: stringOrNumber.optional(), - username: z.string().optional(), - dateFrom: z.string(), - dateTo: z.string(), - eventTypeId: stringOrNumber.optional(), - }) - .refine( - (data) => !!data.username || !!data.userId || !!data.teamId, - "Either username or userId or teamId should be filled in." - ); - -async function handler(req: NextApiRequest) { - const { prisma, isAdmin, userId: reqUserId } = req; - const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query); - if (!teamId) - return getUserAvailability({ - username, - dateFrom, - dateTo, - eventTypeId, - userId, - }); - const team = await prisma.team.findUnique({ - where: { id: teamId }, - select: { members: true }, - }); - if (!team) throw new HttpError({ statusCode: 404, message: "teamId not found" }); - if (!team.members) throw new HttpError({ statusCode: 404, message: "team has no members" }); - const allMemberIds = team.members.reduce((allMemberIds: number[], member) => { - if (member.accepted) { - allMemberIds.push(member.userId); - } - return allMemberIds; - }, []); - const members = await prisma.user.findMany({ - where: { id: { in: allMemberIds } }, - select: availabilityUserSelect, - }); - const memberRoles: MemberRoles = team.members.reduce((acc: MemberRoles, membership) => { - acc[membership.userId] = membership.role; - return acc; - }, {} as MemberRoles); - // check if the user is a team Admin or Owner, if it is a team request, or a system Admin - const isUserAdminOrOwner = - memberRoles[reqUserId] == MembershipRole.ADMIN || - memberRoles[reqUserId] == MembershipRole.OWNER || - isAdmin; - if (!isUserAdminOrOwner) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - const availabilities = members.map(async (user) => { - return { - userId: user.id, - availability: await getUserAvailability({ - userId: user.id, - dateFrom, - dateTo, - eventTypeId, - }), - }; - }); - const settled = await Promise.all(availabilities); - if (!settled) - throw new HttpError({ - statusCode: 401, - message: "We had an issue retrieving all your members availabilities", - }); - return settled; -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/booking-references/[id]/_auth-middleware.ts b/apps/api/pages/api/booking-references/[id]/_auth-middleware.ts deleted file mode 100644 index fb4d179e61eb89..00000000000000 --- a/apps/api/pages/api/booking-references/[id]/_auth-middleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - // Here we make sure to only return references of the user's own bookings if the user is not an admin. - if (isAdmin) return; - // Find all references where the user has bookings - const bookingReference = await prisma.bookingReference.findFirst({ - where: { id, booking: { userId } }, - }); - if (!bookingReference) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/booking-references/[id]/_delete.ts b/apps/api/pages/api/booking-references/[id]/_delete.ts deleted file mode 100644 index 23a83ce311dfcc..00000000000000 --- a/apps/api/pages/api/booking-references/[id]/_delete.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /booking-references/{id}: - * delete: - * operationId: removeBookingReferenceById - * summary: Remove an existing booking reference - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the booking reference to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - booking-references - * responses: - * 201: - * description: OK, bookingReference removed successfully - * 400: - * description: Bad request. BookingReference id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - await prisma.bookingReference.delete({ where: { id } }); - return { message: `BookingReference with id: ${id} deleted` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/booking-references/[id]/_get.ts b/apps/api/pages/api/booking-references/[id]/_get.ts deleted file mode 100644 index 6baf71a550d349..00000000000000 --- a/apps/api/pages/api/booking-references/[id]/_get.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-reference"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /booking-references/{id}: - * get: - * operationId: getBookingReferenceById - * summary: Find a booking reference - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the booking reference to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - booking-references - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: BookingReference was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const booking_reference = await prisma.bookingReference.findUniqueOrThrow({ where: { id } }); - return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/booking-references/[id]/_patch.ts b/apps/api/pages/api/booking-references/[id]/_patch.ts deleted file mode 100644 index 37e17e2e00b152..00000000000000 --- a/apps/api/pages/api/booking-references/[id]/_patch.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaBookingEditBodyParams, - schemaBookingReferenceReadPublic, -} from "~/lib/validations/booking-reference"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /booking-references/{id}: - * patch: - * operationId: editBookingReferenceById - * summary: Edit an existing booking reference - * requestBody: - * description: Edit an existing booking reference related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * type: - * type: string - * meetingId: - * type: string - * meetingPassword: - * type: string - * externalCalendarId: - * type: string - * deleted: - * type: boolean - * credentialId: - * type: integer - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the booking reference to edit - * tags: - * - booking-references - * responses: - * 201: - * description: OK, BookingReference edited successfully - * 400: - * description: Bad request. BookingReference body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body, isAdmin, userId } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = schemaBookingEditBodyParams.parse(body); - /* If user tries to update bookingId, we run extra checks */ - if (data.bookingId) { - const args: Prisma.BookingFindFirstOrThrowArgs = isAdmin - ? /* If admin, we only check that the booking exists */ - { where: { id: data.bookingId } } - : /* For non-admins we make sure the booking belongs to the user */ - { where: { id: data.bookingId, userId } }; - await prisma.booking.findFirstOrThrow(args); - } - const booking_reference = await prisma.bookingReference.update({ where: { id }, data }); - return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/booking-references/_get.ts b/apps/api/pages/api/booking-references/_get.ts deleted file mode 100644 index c3b81a46222fec..00000000000000 --- a/apps/api/pages/api/booking-references/_get.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-reference"; - -/** - * @swagger - * /booking-references: - * get: - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * operationId: listBookingReferences - * summary: Find all booking references - * tags: - * - booking-references - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No booking references were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const args: Prisma.BookingReferenceFindManyArgs = isAdmin ? {} : { where: { booking: { userId } } }; - const data = await prisma.bookingReference.findMany(args); - return { booking_references: data.map((br) => schemaBookingReferenceReadPublic.parse(br)) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/booking-references/_post.ts b/apps/api/pages/api/booking-references/_post.ts deleted file mode 100644 index b3b8b713cffacf..00000000000000 --- a/apps/api/pages/api/booking-references/_post.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaBookingCreateBodyParams, - schemaBookingReferenceReadPublic, -} from "~/lib/validations/booking-reference"; - -/** - * @swagger - * /booking-references: - * post: - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * operationId: addBookingReference - * summary: Creates a new booking reference - * requestBody: - * description: Create a new booking reference related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - type - * - uid - * properties: - * type: - * type: string - * uid: - * type: string - * meetingId: - * type: string - * meetingPassword: - * type: string - * meetingUrl: - * type: string - * bookingId: - * type: boolean - * externalCalendarId: - * type: string - * deleted: - * type: boolean - * credentialId: - * type: integer - * tags: - * - booking-references - * responses: - * 201: - * description: OK, booking reference created - * 400: - * description: Bad request. BookingReference body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const body = schemaBookingCreateBodyParams.parse(req.body); - const args: Prisma.BookingFindFirstOrThrowArgs = isAdmin - ? /* If admin, we only check that the booking exists */ - { where: { id: body.bookingId } } - : /* For non-admins we make sure the booking belongs to the user */ - { where: { id: body.bookingId, userId } }; - await prisma.booking.findFirstOrThrow(args); - - const data = await prisma.bookingReference.create({ - data: { - ...body, - bookingId: body.bookingId, - }, - }); - - return { - booking_reference: schemaBookingReferenceReadPublic.parse(data), - message: "Booking reference created successfully", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/bookings/[id]/_auth-middleware.ts b/apps/api/pages/api/bookings/[id]/_auth-middleware.ts deleted file mode 100644 index c785ccc3bee614..00000000000000 --- a/apps/api/pages/api/bookings/[id]/_auth-middleware.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, prisma, isAdmin, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const userWithBookings = await prisma.user.findUnique({ - where: { id: userId }, - include: { bookings: true }, - }); - - if (!userWithBookings) throw new HttpError({ statusCode: 404, message: "User not found" }); - - const userBookingIds = userWithBookings.bookings.map((booking) => booking.id); - - if (!isAdmin && !userBookingIds.includes(id)) { - throw new HttpError({ statusCode: 401, message: "You are not authorized" }); - } -} - -export default authMiddleware; diff --git a/apps/api/pages/api/bookings/[id]/_delete.ts b/apps/api/pages/api/bookings/[id]/_delete.ts deleted file mode 100644 index a1cdbc9c3ee608..00000000000000 --- a/apps/api/pages/api/bookings/[id]/_delete.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { NextApiRequest } from "next"; - -import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; -import { defaultResponder } from "@calcom/lib/server"; -import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /bookings/{id}/cancel: - * delete: - * summary: Booking cancellation - * operationId: cancelBookingById - * - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the booking to cancel - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: query - * name: allRemainingBookings - * required: false - * schema: - * type: boolean - * description: Delete all remaining bookings - * - in: query - * name: reason - * required: false - * schema: - * type: string - * description: The reason for cancellation of the booking - * tags: - * - bookings - * responses: - * 200: - * description: OK, booking cancelled successfully - * 400: - * description: | - * Bad request - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
MessageCause
Booking not foundThe provided id didn't correspond to any existing booking.
Cannot cancel past eventsThe provided id matched an existing booking with a past startDate.
User not foundThe userId did not matched an existing user.
- * 404: - * description: User not found - */ -async function handler(req: NextApiRequest) { - const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt - .merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true })) - .parse({ - ...req.query, - allRemainingBookings: req.query.allRemainingBookings === "true", - }); - // Normalizing for universal handler - req.body = { id, allRemainingBookings, cancellationReason }; - return await handleCancelBooking(req); -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/bookings/[id]/_get.ts b/apps/api/pages/api/bookings/[id]/_get.ts deleted file mode 100644 index c549af8b86c9ee..00000000000000 --- a/apps/api/pages/api/bookings/[id]/_get.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaBookingReadPublic } from "~/lib/validations/booking"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /bookings/{id}: - * get: - * summary: Find a booking - * operationId: getBookingById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the booking to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - bookings - * responses: - * 200: - * description: OK - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Booking" - * examples: - * booking: - * value: - * { - * "booking": { - * "id": 91, - * "userId": 5, - * "description": "", - * "eventTypeId": 7, - * "uid": "bFJeNb2uX8ANpT3JL5EfXw", - * "title": "60min between Pro Example and John Doe", - * "startTime": "2023-05-25T09:30:00.000Z", - * "endTime": "2023-05-25T10:30:00.000Z", - * "attendees": [ - * { - * "email": "john.doe@example.com", - * "name": "John Doe", - * "timeZone": "Asia/Kolkata", - * "locale": "en" - * } - * ], - * "user": { - * "email": "pro@example.com", - * "name": "Pro Example", - * "timeZone": "Asia/Kolkata", - * "locale": "en" - * }, - * "payment": [ - * { - * "id": 1, - * "success": true, - * "paymentOption": "ON_BOOKING" - * } - * ], - * "metadata": {}, - * "status": "ACCEPTED", - * "responses": { - * "email": "john.doe@example.com", - * "name": "John Doe", - * "location": { - * "optionValue": "", - * "value": "inPerson" - * } - * } - * } - * } - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Booking was not found - */ - -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const booking = await prisma.booking.findUnique({ - where: { id }, - include: { attendees: true, user: true, payment: true }, - }); - return { booking: schemaBookingReadPublic.parse(booking) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/bookings/[id]/_patch.ts b/apps/api/pages/api/bookings/[id]/_patch.ts deleted file mode 100644 index 74999ab13c9a47..00000000000000 --- a/apps/api/pages/api/bookings/[id]/_patch.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { NextApiRequest } from "next"; -import type { z } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaBookingEditBodyParams, schemaBookingReadPublic } from "~/lib/validations/booking"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /bookings/{id}: - * patch: - * summary: Edit an existing booking - * operationId: editBookingById - * requestBody: - * description: Edit an existing booking related to one of your event-types - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * title: - * type: string - * description: 'Booking event title' - * start: - * type: string - * format: date-time - * description: 'Start time of the Event' - * end: - * type: string - * format: date-time - * description: 'End time of the Event' - * status: - * type: string - * description: 'Acceptable values one of ["ACCEPTED", "PENDING", "CANCELLED", "REJECTED"]' - * examples: - * editBooking: - * value: - * { - * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", - * "start": "2023-05-24T13:00:00.000Z", - * "end": "2023-05-24T13:30:00.000Z", - * "status": "CANCELLED" - * } - * - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the booking to edit - * tags: - * - bookings - * responses: - * 200: - * description: OK, booking edited successfully - * content: - * application/json: - * examples: - * bookings: - * value: - * { - * "booking": { - * "id": 11223344, - * "userId": 182, - * "description": null, - * "eventTypeId": 2323232, - * "uid": "stoSJtnh83PEL4rZmqdHe2", - * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", - * "startTime": "2023-05-24T13:00:00.000Z", - * "endTime": "2023-05-24T13:30:00.000Z", - * "metadata": {}, - * "status": "CANCELLED" - * } - * } - * 400: - * description: Bad request. Booking body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = schemaBookingEditBodyParams.parse(body); - await checkPermissions(req, data); - const booking = await prisma.booking.update({ where: { id }, data }); - return { booking: schemaBookingReadPublic.parse(booking) }; -} - -async function checkPermissions(req: NextApiRequest, body: z.infer) { - const { isAdmin } = req; - if (body.userId && !isAdmin) { - // Organizer has to be a cal user and we can't allow a booking to be transfered to some other cal user's name - throw new HttpError({ - statusCode: 403, - message: "Only admin can change the organizer of a booking", - }); - } -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/bookings/_get.ts b/apps/api/pages/api/bookings/_get.ts deleted file mode 100644 index 125bab63f3afc7..00000000000000 --- a/apps/api/pages/api/bookings/_get.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaBookingReadPublic } from "~/lib/validations/booking"; -import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail"; -import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; - -/** - * @swagger - * /bookings: - * get: - * summary: Find all bookings - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * example: 123456789abcdefgh - * - in: query - * name: userId - * required: false - * schema: - * oneOf: - * - type: integer - * example: 1 - * - type: array - * items: - * type: integer - * example: [2, 3, 4] - * - in: query - * name: attendeeEmail - * required: false - * schema: - * oneOf: - * - type: string - * format: email - * example: john.doe@example.com - * - type: array - * items: - * type: string - * format: email - * example: [john.doe@example.com, jane.doe@example.com] - * operationId: listBookings - * tags: - * - bookings - * responses: - * 200: - * description: OK - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/ArrayOfBookings" - * examples: - * bookings: - * value: [ - * { - * "booking": { - * "id": 91, - * "userId": 5, - * "description": "", - * "eventTypeId": 7, - * "uid": "bFJeNb2uX8ANpT3JL5EfXw", - * "title": "60min between Pro Example and John Doe", - * "startTime": "2023-05-25T09:30:00.000Z", - * "endTime": "2023-05-25T10:30:00.000Z", - * "attendees": [ - * { - * "email": "john.doe@example.com", - * "name": "John Doe", - * "timeZone": "Asia/Kolkata", - * "locale": "en" - * } - * ], - * "user": { - * "email": "pro@example.com", - * "name": "Pro Example", - * "timeZone": "Asia/Kolkata", - * "locale": "en" - * }, - * "payment": [ - * { - * "id": 1, - * "success": true, - * "paymentOption": "ON_BOOKING" - * } - * ], - * "metadata": {}, - * "status": "ACCEPTED", - * "responses": { - * "email": "john.doe@example.com", - * "name": "John Doe", - * "location": { - * "optionValue": "", - * "value": "inPerson" - * } - * } - * } - * } - * ] - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No bookings were found - */ - -/** - * Constructs the WHERE clause for Prisma booking findMany operation. - * - * @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee. - * @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls. - * @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it. - * @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array. - * - * @returns An object that represents the WHERE clause for the findMany/findUnique operation. - */ -function buildWhereClause( - userId: number, - attendeeEmails: string[], - userIds: number[] = [], - userEmails: string[] = [] -) { - const filterByAttendeeEmails = attendeeEmails.length > 0; - const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : { userId }; - let whereClause = {}; - if (filterByAttendeeEmails) { - whereClause = { - AND: [ - userFilter, - { - attendees: { - some: { - email: { in: attendeeEmails }, - }, - }, - }, - ], - }; - } else { - whereClause = { - OR: [ - userFilter, - { - attendees: { - some: { - email: { in: userEmails }, - }, - }, - }, - ], - }; - } - - return { - ...whereClause, - }; -} - -async function handler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const args: Prisma.BookingFindManyArgs = {}; - args.include = { - attendees: true, - user: true, - payment: true, - }; - - const queryFilterForAttendeeEmails = schemaQuerySingleOrMultipleAttendeeEmails.parse(req.query); - const attendeeEmails = Array.isArray(queryFilterForAttendeeEmails.attendeeEmail) - ? queryFilterForAttendeeEmails.attendeeEmail - : typeof queryFilterForAttendeeEmails.attendeeEmail === "string" - ? [queryFilterForAttendeeEmails.attendeeEmail] - : []; - const filterByAttendeeEmails = attendeeEmails.length > 0; - - /** Only admins can query other users */ - if (isAdmin) { - if (req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { email: true }, - }); - const userEmails = users.map((u) => u.email); - args.where = buildWhereClause(userId, attendeeEmails, userIds, userEmails); - } else if (filterByAttendeeEmails) { - args.where = buildWhereClause(userId, attendeeEmails, [], []); - } - } else { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - email: true, - }, - }); - if (!user) { - throw new HttpError({ message: "User not found", statusCode: 500 }); - } - args.where = buildWhereClause(userId, attendeeEmails, [], []); - } - const data = await prisma.booking.findMany(args); - return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) }; -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/bookings/_post.ts b/apps/api/pages/api/bookings/_post.ts deleted file mode 100644 index e80481af40dd02..00000000000000 --- a/apps/api/pages/api/bookings/_post.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { NextApiRequest } from "next"; - -import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; -import { defaultResponder } from "@calcom/lib/server"; - -/** - * @swagger - * /bookings: - * post: - * summary: Creates a new booking - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * operationId: addBooking - * requestBody: - * description: Create a new booking related to one of your event-types - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - eventTypeId - * - start - * - name - * - email - * - timeZone - * - language - * - metadata - * - customInputs - * - location - * properties: - * eventTypeId: - * type: integer - * description: 'ID of the event type to book' - * start: - * type: string - * format: date-time - * description: 'Start time of the Event' - * end: - * type: string - * format: date-time - * description: 'End time of the Event' - * name: - * type: string - * description: 'Name of the Attendee' - * email: - * type: string - * format: email - * description: 'Email ID of the Attendee' - * timeZone: - * type: string - * description: 'TimeZone of the Attendee' - * language: - * type: string - * description: 'Language of the Attendee' - * metadata: - * type: object - * properties: {} - * description: 'Any metadata associated with the booking' - * customInputs: - * type: array - * items: {} - * location: - * type: string - * description: 'Meeting location' - * title: - * type: string - * description: 'Booking event title' - * recurringEventId: - * type: integer - * description: 'Recurring event ID if the event is recurring' - * description: - * type: string - * description: 'Event description' - * status: - * type: string - * description: 'Acceptable values one of ["ACCEPTED", "PENDING", "CANCELLED", "REJECTED"]' - * seatsPerTimeSlot: - * type: integer - * description: 'The number of seats for each time slot' - * seatsShowAttendees: - * type: boolean - * description: 'Share Attendee information in seats' - * smsReminderNumber: - * type: number - * description: 'SMS reminder number' - * examples: - * New Booking example: - * value: - * { - * "eventTypeId": 2323232, - * "start": "2023-05-24T13:00:00.000Z", - * "end": "2023-05-24T13:30:00.000Z", - * "name": "Hello Hello", - * "email": "hello@gmail.com", - * "timeZone": "Europe/London", - * "language": "en", - * "metadata": {}, - * "customInputs": [], - * "location": "Calcom HQ", - * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", - * "description": null, - * "status": "PENDING", - * "smsReminderNumber": null - * } - * - * tags: - * - bookings - * responses: - * 200: - * description: Booking(s) created successfully. - * content: - * application/json: - * examples: - * bookings: - * value: - * { - * "id": 11223344, - * "uid": "5yUjmAYTDF6MXo98re8SkX", - * "userId": 123, - * "eventTypeId": 2323232, - * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", - * "description": null, - * "customInputs": {}, - * "responses": null, - * "startTime": "2023-05-24T13:00:00.000Z", - * "endTime": "2023-05-24T13:30:00.000Z", - * "location": "Calcom HQ", - * "createdAt": "2023-04-19T10:17:58.580Z", - * "updatedAt": null, - * "status": "PENDING", - * "paid": false, - * "destinationCalendarId": 2180, - * "cancellationReason": null, - * "rejectionReason": null, - * "dynamicEventSlugRef": null, - * "dynamicGroupSlugRef": null, - * "rescheduled": null, - * "fromReschedule": null, - * "recurringEventId": null, - * "smsReminderNumber": null, - * "scheduledJobs": [], - * "metadata": {}, - * "isRecorded": false, - * "user": { - * "email": "test@cal.com", - * "name": "Syed Ali Shahbaz", - * "timeZone": "Asia/Calcutta" - * }, - * "attendees": [ - * { - * "id": 12345, - * "email": "hello@gmail.com", - * "name": "Hello Hello", - * "timeZone": "Europe/London", - * "locale": "en", - * "bookingId": 11223344 - * } - * ], - * "payment": [], - * "references": [] - * } - * 400: - * description: | - * Bad request - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
MessageCause
Booking body is invalidMissing property on booking entity.
Invalid eventTypeIdThe provided eventTypeId does not exist.
Missing recurringCountThe eventType is recurring, and no recurringCount was passed.
Invalid recurringCountThe provided recurringCount is greater than the eventType recurring config
- * 401: - * description: Authorization information is missing or invalid. - */ -async function handler(req: NextApiRequest) { - const { userId, isAdmin } = req; - if (isAdmin) req.userId = req.body.userId || userId; - const booking = await handleNewBooking(req); - return booking; -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts b/apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts deleted file mode 100644 index 3bae379831ec43..00000000000000 --- a/apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - // Admins can just skip this check - if (isAdmin) return; - // Check if the current user can access the event type of this input - const eventTypeCustomInput = await prisma.eventTypeCustomInput.findFirst({ - where: { id, eventType: { userId } }, - }); - if (!eventTypeCustomInput) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/custom-inputs/[id]/_delete.ts b/apps/api/pages/api/custom-inputs/[id]/_delete.ts deleted file mode 100644 index 747e1954ffa57f..00000000000000 --- a/apps/api/pages/api/custom-inputs/[id]/_delete.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /custom-inputs/{id}: - * delete: - * summary: Remove an existing eventTypeCustomInput - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the eventTypeCustomInput to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - custom-inputs - * responses: - * 201: - * description: OK, eventTypeCustomInput removed successfully - * 400: - * description: Bad request. EventType id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - await prisma.eventTypeCustomInput.delete({ where: { id } }); - return { message: `CustomInputEventType with id: ${id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/custom-inputs/[id]/_get.ts b/apps/api/pages/api/custom-inputs/[id]/_get.ts deleted file mode 100644 index 96cb8133dcee36..00000000000000 --- a/apps/api/pages/api/custom-inputs/[id]/_get.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-custom-input"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /custom-inputs/{id}: - * get: - * summary: Find a eventTypeCustomInput - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the eventTypeCustomInput to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - custom-inputs - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: EventType was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = await prisma.eventTypeCustomInput.findUniqueOrThrow({ where: { id } }); - return { event_type_custom_input: schemaEventTypeCustomInputPublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/custom-inputs/[id]/_patch.ts b/apps/api/pages/api/custom-inputs/[id]/_patch.ts deleted file mode 100644 index 9ecdd822de7f09..00000000000000 --- a/apps/api/pages/api/custom-inputs/[id]/_patch.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaEventTypeCustomInputEditBodyParams, - schemaEventTypeCustomInputPublic, -} from "~/lib/validations/event-type-custom-input"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /custom-inputs/{id}: - * patch: - * summary: Edit an existing eventTypeCustomInput - * requestBody: - * description: Edit an existing eventTypeCustomInput for an event type - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * eventTypeId: - * type: integer - * description: 'ID of the event type to which the custom input is being added' - * label: - * type: string - * description: 'Label of the custom input' - * type: - * type: string - * description: 'Type of the custom input. The value is ENUM; one of [TEXT, TEXTLONG, NUMBER, BOOL, RADIO, PHONE]' - * options: - * type: object - * properties: - * label: - * type: string - * type: - * type: string - * description: 'Options for the custom input' - * required: - * type: boolean - * description: 'If the custom input is required before booking' - * placeholder: - * type: string - * description: 'Placeholder text for the custom input' - * - * examples: - * custom-inputs: - * summary: Example of patching an existing Custom Input - * value: - * required: true - * - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the eventTypeCustomInput to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - * tags: - * - custom-inputs - * responses: - * 201: - * description: OK, eventTypeCustomInput edited successfully - * 400: - * description: Bad request. EventType body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = schemaEventTypeCustomInputEditBodyParams.parse(req.body); - const result = await prisma.eventTypeCustomInput.update({ where: { id }, data }); - return { event_type_custom_input: schemaEventTypeCustomInputPublic.parse(result) }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/custom-inputs/_get.ts b/apps/api/pages/api/custom-inputs/_get.ts deleted file mode 100644 index db63434ddfeae4..00000000000000 --- a/apps/api/pages/api/custom-inputs/_get.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-custom-input"; - -/** - * @swagger - * /custom-inputs: - * get: - * summary: Find all eventTypeCustomInputs - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - custom-inputs - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No eventTypeCustomInputs were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const args: Prisma.EventTypeCustomInputFindManyArgs = isAdmin ? {} : { where: { eventType: { userId } } }; - const data = await prisma.eventTypeCustomInput.findMany(args); - return { event_type_custom_inputs: data.map((v) => schemaEventTypeCustomInputPublic.parse(v)) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/custom-inputs/_post.ts b/apps/api/pages/api/custom-inputs/_post.ts deleted file mode 100644 index 331fdd732fc9ac..00000000000000 --- a/apps/api/pages/api/custom-inputs/_post.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaEventTypeCustomInputBodyParams, - schemaEventTypeCustomInputPublic, -} from "~/lib/validations/event-type-custom-input"; - -/** - * @swagger - * /custom-inputs: - * post: - * summary: Creates a new eventTypeCustomInput - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new custom input for an event type - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - eventTypeId - * - label - * - type - * - required - * - placeholder - * properties: - * eventTypeId: - * type: integer - * description: 'ID of the event type to which the custom input is being added' - * label: - * type: string - * description: 'Label of the custom input' - * type: - * type: string - * description: 'Type of the custom input. The value is ENUM; one of [TEXT, TEXTLONG, NUMBER, BOOL, RADIO, PHONE]' - * options: - * type: object - * properties: - * label: - * type: string - * type: - * type: string - * description: 'Options for the custom input' - * required: - * type: boolean - * description: 'If the custom input is required before booking' - * placeholder: - * type: string - * description: 'Placeholder text for the custom input' - * - * examples: - * custom-inputs: - * summary: An example of custom-inputs - * value: - * eventTypeID: 1 - * label: "Phone Number" - * type: "PHONE" - * required: true - * placeholder: "100 101 1234" - * - * tags: - * - custom-inputs - * responses: - * 201: - * description: OK, eventTypeCustomInput created - * 400: - * description: Bad request. EventTypeCustomInput body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { eventTypeId, ...body } = schemaEventTypeCustomInputBodyParams.parse(req.body); - - if (!isAdmin) { - /* We check that the user has access to the event type he's trying to add a custom input to. */ - const eventType = await prisma.eventType.findFirst({ - where: { id: eventTypeId, userId }, - }); - if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - } - - const data = await prisma.eventTypeCustomInput.create({ - data: { ...body, eventType: { connect: { id: eventTypeId } } }, - }); - - return { - event_type_custom_input: schemaEventTypeCustomInputPublic.parse(data), - message: "EventTypeCustomInput created successfully", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/destination-calendars/[id].ts b/apps/api/pages/api/destination-calendars/[id].ts deleted file mode 100644 index 1f521c40d7dd60..00000000000000 --- a/apps/api/pages/api/destination-calendars/[id].ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import type { DestinationCalendarResponse } from "~/lib/types"; -import { - schemaDestinationCalendarEditBodyParams, - schemaDestinationCalendarReadPublic, -} from "~/lib/validations/destination-calendar"; -import { - schemaQueryIdParseInt, - withValidQueryIdTransformParseInt, -} from "~/lib/validations/shared/queryIdTransformParseInt"; - -export async function destionationCalendarById( - { method, query, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - const safeQuery = schemaQueryIdParseInt.safeParse(query); - const safeBody = schemaDestinationCalendarEditBodyParams.safeParse(body); - if (!safeQuery.success) { - res.status(400).json({ message: "Your query was invalid" }); - return; - } - const data = await prisma.destinationCalendar.findMany({ where: { userId } }); - const userDestinationCalendars = data.map((destinationCalendar) => destinationCalendar.id); - // FIXME: Should we also check ownership of bokingId and eventTypeId to avoid users cross-pollinating other users calendars. - // On a related note, moving from sequential integer IDs to UUIDs would be a good idea. and maybe help avoid having this problem. - if (userDestinationCalendars.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" }); - else { - switch (method) { - /** - * @swagger - * /destination-calendars/{id}: - * get: - * summary: Find a destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: DestinationCalendar was not found - * patch: - * summary: Edit an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new booking related to one of your event-types - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * integration: - * type: string - * description: 'The integration' - * externalId: - * type: string - * description: 'The external ID of the integration' - * eventTypeId: - * type: integer - * description: 'The ID of the eventType it is associated with' - * bookingId: - * type: integer - * description: 'The booking ID it is associated with' - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar edited successfuly - * 400: - * description: Bad request. DestinationCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - * delete: - * summary: Remove an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar removed successfuly - * 400: - * description: Bad request. DestinationCalendar id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "GET": - await prisma.destinationCalendar - .findUnique({ where: { id: safeQuery.data.id } }) - .then((data) => schemaDestinationCalendarReadPublic.parse(data)) - .then((destination_calendar) => res.status(200).json({ destination_calendar })) - .catch((error: Error) => - res.status(404).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - /** - * @swagger - * /destination-calendars/{id}: - * patch: - * summary: Edit an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar edited successfuly - * 400: - * description: Bad request. DestinationCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "PATCH": - if (!safeBody.success) { - { - res.status(400).json({ message: "Invalid request body" }); - return; - } - } - await prisma.destinationCalendar - .update({ where: { id: safeQuery.data.id }, data: safeBody.data }) - .then((data) => schemaDestinationCalendarReadPublic.parse(data)) - .then((destination_calendar) => res.status(200).json({ destination_calendar })) - .catch((error: Error) => - res.status(404).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - /** - * @swagger - * /destination-calendars/{id}: - * delete: - * summary: Remove an existing destination calendar - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the destination calendar to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destinationCalendar removed successfuly - * 400: - * description: Bad request. DestinationCalendar id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "DELETE": - await prisma.destinationCalendar - .delete({ - where: { id: safeQuery.data.id }, - }) - .then(() => - res.status(200).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} deleted`, - }) - ) - .catch((error: Error) => - res.status(404).json({ - message: `DestinationCalendar with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - - default: - res.status(405).json({ message: "Method not allowed" }); - break; - } - } -} - -export default withMiddleware("HTTP_GET_DELETE_PATCH")( - withValidQueryIdTransformParseInt(destionationCalendarById) -); diff --git a/apps/api/pages/api/destination-calendars/index.ts b/apps/api/pages/api/destination-calendars/index.ts deleted file mode 100644 index c1330d49fad819..00000000000000 --- a/apps/api/pages/api/destination-calendars/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import type { DestinationCalendarResponse, DestinationCalendarsResponse } from "~/lib/types"; -import { - schemaDestinationCalendarCreateBodyParams, - schemaDestinationCalendarReadPublic, -} from "~/lib/validations/destination-calendar"; - -async function createOrlistAllDestinationCalendars( - { method, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - if (method === "GET") { - /** - * @swagger - * /destination-calendars: - * get: - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * summary: Find all destination calendars - * tags: - * - destination-calendars - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No destination calendars were found - */ - const data = await prisma.destinationCalendar.findMany({ where: { userId } }); - const destination_calendars = data.map((destinationCalendar) => - schemaDestinationCalendarReadPublic.parse(destinationCalendar) - ); - if (data) res.status(200).json({ destination_calendars }); - else - (error: Error) => - res.status(404).json({ - message: "No DestinationCalendars were found", - error, - }); - } else if (method === "POST") { - /** - * @swagger - * /destination-calendars: - * post: - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * summary: Creates a new destination calendar - * requestBody: - * description: Create a new destination calendar for your events - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - integration - * - externalId - * properties: - * integration: - * type: string - * description: 'The integration' - * externalId: - * type: string - * description: 'The external ID of the integration' - * eventTypeId: - * type: integer - * description: 'The ID of the eventType it is associated with' - * bookingId: - * type: integer - * description: 'The booking ID it is associated with' - * tags: - * - destination-calendars - * responses: - * 201: - * description: OK, destination calendar created - * 400: - * description: Bad request. DestinationCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - const safe = schemaDestinationCalendarCreateBodyParams.safeParse(body); - if (!safe.success) { - res.status(400).json({ message: "Invalid request body" }); - return; - } - - const data = await prisma.destinationCalendar.create({ data: { ...safe.data, userId } }); - const destination_calendar = schemaDestinationCalendarReadPublic.parse(data); - - if (destination_calendar) - res.status(201).json({ destination_calendar, message: "DestinationCalendar created successfully" }); - else - (error: Error) => - res.status(400).json({ - message: "Could not create new destinationCalendar", - error, - }); - } else res.status(405).json({ message: `Method ${method} not allowed` }); -} - -export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllDestinationCalendars); diff --git a/apps/api/pages/api/event-types/[id]/_auth-middleware.ts b/apps/api/pages/api/event-types/[id]/_auth-middleware.ts deleted file mode 100644 index 8907d7cd3b54a6..00000000000000 --- a/apps/api/pages/api/event-types/[id]/_auth-middleware.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - if (isAdmin) return; - const eventType = await prisma.eventType.findFirst({ - where: { id, users: { some: { id: userId } } }, - }); - if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/event-types/[id]/_delete.ts b/apps/api/pages/api/event-types/[id]/_delete.ts deleted file mode 100644 index 2e598afc545f37..00000000000000 --- a/apps/api/pages/api/event-types/[id]/_delete.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /event-types/{id}: - * delete: - * operationId: removeEventTypeById - * summary: Remove an existing eventType - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the eventType to delete - * tags: - * - event-types - * externalDocs: - * url: https://docs.cal.com/core-features/event-types - * responses: - * 201: - * description: OK, eventType removed successfully - * 400: - * description: Bad request. EventType id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - await checkPermissions(req); - await prisma.eventType.delete({ where: { id } }); - return { message: `Event Type with id: ${id} deleted successfully` }; -} - -async function checkPermissions(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - if (isAdmin) return; - /** Only event type owners can delete it */ - const eventType = await prisma.eventType.findFirst({ where: { id, userId } }); - if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/event-types/[id]/_get.ts b/apps/api/pages/api/event-types/[id]/_get.ts deleted file mode 100644 index 74d9d144564e08..00000000000000 --- a/apps/api/pages/api/event-types/[id]/_get.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -import getCalLink from "../_utils/getCalLink"; - -/** - * @swagger - * /event-types/{id}: - * get: - * operationId: getEventTypeById - * summary: Find a eventType - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: id - * example: 4 - * schema: - * type: integer - * required: true - * description: ID of the eventType to get - * tags: - * - event-types - * externalDocs: - * url: https://docs.cal.com/core-features/event-types - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: EventType was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const event_type = await prisma.eventType.findUnique({ - where: { id }, - include: { - customInputs: true, - team: { select: { slug: true } }, - users: true, - owner: { select: { username: true, id: true } }, - }, - }); - - const link = event_type ? getCalLink(event_type) : null; - - return { event_type: schemaEventTypeReadPublic.parse({ ...event_type, link }) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/event-types/[id]/_patch.ts b/apps/api/pages/api/event-types/[id]/_patch.ts deleted file mode 100644 index 2468d1621b7ece..00000000000000 --- a/apps/api/pages/api/event-types/[id]/_patch.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; -import type { z } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; -import { SchedulingType } from "@calcom/prisma/enums"; - -import type { schemaEventTypeBaseBodyParams } from "~/lib/validations/event-type"; -import { schemaEventTypeEditBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; -import ensureOnlyMembersAsHosts from "~/pages/api/event-types/_utils/ensureOnlyMembersAsHosts"; - -import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission"; - -/** - * @swagger - * /event-types/{id}: - * patch: - * operationId: editEventTypeById - * summary: Edit an existing eventType - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the eventType to edit - * requestBody: - * description: Create a new event-type related to your user or team - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * length: - * type: integer - * description: Duration of the event type in minutes - * metadata: - * type: object - * description: Metadata relating to event type. Pass {} if empty - * title: - * type: string - * description: Title of the event type - * slug: - * type: string - * description: Unique slug for the event type - * hosts: - * type: array - * items: - * type: object - * properties: - * userId: - * type: number - * isFixed: - * type: boolean - * description: Host MUST be available for any slot to be bookable. - * hidden: - * type: boolean - * description: If the event type should be hidden from your public booking page - * position: - * type: integer - * description: The position of the event type on the public booking page - * teamId: - * type: integer - * description: Team ID if the event type should belong to a team - * periodType: - * type: string - * enum: [UNLIMITED, ROLLING, RANGE] - * description: To decide how far into the future an invitee can book an event with you - * periodStartDate: - * type: string - * format: date-time - * description: Start date of bookable period (Required if periodType is 'range') - * periodEndDate: - * type: string - * format: date-time - * description: End date of bookable period (Required if periodType is 'range') - * periodDays: - * type: integer - * description: Number of bookable days (Required if periodType is rolling) - * periodCountCalendarDays: - * type: boolean - * description: If calendar days should be counted for period days - * requiresConfirmation: - * type: boolean - * description: If the event type should require your confirmation before completing the booking - * recurringEvent: - * type: object - * description: If the event should recur every week/month/year with the selected frequency - * properties: - * interval: - * type: integer - * count: - * type: integer - * freq: - * type: integer - * disableGuests: - * type: boolean - * description: If the event type should disable adding guests to the booking - * hideCalendarNotes: - * type: boolean - * description: If the calendar notes should be hidden from the booking - * minimumBookingNotice: - * type: integer - * description: Minimum time in minutes before the event is bookable - * beforeEventBuffer: - * type: integer - * description: Number of minutes of buffer time before a Cal Event - * afterEventBuffer: - * type: integer - * description: Number of minutes of buffer time after a Cal Event - * schedulingType: - * type: string - * description: The type of scheduling if a Team event. Required for team events only - * enum: [ROUND_ROBIN, COLLECTIVE] - * price: - * type: integer - * description: Price of the event type booking - * currency: - * type: string - * description: Currency acronym. Eg- usd, eur, gbp, etc. - * slotInterval: - * type: integer - * description: The intervals of available bookable slots in minutes - * successRedirectUrl: - * type: string - * format: url - * description: A valid URL where the booker will redirect to, once the booking is completed successfully - * description: - * type: string - * description: Description of the event type - * seatsPerTimeSlot: - * type: integer - * description: 'The number of seats for each time slot' - * seatsShowAttendees: - * type: boolean - * description: 'Share Attendee information in seats' - * locations: - * type: array - * description: A list of all available locations for the event type - * items: - * type: array - * items: - * oneOf: - * - type: object - * properties: - * type: - * type: string - * enum: ['integrations:daily'] - * - type: object - * properties: - * type: - * type: string - * enum: ['attendeeInPerson'] - * - type: object - * properties: - * type: - * type: string - * enum: ['inPerson'] - * address: - * type: string - * displayLocationPublicly: - * type: boolean - * - type: object - * properties: - * type: - * type: string - * enum: ['link'] - * link: - * type: string - * displayLocationPublicly: - * type: boolean - * example: - * event-type: - * summary: An example of event type PATCH request - * value: - * length: 60 - * requiresConfirmation: true - * tags: - * - event-types - * externalDocs: - * url: https://docs.cal.com/core-features/event-types - * responses: - * 201: - * description: OK, eventType edited successfully - * 400: - * description: Bad request. EventType body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body); - - const data: Prisma.EventTypeUpdateArgs["data"] = { - ...parsedBody, - }; - - if (hosts) { - await ensureOnlyMembersAsHosts(req, parsedBody); - data.hosts = { - deleteMany: {}, - create: hosts.map((host) => ({ - ...host, - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - })), - }; - } - await checkPermissions(req, parsedBody); - const eventType = await prisma.eventType.update({ where: { id }, data }); - return { event_type: schemaEventTypeReadPublic.parse(eventType) }; -} - -async function checkPermissions(req: NextApiRequest, body: z.infer) { - const { userId, prisma, isAdmin } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - if (isAdmin) return; - /** Only event type owners can modify it */ - const eventType = await prisma.eventType.findFirst({ where: { id, userId } }); - if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - await checkTeamEventEditPermission(req, body); -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/event-types/_get.ts b/apps/api/pages/api/event-types/_get.ts deleted file mode 100644 index acdfdc4dee6c11..00000000000000 --- a/apps/api/pages/api/event-types/_get.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; -import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; - -import getCalLink from "./_utils/getCalLink"; - -/** - * @swagger - * /event-types: - * get: - * summary: Find all event types - * operationId: listEventTypes - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * tags: - * - event-types - * externalDocs: - * url: https://docs.cal.com/core-features/event-types - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No event types were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - - const args: Prisma.EventTypeFindManyArgs = { - where: { userId }, - }; - /** Only admins can query other users */ - if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 401, message: "ADMIN required" }); - if (isAdmin && req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - args.where = { userId: { in: userIds } }; - } - const data = await prisma.eventType.findMany({ - ...args, - include: { - customInputs: true, - team: { select: { slug: true } }, - users: true, - owner: { select: { username: true, id: true } }, - }, - }); - return { - event_types: data.map((eventType) => { - const link = getCalLink(eventType); - return schemaEventTypeReadPublic.parse({ ...eventType, link }); - }), - }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts deleted file mode 100644 index 488a15e6b9eac1..00000000000000 --- a/apps/api/pages/api/event-types/_post.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type"; - -import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission"; -import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts"; - -/** - * @swagger - * /event-types: - * post: - * summary: Creates a new event type - * operationId: addEventType - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * requestBody: - * description: Create a new event-type related to your user or team - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - title - * - slug - * - length - * - metadata - * properties: - * length: - * type: integer - * description: Duration of the event type in minutes - * metadata: - * type: object - * description: Metadata relating to event type. Pass {} if empty - * title: - * type: string - * description: Title of the event type - * slug: - * type: string - * description: Unique slug for the event type - * example: my-event - * hosts: - * type: array - * items: - * type: object - * properties: - * userId: - * type: number - * isFixed: - * type: boolean - * description: Host MUST be available for any slot to be bookable. - * hidden: - * type: boolean - * description: If the event type should be hidden from your public booking page - * position: - * type: integer - * description: The position of the event type on the public booking page - * teamId: - * type: integer - * description: Team ID if the event type should belong to a team - * periodType: - * type: string - * enum: [UNLIMITED, ROLLING, RANGE] - * description: To decide how far into the future an invitee can book an event with you - * periodStartDate: - * type: string - * format: date-time - * description: Start date of bookable period (Required if periodType is 'range') - * periodEndDate: - * type: string - * format: date-time - * description: End date of bookable period (Required if periodType is 'range') - * periodDays: - * type: integer - * description: Number of bookable days (Required if periodType is rolling) - * periodCountCalendarDays: - * type: boolean - * description: If calendar days should be counted for period days - * requiresConfirmation: - * type: boolean - * description: If the event type should require your confirmation before completing the booking - * recurringEvent: - * type: object - * description: If the event should recur every week/month/year with the selected frequency - * properties: - * interval: - * type: integer - * count: - * type: integer - * freq: - * type: integer - * disableGuests: - * type: boolean - * description: If the event type should disable adding guests to the booking - * hideCalendarNotes: - * type: boolean - * description: If the calendar notes should be hidden from the booking - * minimumBookingNotice: - * type: integer - * description: Minimum time in minutes before the event is bookable - * beforeEventBuffer: - * type: integer - * description: Number of minutes of buffer time before a Cal Event - * afterEventBuffer: - * type: integer - * description: Number of minutes of buffer time after a Cal Event - * schedulingType: - * type: string - * description: The type of scheduling if a Team event. Required for team events only - * enum: [ROUND_ROBIN, COLLECTIVE] - * price: - * type: integer - * description: Price of the event type booking - * currency: - * type: string - * description: Currency acronym. Eg- usd, eur, gbp, etc. - * slotInterval: - * type: integer - * description: The intervals of available bookable slots in minutes - * successRedirectUrl: - * type: string - * format: url - * description: A valid URL where the booker will redirect to, once the booking is completed successfully - * description: - * type: string - * description: Description of the event type - * locations: - * type: array - * description: A list of all available locations for the event type - * items: - * type: array - * items: - * oneOf: - * - type: object - * properties: - * type: - * type: string - * enum: ['integrations:daily'] - * - type: object - * properties: - * type: - * type: string - * enum: ['attendeeInPerson'] - * - type: object - * properties: - * type: - * type: string - * enum: ['inPerson'] - * address: - * type: string - * displayLocationPublicly: - * type: boolean - * - type: object - * properties: - * type: - * type: string - * enum: ['link'] - * link: - * type: string - * displayLocationPublicly: - * type: boolean - * example: - * event-type: - * summary: An example of event type POST request - * value: - * title: Hello World - * slug: hello-world - * length: 30 - * hidden: false - * position: 0 - * eventName: null - * timeZone: null - * periodType: UNLIMITED - * periodStartDate: 2023-02-15T08:46:16.000Z - * periodEndDate: 2023-0-15T08:46:16.000Z - * periodDays: null - * periodCountCalendarDays: false - * requiresConfirmation: false - * recurringEvent: null - * disableGuests: false - * hideCalendarNotes: false - * minimumBookingNotice: 120 - * beforeEventBuffer: 0 - * afterEventBuffer: 0 - * price: 0 - * currency: usd - * slotInterval: null - * successRedirectUrl: null - * description: A test event type - * metadata: { - * apps: { - * stripe: { - * price: 0, - * enabled: false, - * currency: usd - * } - * } - * } - * tags: - * - event-types - * externalDocs: - * url: https://docs.cal.com/core-features/event-types - * responses: - * 201: - * description: OK, event type created - * 400: - * description: Bad request. EventType body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma, body } = req; - - const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {}); - - let data: Prisma.EventTypeCreateArgs["data"] = { - ...parsedBody, - userId, - users: { connect: { id: userId } }, - }; - - await checkPermissions(req); - - if (isAdmin && parsedBody.userId) { - data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } }; - } - - await checkTeamEventEditPermission(req, parsedBody); - await ensureOnlyMembersAsHosts(req, parsedBody); - - if (hosts) { - data.hosts = { createMany: { data: hosts } }; - } - - const eventType = await prisma.eventType.create({ data }); - - return { - event_type: schemaEventTypeReadPublic.parse(eventType), - message: "Event type created successfully", - }; -} - -async function checkPermissions(req: NextApiRequest) { - const { isAdmin } = req; - const body = schemaEventTypeCreateBodyParams.parse(req.body); - /* Non-admin users can only create event types for themselves */ - if (!isAdmin && body.userId) - throw new HttpError({ - statusCode: 401, - message: "ADMIN required for `userId`", - }); - /* Admin users are required to pass in a userId */ - if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" }); -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts b/apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts deleted file mode 100644 index abcfc055f2dda0..00000000000000 --- a/apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { NextApiRequest } from "next"; -import type { z } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; - -import type { schemaEventTypeBaseBodyParams } from "~/lib/validations/event-type"; - -export default async function checkTeamEventEditPermission( - req: NextApiRequest, - body: Pick, "teamId"> -) { - const { prisma, userId } = req; - if (body.teamId) { - const membership = await prisma.membership.findFirst({ - where: { - userId, - teamId: body.teamId, - accepted: true, - }, - }); - - if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) { - throw new HttpError({ - statusCode: 401, - message: "No permission to operate on event-type for this team", - }); - } - } -} diff --git a/apps/api/pages/api/event-types/_utils/getCalLink.ts b/apps/api/pages/api/event-types/_utils/getCalLink.ts deleted file mode 100644 index 5c4657558568bb..00000000000000 --- a/apps/api/pages/api/event-types/_utils/getCalLink.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CAL_URL } from "@calcom/lib/constants"; - -export default function getCalLink(eventType: { - team?: { slug: string | null } | null; - owner: { username: string | null } | null; - users?: { username: string | null }[]; - slug: string; -}) { - return `${CAL_URL}/${ - eventType?.team - ? `team/${eventType?.team?.slug}` - : eventType?.owner - ? eventType.owner.username - : eventType?.users?.[0]?.username - }/${eventType?.slug}`; -} diff --git a/apps/api/pages/api/index.ts b/apps/api/pages/api/index.ts deleted file mode 100644 index 85f1d6be3bede6..00000000000000 --- a/apps/api/pages/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) { - res.status(201).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" }); -} diff --git a/apps/api/pages/api/me/_get.ts b/apps/api/pages/api/me/_get.ts deleted file mode 100644 index d0a375973059e5..00000000000000 --- a/apps/api/pages/api/me/_get.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaUserReadPublic } from "~/lib/validations/user"; - -async function handler({ userId, prisma }: NextApiRequest) { - const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); - return { user: schemaUserReadPublic.parse(data) }; -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/memberships/[id]/_auth-middleware.ts b/apps/api/pages/api/memberships/[id]/_auth-middleware.ts deleted file mode 100644 index 97ac4ff16d4d63..00000000000000 --- a/apps/api/pages/api/memberships/[id]/_auth-middleware.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { membershipIdSchema } from "~/lib/validations/membership"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { teamId } = membershipIdSchema.parse(req.query); - // Admins can just skip this check - if (isAdmin) return; - // Only team members can modify a membership - const membership = await prisma.membership.findFirst({ where: { userId, teamId } }); - if (!membership) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/memberships/[id]/_delete.ts b/apps/api/pages/api/memberships/[id]/_delete.ts deleted file mode 100644 index 06acefb3bb9f23..00000000000000 --- a/apps/api/pages/api/memberships/[id]/_delete.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { membershipIdSchema } from "~/lib/validations/membership"; - -/** - * @swagger - * /memberships/{userId}_{teamId}: - * delete: - * summary: Remove an existing membership - * parameters: - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: Numeric userId of the membership to get - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: Numeric teamId of the membership to get - * tags: - * - memberships - * responses: - * 201: - * description: OK, membership removed successfuly - * 400: - * description: Bad request. Membership id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const userId_teamId = membershipIdSchema.parse(query); - await checkPermissions(req); - await prisma.membership.delete({ where: { userId_teamId } }); - return { message: `Membership with id: ${query.id} deleted successfully` }; -} - -async function checkPermissions(req: NextApiRequest) { - const { prisma, isAdmin, userId, query } = req; - const userId_teamId = membershipIdSchema.parse(query); - // Admin User can do anything including deletion of Admin Team Member in any team - if (isAdmin) { - return; - } - - // Owner can delete Admin and Member - // Admin Team Member can delete Member - // Member can't delete anyone - const PRIVILEGE_ORDER = ["OWNER", "ADMIN", "MEMBER"]; - - const memberShipToBeDeleted = await prisma.membership.findUnique({ - where: { userId_teamId }, - }); - - if (!memberShipToBeDeleted) { - throw new HttpError({ statusCode: 404, message: "Membership not found" }); - } - - // If a user is deleting their own membership, then they can do it - if (userId === memberShipToBeDeleted.userId) { - return; - } - - const currentUserMembership = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId, - teamId: memberShipToBeDeleted.teamId, - }, - }, - }); - - if (!currentUserMembership) { - // Current User isn't a member of the team - throw new HttpError({ statusCode: 403, message: "You are not a member of the team" }); - } - - if ( - PRIVILEGE_ORDER.indexOf(memberShipToBeDeleted.role) === -1 || - PRIVILEGE_ORDER.indexOf(currentUserMembership.role) === -1 - ) { - throw new HttpError({ statusCode: 400, message: "Invalid role" }); - } - - // If Role that is being deleted comes before the current User's Role, or it's the same ROLE, throw error - if ( - PRIVILEGE_ORDER.indexOf(memberShipToBeDeleted.role) <= PRIVILEGE_ORDER.indexOf(currentUserMembership.role) - ) { - throw new HttpError({ - statusCode: 403, - message: "You don't have the appropriate role to delete this membership", - }); - } -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/memberships/[id]/_get.ts b/apps/api/pages/api/memberships/[id]/_get.ts deleted file mode 100644 index cf44094bfa2784..00000000000000 --- a/apps/api/pages/api/memberships/[id]/_get.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { membershipIdSchema, schemaMembershipPublic } from "~/lib/validations/membership"; - -/** - * @swagger - * /memberships/{userId}_{teamId}: - * get: - * summary: Find a membership by userID and teamID - * parameters: - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: Numeric userId of the membership to get - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: Numeric teamId of the membership to get - * tags: - * - memberships - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Membership was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const userId_teamId = membershipIdSchema.parse(query); - const args: Prisma.MembershipFindUniqueOrThrowArgs = { where: { userId_teamId } }; - // Just in case the user want to get more info about the team itself - if (req.query.include === "team") args.include = { team: true }; - const data = await prisma.membership.findUniqueOrThrow(args); - return { membership: schemaMembershipPublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/memberships/[id]/_patch.ts b/apps/api/pages/api/memberships/[id]/_patch.ts deleted file mode 100644 index 98115eff39f5c8..00000000000000 --- a/apps/api/pages/api/memberships/[id]/_patch.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { - membershipEditBodySchema, - membershipIdSchema, - schemaMembershipPublic, -} from "~/lib/validations/membership"; - -/** - * @swagger - * /memberships/{userId}_{teamId}: - * patch: - * summary: Edit an existing membership - * parameters: - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: Numeric userId of the membership to get - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: Numeric teamId of the membership to get - * tags: - * - memberships - * responses: - * 201: - * description: OK, membership edited successfully - * 400: - * description: Bad request. Membership body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query } = req; - const userId_teamId = membershipIdSchema.parse(query); - const data = membershipEditBodySchema.parse(req.body); - const args: Prisma.MembershipUpdateArgs = { where: { userId_teamId }, data }; - - await checkPermissions(req); - - const result = await prisma.membership.update(args); - return { membership: schemaMembershipPublic.parse(result) }; -} - -async function checkPermissions(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { userId: queryUserId, teamId } = membershipIdSchema.parse(req.query); - const data = membershipEditBodySchema.parse(req.body); - // Admins can just skip this check - if (isAdmin) return; - // Only the invited user can accept the invite - if ("accepted" in data && queryUserId !== userId) - throw new HttpError({ - statusCode: 403, - message: "Only the invited user can accept the invite", - }); - // Only team OWNERS and ADMINS can modify `role` - if ("role" in data) { - const membership = await prisma.membership.findFirst({ - where: { userId, teamId, role: { in: ["ADMIN", "OWNER"] } }, - }); - if (!membership || (membership.role !== "OWNER" && req.body.role === "OWNER")) - throw new HttpError({ statusCode: 403, message: "Forbidden" }); - } -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/memberships/_get.ts b/apps/api/pages/api/memberships/_get.ts deleted file mode 100644 index da6e936df3b270..00000000000000 --- a/apps/api/pages/api/memberships/_get.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaMembershipPublic } from "~/lib/validations/membership"; -import { - schemaQuerySingleOrMultipleTeamIds, - schemaQuerySingleOrMultipleUserIds, -} from "~/lib/validations/shared/queryUserId"; - -/** - * @swagger - * /memberships: - * get: - * summary: Find all memberships - * tags: - * - memberships - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No memberships were found - */ -async function getHandler(req: NextApiRequest) { - const { prisma } = req; - const args: Prisma.MembershipFindManyArgs = { - where: { - /** Admins can query multiple users */ - userId: { in: getUserIds(req) }, - /** Admins can query multiple teams as well */ - teamId: { in: getTeamIds(req) }, - }, - }; - // Just in case the user want to get more info about the team itself - if (req.query.include === "team") args.include = { team: true }; - - const data = await prisma.membership.findMany(args); - return { memberships: data.map((v) => schemaMembershipPublic.parse(v)) }; -} - -/** - * Returns requested users IDs only if admin, otherwise return only current user ID - */ -function getUserIds(req: NextApiRequest) { - const { userId, isAdmin } = req; - /** Only admins can query other users */ - if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); - if (isAdmin && req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - return userIds; - } - // Return all memberships for ADMIN, limit to current user to non-admins - return isAdmin ? undefined : [userId]; -} - -/** - * Returns requested teams IDs only if admin - */ -function getTeamIds(req: NextApiRequest) { - const { isAdmin } = req; - /** Only admins can query other teams */ - if (!isAdmin && req.query.teamId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); - if (isAdmin && req.query.teamId) { - const query = schemaQuerySingleOrMultipleTeamIds.parse(req.query); - const teamIds = Array.isArray(query.teamId) ? query.teamId : [query.teamId]; - return teamIds; - } - return undefined; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/memberships/_post.ts b/apps/api/pages/api/memberships/_post.ts deleted file mode 100644 index 11cba74938de35..00000000000000 --- a/apps/api/pages/api/memberships/_post.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/validations/membership"; - -/** - * @swagger - * /memberships: - * post: - * summary: Creates a new membership - * tags: - * - memberships - * responses: - * 201: - * description: OK, membership created - * 400: - * description: Bad request. Membership body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { prisma } = req; - const data = membershipCreateBodySchema.parse(req.body); - const args: Prisma.MembershipCreateArgs = { data }; - - await checkPermissions(req); - - const result = await prisma.membership.create(args); - - return { - membership: schemaMembershipPublic.parse(result), - message: "Membership created successfully", - }; -} - -async function checkPermissions(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - if (isAdmin) return; - const body = membershipCreateBodySchema.parse(req.body); - // To prevent auto-accepted invites, limit it to ADMIN users - if (!isAdmin && "accepted" in body) - throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" }); - // Only team OWNERS and ADMINS can add other members - const membership = await prisma.membership.findFirst({ - where: { userId, teamId: body.teamId, role: { in: ["ADMIN", "OWNER"] } }, - }); - if (!membership) throw new HttpError({ statusCode: 403, message: "You can't add members to this team" }); -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/payments/[id].ts b/apps/api/pages/api/payments/[id].ts deleted file mode 100644 index f8a54ff7f4a17b..00000000000000 --- a/apps/api/pages/api/payments/[id].ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import type { PaymentResponse } from "~/lib/types"; -import { schemaPaymentPublic } from "~/lib/validations/payment"; -import { - schemaQueryIdParseInt, - withValidQueryIdTransformParseInt, -} from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /payments/{id}: - * get: - * summary: Find a payment - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the payment to get - * tags: - * - payments - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Payment was not found - */ -export async function paymentById( - { method, query, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - const safeQuery = schemaQueryIdParseInt.safeParse(query); - if (safeQuery.success && method === "GET") { - const userWithBookings = await prisma.user.findUnique({ - where: { id: userId }, - include: { bookings: true }, - }); - await prisma.payment - .findUnique({ where: { id: safeQuery.data.id } }) - .then((data) => schemaPaymentPublic.parse(data)) - .then((payment) => { - if (!userWithBookings?.bookings.map((b) => b.id).includes(payment.bookingId)) { - res.status(401).json({ message: "Unauthorized" }); - } else { - res.status(200).json({ payment }); - } - }) - .catch((error: Error) => - res.status(404).json({ - message: `Payment with id: ${safeQuery.data.id} not found`, - error, - }) - ); - } -} -export default withMiddleware("HTTP_GET")(withValidQueryIdTransformParseInt(paymentById)); diff --git a/apps/api/pages/api/payments/index.ts b/apps/api/pages/api/payments/index.ts deleted file mode 100644 index c6f8c79fecf766..00000000000000 --- a/apps/api/pages/api/payments/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import type { PaymentsResponse } from "~/lib/types"; -import { schemaPaymentPublic } from "~/lib/validations/payment"; - -/** - * @swagger - * /payments: - * get: - * summary: Find all payments - * tags: - * - payments - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No payments were found - */ -async function allPayments({ userId, prisma }: NextApiRequest, res: NextApiResponse) { - const userWithBookings = await prisma.user.findUnique({ - where: { id: userId }, - include: { bookings: true }, - }); - if (!userWithBookings) throw new Error("No user found"); - const bookings = userWithBookings.bookings; - const bookingIds = bookings.map((booking) => booking.id); - const data = await prisma.payment.findMany({ where: { bookingId: { in: bookingIds } } }); - const payments = data.map((payment) => schemaPaymentPublic.parse(payment)); - - if (payments) res.status(200).json({ payments }); - else - (error: Error) => - res.status(404).json({ - message: "No Payments were found", - error, - }); -} -// NO POST FOR PAYMENTS FOR NOW -export default withMiddleware("HTTP_GET")(allPayments); diff --git a/apps/api/pages/api/schedules/[id]/_auth-middleware.ts b/apps/api/pages/api/schedules/[id]/_auth-middleware.ts deleted file mode 100644 index 42184440d3182d..00000000000000 --- a/apps/api/pages/api/schedules/[id]/_auth-middleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - // Admins can just skip this check - if (isAdmin) return; - // Check if the current user can access the schedule - const schedule = await prisma.schedule.findFirst({ - where: { id, userId }, - }); - if (!schedule) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/schedules/[id]/_delete.ts b/apps/api/pages/api/schedules/[id]/_delete.ts deleted file mode 100644 index e48c76c4697f9c..00000000000000 --- a/apps/api/pages/api/schedules/[id]/_delete.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /schedules/{id}: - * delete: - * operationId: removeScheduleById - * summary: Remove an existing schedule - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the schedule to delete - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * tags: - * - schedules - * responses: - * 201: - * description: OK, schedule removed successfully - * 400: - * description: Bad request. Schedule id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - - /* If we're deleting any default user schedule, we unset it */ - await prisma.user.updateMany({ where: { defaultScheduleId: id }, data: { defaultScheduleId: undefined } }); - - await prisma.schedule.delete({ where: { id } }); - return { message: `Schedule with id: ${id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/schedules/[id]/_get.ts b/apps/api/pages/api/schedules/[id]/_get.ts deleted file mode 100644 index fdadce2cba8035..00000000000000 --- a/apps/api/pages/api/schedules/[id]/_get.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaSchedulePublic } from "~/lib/validations/schedule"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /schedules/{id}: - * get: - * operationId: getScheduleById - * summary: Find a schedule - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the schedule to get - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * tags: - * - schedules - * responses: - * 200: - * description: OK - * content: - * application/json: - * examples: - * schedule: - * value: - * { - * "schedule": { - * "id": 12345, - * "userId": 182, - * "name": "Sample Schedule", - * "timeZone": "Asia/Calcutta", - * "availability": [ - * { - * "id": 111, - * "eventTypeId": null, - * "days": [0, 1, 2, 3, 4, 6], - * "startTime": "00:00:00", - * "endTime": "23:45:00" - * }, - * { - * "id": 112, - * "eventTypeId": null, - * "days": [5], - * "startTime": "00:00:00", - * "endTime": "12:00:00" - * }, - * { - * "id": 113, - * "eventTypeId": null, - * "days": [5], - * "startTime": "15:00:00", - * "endTime": "23:45:00" - * } - * ] - * } - * } - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Schedule was not found - */ - -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = await prisma.schedule.findUniqueOrThrow({ where: { id }, include: { availability: true } }); - return { schedule: schemaSchedulePublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/schedules/[id]/_patch.ts b/apps/api/pages/api/schedules/[id]/_patch.ts deleted file mode 100644 index 1c8f52d688e81c..00000000000000 --- a/apps/api/pages/api/schedules/[id]/_patch.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { NextApiRequest } from "next"; -import type { z } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaSchedulePublic, schemaSingleScheduleBodyParams } from "~/lib/validations/schedule"; -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -/** - * @swagger - * /schedules/{id}: - * patch: - * operationId: editScheduleById - * summary: Edit an existing schedule - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: ID of the schedule to edit - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * requestBody: - * description: Edit an existing schedule - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * name: - * type: string - * description: Name of the schedule - * timeZone: - * type: string - * description: The timezone for this schedule - * examples: - * schedule: - * value: - * { - * "name": "Updated Schedule", - * "timeZone": "Asia/Calcutta" - * } - * tags: - * - schedules - * responses: - * 200: - * description: OK, schedule edited successfully - * content: - * application/json: - * examples: - * schedule: - * value: - * { - * "schedule": { - * "id": 12345, - * "userId": 1, - * "name": "Total Testing Part 2", - * "timeZone": "Asia/Calcutta", - * "availability": [ - * { - * "id": 4567, - * "eventTypeId": null, - * "days": [1, 2, 3, 4, 5], - * "startTime": "09:00:00", - * "endTime": "17:00:00" - * } - * ] - * } - * } - * 400: - * description: Bad request. Schedule body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - -export async function patchHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdParseInt.parse(query); - const data = schemaSingleScheduleBodyParams.parse(req.body); - await checkPermissions(req, data); - const result = await prisma.schedule.update({ where: { id }, data, include: { availability: true } }); - return { schedule: schemaSchedulePublic.parse(result) }; -} - -async function checkPermissions(req: NextApiRequest, body: z.infer) { - const { isAdmin } = req; - if (isAdmin) return; - if (body.userId) { - throw new HttpError({ statusCode: 403, message: "Non admin cannot change the owner of a schedule" }); - } - //_auth-middleware takes care of verifying the ownership of schedule. -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/schedules/_get.ts b/apps/api/pages/api/schedules/_get.ts deleted file mode 100644 index 1b06a116e9a987..00000000000000 --- a/apps/api/pages/api/schedules/_get.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; -import { z } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaSchedulePublic } from "~/lib/validations/schedule"; -import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; - -export const schemaUserIds = z - .union([z.string(), z.array(z.string())]) - .transform((val) => (Array.isArray(val) ? val.map((v) => parseInt(v, 10)) : [parseInt(val, 10)])); - -/** - * @swagger - * /schedules: - * get: - * operationId: listSchedules - * summary: Find all schedules - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * tags: - * - schedules - * responses: - * 200: - * description: OK - * content: - * application/json: - * examples: - * schedules: - * value: - * { - * "schedules": [ - * { - * "id": 1234, - * "userId": 5678, - * "name": "Sample Schedule 1", - * "timeZone": "America/Chicago", - * "availability": [ - * { - * "id": 987, - * "eventTypeId": null, - * "days": [1, 2, 3, 4, 5], - * "startTime": "09:00:00", - * "endTime": "23:00:00" - * } - * ] - * }, - * { - * "id": 2345, - * "userId": 6789, - * "name": "Sample Schedule 2", - * "timeZone": "Europe/Amsterdam", - * "availability": [ - * { - * "id": 876, - * "eventTypeId": null, - * "days": [1, 2, 3, 4, 5], - * "startTime": "09:00:00", - * "endTime": "17:00:00" - * } - * ] - * } - * ] - * } - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No schedules were found - */ - -async function handler(req: NextApiRequest) { - const { prisma, userId, isAdmin } = req; - const args: Prisma.ScheduleFindManyArgs = isAdmin ? {} : { where: { userId } }; - args.include = { availability: true }; - - if (!isAdmin && req.query.userId) - throw new HttpError({ - statusCode: 401, - message: "Unauthorized: Only admins can query other users", - }); - - if (isAdmin && req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - args.where = { userId: { in: userIds } }; - if (Array.isArray(query.userId)) args.orderBy = { userId: "asc" }; - } - const data = await prisma.schedule.findMany(args); - return { schedules: data.map((s) => schemaSchedulePublic.parse(s)) }; -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/schedules/_post.ts b/apps/api/pages/api/schedules/_post.ts deleted file mode 100644 index a50f2cd5b61123..00000000000000 --- a/apps/api/pages/api/schedules/_post.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaCreateScheduleBodyParams, schemaSchedulePublic } from "~/lib/validations/schedule"; - -/** - * @swagger - * /schedules: - * post: - * operationId: addSchedule - * summary: Creates a new schedule - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * requestBody: - * description: Create a new schedule - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - name - * - timeZone - * properties: - * name: - * type: string - * description: Name of the schedule - * timeZone: - * type: string - * description: The timeZone for this schedule - * examples: - * schedule: - * value: - * { - * "name": "Sample Schedule", - * "timeZone": "Asia/Calcutta" - * } - * tags: - * - schedules - * responses: - * 200: - * description: OK, schedule created - * content: - * application/json: - * examples: - * schedule: - * value: - * { - * "schedule": { - * "id": 79471, - * "userId": 182, - * "name": "Total Testing", - * "timeZone": "Asia/Calcutta", - * "availability": [ - * { - * "id": 337917, - * "eventTypeId": null, - * "days": [1, 2, 3, 4, 5], - * "startTime": "09:00:00", - * "endTime": "17:00:00" - * } - * ] - * }, - * "message": "Schedule created successfully" - * } - * 400: - * description: Bad request. Schedule body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const body = schemaCreateScheduleBodyParams.parse(req.body); - let args: Prisma.ScheduleCreateArgs = { data: { ...body, userId } }; - - /* If ADMIN we create the schedule for selected user */ - if (isAdmin && body.userId) args = { data: { ...body, userId: body.userId } }; - - if (!isAdmin && body.userId) - throw new HttpError({ statusCode: 403, message: "ADMIN required for `userId`" }); - - // We create default availabilities for the schedule - args.data.availability = { - createMany: { - data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE).map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), - }, - }; - // We include the recently created availability - args.include = { availability: true }; - - const data = await prisma.schedule.create(args); - - return { - schedule: schemaSchedulePublic.parse(data), - message: "Schedule created successfully", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts b/apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts deleted file mode 100644 index 620ba818fb3418..00000000000000 --- a/apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin } = req; - const { userId: queryUserId } = selectedCalendarIdSchema.parse(req.query); - // Admins can just skip this check - if (isAdmin) return; - // Check if the current user requesting is the same as the one being requested - if (userId !== queryUserId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/selected-calendars/[id]/_delete.ts b/apps/api/pages/api/selected-calendars/[id]/_delete.ts deleted file mode 100644 index e04b67f711261e..00000000000000 --- a/apps/api/pages/api/selected-calendars/[id]/_delete.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; - -/** - * @swagger - * /selected-calendars/{userId}_{integration}_{externalId}: - * delete: - * operationId: removeSelectedCalendarById - * summary: Remove a selected calendar - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: userId of the selected calendar to get - * - in: path - * name: externalId - * schema: - * type: integer - * required: true - * description: externalId of the selected-calendar to get - * - in: path - * name: integration - * schema: - * type: string - * required: true - * description: integration of the selected calendar to get - * tags: - * - selected-calendars - * responses: - * 201: - * description: OK, selected-calendar removed successfully - * 400: - * description: Bad request. SelectedCalendar id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const userId_integration_externalId = selectedCalendarIdSchema.parse(query); - await prisma.selectedCalendar.delete({ where: { userId_integration_externalId } }); - return { message: `Selected Calendar with id: ${query.id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/selected-calendars/[id]/_get.ts b/apps/api/pages/api/selected-calendars/[id]/_get.ts deleted file mode 100644 index a5549f1a214224..00000000000000 --- a/apps/api/pages/api/selected-calendars/[id]/_get.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; - -/** - * @swagger - * /selected-calendars/{userId}_{integration}_{externalId}: - * get: - * operationId: getSelectedCalendarById - * summary: Find a selected calendar by providing the compoundId(userId_integration_externalId) separated by `_` - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: userId of the selected calendar to get - * - in: path - * name: externalId - * schema: - * type: string - * required: true - * description: externalId of the selected calendar to get - * - in: path - * name: integration - * schema: - * type: string - * required: true - * description: integration of the selected calendar to get - * tags: - * - selected-calendars - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: SelectedCalendar was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const userId_integration_externalId = selectedCalendarIdSchema.parse(query); - const data = await prisma.selectedCalendar.findUniqueOrThrow({ - where: { userId_integration_externalId }, - }); - return { selected_calendar: schemaSelectedCalendarPublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/selected-calendars/[id]/_patch.ts b/apps/api/pages/api/selected-calendars/[id]/_patch.ts deleted file mode 100644 index c2b526303daa70..00000000000000 --- a/apps/api/pages/api/selected-calendars/[id]/_patch.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaSelectedCalendarPublic, - schemaSelectedCalendarUpdateBodyParams, - selectedCalendarIdSchema, -} from "~/lib/validations/selected-calendar"; - -/** - * @swagger - * /selected-calendars/{userId}_{integration}_{externalId}: - * patch: - * operationId: editSelectedCalendarById - * summary: Edit a selected calendar - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: userId of the selected calendar to get - * - in: path - * name: externalId - * schema: - * type: string - * required: true - * description: externalId of the selected calendar to get - * - in: path - * name: integration - * schema: - * type: string - * required: true - * description: integration of the selected calendar to get - * tags: - * - selected-calendars - * responses: - * 201: - * description: OK, selected-calendar edited successfully - * 400: - * description: Bad request. SelectedCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, isAdmin } = req; - const userId_integration_externalId = selectedCalendarIdSchema.parse(query); - const { userId: bodyUserId, ...data } = schemaSelectedCalendarUpdateBodyParams.parse(req.body); - const args: Prisma.SelectedCalendarUpdateArgs = { where: { userId_integration_externalId }, data }; - - if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); - - if (isAdmin && bodyUserId) { - const where: Prisma.UserWhereInput = { id: bodyUserId }; - await prisma.user.findFirstOrThrow({ where }); - args.data.userId = bodyUserId; - } - - const result = await prisma.selectedCalendar.update(args); - return { selected_calendar: schemaSelectedCalendarPublic.parse(result) }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/selected-calendars/_get.ts b/apps/api/pages/api/selected-calendars/_get.ts deleted file mode 100644 index c5e2182adf54c3..00000000000000 --- a/apps/api/pages/api/selected-calendars/_get.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaSelectedCalendarPublic } from "~/lib/validations/selected-calendar"; -import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; - -/** - * @swagger - * /selected-calendars: - * get: - * operationId: listSelectedCalendars - * summary: Find all selected calendars - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * tags: - * - selected-calendars - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No selected calendars were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - /* Admin gets all selected calendar by default, otherwise only the user's ones */ - const args: Prisma.SelectedCalendarFindManyArgs = isAdmin ? {} : { where: { userId } }; - - /** Only admins can query other users */ - if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); - if (isAdmin && req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - args.where = { userId: { in: userIds } }; - if (Array.isArray(query.userId)) args.orderBy = { userId: "asc" }; - } - - const data = await prisma.selectedCalendar.findMany(args); - return { selected_calendars: data.map((v) => schemaSelectedCalendarPublic.parse(v)) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/selected-calendars/_post.ts b/apps/api/pages/api/selected-calendars/_post.ts deleted file mode 100644 index 23b26a76e981cd..00000000000000 --- a/apps/api/pages/api/selected-calendars/_post.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { - schemaSelectedCalendarBodyParams, - schemaSelectedCalendarPublic, -} from "~/lib/validations/selected-calendar"; - -/** - * @swagger - * /selected-calendars: - * post: - * summary: Creates a new selected calendar - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * requestBody: - * description: Create a new selected calendar - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - integration - * - externalId - * properties: - * integration: - * type: string - * description: The integration name - * externalId: - * type: string - * description: The external ID of the integration - * tags: - * - selected-calendars - * responses: - * 201: - * description: OK, selected calendar created - * 400: - * description: Bad request. SelectedCalendar body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { userId: bodyUserId, ...body } = schemaSelectedCalendarBodyParams.parse(req.body); - const args: Prisma.SelectedCalendarCreateArgs = { data: { ...body, userId } }; - - if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); - - if (isAdmin && bodyUserId) { - const where: Prisma.UserWhereInput = { id: bodyUserId }; - await prisma.user.findFirstOrThrow({ where }); - args.data.userId = bodyUserId; - } - - const data = await prisma.selectedCalendar.create(args); - - return { - selected_calendar: schemaSelectedCalendarPublic.parse(data), - message: "Selected Calendar created successfully", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/slots/_get.ts b/apps/api/pages/api/slots/_get.ts deleted file mode 100644 index a74d3ae83f6728..00000000000000 --- a/apps/api/pages/api/slots/_get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; -import { createContext } from "@calcom/trpc/server/createContext"; -import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router"; - -import { TRPCError } from "@trpc/server"; -import { getHTTPStatusCodeFromError } from "@trpc/server/http"; - -async function handler(req: NextApiRequest, res: NextApiResponse) { - /** @see https://trpc.io/docs/server-side-calls */ - const ctx = await createContext({ req, res }); - const caller = viewerRouter.createCaller(ctx); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await caller.slots.getSchedule(req.query as any /* Let tRPC handle this */); - } catch (cause) { - if (cause instanceof TRPCError) { - const statusCode = getHTTPStatusCodeFromError(cause); - throw new HttpError({ statusCode, message: cause.message }); - } - throw cause; - } -} - -export default defaultResponder(handler); diff --git a/apps/api/pages/api/teams/[teamId]/_auth-middleware.ts b/apps/api/pages/api/teams/[teamId]/_auth-middleware.ts deleted file mode 100644 index bde11bb75c3cc0..00000000000000 --- a/apps/api/pages/api/teams/[teamId]/_auth-middleware.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { MembershipRole } from "@calcom/prisma/enums"; - -import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; - const { teamId } = schemaQueryTeamId.parse(req.query); - /** Admins can skip the ownership verification */ - if (isAdmin) return; - /** Non-members will see a 404 error which may or not be the desired behavior. */ - await prisma.team.findFirstOrThrow({ - where: { id: teamId, members: { some: { userId } } }, - }); -} - -export async function checkPermissions( - req: NextApiRequest, - role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER -) { - const { userId, prisma, isAdmin } = req; - const { teamId } = schemaQueryTeamId.parse(req.query); - const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } }; - /** If not ADMIN then we check if the actual user belongs to team and matches the required role */ - if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } }; - const team = await prisma.team.findFirst(args); - if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` }); - return team; -} - -export default authMiddleware; diff --git a/apps/api/pages/api/teams/[teamId]/_delete.ts b/apps/api/pages/api/teams/[teamId]/_delete.ts deleted file mode 100644 index 13c5c35370f3c3..00000000000000 --- a/apps/api/pages/api/teams/[teamId]/_delete.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; - -import { checkPermissions } from "./_auth-middleware"; - -/** - * @swagger - * /teams/{teamId}: - * delete: - * operationId: removeTeamById - * summary: Remove an existing team - * parameters: - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: ID of the team to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - teams - * responses: - * 201: - * description: OK, team removed successfully - * 400: - * description: Bad request. Team id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { teamId } = schemaQueryTeamId.parse(query); - await checkPermissions(req); - await prisma.team.delete({ where: { id: teamId } }); - return { message: `Team with id: ${teamId} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/teams/[teamId]/_get.ts b/apps/api/pages/api/teams/[teamId]/_get.ts deleted file mode 100644 index cff0e987c907c5..00000000000000 --- a/apps/api/pages/api/teams/[teamId]/_get.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; -import { schemaTeamReadPublic } from "~/lib/validations/team"; - -/** - * @swagger - * /teams/{teamId}: - * get: - * operationId: getTeamById - * summary: Find a team - * parameters: - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: ID of the team to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - teams - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Team was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, isAdmin, userId } = req; - const { teamId } = schemaQueryTeamId.parse(req.query); - const where: Prisma.TeamWhereInput = { id: teamId }; - // Non-admins can only query the teams they're part of - if (!isAdmin) where.members = { some: { userId } }; - const data = await prisma.team.findFirstOrThrow({ where }); - return { team: schemaTeamReadPublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/teams/[teamId]/_patch.ts b/apps/api/pages/api/teams/[teamId]/_patch.ts deleted file mode 100644 index 93d1a3a46a4f51..00000000000000 --- a/apps/api/pages/api/teams/[teamId]/_patch.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { TRPCError } from "@trpc/server"; - -import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; -import { schemaTeamReadPublic, schemaTeamUpdateBodyParams } from "~/lib/validations/team"; - -/** - * @swagger - * /teams/{teamId}: - * patch: - * operationId: editTeamById - * summary: Edit an existing team - * parameters: - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: ID of the team to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new custom input for an event type - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * name: - * type: string - * description: Name of the team - * slug: - * type: string - * description: A unique slug that works as path for the team public page - * tags: - * - teams - * responses: - * 201: - * description: OK, team edited successfully - * 400: - * description: Bad request. Team body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, body, userId } = req; - const data = schemaTeamUpdateBodyParams.parse(body); - const { teamId } = schemaQueryTeamId.parse(req.query); - /** Only OWNERS and ADMINS can edit teams */ - const _team = await prisma.team.findFirst({ - include: { members: true }, - where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } }, - }); - if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" }); - let paymentUrl; - if (_team.slug === null && data.slug) { - data.metadata = { - ...(_team.metadata as Prisma.JsonObject), - requestedSlug: data.slug, - }; - delete data.slug; - if (IS_TEAM_BILLING_ENABLED) { - const checkoutSession = await purchaseTeamSubscription({ - teamId: _team.id, - seats: _team.members.length, - userId, - }); - if (!checkoutSession.url) - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed retrieving a checkout session URL.", - }); - paymentUrl = checkoutSession.url; - } - } - - // TODO: Perhaps there is a better fix for this? - const cloneData: typeof data & { - metadata: NonNullable | undefined; - } = { - ...data, - metadata: data.metadata === null ? {} : data.metadata || undefined, - }; - const team = await prisma.team.update({ where: { id: teamId }, data: cloneData }); - const result = { - team: schemaTeamReadPublic.parse(team), - paymentUrl, - }; - if (!paymentUrl) { - delete result.paymentUrl; - } - return result; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/teams/[teamId]/event-types/_get.ts b/apps/api/pages/api/teams/[teamId]/event-types/_get.ts deleted file mode 100644 index 38d1a6d425aa2b..00000000000000 --- a/apps/api/pages/api/teams/[teamId]/event-types/_get.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; -import { z } from "zod"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; - -const querySchema = z.object({ - teamId: z.coerce.number(), -}); - -/** - * @swagger - * /teams/{teamId}/event-types: - * get: - * summary: Find all event types that belong to teamId - * operationId: listEventTypesByTeamId - * parameters: - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API Key - * - in: path - * name: teamId - * schema: - * type: number - * required: true - * tags: - * - event-types - * externalDocs: - * url: https://docs.cal.com/core-features/event-types - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No event types were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - - const { teamId } = querySchema.parse(req.query); - - const args: Prisma.EventTypeFindManyArgs = { - where: { - team: isAdmin - ? { - id: teamId, - } - : { - id: teamId, - members: { some: { userId } }, - }, - }, - }; - - const data = await prisma.eventType.findMany(args); - return { event_types: data.map((attendee) => schemaEventTypeReadPublic.parse(attendee)) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/teams/[teamId]/publish.ts b/apps/api/pages/api/teams/[teamId]/publish.ts deleted file mode 100644 index 4cf45e12fd0c68..00000000000000 --- a/apps/api/pages/api/teams/[teamId]/publish.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import { MembershipRole } from "@calcom/prisma/enums"; -import { createContext } from "@calcom/trpc/server/createContext"; -import { publishHandler } from "@calcom/trpc/server/routers/viewer/teams/publish.handler"; - -import { TRPCError } from "@trpc/server"; -import { getHTTPStatusCodeFromError } from "@trpc/server/http"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; - -import authMiddleware, { checkPermissions } from "./_auth-middleware"; - -const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => { - await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] }); - /** @see https://trpc.io/docs/server-side-calls */ - const ctx = await createContext({ req, res }); - const user = ctx.user; - if (!user) { - throw new Error("Internal Error."); - } - try { - const { teamId } = schemaQueryTeamId.parse(req.query); - return await publishHandler({ input: { teamId }, ctx: { ...ctx, user } }); - } catch (cause) { - if (cause instanceof TRPCError) { - const statusCode = getHTTPStatusCodeFromError(cause); - throw new HttpError({ statusCode, message: cause.message }); - } - throw cause; - } -}; - -export default withMiddleware()( - defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { - await authMiddleware(req); - return defaultHandler({ - PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }), - })(req, res); - }) -); diff --git a/apps/api/pages/api/teams/_get.ts b/apps/api/pages/api/teams/_get.ts deleted file mode 100644 index 49af07ac8ee467..00000000000000 --- a/apps/api/pages/api/teams/_get.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaTeamsReadPublic } from "~/lib/validations/team"; - -/** - * @swagger - * /teams: - * get: - * operationId: listTeams - * summary: Find all teams - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - teams - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No teams were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; - const where: Prisma.TeamWhereInput = {}; - // If user is not ADMIN, return only his data. - if (!isAdmin) where.members = { some: { userId } }; - const data = await prisma.team.findMany({ where }); - return { teams: schemaTeamsReadPublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/teams/_post.ts b/apps/api/pages/api/teams/_post.ts deleted file mode 100644 index 56e0820535eb17..00000000000000 --- a/apps/api/pages/api/teams/_post.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; -import { MembershipRole } from "@calcom/prisma/enums"; - -import { schemaMembershipPublic } from "~/lib/validations/membership"; -import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/team"; - -/** - * @swagger - * /teams: - * post: - * operationId: addTeam - * summary: Creates a new team - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new custom input for an event type - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - name - * - slug - * properties: - * name: - * type: string - * description: Name of the team - * slug: - * type: string - * description: A unique slug that works as path for the team public page - * tags: - * - teams - * responses: - * 201: - * description: OK, team created - * 400: - * description: Bad request. Team body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { prisma, body, userId } = req; - const data = schemaTeamBodyParams.parse(body); - - if (data.slug) { - const alreadyExist = await prisma.team.findFirst({ - where: { - slug: data.slug, - }, - }); - if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); - if (IS_TEAM_BILLING_ENABLED) { - // Setting slug in metadata, so it can be published later - data.metadata = { - requestedSlug: data.slug, - }; - delete data.slug; - } - } - - // TODO: Perhaps there is a better fix for this? - const cloneData: typeof data & { - metadata: NonNullable | undefined; - } = { - ...data, - metadata: data.metadata === null ? {} : data.metadata || undefined, - }; - const team = await prisma.team.create({ - data: { - ...cloneData, - createdAt: new Date(), - members: { - // We're also creating the relation membership of team ownership in this call. - create: { userId, role: MembershipRole.OWNER, accepted: true }, - }, - }, - include: { members: true }, - }); - req.statusCode = 201; - // We are also returning the new ownership relation as owner besides team. - return { - team: schemaTeamReadPublic.parse(team), - owner: schemaMembershipPublic.parse(team.members[0]), - message: "Team created successfully, we also made you the owner of this team", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/users/[userId]/_delete.ts b/apps/api/pages/api/users/[userId]/_delete.ts deleted file mode 100644 index 44e8f0df0f605a..00000000000000 --- a/apps/api/pages/api/users/[userId]/_delete.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; - -/** - * @swagger - * /users/{userId}: - * delete: - * summary: Remove an existing user - * operationId: removeUserById - * parameters: - * - in: path - * name: userId - * example: 1 - * schema: - * type: integer - * required: true - * description: ID of the user to delete - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API key - * tags: - * - users - * responses: - * 201: - * description: OK, user removed successfuly - * 400: - * description: Bad request. User id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; - const query = schemaQueryUserId.parse(req.query); - // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user - if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - - const user = await prisma.user.findUnique({ where: { id: query.userId } }); - if (!user) throw new HttpError({ statusCode: 404, message: "User not found" }); - - await prisma.user.delete({ where: { id: user.id } }); - return { message: `User with id: ${user.id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/users/[userId]/_get.ts b/apps/api/pages/api/users/[userId]/_get.ts deleted file mode 100644 index 5be3926ae06488..00000000000000 --- a/apps/api/pages/api/users/[userId]/_get.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; -import { schemaUserReadPublic } from "~/lib/validations/user"; - -/** - * @swagger - * /users/{userId}: - * get: - * summary: Find a user, returns your user if regular user. - * operationId: getUserById - * parameters: - * - in: path - * name: userId - * example: 4 - * schema: - * type: integer - * required: true - * description: ID of the user to get - * - in: query - * name: apiKey - * schema: - * type: string - * required: true - * description: Your API key - * tags: - * - users - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: User was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; - - const query = schemaQueryUserId.parse(req.query); - // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user - if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - const data = await prisma.user.findUnique({ where: { id: query.userId } }); - const user = schemaUserReadPublic.parse(data); - return { user }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/users/[userId]/_patch.ts b/apps/api/pages/api/users/[userId]/_patch.ts deleted file mode 100644 index 59d8b76f946c65..00000000000000 --- a/apps/api/pages/api/users/[userId]/_patch.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; -import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user"; - -/** - * @swagger - * /users/{userId}: - * patch: - * summary: Edit an existing user - * operationId: editUserById - * parameters: - * - in: path - * name: userId - * example: 4 - * schema: - * type: integer - * required: true - * description: ID of the user to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Edit an existing attendee related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * format: email - * description: Email that belongs to the user being edited - * username: - * type: string - * description: Username for the user being edited - * brandColor: - * description: The user's brand color - * type: string - * darkBrandColor: - * description: The user's brand color for dark mode - * type: string - * weekStart: - * description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY] - * type: string - * timeZone: - * description: The user's time zone - * type: string - * theme: - * description: Default theme for the user. Acceptable values are one of [DARK, LIGHT] - * type: string - * timeFormat: - * description: The user's time format. Acceptable values are one of [TWELVE, TWENTY_FOUR] - * type: string - * locale: - * description: The user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI] - * type: string - * examples: - * user: - * summary: An example of USER - * value: - * email: email@example.com - * username: johndoe - * weekStart: MONDAY - * brandColor: #555555 - * darkBrandColor: #111111 - * timeZone: EUROPE/PARIS - * theme: LIGHT - * timeFormat: TWELVE - * locale: FR - * tags: - * - users - * responses: - * 200: - * description: OK, user edited successfuly - * 400: - * description: Bad request. User body is invalid. - * 401: - * description: Authorization information is missing or invalid. - * 403: - * description: Insufficient permissions to access resource. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; - const query = schemaQueryUserId.parse(req.query); - // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user - if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); - - const body = await schemaUserEditBodyParams.parseAsync(req.body); - // disable role changes unless admin. - if (!isAdmin && body.role) { - body.role = undefined; - } - - const userSchedules = await prisma.schedule.findMany({ - where: { userId: query.userId }, - }); - const userSchedulesIds = userSchedules.map((schedule) => schedule.id); - // @note: here we make sure user can only make as default his own scheudles - if (body.defaultScheduleId && !userSchedulesIds.includes(Number(body.defaultScheduleId))) { - throw new HttpError({ - statusCode: 400, - message: "Bad request: Invalid default schedule id", - }); - } - const data = await prisma.user.update({ - where: { id: query.userId }, - data: body, - }); - const user = schemaUserReadPublic.parse(data); - return { user }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/users/_get.ts b/apps/api/pages/api/users/_get.ts deleted file mode 100644 index 59e09bdeb54b95..00000000000000 --- a/apps/api/pages/api/users/_get.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import { schemaUsersReadPublic } from "~/lib/validations/user"; - -/** - * @swagger - * /users: - * get: - * operationId: listUsers - * summary: Find all users. - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - users - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No users were found - */ -export async function getHandler(req: NextApiRequest) { - const { - userId, - prisma, - isAdmin, - pagination: { take, skip }, - } = req; - const where: Prisma.UserWhereInput = {}; - // If user is not ADMIN, return only his data. - if (!isAdmin) where.id = userId; - const [total, data] = await prisma.$transaction([ - prisma.user.count(), - prisma.user.findMany({ where, take, skip }), - ]); - const users = schemaUsersReadPublic.parse(data); - return { users, total }; -} - -export default withMiddleware("pagination")(defaultResponder(getHandler)); diff --git a/apps/api/pages/api/users/_post.ts b/apps/api/pages/api/users/_post.ts deleted file mode 100644 index 7c945399d009a7..00000000000000 --- a/apps/api/pages/api/users/_post.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaUserCreateBodyParams } from "~/lib/validations/user"; - -/** - * @swagger - * /users: - * post: - * operationId: addUser - * summary: Creates a new user - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new user - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - email - * - username - * properties: - * email: - * type: string - * format: email - * description: Email that belongs to the user being edited - * username: - * type: string - * description: Username for the user being created - * brandColor: - * description: The new user's brand color - * type: string - * darkBrandColor: - * description: The new user's brand color for dark mode - * type: string - * weekStart: - * description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY] - * type: string - * timeZone: - * description: The new user's time zone. Eg- 'EUROPE/PARIS' - * type: string - * theme: - * description: Default theme for the new user. Acceptable values are one of [DARK, LIGHT] - * type: string - * timeFormat: - * description: The new user's time format. Acceptable values are one of [TWELVE, TWENTY_FOUR] - * type: string - * locale: - * description: The new user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI] - * type: string - * examples: - * user: - * summary: An example of USER - * value: - * email: 'email@example.com' - * username: 'johndoe' - * weekStart: 'MONDAY' - * brandColor: '#555555' - * darkBrandColor: '#111111' - * timeZone: 'EUROPE/PARIS' - * theme: 'LIGHT' - * timeFormat: 'TWELVE' - * locale: 'FR' - * tags: - * - users - * responses: - * 201: - * description: OK, user created - * 400: - * description: Bad request. user body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; - // If user is not ADMIN, return unauthorized. - if (!isAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" }); - const data = await schemaUserCreateBodyParams.parseAsync(req.body); - const user = await prisma.user.create({ data }); - req.statusCode = 201; - return { user }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/webhooks/[id]/_auth-middleware.ts b/apps/api/pages/api/webhooks/[id]/_auth-middleware.ts deleted file mode 100644 index 6f2eb04db50f90..00000000000000 --- a/apps/api/pages/api/webhooks/[id]/_auth-middleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdAsString.parse(req.query); - // Admins can just skip this check - if (isAdmin) return; - // Check if the current user can access the webhook - const webhook = await prisma.webhook.findFirst({ - where: { id, appId: null, OR: [{ userId }, { eventType: { team: { members: { some: { userId } } } } }] }, - }); - if (!webhook) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/pages/api/webhooks/[id]/_delete.ts b/apps/api/pages/api/webhooks/[id]/_delete.ts deleted file mode 100644 index 4750741338d88c..00000000000000 --- a/apps/api/pages/api/webhooks/[id]/_delete.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; - -/** - * @swagger - * /webhooks/{id}: - * delete: - * summary: Remove an existing hook - * operationId: removeWebhookById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: Numeric ID of the hooks to delete - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - webhooks - * externalDocs: - * url: https://docs.cal.com/core-features/webhooks - * responses: - * 201: - * description: OK, hook removed successfully - * 400: - * description: Bad request. hook id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdAsString.parse(query); - await prisma.webhook.delete({ where: { id } }); - return { message: `Schedule with id: ${id} deleted successfully` }; -} - -export default defaultResponder(deleteHandler); diff --git a/apps/api/pages/api/webhooks/[id]/_get.ts b/apps/api/pages/api/webhooks/[id]/_get.ts deleted file mode 100644 index 3bde62987a958f..00000000000000 --- a/apps/api/pages/api/webhooks/[id]/_get.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; -import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; - -/** - * @swagger - * /webhooks/{id}: - * get: - * summary: Find a webhook - * operationId: getWebhookById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: Numeric ID of the webhook to get - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - webhooks - * externalDocs: - * url: https://docs.cal.com/core-features/webhooks - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Webhook was not found - */ -export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; - const { id } = schemaQueryIdAsString.parse(query); - const data = await prisma.webhook.findUniqueOrThrow({ where: { id } }); - return { webhook: schemaWebhookReadPublic.parse(data) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/webhooks/[id]/_patch.ts b/apps/api/pages/api/webhooks/[id]/_patch.ts deleted file mode 100644 index 35c2810f393d56..00000000000000 --- a/apps/api/pages/api/webhooks/[id]/_patch.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; -import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/validations/webhook"; - -/** - * @swagger - * /webhooks/{id}: - * patch: - * summary: Edit an existing webhook - * operationId: editWebhookById - * parameters: - * - in: path - * name: id - * schema: - * type: integer - * required: true - * description: Numeric ID of the webhook to edit - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Edit an existing webhook - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * subscriberUrl: - * type: string - * format: uri - * description: The URL to subscribe to this webhook - * eventTriggers: - * type: string - * enum: [BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, MEETING_ENDED] - * description: The events which should trigger this webhook call - * active: - * type: boolean - * description: Whether the webhook is active and should trigger on associated trigger events - * payloadTemplate: - * type: string - * description: The template of the webhook's payload - * eventTypeId: - * type: number - * description: The event type ID if this webhook should be associated with only that event type - * tags: - * - webhooks - * externalDocs: - * url: https://docs.cal.com/core-features/webhooks - * responses: - * 201: - * description: OK, webhook edited successfully - * 400: - * description: Bad request. Webhook body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -export async function patchHandler(req: NextApiRequest) { - const { prisma, query, userId, isAdmin } = req; - const { id } = schemaQueryIdAsString.parse(query); - const { eventTypeId, userId: bodyUserId, ...data } = schemaWebhookEditBodyParams.parse(req.body); - const args: Prisma.WebhookUpdateArgs = { where: { id }, data }; - - if (eventTypeId) { - const where: Prisma.EventTypeWhereInput = { id: eventTypeId }; - if (!isAdmin) where.userId = userId; - await prisma.eventType.findFirstOrThrow({ where }); - args.data.eventTypeId = eventTypeId; - } - - if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); - - if (isAdmin && bodyUserId) { - const where: Prisma.UserWhereInput = { id: bodyUserId }; - await prisma.user.findFirstOrThrow({ where }); - args.data.userId = bodyUserId; - } - - const result = await prisma.webhook.update(args); - return { webhook: schemaWebhookReadPublic.parse(result) }; -} - -export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/webhooks/_get.ts b/apps/api/pages/api/webhooks/_get.ts deleted file mode 100644 index 8708c303e8e1a1..00000000000000 --- a/apps/api/pages/api/webhooks/_get.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; -import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; - -/** - * @swagger - * /webhooks: - * get: - * summary: Find all webhooks - * operationId: listWebhooks - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * tags: - * - webhooks - * externalDocs: - * url: https://docs.cal.com/core-features/webhooks - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No webhooks were found - */ -async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const args: Prisma.WebhookFindManyArgs = isAdmin - ? {} - : { where: { OR: [{ eventType: { userId } }, { userId }] } }; - - /** Only admins can query other users */ - if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); - if (isAdmin && req.query.userId) { - const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); - const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; - args.where = { OR: [{ eventType: { userId: { in: userIds } } }, { userId: { in: userIds } }] }; - if (Array.isArray(query.userId)) args.orderBy = { userId: "asc", eventType: { userId: "asc" } }; - } - - const data = await prisma.webhook.findMany(args); - return { webhooks: data.map((v) => schemaWebhookReadPublic.parse(v)) }; -} - -export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/webhooks/_post.ts b/apps/api/pages/api/webhooks/_post.ts deleted file mode 100644 index 2a99c903e8fb79..00000000000000 --- a/apps/api/pages/api/webhooks/_post.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import type { NextApiRequest } from "next"; -import { v4 as uuidv4 } from "uuid"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/validations/webhook"; - -/** - * @swagger - * /webhooks: - * post: - * summary: Creates a new webhook - * operationId: addWebhook - * parameters: - * - in: query - * name: apiKey - * required: true - * schema: - * type: string - * description: Your API key - * requestBody: - * description: Create a new webhook - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - subscriberUrl - * - eventTriggers - * - active - * properties: - * subscriberUrl: - * type: string - * format: uri - * description: The URL to subscribe to this webhook - * eventTriggers: - * type: string - * enum: [BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, MEETING_ENDED] - * description: The events which should trigger this webhook call - * active: - * type: boolean - * description: Whether the webhook is active and should trigger on associated trigger events - * payloadTemplate: - * type: string - * description: The template of the webhook's payload - * eventTypeId: - * type: number - * description: The event type ID if this webhook should be associated with only that event type - * tags: - * - webhooks - * externalDocs: - * url: https://docs.cal.com/core-features/webhooks - * responses: - * 201: - * description: OK, webhook created - * 400: - * description: Bad request. webhook body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ -async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { eventTypeId, userId: bodyUserId, ...body } = schemaWebhookCreateBodyParams.parse(req.body); - const args: Prisma.WebhookCreateArgs = { data: { id: uuidv4(), ...body } }; - - // If no event type, we assume is for the current user. If admin we run more checks below... - if (!eventTypeId) args.data.userId = userId; - - if (eventTypeId) { - const where: Prisma.EventTypeWhereInput = { id: eventTypeId }; - if (!isAdmin) where.userId = userId; - await prisma.eventType.findFirstOrThrow({ where }); - args.data.eventTypeId = eventTypeId; - } - - if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); - - if (isAdmin && bodyUserId) { - const where: Prisma.UserWhereInput = { id: bodyUserId }; - await prisma.user.findFirstOrThrow({ where }); - args.data.userId = bodyUserId; - } - - const data = await prisma.webhook.create(args); - - return { - webhook: schemaWebhookReadPublic.parse(data), - message: "Webhook created successfully", - }; -} - -export default defaultResponder(postHandler); diff --git a/apps/api/test/docker-compose.yml b/apps/api/test/docker-compose.yml deleted file mode 100644 index 769adab0978db7..00000000000000 --- a/apps/api/test/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Set the version of docker compose to use -version: '3.9' - -# The containers that compose the project -services: - db: - image: postgres:13 - restart: always - container_name: integration-tests-prisma - ports: - - '5433:5432' - environment: - POSTGRES_USER: prisma - POSTGRES_PASSWORD: prisma - POSTGRES_DB: tests \ No newline at end of file diff --git a/apps/api/test/lib/bookings/_post.test.ts b/apps/api/test/lib/bookings/_post.test.ts deleted file mode 100644 index 5e4b17f23d8c54..00000000000000 --- a/apps/api/test/lib/bookings/_post.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { Request, Response } from "express"; -import type { NextApiRequest, NextApiResponse } from "next"; -import { createMocks } from "node-mocks-http"; -import { describe, expect, test, vi } from "vitest"; - -import dayjs from "@calcom/dayjs"; -import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder"; -import prisma from "@calcom/prisma"; - -import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; -import handler from "../../../pages/api/bookings/_post"; - -type CustomNextApiRequest = NextApiRequest & Request; -type CustomNextApiResponse = NextApiResponse & Response; -vi.mock("@calcom/features/webhooks/lib/sendPayload"); -vi.mock("@calcom/lib/server/i18n", () => { - return { - getTranslation: (key: string) => key, - }; -}); - -describe("POST /api/bookings", () => { - describe("Errors", () => { - test("Missing required data", async () => { - const { req, res } = createMocks({ - method: "POST", - body: {}, - }); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual( - expect.objectContaining({ - message: - "invalid_type in 'eventTypeId': Required; invalid_type in 'title': Required; invalid_type in 'startTime': Required; invalid_type in 'startTime': Required; invalid_type in 'endTime': Required; invalid_type in 'endTime': Required", - }) - ); - }); - - test("Invalid eventTypeId", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - title: "test", - eventTypeId: 2, - startTime: dayjs().toDate(), - endTime: dayjs().add(1, "day").toDate(), - }, - prisma, - }); - - prismaMock.eventType.findUnique.mockResolvedValue(null); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual( - expect.objectContaining({ - message: - "'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required", - }) - ); - }); - - test("Missing recurringCount", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - title: "test", - eventTypeId: 2, - startTime: dayjs().toDate(), - endTime: dayjs().add(1, "day").toDate(), - }, - prisma, - }); - - prismaMock.eventType.findUnique.mockResolvedValue( - buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) - ); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual( - expect.objectContaining({ - message: - "'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required", - }) - ); - }); - - test("Invalid recurringCount", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - title: "test", - eventTypeId: 2, - startTime: dayjs().toDate(), - endTime: dayjs().add(1, "day").toDate(), - recurringCount: 15, - }, - prisma, - }); - - prismaMock.eventType.findUnique.mockResolvedValue( - buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) - ); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual( - expect.objectContaining({ - message: - "'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required", - }) - ); - }); - - test("No available users", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - name: "test", - start: dayjs().format(), - end: dayjs().add(1, "day").format(), - eventTypeId: 2, - email: "test@example.com", - location: "Cal.com Video", - timeZone: "America/Montevideo", - language: "en", - customInputs: [], - metadata: {}, - userId: 4, - }, - prisma, - }); - - prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType()); - - await handler(req, res); - console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); - - expect(res._getStatusCode()).toBe(500); - expect(JSON.parse(res._getData())).toEqual( - expect.objectContaining({ - message: "No available users found.", - }) - ); - }); - }); - - describe("Success", () => { - describe("Regular event-type", () => { - test("Creates one single booking", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - name: "test", - start: dayjs().format(), - end: dayjs().add(1, "day").format(), - eventTypeId: 2, - email: "test@example.com", - location: "Cal.com Video", - timeZone: "America/Montevideo", - language: "en", - customInputs: [], - metadata: {}, - userId: 4, - }, - prisma, - }); - - prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType()); - prismaMock.booking.findMany.mockResolvedValue([]); - - await handler(req, res); - console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); - - expect(prismaMock.booking.create).toHaveBeenCalledTimes(1); - }); - }); - - describe("Recurring event-type", () => { - test("Creates multiple bookings", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - title: "test", - eventTypeId: 2, - startTime: dayjs().toDate(), - endTime: dayjs().add(1, "day").toDate(), - recurringCount: 12, - }, - prisma, - }); - - prismaMock.eventType.findUnique.mockResolvedValue( - buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) - ); - - Array.from(Array(12).keys()).map(async () => { - prismaMock.booking.create.mockResolvedValue(buildBooking()); - }); - - prismaMock.webhook.findMany.mockResolvedValue([]); - - await handler(req, res); - const data = JSON.parse(res._getData()); - - expect(prismaMock.booking.create).toHaveBeenCalledTimes(12); - expect(res._getStatusCode()).toBe(201); - expect(data.message).toEqual("Bookings created successfully."); - expect(data.bookings.length).toEqual(12); - }); - }); - test("Notifies multiple bookings", async () => { - const { req, res } = createMocks({ - method: "POST", - body: { - title: "test", - eventTypeId: 2, - startTime: dayjs().toDate(), - endTime: dayjs().add(1, "day").toDate(), - recurringCount: 12, - }, - prisma, - }); - - prismaMock.eventType.findUnique.mockResolvedValue( - buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) - ); - - const createdAt = new Date(); - Array.from(Array(12).keys()).map(async () => { - prismaMock.booking.create.mockResolvedValue(buildBooking({ createdAt })); - }); - - const mockedWebhooks = [ - buildWebhook({ - subscriberUrl: "http://mockedURL1.com", - createdAt, - eventTypeId: 1, - secret: "secret1", - }), - buildWebhook({ - subscriberUrl: "http://mockedURL2.com", - createdAt, - eventTypeId: 2, - secret: "secret2", - }), - ]; - prismaMock.webhook.findMany.mockResolvedValue(mockedWebhooks); - - await handler(req, res); - const data = JSON.parse(res._getData()); - - expect(sendPayload).toHaveBeenCalledTimes(24); - expect(data.message).toEqual("Bookings created successfully."); - expect(data.bookings.length).toEqual(12); - }); - }); -}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index c6b3666313f6f3..00000000000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "@calcom/tsconfig/nextjs.json", - "compilerOptions": { - "strict": true, - "jsx": "preserve", - "baseUrl": ".", - "paths": { - "~/*": ["*"], - "@prisma/client/*": ["@calcom/prisma/client/*"] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "../../packages/types/*.d.ts", - "../../packages/types/next-auth.d.ts" - ], - "exclude": ["node_modules", "templates", "auth"] -} diff --git a/apps/api/v1/.env.example b/apps/api/v1/.env.example new file mode 100644 index 00000000000000..37b43dd55744bf --- /dev/null +++ b/apps/api/v1/.env.example @@ -0,0 +1,7 @@ +API_KEY_PREFIX=cal_ +DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" +NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 + +# Get it in console.cal.com +CALCOM_LICENSE_KEY="" +NEXT_PUBLIC_API_V2_ROOT_URL=http://localhost:5555 \ No newline at end of file diff --git a/apps/api/.gitignore b/apps/api/v1/.gitignore similarity index 100% rename from apps/api/.gitignore rename to apps/api/v1/.gitignore diff --git a/apps/api/v1/.gitkeep b/apps/api/v1/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/apps/api/.prettierignore b/apps/api/v1/.prettierignore similarity index 100% rename from apps/api/.prettierignore rename to apps/api/v1/.prettierignore diff --git a/apps/api/v1/LICENSE b/apps/api/v1/LICENSE new file mode 100644 index 00000000000000..a8c6744758303a --- /dev/null +++ b/apps/api/v1/LICENSE @@ -0,0 +1,42 @@ +The Cal.com Commercial License (the “Commercial License”) +Copyright (c) 2020-present Cal.com, Inc + +With regard to the Cal.com Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Cal.com Subscription Terms available +at https://cal.com/terms, or other agreements governing +the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"), +and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription") +for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence, +you are free to modify this Software and publish patches to the Software. You agree +that Cal.com and/or its licensors (as applicable) retain all right, title and interest in +and to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Commercial Subscription for the correct number of hosts. +Notwithstanding the foregoing, you may copy and modify the Software for development +and testing purposes, without requiring a subscription. You agree that Cal.com and/or +its licensors (as applicable) retain all right, title and interest in and to all such +modifications. You are not granted any other rights beyond what is expressly stated herein. +Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +This Commercial License applies only to the part of this Software that is not distributed under +the AGPLv3 license. Any part of this Software distributed under the MIT license or which +is served client-side as an image, font, cascading stylesheet (CSS), file which produces +or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or +in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall +be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Cal.com Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/apps/api/v1/README.md b/apps/api/v1/README.md new file mode 100644 index 00000000000000..1a3e1a7c4984c5 --- /dev/null +++ b/apps/api/v1/README.md @@ -0,0 +1,225 @@ + + + +# Commercial Cal.com Public API + +Welcome to the Public API ("/apps/api") of the Cal.com. + +This is the public REST api for cal.com. +It exposes CRUD Endpoints of all our most important resources. +And it makes it easy for anyone to integrate with Cal.com at the application programming level. + +## Stack + +- NextJS +- TypeScript +- Prisma + +## Development + +### Setup + +1. Clone the main repo (NOT THIS ONE) + + ```sh + git clone --recurse-submodules -j8 https://github.com/calcom/cal.com.git + ``` + +1. Go to the project folder + + ```sh + cd cal.com + ``` + +1. Copy `apps/api/.env.example` to `apps/api/.env` + + ```sh + cp apps/api/.env.example apps/api/.env + cp .env.example .env + ``` + +1. Install packages with yarn + + ```sh + yarn + ``` + +1. Start developing + + ```sh + yarn workspace @calcom/api dev + ``` + +1. Open [http://localhost:3002](http://localhost:3002) with your browser to see the result. + +## API Authentication (API Keys) + +The API requires a valid apiKey query param to be passed: +You can generate them at + +For example: + +```sh +GET https://api.cal.com/v1/users?apiKey={INSERT_YOUR_CAL.COM_API_KEY_HERE} +``` + +API Keys optionally may have expiry dates, if they are expired they won't work. If you create an apiKey without a userId relation, it won't work either for now as it relies on it to establish the current authenticated user. + +In the future we might add support for header Bearer Auth if we need to or if our customers require it. + +## Middlewares + +We don't use the new NextJS 12 Beta Middlewares, mainly because they run on the edge, and are not able to call prisma from api endpoints. We use instead a very nifty library called next-api-middleware that let's us use a similar approach building our own middlewares and applying them as we see fit. + +- withMiddleware() requires some default middlewares (verifyApiKey, etc...) + +## Next.config.js + +### Redirects + +Since this is an API only project, we don't want to have to type /api/ in all the routes, and so redirect all traffic to api, so a call to `api.cal.com/v1` will resolve to `api.cal.com/api/v1` + +Likewise, v1 is added as param query called version to final /api call so we don't duplicate endpoints in the future for versioning if needed. + +### Transpiling locally shared monorepo modules + +We're calling several packages from monorepo, this need to be transpiled before building since are not available as regular npm packages. That's what withTM does. + +```js + "@calcom/app-store", + "@calcom/prisma", + "@calcom/lib", + "@calcom/features", +``` + +## API Endpoint Validation + +We validate that only the supported methods are accepted at each endpoint, so in + +- **/endpoint**: you can only [GET] (all) and [POST] (create new) +- **/endpoint/id**: you can read create and edit [GET, PATCH, DELETE] + +### Zod Validations + +The API uses `zod` library like our main web repo. It validates that either GET query parameters or POST body content's are valid and up to our spec. It gives errors when parsing result's with schemas and failing validation. + +We use it in several ways, but mainly, we first import the auto-generated schema from @calcom/prisma for each model, which lives in `lib/validations/` + +We have some shared validations which several resources require, like baseApiParams which parses apiKey in all requests, or querIdAsString or TransformParseInt which deal with the id's coming from req.query. + +- **[*]BaseBodyParams** that omits any values from the model that are too sensitive or we don't want to pick when creating a new resource like id, userId, etc.. (those are gotten from context or elswhere) + +- **[*]Public** that also omits any values that we don't want to expose when returning the model as a response, which we parse against before returning all resources. + +- **[*]BodyParams** which merges both `[*]BaseBodyParams.merge([*]RequiredParams);` + +### Next Validations + +[Next-Validations Docs](https://next-validations.productsway.com/) +[Next-Validations Repo](https://github.com/jellydn/next-validations) +We also use this useful helper library that let us wrap our endpoints in a validate HOC that checks the req against our validation schema built out with zod for either query and / or body's requests. + +## Testing with Jest + node-mocks-http + +We aim to provide a fully tested API for our peace of mind, this is accomplished by using jest + node-mocks-http + +## Endpoints matrix + +| resource | get [id] | get all | create | edit | delete | +| --------------------- | -------- | ------- | ------ | ---- | ------ | +| attendees | ✅ | ✅ | ✅ | ✅ | ✅ | +| availabilities | ✅ | ✅ | ✅ | ✅ | ✅ | +| booking-references | ✅ | ✅ | ✅ | ✅ | ✅ | +| event-references | ✅ | ✅ | ✅ | ✅ | ✅ | +| destination-calendars | ✅ | ✅ | ✅ | ✅ | ✅ | +| custom-inputs | ✅ | ✅ | ✅ | ✅ | ✅ | +| event-types | ✅ | ✅ | ✅ | ✅ | ✅ | +| memberships | ✅ | ✅ | ✅ | ✅ | ✅ | +| payments | ✅ | ✅ | ❌ | ❌ | ❌ | +| schedules | ✅ | ✅ | ✅ | ✅ | ✅ | +| selected-calendars | ✅ | ✅ | ✅ | ✅ | ✅ | +| teams | ✅ | ✅ | ✅ | ✅ | ✅ | +| users | ✅ | 👤[1] | ✅ | ✅ | ✅ | + +## Models from database that are not exposed + +mostly because they're deemed too sensitive can be revisited if needed. Also they are expected to be used via cal's webapp. + +- [ ] Api Keys +- [ ] Credentials +- [ ] Webhooks +- [ ] ResetPasswordRequest +- [ ] VerificationToken +- [ ] ReminderMail + +## Documentation (OpenAPI) + +You will see that each endpoint has a comment at the top with the annotation `@swagger` with the documentation of the endpoint, **please update it if you change the code!** This is what auto-generates the OpenAPI spec by collecting the YAML in each endpoint and parsing it in /docs alongside the json-schema (auto-generated from prisma package, not added to code but manually for now, need to fix later) + +### @calcom/apps/swagger + +The documentation of the API lives inside the code, and it's auto-generated, the only endpoints that return without a valid apiKey are the homepage, with a JSON message redirecting you to the docs. and the /docs endpoint, which returns the OpenAPI 3.0 JSON Spec. Which SwaggerUi then consumes and generates the docs on. + +## Deployment + +`scripts/vercel-deploy.sh` +The API is deployed to vercel.com, it uses a similar deployment script to website or webapp, and requires transpilation of several shared packages that are part of our turborepo ["app-store", "prisma", "lib", "ee"] +in order to build and deploy properly. + +## Envirorment variables + +### Required + +DATABASE_URL=DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" + +## Optional + +API*KEY_PREFIX=cal*# This can be changed per envirorment so cal*test* for staging for example. + +> If you're self-hosting under our commercial license, you can use any prefix you want for api keys. either leave the default cal\_ (not providing any envirorment variable) or modify it + +**Ensure that while testing swagger, API project should be run in production mode** +We make sure of this by not using next in dev, but next build && next start, if you want hot module reloading and such when developing, please use yarn run next directly on apps/api. + +See . Here in dev mode OPTIONS method is hardcoded to return only GET and OPTIONS as allowed method. Running in Production mode would cause this file to be not used. This is hot-reloading logic only. +To remove this limitation, we need to ensure that on local endpoints are requested by swagger at /api/v1 and not /v1 + +## Hosted api through cal.com + +> _❗ WARNING: This is still experimental and not fully implemented yet❗_ + +Go to console.cal.com +Add a deployment or go to an existing one. +Activate API or Admin addon +Provide your `DATABASE_URL` +Now you can call api.cal.com?key=CALCOM_LICENSE_KEY, which will connect to your own databaseUrl. + +## How to deploy + +We recommend deploying API in vercel. + +There's some settings that you'll need to setup. + +Under Vercel > Your API Project > Settings + +In General > Build & Development Settings +BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps` +OUTPUT DIRECTORY: `apps/api/.next` + +In Git > Ignored Build Step + +Add this command: `./scripts/vercel-deploy.sh` + +See `scripts/vercel-deploy.sh` for more info on how the deployment is done. + +> _❗ IMORTANT: If you're forking the API repo you will need to update the URLs in both the main repo [`.gitmodules`](https://github.com/calcom/cal.com/blob/main/.gitmodules#L7) and this repo [`./scripts/vercel-deploy.sh`](https://github.com/calcom/api/blob/main/scripts/vercel-deploy.sh#L3) ❗_ + +## Environment variables + +Lastly API requires an env var for `DATABASE_URL` and `CALCOM_LICENSE_KEY` diff --git a/apps/api/v1/instrumentation.ts b/apps/api/v1/instrumentation.ts new file mode 100644 index 00000000000000..79040c9dbb19cb --- /dev/null +++ b/apps/api/v1/instrumentation.ts @@ -0,0 +1,15 @@ +import * as Sentry from "@sentry/nextjs"; + +export function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + }); + } + + if (process.env.NEXT_RUNTIME === "edge") { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + }); + } +} diff --git a/apps/api/lib/constants.ts b/apps/api/v1/lib/constants.ts similarity index 100% rename from apps/api/lib/constants.ts rename to apps/api/v1/lib/constants.ts diff --git a/apps/api/lib/helpers/addRequestid.ts b/apps/api/v1/lib/helpers/addRequestid.ts similarity index 100% rename from apps/api/lib/helpers/addRequestid.ts rename to apps/api/v1/lib/helpers/addRequestid.ts diff --git a/apps/api/v1/lib/helpers/captureErrors.ts b/apps/api/v1/lib/helpers/captureErrors.ts new file mode 100644 index 00000000000000..654750c074f430 --- /dev/null +++ b/apps/api/v1/lib/helpers/captureErrors.ts @@ -0,0 +1,20 @@ +import { captureException as SentryCaptureException } from "@sentry/nextjs"; +import type { NextMiddleware } from "next-api-middleware"; + +import { redactError } from "@calcom/lib/redactError"; + +export const captureErrors: NextMiddleware = async (_req, res, next) => { + try { + // Catch any errors that are thrown in remaining + // middleware and the API route handler + await next(); + } catch (error) { + SentryCaptureException(error); + const redactedError = redactError(error); + if (redactedError instanceof Error) { + res.status(400).json({ message: redactedError.message, error: redactedError }); + return; + } + res.status(400).json({ message: "Something went wrong", error }); + } +}; diff --git a/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts b/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts new file mode 100644 index 00000000000000..8f2f0879b32be3 --- /dev/null +++ b/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts @@ -0,0 +1,23 @@ +import { get } from "@vercel/edge-config"; +import type { NextMiddleware } from "next-api-middleware"; + +const safeGet = async (key: string): Promise => { + try { + return get(key); + } catch (error) { + // Don't crash if EDGE_CONFIG env var is missing + } +}; + +export const config = { matcher: "/:path*" }; + +export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => { + const isInMaintenanceMode = await safeGet("isInMaintenanceMode"); + if (isInMaintenanceMode) { + return res + .status(503) + .json({ message: "API is currently under maintenance. Please try again at a later time." }); + } + + await next(); +}; diff --git a/apps/api/lib/helpers/extendRequest.ts b/apps/api/v1/lib/helpers/extendRequest.ts similarity index 100% rename from apps/api/lib/helpers/extendRequest.ts rename to apps/api/v1/lib/helpers/extendRequest.ts diff --git a/apps/api/lib/helpers/httpMethods.ts b/apps/api/v1/lib/helpers/httpMethods.ts similarity index 100% rename from apps/api/lib/helpers/httpMethods.ts rename to apps/api/v1/lib/helpers/httpMethods.ts diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts new file mode 100644 index 00000000000000..3b57cfeb4ff323 --- /dev/null +++ b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts @@ -0,0 +1,243 @@ +import type { RatelimitResponse } from "@unkey/ratelimit"; +import type { Request, Response } from "express"; +import type { NextApiResponse, NextApiRequest } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, it, expect, vi } from "vitest"; + +import { handleAutoLock } from "@calcom/lib/autoLock"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { HttpError } from "@calcom/lib/http-error"; + +import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +const testUserId = 123; + +vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({ + checkRateLimitAndThrowError: vi.fn(), +})); + +vi.mock("@calcom/lib/autoLock", () => ({ + handleAutoLock: vi.fn(), +})); + +describe("rateLimitApiKey middleware", () => { + it("should return 401 if no apiKey is provided", async () => { + const { req, res } = createMocks({ + method: "GET", + query: {}, + userId: testUserId, + }); + + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(res._getStatusCode()).toBe(401); + expect(res._getJSONData()).toEqual({ message: "No apiKey provided" }); + }); + + it("should call checkRateLimitAndThrowError with correct parameters", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + (checkRateLimitAndThrowError as any).mockResolvedValueOnce({ + limit: 100, + remaining: 99, + reset: Date.now(), + }); + + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({ + identifier: testUserId.toString(), + rateLimitingType: "api", + onRateLimiterResponse: expect.any(Function), + }); + }); + + it("should set rate limit headers correctly", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + const rateLimiterResponse: RatelimitResponse = { + limit: 100, + remaining: 99, + reset: Date.now(), + success: true, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (checkRateLimitAndThrowError as any).mockImplementationOnce( + ({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => { + onRateLimiterResponse(rateLimiterResponse); + } + ); + + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit); + expect(res.getHeader("X-RateLimit-Remaining")).toBe(rateLimiterResponse.remaining); + expect(res.getHeader("X-RateLimit-Reset")).toBe(rateLimiterResponse.reset); + }); + + it("should return 429 if rate limit is exceeded", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + (checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded")); + + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(res._getStatusCode()).toBe(429); + expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" }); + }); + + it("should lock API key when rate limit is repeatedly exceeded", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + const rateLimiterResponse: RatelimitResponse = { + success: false, + remaining: 0, + limit: 100, + reset: Date.now(), + }; + + // Mock rate limiter to trigger the onRateLimiterResponse callback + (checkRateLimitAndThrowError as any).mockImplementationOnce( + ({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => { + onRateLimiterResponse(rateLimiterResponse); + } + ); + + // Mock handleAutoLock to indicate the key was locked + vi.mocked(handleAutoLock).mockResolvedValueOnce(true); + + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(handleAutoLock).toHaveBeenCalledWith({ + identifier: testUserId.toString(), + identifierType: "userId", + rateLimitResponse: rateLimiterResponse, + }); + + expect(res._getStatusCode()).toBe(429); + expect(res._getJSONData()).toEqual({ message: "Too many requests" }); + }); + + it("should handle API key not found error during auto-lock", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + const rateLimiterResponse: RatelimitResponse = { + success: false, + remaining: 0, + limit: 100, + reset: Date.now(), + }; + + // Mock rate limiter to trigger the onRateLimiterResponse callback + (checkRateLimitAndThrowError as any).mockImplementationOnce( + ({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => { + onRateLimiterResponse(rateLimiterResponse); + } + ); + + // Mock handleAutoLock to throw a "No user found" error + vi.mocked(handleAutoLock).mockRejectedValueOnce(new Error("No user found for this API key.")); + + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(handleAutoLock).toHaveBeenCalledWith({ + identifier: testUserId.toString(), + identifierType: "userId", + rateLimitResponse: rateLimiterResponse, + }); + + expect(res._getStatusCode()).toBe(401); + expect(res._getJSONData()).toEqual({ message: "No user found for this API key." }); + }); + + it("should continue if auto-lock returns false (not locked)", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + const rateLimiterResponse: RatelimitResponse = { + success: false, + remaining: 0, + limit: 100, + reset: Date.now(), + }; + + // Mock rate limiter to trigger the onRateLimiterResponse callback + (checkRateLimitAndThrowError as any).mockImplementationOnce( + ({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => { + onRateLimiterResponse(rateLimiterResponse); + } + ); + + // Mock handleAutoLock to indicate the key was not locked + vi.mocked(handleAutoLock).mockResolvedValueOnce(false); + + const next = vi.fn(); + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, next); + + expect(handleAutoLock).toHaveBeenCalledWith({ + identifier: testUserId.toString(), + identifierType: "userId", + rateLimitResponse: rateLimiterResponse, + }); + + // Verify headers were set but request continued + expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit); + expect(next).toHaveBeenCalled(); + }); + + it("should handle HttpError during rate limiting", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { apiKey: "test-key" }, + userId: testUserId, + }); + + // Mock checkRateLimitAndThrowError to throw HttpError + vi.mocked(checkRateLimitAndThrowError).mockRejectedValueOnce( + new HttpError({ + statusCode: 429, + message: "Custom rate limit error", + }) + ); + + // @ts-expect-error weird typing between middleware and createMocks + await rateLimitApiKey(req, res, vi.fn() as any); + + expect(res._getStatusCode()).toBe(429); + expect(res._getJSONData()).toEqual({ message: "Custom rate limit error" }); + }); +}); diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.ts new file mode 100644 index 00000000000000..b490c011bc61ff --- /dev/null +++ b/apps/api/v1/lib/helpers/rateLimitApiKey.ts @@ -0,0 +1,48 @@ +import type { NextMiddleware } from "next-api-middleware"; + +import { handleAutoLock } from "@calcom/lib/autoLock"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { HttpError } from "@calcom/lib/http-error"; + +export const rateLimitApiKey: NextMiddleware = async (req, res, next) => { + if (!req.userId) return res.status(401).json({ message: "No userId provided" }); + if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); + + // TODO: Add a way to add trusted api keys + try { + const identifier = req.userId.toString(); + await checkRateLimitAndThrowError({ + identifier, + rateLimitingType: "api", + onRateLimiterResponse: async (response) => { + res.setHeader("X-RateLimit-Limit", response.limit); + res.setHeader("X-RateLimit-Remaining", response.remaining); + res.setHeader("X-RateLimit-Reset", response.reset); + + try { + const didLock = await handleAutoLock({ + identifier, + identifierType: "userId", + rateLimitResponse: response, + }); + + if (didLock) { + return res.status(429).json({ message: "Too many requests" }); + } + } catch (error) { + if (error instanceof Error && error.message === "No user found for this API key.") { + return res.status(401).json({ message: error.message }); + } + throw error; + } + }, + }); + } catch (error) { + if (error instanceof HttpError) { + return res.status(error.statusCode).json({ message: error.message }); + } + return res.status(429).json({ message: "Rate limit exceeded" }); + } + + await next(); +}; diff --git a/apps/api/lib/helpers/safeParseJSON.ts b/apps/api/v1/lib/helpers/safeParseJSON.ts similarity index 100% rename from apps/api/lib/helpers/safeParseJSON.ts rename to apps/api/v1/lib/helpers/safeParseJSON.ts diff --git a/apps/api/v1/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts new file mode 100644 index 00000000000000..ebe8fd03e5eaf9 --- /dev/null +++ b/apps/api/v1/lib/helpers/verifyApiKey.ts @@ -0,0 +1,62 @@ +import type { NextMiddleware } from "next-api-middleware"; + +import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; +import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "../utils/isAdmin"; +import { isLockedOrBlocked } from "../utils/isLockedOrBlocked"; +import { ScopeOfAdmin } from "../utils/scopeOfAdmin"; + +// Used to check if the apiKey is not expired, could be extracted if reused. but not for now. +export const dateNotInPast = function (date: Date) { + const now = new Date(); + if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) { + return true; + } +}; + +// This verifies the apiKey and sets the user if it is valid. +export const verifyApiKey: NextMiddleware = async (req, res, next) => { + const licenseKeyService = await LicenseKeySingleton.getInstance(); + const hasValidLicense = await licenseKeyService.checkLicense(); + + if (!hasValidLicense && IS_PRODUCTION) { + return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" }); + } + + if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); + + const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", ""); + const hashedKey = hashAPIKey(strippedApiKey); + const apiKey = await prisma.apiKey.findUnique({ + where: { hashedKey }, + include: { + user: { + select: { role: true, locked: true, email: true }, + }, + }, + }); + if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." }); + if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) { + return res.status(401).json({ error: "This API key is expired." }); + } + if (!apiKey.userId || !apiKey.user) + return res.status(404).json({ error: "No user found for this API key." }); + + // save the user id in the request for later use + req.userId = apiKey.userId; + req.user = apiKey.user; + + const { isAdmin, scope } = await isAdminGuard(req); + const userIsLockedOrBlocked = await isLockedOrBlocked(req); + + if (userIsLockedOrBlocked) + return res.status(403).json({ error: "You are not authorized to perform this request." }); + + req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide; + req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin; + + await next(); +}; diff --git a/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts new file mode 100644 index 00000000000000..b7922ae54df1d4 --- /dev/null +++ b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts @@ -0,0 +1,24 @@ +import type { NextMiddleware } from "next-api-middleware"; + +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; + +export const verifyCredentialSyncEnabled: NextMiddleware = async (req, res, next) => { + const { isSystemWideAdmin } = req; + + if (!isSystemWideAdmin) { + return res.status(403).json({ error: "Only admin API keys can access credential syncing endpoints" }); + } + + if (!APP_CREDENTIAL_SHARING_ENABLED) { + return res.status(501).json({ error: "Credential syncing is not enabled" }); + } + + if ( + req.headers[process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"] !== + process.env.CALCOM_CREDENTIAL_SYNC_SECRET + ) { + return res.status(401).json({ message: "Invalid credential sync secret" }); + } + + await next(); +}; diff --git a/apps/api/v1/lib/helpers/withMiddleware.ts b/apps/api/v1/lib/helpers/withMiddleware.ts new file mode 100644 index 00000000000000..95b8984ee1d225 --- /dev/null +++ b/apps/api/v1/lib/helpers/withMiddleware.ts @@ -0,0 +1,51 @@ +import { label } from "next-api-middleware"; + +import { addRequestId } from "./addRequestid"; +import { captureErrors } from "./captureErrors"; +import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode"; +import { extendRequest } from "./extendRequest"; +import { + HTTP_POST, + HTTP_DELETE, + HTTP_PATCH, + HTTP_GET, + HTTP_GET_OR_POST, + HTTP_GET_DELETE_PATCH, +} from "./httpMethods"; +import { rateLimitApiKey } from "./rateLimitApiKey"; +import { verifyApiKey } from "./verifyApiKey"; +import { verifyCredentialSyncEnabled } from "./verifyCredentialSyncEnabled"; +import { withPagination } from "./withPagination"; + +const middleware = { + HTTP_GET_OR_POST, + HTTP_GET_DELETE_PATCH, + HTTP_GET, + HTTP_PATCH, + HTTP_POST, + HTTP_DELETE, + addRequestId, + checkIsInMaintenanceMode, + verifyApiKey, + rateLimitApiKey, + extendRequest, + pagination: withPagination, + captureErrors, + verifyCredentialSyncEnabled, +}; + +type Middleware = keyof typeof middleware; + +const middlewareOrder = [ + // The order here, determines the order of execution + "checkIsInMaintenanceMode", + "extendRequest", + "captureErrors", + "verifyApiKey", + "rateLimitApiKey", + "addRequestId", +] as Middleware[]; // <-- Provide a list of middleware to call automatically + +const withMiddleware = label(middleware, middlewareOrder); + +export { withMiddleware, middleware, middlewareOrder }; diff --git a/apps/api/lib/helpers/withPagination.ts b/apps/api/v1/lib/helpers/withPagination.ts similarity index 100% rename from apps/api/lib/helpers/withPagination.ts rename to apps/api/v1/lib/helpers/withPagination.ts diff --git a/apps/api/lib/types.ts b/apps/api/v1/lib/types.ts similarity index 100% rename from apps/api/lib/types.ts rename to apps/api/v1/lib/types.ts diff --git a/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts b/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts new file mode 100644 index 00000000000000..8faffc4b986054 --- /dev/null +++ b/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +export function extractUserIdsFromQuery({ isSystemWideAdmin, query }: NextApiRequest) { + /** Guard: Only admins can query other users */ + if (!isSystemWideAdmin) { + throw new HttpError({ statusCode: 401, message: "ADMIN required" }); + } + const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query); + return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds]; +} diff --git a/apps/api/v1/lib/utils/isAdmin.ts b/apps/api/v1/lib/utils/isAdmin.ts new file mode 100644 index 00000000000000..38f3cc3099874c --- /dev/null +++ b/apps/api/v1/lib/utils/isAdmin.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest } from "next"; + +import prisma from "@calcom/prisma"; +import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums"; + +import { ScopeOfAdmin } from "./scopeOfAdmin"; + +export const isAdminGuard = async (req: NextApiRequest) => { + const { user, userId } = req; + if (!user) return { isAdmin: false, scope: null }; + + const { role: userRole } = user; + if (userRole === UserPermissionRole.ADMIN) return { isAdmin: true, scope: ScopeOfAdmin.SystemWide }; + + const orgOwnerOrAdminMemberships = await prisma.membership.findMany({ + where: { + userId: userId, + accepted: true, + team: { + isOrganization: true, + organizationSettings: { + isAdminAPIEnabled: true, + }, + }, + OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }], + }, + select: { + team: { + select: { + id: true, + isOrganization: true, + }, + }, + }, + }); + if (orgOwnerOrAdminMemberships.length > 0) return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin }; + + return { isAdmin: false, scope: null }; +}; diff --git a/apps/api/v1/lib/utils/isLockedOrBlocked.ts b/apps/api/v1/lib/utils/isLockedOrBlocked.ts new file mode 100644 index 00000000000000..0786678a24500e --- /dev/null +++ b/apps/api/v1/lib/utils/isLockedOrBlocked.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest } from "next"; + +import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; + +export async function isLockedOrBlocked(req: NextApiRequest) { + const user = req.user; + if (!user?.email) return false; + return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email)); +} diff --git a/apps/api/v1/lib/utils/isValidBase64Image.ts b/apps/api/v1/lib/utils/isValidBase64Image.ts new file mode 100644 index 00000000000000..8c69df55067390 --- /dev/null +++ b/apps/api/v1/lib/utils/isValidBase64Image.ts @@ -0,0 +1,4 @@ +export function isValidBase64Image(input: string): boolean { + const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + return regex.test(input); +} diff --git a/apps/api/v1/lib/utils/retrieveScopedAccessibleUsers.ts b/apps/api/v1/lib/utils/retrieveScopedAccessibleUsers.ts new file mode 100644 index 00000000000000..9e80317de0a8f2 --- /dev/null +++ b/apps/api/v1/lib/utils/retrieveScopedAccessibleUsers.ts @@ -0,0 +1,92 @@ +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +type AccessibleUsersType = { + memberUserIds: number[]; + adminUserId: number; +}; + +const getAllOrganizationMemberships = async ( + memberships: { + userId: number; + role: MembershipRole; + teamId: number; + }[], + orgId: number +) => { + return memberships.reduce((acc, membership) => { + if (membership.teamId === orgId) { + acc.push(membership.userId); + } + return acc; + }, []); +}; + +const getAllAdminMemberships = async (userId: number) => { + return await prisma.membership.findMany({ + where: { + userId: userId, + accepted: true, + OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }], + }, + select: { + team: { + select: { + id: true, + isOrganization: true, + }, + }, + }, + }); +}; + +const getAllOrganizationMembers = async (organizationId: number) => { + return await prisma.membership.findMany({ + where: { + teamId: organizationId, + accepted: true, + }, + select: { + userId: true, + }, + }); +}; + +export const getAccessibleUsers = async ({ + memberUserIds, + adminUserId, +}: AccessibleUsersType): Promise => { + const memberships = await prisma.membership.findMany({ + where: { + team: { + isOrganization: true, + }, + accepted: true, + OR: [ + { userId: { in: memberUserIds } }, + { userId: adminUserId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } }, + ], + }, + select: { + userId: true, + role: true, + teamId: true, + }, + }); + + const orgId = memberships.find((membership) => membership.userId === adminUserId)?.teamId; + if (!orgId) return []; + + const allAccessibleMemberUserIds = await getAllOrganizationMemberships(memberships, orgId); + const accessibleUserIds = allAccessibleMemberUserIds.filter((userId) => userId !== adminUserId); + return accessibleUserIds; +}; + +export const retrieveOrgScopedAccessibleUsers = async ({ adminId }: { adminId: number }) => { + const adminMemberships = await getAllAdminMemberships(adminId); + const organizationId = adminMemberships.find((membership) => membership.team.isOrganization)?.team.id; + if (!organizationId) return []; + + const allMemberships = await getAllOrganizationMembers(organizationId); + return allMemberships.map((membership) => membership.userId); +}; diff --git a/apps/api/v1/lib/utils/scopeOfAdmin.ts b/apps/api/v1/lib/utils/scopeOfAdmin.ts new file mode 100644 index 00000000000000..ed0985669962de --- /dev/null +++ b/apps/api/v1/lib/utils/scopeOfAdmin.ts @@ -0,0 +1,4 @@ +export const ScopeOfAdmin = { + SystemWide: "SystemWide", + OrgOwnerOrAdmin: "OrgOwnerOrAdmin", +} as const; diff --git a/apps/api/lib/utils/stringifyISODate.ts b/apps/api/v1/lib/utils/stringifyISODate.ts similarity index 100% rename from apps/api/lib/utils/stringifyISODate.ts rename to apps/api/v1/lib/utils/stringifyISODate.ts diff --git a/apps/api/lib/validations/api-key.ts b/apps/api/v1/lib/validations/api-key.ts similarity index 100% rename from apps/api/lib/validations/api-key.ts rename to apps/api/v1/lib/validations/api-key.ts diff --git a/apps/api/lib/validations/attendee.ts b/apps/api/v1/lib/validations/attendee.ts similarity index 88% rename from apps/api/lib/validations/attendee.ts rename to apps/api/v1/lib/validations/attendee.ts index b1be1c2024b455..17338f0e8fa463 100644 --- a/apps/api/lib/validations/attendee.ts +++ b/apps/api/v1/lib/validations/attendee.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; import { _AttendeeModel as Attendee } from "@calcom/prisma/zod"; import { timeZone } from "~/lib/validations/shared/timeZone"; @@ -14,7 +15,7 @@ export const schemaAttendeeBaseBodyParams = Attendee.pick({ const schemaAttendeeCreateParams = z .object({ bookingId: z.number().int(), - email: z.string().email(), + email: emailSchema, name: z.string(), timeZone: timeZone, }) @@ -23,7 +24,7 @@ const schemaAttendeeCreateParams = z const schemaAttendeeEditParams = z .object({ name: z.string().optional(), - email: z.string().email().optional(), + email: emailSchema.optional(), timeZone: timeZone.optional(), }) .strict(); diff --git a/apps/api/lib/validations/availability.ts b/apps/api/v1/lib/validations/availability.ts similarity index 94% rename from apps/api/lib/validations/availability.ts rename to apps/api/v1/lib/validations/availability.ts index 9d6fd20d1f7da9..5d62a9fe344329 100644 --- a/apps/api/lib/validations/availability.ts +++ b/apps/api/v1/lib/validations/availability.ts @@ -26,6 +26,7 @@ const schemaAvailabilityCreateParams = z startTime: z.date().or(z.string()), endTime: z.date().or(z.string()), days: z.array(z.number()).optional(), + date: z.date().or(z.string()).optional(), }) .strict(); @@ -34,6 +35,7 @@ const schemaAvailabilityEditParams = z startTime: z.date().or(z.string()).optional(), endTime: z.date().or(z.string()).optional(), days: z.array(z.number()).optional(), + date: z.date().or(z.string()).optional(), }) .strict(); diff --git a/apps/api/lib/validations/booking-reference.ts b/apps/api/v1/lib/validations/booking-reference.ts similarity index 100% rename from apps/api/lib/validations/booking-reference.ts rename to apps/api/v1/lib/validations/booking-reference.ts diff --git a/apps/api/v1/lib/validations/booking.ts b/apps/api/v1/lib/validations/booking.ts new file mode 100644 index 00000000000000..acba26f724e3e2 --- /dev/null +++ b/apps/api/v1/lib/validations/booking.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; + +import { + _AttendeeModel, + _BookingModel as Booking, + _EventTypeModel, + _PaymentModel, + _TeamModel, + _UserModel, +} from "@calcom/prisma/zod"; +import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils"; + +import { schemaQueryUserId } from "./shared/queryUserId"; + +const schemaBookingBaseBodyParams = Booking.pick({ + uid: true, + userId: true, + eventTypeId: true, + title: true, + description: true, + startTime: true, + endTime: true, + status: true, + rescheduledBy: true, + cancelledBy: true, + createdAt: true, +}).partial(); + +export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial()); + +export const schemaBookingGetParams = z.object({ + dateFrom: iso8601.optional(), + dateTo: iso8601.optional(), + order: z.enum(["asc", "desc"]).default("asc"), + sortBy: z.enum(["createdAt", "updatedAt"]).optional(), + status: z.enum(["upcoming"]).optional(), +}); + +export type Status = z.infer["status"]; + +export const bookingCancelSchema = z.object({ + id: z.number(), + allRemainingBookings: z.boolean().optional(), + cancelSubsequentBookings: z.boolean().optional(), + cancellationReason: z.string().optional().default("Not Provided"), + seatReferenceUid: z.string().optional(), + cancelledBy: z.string().email({ message: "Invalid email" }).optional(), + internalNote: z + .object({ + id: z.number(), + name: z.string(), + cancellationReason: z.string().optional().nullable(), + }) + .optional() + .nullable(), +}); + +const schemaBookingEditParams = z + .object({ + title: z.string().optional(), + startTime: iso8601.optional(), + endTime: iso8601.optional(), + cancelledBy: z.string().email({ message: "Invalid Email" }).optional(), + rescheduledBy: z.string().email({ message: "Invalid Email" }).optional(), + // Not supporting responses in edit as that might require re-triggering emails + // responses + }) + .strict(); + +export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams + .merge(schemaBookingEditParams) + .omit({ uid: true }); + +const teamSchema = _TeamModel.pick({ + name: true, + slug: true, +}); + +export const schemaBookingReadPublic = Booking.extend({ + eventType: _EventTypeModel + .pick({ + title: true, + slug: true, + }) + .merge( + z.object({ + team: teamSchema.nullish(), + }) + ) + .nullish(), + attendees: z + .array( + _AttendeeModel.pick({ + id: true, + email: true, + name: true, + timeZone: true, + locale: true, + }) + ) + .optional(), + user: _UserModel + .pick({ + email: true, + name: true, + timeZone: true, + locale: true, + }) + .nullish(), + payment: z + .array( + _PaymentModel.pick({ + id: true, + success: true, + paymentOption: true, + }) + ) + .optional(), + responses: z.record(z.any()).nullable(), +}).pick({ + id: true, + userId: true, + description: true, + eventTypeId: true, + uid: true, + title: true, + startTime: true, + endTime: true, + timeZone: true, + attendees: true, + user: true, + eventType: true, + payment: true, + metadata: true, + status: true, + responses: true, + fromReschedule: true, + cancelledBy: true, + rescheduledBy: true, + createdAt: true, +}); diff --git a/apps/api/v1/lib/validations/connected-calendar.ts b/apps/api/v1/lib/validations/connected-calendar.ts new file mode 100644 index 00000000000000..470aea1bd3702e --- /dev/null +++ b/apps/api/v1/lib/validations/connected-calendar.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const CalendarSchema = z.object({ + externalId: z.string(), + name: z.string(), + primary: z.boolean(), + readOnly: z.boolean(), +}); + +const IntegrationSchema = z.object({ + name: z.string(), + appId: z.string(), + userId: z.number(), + integration: z.string(), + calendars: z.array(CalendarSchema), +}); + +export const schemaConnectedCalendarsReadPublic = z.array(IntegrationSchema); diff --git a/apps/api/v1/lib/validations/credential-sync.ts b/apps/api/v1/lib/validations/credential-sync.ts new file mode 100644 index 00000000000000..04e248c047025d --- /dev/null +++ b/apps/api/v1/lib/validations/credential-sync.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; + +const userId = z.string().transform((val) => { + const userIdInt = parseInt(val); + + if (isNaN(userIdInt)) { + throw new HttpError({ message: "userId is not a valid number", statusCode: 400 }); + } + + return userIdInt; +}); +const appSlug = z.string(); +const credentialId = z.string().transform((val) => { + const credentialIdInt = parseInt(val); + + if (isNaN(credentialIdInt)) { + throw new HttpError({ message: "credentialId is not a valid number", statusCode: 400 }); + } + + return credentialIdInt; +}); +const encryptedKey = z.string(); + +export const schemaCredentialGetParams = z.object({ + userId, + appSlug: appSlug.optional(), +}); + +export const schemaCredentialPostParams = z.object({ + userId, + createSelectedCalendar: z + .string() + .optional() + .transform((val) => { + return val === "true"; + }), + createDestinationCalendar: z + .string() + .optional() + .transform((val) => { + return val === "true"; + }), +}); + +export const schemaCredentialPostBody = z.object({ + appSlug, + encryptedKey, +}); + +export const schemaCredentialPatchParams = z.object({ + userId, + credentialId, +}); + +export const schemaCredentialPatchBody = z.object({ + encryptedKey, +}); + +export const schemaCredentialDeleteParams = z.object({ + userId, + credentialId, +}); diff --git a/apps/api/lib/validations/destination-calendar.ts b/apps/api/v1/lib/validations/destination-calendar.ts similarity index 91% rename from apps/api/lib/validations/destination-calendar.ts rename to apps/api/v1/lib/validations/destination-calendar.ts index 7f90cd0002b4af..15d1d8672ccd96 100644 --- a/apps/api/lib/validations/destination-calendar.ts +++ b/apps/api/v1/lib/validations/destination-calendar.ts @@ -14,9 +14,9 @@ const schemaDestinationCalendarCreateParams = z .object({ integration: z.string(), externalId: z.string(), - eventTypeId: z.number(), - bookingId: z.number(), - userId: z.number(), + eventTypeId: z.number().optional(), + bookingId: z.number().optional(), + userId: z.number().optional(), }) .strict(); diff --git a/apps/api/lib/validations/event-type-custom-input.ts b/apps/api/v1/lib/validations/event-type-custom-input.ts similarity index 100% rename from apps/api/lib/validations/event-type-custom-input.ts rename to apps/api/v1/lib/validations/event-type-custom-input.ts diff --git a/apps/api/v1/lib/validations/event-type.ts b/apps/api/v1/lib/validations/event-type.ts new file mode 100644 index 00000000000000..f475a18b843b47 --- /dev/null +++ b/apps/api/v1/lib/validations/event-type.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; + +import slugify from "@calcom/lib/slugify"; +import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod"; +import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils"; + +import { Frequency } from "~/lib/types"; + +import { jsonSchema } from "./shared/jsonSchema"; +import { schemaQueryUserId } from "./shared/queryUserId"; +import { timeZone } from "./shared/timeZone"; + +const recurringEventInputSchema = z.object({ + dtstart: z.string().optional(), + interval: z.number().int().optional(), + count: z.number().int().optional(), + freq: z.nativeEnum(Frequency).optional(), + until: z.string().optional(), + tzid: timeZone.optional(), +}); + +const hostSchema = _HostModel.pick({ + isFixed: true, + userId: true, +}); + +export const childrenSchema = z.object({ + id: z.number().int(), + userId: z.number().int(), +}); + +export const schemaEventTypeBaseBodyParams = EventType.pick({ + title: true, + description: true, + slug: true, + length: true, + hidden: true, + position: true, + eventName: true, + timeZone: true, + schedulingType: true, + // START Limit future bookings + periodType: true, + periodStartDate: true, + periodEndDate: true, + periodDays: true, + periodCountCalendarDays: true, + // END Limit future bookings + requiresConfirmation: true, + disableGuests: true, + hideCalendarNotes: true, + minimumBookingNotice: true, + parentId: true, + beforeEventBuffer: true, + afterEventBuffer: true, + teamId: true, + price: true, + currency: true, + slotInterval: true, + successRedirectUrl: true, + locations: true, + bookingLimits: true, + onlyShowFirstAvailableSlot: true, + durationLimits: true, + assignAllTeamMembers: true, +}) + .merge( + z.object({ + children: z.array(childrenSchema).optional().default([]), + hosts: z.array(hostSchema).optional().default([]), + }) + ) + .partial() + .strict(); + +const schemaEventTypeCreateParams = z + .object({ + title: z.string(), + slug: z.string().transform((s) => slugify(s)), + description: z.string().optional().nullable(), + length: z.number().int(), + metadata: z.any().optional(), + recurringEvent: recurringEventInputSchema.optional(), + seatsPerTimeSlot: z.number().optional(), + seatsShowAttendees: z.boolean().optional(), + seatsShowAvailabilityCount: z.boolean().optional(), + bookingFields: eventTypeBookingFields.optional(), + scheduleId: z.number().optional(), + parentId: z.number().optional(), + }) + .strict(); + +export const schemaEventTypeCreateBodyParams = schemaEventTypeBaseBodyParams + .merge(schemaEventTypeCreateParams) + .merge(schemaQueryUserId.partial()); + +const schemaEventTypeEditParams = z + .object({ + title: z.string().optional(), + slug: z + .string() + .transform((s) => slugify(s)) + .optional(), + length: z.number().int().optional(), + seatsPerTimeSlot: z.number().optional(), + seatsShowAttendees: z.boolean().optional(), + seatsShowAvailabilityCount: z.boolean().optional(), + bookingFields: eventTypeBookingFields.optional(), + scheduleId: z.number().optional(), + }) + .strict(); + +export const schemaEventTypeEditBodyParams = schemaEventTypeBaseBodyParams.merge(schemaEventTypeEditParams); +export const schemaEventTypeReadPublic = EventType.pick({ + id: true, + title: true, + slug: true, + length: true, + hidden: true, + position: true, + userId: true, + teamId: true, + scheduleId: true, + eventName: true, + timeZone: true, + periodType: true, + periodStartDate: true, + periodEndDate: true, + periodDays: true, + periodCountCalendarDays: true, + requiresConfirmation: true, + recurringEvent: true, + disableGuests: true, + hideCalendarNotes: true, + minimumBookingNotice: true, + beforeEventBuffer: true, + afterEventBuffer: true, + schedulingType: true, + price: true, + currency: true, + slotInterval: true, + parentId: true, + successRedirectUrl: true, + description: true, + locations: true, + metadata: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + bookingFields: true, + bookingLimits: true, + onlyShowFirstAvailableSlot: true, + durationLimits: true, +}).merge( + z.object({ + children: z.array(childrenSchema).optional().default([]), + hosts: z.array(hostSchema).optional().default([]), + locations: z + .array( + z.object({ + link: z.string().optional(), + address: z.string().optional(), + hostPhoneNumber: z.string().optional(), + type: z.any().optional(), + }) + ) + .nullable(), + metadata: jsonSchema.nullable(), + customInputs: customInputSchema.array().optional(), + link: z.string().optional(), + hashedLink: z + .array(z.object({ link: z.string() })) + .optional() + .default([]), + bookingFields: eventTypeBookingFields.optional().nullable(), + }) +); diff --git a/apps/api/v1/lib/validations/membership.ts b/apps/api/v1/lib/validations/membership.ts new file mode 100644 index 00000000000000..fc6ac42208e905 --- /dev/null +++ b/apps/api/v1/lib/validations/membership.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { MembershipRole } from "@calcom/prisma/enums"; +import { _MembershipModel as Membership, _TeamModel } from "@calcom/prisma/zod"; +import { stringOrNumber } from "@calcom/prisma/zod-utils"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +export const schemaMembershipBaseBodyParams = Membership.omit({}); + +const schemaMembershipRequiredParams = z.object({ + teamId: z.number(), +}); + +export const membershipCreateBodySchema = Membership.omit({ id: true }) + .partial({ + accepted: true, + role: true, + disableImpersonation: true, + }) + .transform((v) => ({ + accepted: false, + role: MembershipRole.MEMBER, + disableImpersonation: false, + ...v, + })); + +export const membershipEditBodySchema = Membership.omit({ + /** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */ + teamId: true, + userId: true, + id: true, +}) + .partial({ + accepted: true, + role: true, + disableImpersonation: true, + }) + .strict(); + +export const schemaMembershipBodyParams = schemaMembershipBaseBodyParams.merge( + schemaMembershipRequiredParams +); + +export const schemaMembershipPublic = Membership.merge(z.object({ team: _TeamModel }).partial()); + +/** We extract userId and teamId from compound ID string */ +export const membershipIdSchema = schemaQueryIdAsString + // So we can query additional team data in memberships + .merge(z.object({ teamId: z.union([stringOrNumber, z.array(stringOrNumber)]) }).partial()) + .transform((v, ctx) => { + const [userIdStr, teamIdStr] = v.id.split("_"); + const userIdInt = schemaQueryIdParseInt.safeParse({ id: userIdStr }); + const teamIdInt = schemaQueryIdParseInt.safeParse({ id: teamIdStr }); + if (!userIdInt.success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "userId is not a number" }); + return z.NEVER; + } + if (!teamIdInt.success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "teamId is not a number " }); + return z.NEVER; + } + return { + userId: userIdInt.data.id, + teamId: teamIdInt.data.id, + }; + }); diff --git a/apps/api/v1/lib/validations/payment.ts b/apps/api/v1/lib/validations/payment.ts new file mode 100644 index 00000000000000..8c3b08413b6bea --- /dev/null +++ b/apps/api/v1/lib/validations/payment.ts @@ -0,0 +1,12 @@ +import { _PaymentModel as Payment } from "@calcom/prisma/zod"; + +export const schemaPaymentPublic = Payment.pick({ + id: true, + amount: true, + success: true, + refunded: true, + fee: true, + paymentOption: true, + currency: true, + bookingId: true, +}); diff --git a/apps/api/lib/validations/reminder-mail.ts b/apps/api/v1/lib/validations/reminder-mail.ts similarity index 100% rename from apps/api/lib/validations/reminder-mail.ts rename to apps/api/v1/lib/validations/reminder-mail.ts diff --git a/apps/api/v1/lib/validations/schedule.ts b/apps/api/v1/lib/validations/schedule.ts new file mode 100644 index 00000000000000..ed7077560078c5 --- /dev/null +++ b/apps/api/v1/lib/validations/schedule.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import dayjs from "@calcom/dayjs"; +import { _ScheduleModel as Schedule, _AvailabilityModel as Availability } from "@calcom/prisma/zod"; + +import { timeZone } from "./shared/timeZone"; + +const schemaScheduleBaseBodyParams = Schedule.omit({ id: true, timeZone: true }).partial(); + +export const schemaSingleScheduleBodyParams = schemaScheduleBaseBodyParams.merge( + z.object({ userId: z.number().optional(), timeZone: timeZone.optional() }) +); + +export const schemaCreateScheduleBodyParams = schemaScheduleBaseBodyParams.merge( + z.object({ userId: z.number().optional(), name: z.string(), timeZone }) +); + +export const schemaSchedulePublic = z + .object({ id: z.number() }) + .merge(Schedule) + .merge( + z.object({ + availability: z + .array( + Availability.pick({ + id: true, + eventTypeId: true, + date: true, + days: true, + startTime: true, + endTime: true, + }) + ) + .transform((v) => + v.map((item) => ({ + ...item, + startTime: dayjs.utc(item.startTime).format("HH:mm:ss"), + endTime: dayjs.utc(item.endTime).format("HH:mm:ss"), + })) + ) + .optional(), + }) + ); diff --git a/apps/api/lib/validations/selected-calendar.ts b/apps/api/v1/lib/validations/selected-calendar.ts similarity index 83% rename from apps/api/lib/validations/selected-calendar.ts rename to apps/api/v1/lib/validations/selected-calendar.ts index e1b2138e5153a5..e012efcb6a5396 100644 --- a/apps/api/lib/validations/selected-calendar.ts +++ b/apps/api/v1/lib/validations/selected-calendar.ts @@ -9,11 +9,25 @@ export const schemaSelectedCalendarBaseBodyParams = SelectedCalendar; export const schemaSelectedCalendarPublic = SelectedCalendar.omit({}); -export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams.partial({ - userId: true, -}); +export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams + .partial({ + userId: true, + }) + .omit({ + // id will be set by the database + id: true, + // No eventTypeId support in API v1 + eventTypeId: true, + }); -export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams.partial(); +export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams + .omit({ + // id is decided by DB + id: true, + // No eventTypeId support in API v1 + eventTypeId: true, + }) + .partial(); export const selectedCalendarIdSchema = schemaQueryIdAsString.transform((v, ctx) => { /** We can assume the first part is the userId since it's an integer */ diff --git a/apps/api/lib/validations/shared/baseApiParams.ts b/apps/api/v1/lib/validations/shared/baseApiParams.ts similarity index 100% rename from apps/api/lib/validations/shared/baseApiParams.ts rename to apps/api/v1/lib/validations/shared/baseApiParams.ts diff --git a/apps/api/v1/lib/validations/shared/jsonSchema.ts b/apps/api/v1/lib/validations/shared/jsonSchema.ts new file mode 100644 index 00000000000000..423b28e95528db --- /dev/null +++ b/apps/api/v1/lib/validations/shared/jsonSchema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +// Helper schema for JSON fields +type Literal = boolean | number | string | null; +type Json = Literal | { [key: string]: Json } | Json[]; +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const jsonObjectSchema = z.record(z.lazy(() => jsonSchema)); +const jsonArraySchema = z.array(z.lazy(() => jsonSchema)); +export const jsonSchema: z.ZodSchema = z.lazy(() => + z.union([literalSchema, jsonObjectSchema, jsonArraySchema]) +); diff --git a/apps/api/lib/validations/shared/queryAttendeeEmail.ts b/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts similarity index 76% rename from apps/api/lib/validations/shared/queryAttendeeEmail.ts rename to apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts index d7919bf53fd2d4..c90abc5b421e88 100644 --- a/apps/api/lib/validations/shared/queryAttendeeEmail.ts +++ b/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts @@ -1,16 +1,18 @@ import { withValidation } from "next-validations"; import { z } from "zod"; +import { emailSchema } from "@calcom/lib/emailSchema"; + import { baseApiParams } from "./baseApiParams"; // Extracted out as utility function so can be reused // at different endpoints that require this validation. export const schemaQueryAttendeeEmail = baseApiParams.extend({ - attendeeEmail: z.string().email(), + attendeeEmail: emailSchema, }); export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({ - attendeeEmail: z.union([z.string().email(), z.array(z.string().email())]).optional(), + attendeeEmail: z.union([emailSchema, z.array(emailSchema)]).optional(), }); export const withValidQueryAttendeeEmail = withValidation({ diff --git a/apps/api/v1/lib/validations/shared/queryExpandRelations.ts b/apps/api/v1/lib/validations/shared/queryExpandRelations.ts new file mode 100644 index 00000000000000..f6a5115deb9fcf --- /dev/null +++ b/apps/api/v1/lib/validations/shared/queryExpandRelations.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +const expandEnum = z.enum(["team"]); + +export const schemaQuerySingleOrMultipleExpand = z + .union([ + expandEnum, // Allow a single value from the enum + z.array(expandEnum).refine((arr) => new Set(arr).size === arr.length, { + message: "Array values must be unique", + }), // Allow an array of enum values, with uniqueness constraint + ]) + .optional(); diff --git a/apps/api/lib/validations/shared/queryIdString.ts b/apps/api/v1/lib/validations/shared/queryIdString.ts similarity index 100% rename from apps/api/lib/validations/shared/queryIdString.ts rename to apps/api/v1/lib/validations/shared/queryIdString.ts diff --git a/apps/api/lib/validations/shared/queryIdTransformParseInt.ts b/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts similarity index 81% rename from apps/api/lib/validations/shared/queryIdTransformParseInt.ts rename to apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts index b9ec495f47bfe3..ef6d811ea996c3 100644 --- a/apps/api/lib/validations/shared/queryIdTransformParseInt.ts +++ b/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts @@ -14,3 +14,7 @@ export const withValidQueryIdTransformParseInt = withValidation({ type: "Zod", mode: "query", }); + +export const getTranscriptFromRecordingId = schemaQueryIdParseInt.extend({ + recordingId: z.string(), +}); diff --git a/apps/api/v1/lib/validations/shared/querySlug.ts b/apps/api/v1/lib/validations/shared/querySlug.ts new file mode 100644 index 00000000000000..e8fefc3fd0b7bd --- /dev/null +++ b/apps/api/v1/lib/validations/shared/querySlug.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { baseApiParams } from "./baseApiParams"; + +export const schemaQuerySlug = baseApiParams.extend({ + slug: z.string().optional(), +}); diff --git a/apps/api/lib/validations/shared/queryTeamId.ts b/apps/api/v1/lib/validations/shared/queryTeamId.ts similarity index 100% rename from apps/api/lib/validations/shared/queryTeamId.ts rename to apps/api/v1/lib/validations/shared/queryTeamId.ts diff --git a/apps/api/v1/lib/validations/shared/queryUserEmail.ts b/apps/api/v1/lib/validations/shared/queryUserEmail.ts new file mode 100644 index 00000000000000..49f96f6354ba7b --- /dev/null +++ b/apps/api/v1/lib/validations/shared/queryUserEmail.ts @@ -0,0 +1,22 @@ +import { withValidation } from "next-validations"; +import { z } from "zod"; + +import { emailSchema } from "@calcom/lib/emailSchema"; + +import { baseApiParams } from "./baseApiParams"; + +// Extracted out as utility function so can be reused +// at different endpoints that require this validation. +export const schemaQueryUserEmail = baseApiParams.extend({ + email: emailSchema, +}); + +export const schemaQuerySingleOrMultipleUserEmails = z.object({ + email: z.union([emailSchema, z.array(emailSchema)]), +}); + +export const withValidQueryUserEmail = withValidation({ + schema: schemaQueryUserEmail, + type: "Zod", + mode: "query", +}); diff --git a/apps/api/lib/validations/shared/queryUserId.ts b/apps/api/v1/lib/validations/shared/queryUserId.ts similarity index 100% rename from apps/api/lib/validations/shared/queryUserId.ts rename to apps/api/v1/lib/validations/shared/queryUserId.ts diff --git a/apps/api/lib/validations/shared/timeZone.ts b/apps/api/v1/lib/validations/shared/timeZone.ts similarity index 92% rename from apps/api/lib/validations/shared/timeZone.ts rename to apps/api/v1/lib/validations/shared/timeZone.ts index e94b0afa09afba..290bea55faf990 100644 --- a/apps/api/lib/validations/shared/timeZone.ts +++ b/apps/api/v1/lib/validations/shared/timeZone.ts @@ -1,5 +1,5 @@ import tzdata from "tzdata"; -import * as z from "zod"; +import { z } from "zod"; // @note: This is a custom validation that checks if the timezone is valid and exists in the tzdb library export const timeZone = z.string().refine((tz: string) => Object.keys(tzdata.zones).includes(tz), { diff --git a/apps/api/v1/lib/validations/team.ts b/apps/api/v1/lib/validations/team.ts new file mode 100644 index 00000000000000..9a993272520779 --- /dev/null +++ b/apps/api/v1/lib/validations/team.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +import { _TeamModel as Team } from "@calcom/prisma/zod"; + +export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }).partial({ + hideBranding: true, + metadata: true, + pendingPayment: true, + isOrganization: true, + isPlatform: true, + smsLockState: true, + smsLockReviewedByAdmin: true, + bookingLimits: true, + includeManagedEventsInLimits: true, +}); + +const schemaTeamRequiredParams = z.object({ + name: z.string().max(255), +}); + +export const schemaTeamBodyParams = schemaTeamBaseBodyParams.merge(schemaTeamRequiredParams).strict(); + +export const schemaTeamUpdateBodyParams = schemaTeamBodyParams.partial(); + +const schemaOwnerId = z.object({ + ownerId: z.number().optional(), +}); + +export const schemaTeamCreateBodyParams = schemaTeamBodyParams.merge(schemaOwnerId).strict(); + +export const schemaTeamReadPublic = Team.omit({}); + +export const schemaTeamsReadPublic = z.array(schemaTeamReadPublic); diff --git a/apps/api/v1/lib/validations/user.ts b/apps/api/v1/lib/validations/user.ts new file mode 100644 index 00000000000000..2b86addd213e67 --- /dev/null +++ b/apps/api/v1/lib/validations/user.ts @@ -0,0 +1,180 @@ +import { z } from "zod"; + +import { emailSchema } from "@calcom/lib/emailSchema"; +import { checkUsername } from "@calcom/lib/server/checkUsername"; +import { _UserModel as User } from "@calcom/prisma/zod"; +import { iso8601 } from "@calcom/prisma/zod-utils"; + +import { isValidBase64Image } from "~/lib/utils/isValidBase64Image"; +import { timeZone } from "~/lib/validations/shared/timeZone"; + +// @note: These are the ONLY values allowed as weekStart. So user don't introduce bad data. +enum weekdays { + MONDAY = "Monday", + TUESDAY = "Tuesday", + WEDNESDAY = "Wednesday", + THURSDAY = "Thursday", + FRIDAY = "Friday", + SATURDAY = "Saturday", + SUNDAY = "Sunday", +} + +// @note: extracted from apps/web/next-i18next.config.js, update if new locales. +enum locales { + EN = "en", + FR = "fr", + IT = "it", + RU = "ru", + ES = "es", + DE = "de", + PT = "pt", + RO = "ro", + NL = "nl", + PT_BR = "pt-BR", + ES_419 = "es-419", + KO = "ko", + JA = "ja", + PL = "pl", + AR = "ar", + IW = "iw", + ZH_CN = "zh-CN", + ZH_TW = "zh-TW", + CS = "cs", + SR = "sr", + SV = "sv", + VI = "vi", +} +enum theme { + DARK = "dark", + LIGHT = "light", +} + +enum timeFormat { + TWELVE = 12, + TWENTY_FOUR = 24, +} + +const usernameSchema = z + .string() + .transform((v) => v.toLowerCase()) + // .refine(() => {}) + .superRefine(async (val, ctx) => { + if (val) { + const result = await checkUsername(val); + if (!result.available) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "already_in_use_error" }); + if (result.premium) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "premium_username" }); + } + }); + +// @note: These are the values that are editable via PATCH method on the user Model +export const schemaUserBaseBodyParams = User.pick({ + name: true, + email: true, + username: true, + bio: true, + timeZone: true, + weekStart: true, + theme: true, + appTheme: true, + defaultScheduleId: true, + locale: true, + hideBranding: true, + timeFormat: true, + brandColor: true, + darkBrandColor: true, + allowDynamicBooking: true, + role: true, + // @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI. + // avatar: true, +}).partial(); +// @note: partial() is used to allow for the user to edit only the fields they want to edit making all optional, +// if want to make any required do it in the schemaRequiredParams + +// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value +// for example making weekStart only accept weekdays as input +const schemaUserEditParams = z.object({ + email: emailSchema.toLowerCase(), + username: usernameSchema, + weekStart: z.nativeEnum(weekdays).optional(), + brandColor: z.string().min(4).max(9).regex(/^#/).optional(), + darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), + hideBranding: z.boolean().optional(), + timeZone: timeZone.optional(), + theme: z.nativeEnum(theme).optional().nullable(), + appTheme: z.nativeEnum(theme).optional().nullable(), + timeFormat: z.nativeEnum(timeFormat).optional(), + defaultScheduleId: z + .number() + .refine((id: number) => id > 0) + .optional() + .nullable(), + locale: z.nativeEnum(locales).optional().nullable(), + avatar: z.string().refine(isValidBase64Image).optional(), +}); + +// @note: These are the values that are editable via PATCH method on the user Model, +// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. + +const schemaUserCreateParams = z.object({ + email: emailSchema.toLowerCase(), + username: usernameSchema, + weekStart: z.nativeEnum(weekdays).optional(), + brandColor: z.string().min(4).max(9).regex(/^#/).optional(), + darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), + hideBranding: z.boolean().optional(), + timeZone: timeZone.optional(), + theme: z.nativeEnum(theme).optional().nullable(), + appTheme: z.nativeEnum(theme).optional().nullable(), + timeFormat: z.nativeEnum(timeFormat).optional(), + defaultScheduleId: z + .number() + .refine((id: number) => id > 0) + .optional() + .nullable(), + locale: z.nativeEnum(locales).optional(), + createdDate: iso8601.optional(), + avatar: z.string().refine(isValidBase64Image).optional(), +}); + +// @note: These are the values that are editable via PATCH method on the user Model, +// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. +export const schemaUserEditBodyParams = schemaUserBaseBodyParams + .merge(schemaUserEditParams) + .omit({}) + .partial() + .strict(); + +export const schemaUserCreateBodyParams = schemaUserBaseBodyParams + .merge(schemaUserCreateParams) + .omit({}) + .strict(); + +// @note: These are the values that are always returned when reading a user +export const schemaUserReadPublic = User.pick({ + id: true, + username: true, + name: true, + email: true, + emailVerified: true, + bio: true, + avatar: true, + timeZone: true, + weekStart: true, + endTime: true, + bufferTime: true, + appTheme: true, + theme: true, + defaultScheduleId: true, + locale: true, + timeFormat: true, + hideBranding: true, + brandColor: true, + darkBrandColor: true, + allowDynamicBooking: true, + createdDate: true, + verified: true, + invitedTo: true, + role: true, +}); + +export const schemaUsersReadPublic = z.array(schemaUserReadPublic); diff --git a/apps/api/v1/lib/validations/webhook.ts b/apps/api/v1/lib/validations/webhook.ts new file mode 100644 index 00000000000000..71219d2fa04529 --- /dev/null +++ b/apps/api/v1/lib/validations/webhook.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants"; +import { _WebhookModel as Webhook } from "@calcom/prisma/zod"; + +const schemaWebhookBaseBodyParams = Webhook.pick({ + userId: true, + eventTypeId: true, + eventTriggers: true, + active: true, + subscriberUrl: true, + payloadTemplate: true, +}); + +export const schemaWebhookCreateParams = z + .object({ + // subscriberUrl: z.string().url(), + // eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(), + // active: z.boolean(), + payloadTemplate: z.string().optional().nullable(), + eventTypeId: z.number().optional(), + userId: z.number().optional(), + secret: z.string().optional().nullable(), + // API shouldn't mess with Apps webhooks yet (ie. Zapier) + // appId: z.string().optional().nullable(), + }) + .strict(); + +export const schemaWebhookCreateBodyParams = schemaWebhookBaseBodyParams.merge(schemaWebhookCreateParams); + +export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams + .merge( + z.object({ + eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), + secret: z.string().optional().nullable(), + }) + ) + .partial() + .strict(); + +export const schemaWebhookReadPublic = Webhook.pick({ + id: true, + userId: true, + eventTypeId: true, + payloadTemplate: true, + eventTriggers: true, + // FIXME: We have some invalid urls saved in the DB + // subscriberUrl: true, + /** @todo: find out how to properly add back and validate those. */ + // eventType: true, + // app: true, + appId: true, +}).merge( + z.object({ + subscriberUrl: z.string(), + }) +); diff --git a/apps/api/next-env.d.ts b/apps/api/v1/next-env.d.ts similarity index 100% rename from apps/api/next-env.d.ts rename to apps/api/v1/next-env.d.ts diff --git a/apps/api/v1/next-i18next.config.js b/apps/api/v1/next-i18next.config.js new file mode 100644 index 00000000000000..cab1a8b008039f --- /dev/null +++ b/apps/api/v1/next-i18next.config.js @@ -0,0 +1,10 @@ +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; diff --git a/apps/api/v1/next.config.js b/apps/api/v1/next.config.js new file mode 100644 index 00000000000000..289dfbb9e8049a --- /dev/null +++ b/apps/api/v1/next.config.js @@ -0,0 +1,103 @@ +const { withAxiom } = require("next-axiom"); +const { withSentryConfig } = require("@sentry/nextjs"); + +const plugins = [withAxiom]; + +/** @type {import("next").NextConfig} */ +const nextConfig = { + experimental: { + instrumentationHook: true, + }, + transpilePackages: [ + "@calcom/app-store", + "@calcom/core", + "@calcom/dayjs", + "@calcom/emails", + "@calcom/features", + "@calcom/lib", + "@calcom/prisma", + "@calcom/trpc", + ], + async headers() { + return [ + { + source: "/docs", + headers: [ + { + key: "Access-Control-Allow-Credentials", + value: "true", + }, + { + key: "Access-Control-Allow-Origin", + value: "*", + }, + { + key: "Access-Control-Allow-Methods", + value: "GET, OPTIONS, PATCH, DELETE, POST, PUT", + }, + { + key: "Access-Control-Allow-Headers", + value: + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, api_key, Authorization", + }, + ], + }, + ]; + }, + async rewrites() { + return { + afterFiles: [ + // This redirects requests recieved at / the root to the /api/ folder. + { + source: "/v:version/:rest*", + destination: "/api/v:version/:rest*", + }, + { + source: "/api/v2", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`, + }, + { + source: "/api/v2/health", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`, + }, + { + source: "/api/v2/docs/:path*", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/docs/:path*`, + }, + { + source: "/api/v2/:path*", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/api/v2/:path*`, + }, + // This redirects requests to api/v*/ to /api/ passing version as a query parameter. + { + source: "/api/v:version/:rest*", + destination: "/api/:rest*?version=:version", + }, + // Keeps backwards compatibility with old webhook URLs + { + source: "/api/hooks/:rest*", + destination: "/api/webhooks/:rest*", + }, + ], + fallback: [ + // These rewrites are checked after both pages/public files + // and dynamic routes are checked + { + source: "/:path*", + destination: `/api/:path*`, + }, + ], + }; + }, +}; + +if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) { + plugins.push((nextConfig) => + withSentryConfig(nextConfig, { + autoInstrumentServerFunctions: true, + hideSourceMaps: true, + }) + ); +} + +module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig); diff --git a/apps/api/next.d.ts b/apps/api/v1/next.d.ts similarity index 76% rename from apps/api/next.d.ts rename to apps/api/v1/next.d.ts index 7d5f82441a9a52..6816e97e4fc95c 100644 --- a/apps/api/next.d.ts +++ b/apps/api/v1/next.d.ts @@ -1,8 +1,6 @@ import type { Session } from "next-auth"; import type { NextApiRequest as BaseNextApiRequest } from "next/types"; -import type { PrismaClient } from "@calcom/prisma/client"; - export type * from "next/types"; export declare module "next" { @@ -10,12 +8,12 @@ export declare module "next" { session?: Session | null; userId: number; + user?: { role: string; locked: boolean; email: string } | null; method: string; - prisma: PrismaClient; // session: { user: { id: number } }; // query: Partial<{ [key: string]: string | string[] }>; - isAdmin: boolean; - isCustomPrisma: boolean; + isSystemWideAdmin: boolean; + isOrganizationOwnerOrAdmin: boolean; pagination: { take: number; skip: number }; } } diff --git a/apps/api/v1/package.json b/apps/api/v1/package.json new file mode 100644 index 00000000000000..8546d248a6c721 --- /dev/null +++ b/apps/api/v1/package.json @@ -0,0 +1,47 @@ +{ + "name": "@calcom/api", + "version": "1.0.0", + "description": "Public API for Cal.com", + "main": "index.ts", + "repository": "git@github.com:calcom/api.git", + "author": "Cal.com Inc.", + "private": true, + "scripts": { + "build": "next build", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", + "dev": "PORT=3003 next dev", + "lint": "eslint . --ignore-path .gitignore", + "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", + "start": "PORT=3003 next start", + "docker-start-api": "PORT=80 next start", + "type-check": "tsc --pretty --noEmit", + "type-check:ci": "tsc-absolute --pretty --noEmit" + }, + "devDependencies": { + "@calcom/tsconfig": "*", + "@calcom/types": "*", + "node-mocks-http": "^1.11.0" + }, + "dependencies": { + "@calcom/app-store": "*", + "@calcom/core": "*", + "@calcom/dayjs": "*", + "@calcom/emails": "*", + "@calcom/features": "*", + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@calcom/trpc": "*", + "@sentry/nextjs": "^8.8.0", + "bcryptjs": "^2.4.3", + "memory-cache": "^0.2.0", + "next": "^13.5.6", + "next-api-middleware": "^1.0.1", + "next-axiom": "^0.17.0", + "next-swagger-doc": "^0.3.6", + "next-validations": "^0.2.0", + "typescript": "^4.9.4", + "tzdata": "^1.0.30", + "uuid": "^8.3.2", + "zod": "^3.22.4" + } +} diff --git a/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..f76ec117c606b3 --- /dev/null +++ b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts @@ -0,0 +1,18 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; + +export async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdAsString.parse(req.query); + // Admin can check any api key + if (isSystemWideAdmin) return; + // Check if user can access the api key + const apiKey = await prisma.apiKey.findFirst({ + where: { id, userId }, + }); + if (!apiKey) throw new HttpError({ statusCode: 404, message: "API key not found" }); +} diff --git a/apps/api/v1/pages/api/api-keys/[id]/_delete.ts b/apps/api/v1/pages/api/api-keys/[id]/_delete.ts new file mode 100644 index 00000000000000..f42146caa886a5 --- /dev/null +++ b/apps/api/v1/pages/api/api-keys/[id]/_delete.ts @@ -0,0 +1,15 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; + +async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdAsString.parse(query); + await prisma.apiKey.delete({ where: { id } }); + return { message: `ApiKey with id: ${id} deleted` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/api-keys/[id]/_get.ts b/apps/api/v1/pages/api/api-keys/[id]/_get.ts new file mode 100644 index 00000000000000..7c9645ce8759bc --- /dev/null +++ b/apps/api/v1/pages/api/api-keys/[id]/_get.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { apiKeyPublicSchema } from "~/lib/validations/api-key"; +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; + +async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdAsString.parse(query); + const api_key = await prisma.apiKey.findUniqueOrThrow({ where: { id } }); + return { api_key: apiKeyPublicSchema.parse(api_key) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/api-keys/[id]/_patch.ts b/apps/api/v1/pages/api/api-keys/[id]/_patch.ts new file mode 100644 index 00000000000000..b08c8b97ab4113 --- /dev/null +++ b/apps/api/v1/pages/api/api-keys/[id]/_patch.ts @@ -0,0 +1,17 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { apiKeyEditBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key"; +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; + +async function patchHandler(req: NextApiRequest) { + const { body } = req; + const { id } = schemaQueryIdAsString.parse(req.query); + const data = apiKeyEditBodySchema.parse(body); + const api_key = await prisma.apiKey.update({ where: { id }, data }); + return { api_key: apiKeyPublicSchema.parse(api_key) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/api-keys/[id]/index.ts b/apps/api/v1/pages/api/api-keys/[id]/index.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/index.ts rename to apps/api/v1/pages/api/api-keys/[id]/index.ts diff --git a/apps/api/v1/pages/api/api-keys/_get.ts b/apps/api/v1/pages/api/api-keys/_get.ts new file mode 100644 index 00000000000000..84d61c3879be3e --- /dev/null +++ b/apps/api/v1/pages/api/api-keys/_get.ts @@ -0,0 +1,41 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { Ensure } from "@calcom/types/utils"; + +import { apiKeyPublicSchema } from "~/lib/validations/api-key"; +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +type CustomNextApiRequest = NextApiRequest & { + args?: Prisma.ApiKeyFindManyArgs; +}; + +/** Admins can query other users' API keys */ +function handleAdminRequests(req: CustomNextApiRequest) { + // To match type safety with runtime + if (!hasReqArgs(req)) throw Error("Missing req.args"); + const { userId, isSystemWideAdmin } = req; + if (isSystemWideAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + req.args.where = { userId: { in: userIds } }; + if (Array.isArray(query.userId)) req.args.orderBy = { userId: "asc" }; + } +} + +function hasReqArgs(req: CustomNextApiRequest): req is Ensure { + return "args" in req; +} + +async function getHandler(req: CustomNextApiRequest) { + const { userId, isSystemWideAdmin } = req; + req.args = isSystemWideAdmin ? {} : { where: { userId } }; + // Proof of concept: allowing mutation in exchange of composability + handleAdminRequests(req); + const data = await prisma.apiKey.findMany(req.args); + return { api_keys: data.map((v) => apiKeyPublicSchema.parse(v)) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/api-keys/_post.ts b/apps/api/v1/pages/api/api-keys/_post.ts new file mode 100644 index 00000000000000..3331585c355435 --- /dev/null +++ b/apps/api/v1/pages/api/api-keys/_post.ts @@ -0,0 +1,46 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import { v4 } from "uuid"; + +import { generateUniqueAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { apiKeyCreateBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key"; + +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { neverExpires, userId: bodyUserId, ...input } = apiKeyCreateBodySchema.parse(req.body); + const [hashedKey, apiKey] = generateUniqueAPIKey(); + const args: Prisma.ApiKeyCreateArgs = { + data: { + id: v4(), + userId, + ...input, + // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input + expiresAt: neverExpires ? null : input.expiresAt, + hashedKey, + }, + }; + + if (!isSystemWideAdmin && bodyUserId) + throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isSystemWideAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: bodyUserId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = bodyUserId; + } + + const result = await prisma.apiKey.create(args); + return { + api_key: { + ...apiKeyPublicSchema.parse(result), + key: `${process.env.API_KEY_PREFIX ?? "cal_"}${apiKey}`, + }, + message: "API key created successfully. Save the `key` value as it won't be displayed again.", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/api-keys/index.ts b/apps/api/v1/pages/api/api-keys/index.ts similarity index 100% rename from apps/api/pages/api/api-keys/index.ts rename to apps/api/v1/pages/api/api-keys/index.ts diff --git a/apps/api/v1/pages/api/attendees/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/attendees/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..8f6a5058a53e50 --- /dev/null +++ b/apps/api/v1/pages/api/attendees/[id]/_auth-middleware.ts @@ -0,0 +1,21 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const query = schemaQueryIdParseInt.parse(req.query); + // @note: Here we make sure to only return attendee's of the user's own bookings if the user is not an admin. + if (isSystemWideAdmin) return; + // Find all user bookings, including attendees + const attendee = await prisma.attendee.findFirst({ + where: { id: query.id, booking: { userId } }, + }); + // Flatten and merge all the attendees in one array + if (!attendee) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/attendees/[id]/_delete.ts b/apps/api/v1/pages/api/attendees/[id]/_delete.ts new file mode 100644 index 00000000000000..4fb507911804fa --- /dev/null +++ b/apps/api/v1/pages/api/attendees/[id]/_delete.ts @@ -0,0 +1,44 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /attendees/{id}: + * delete: + * operationId: removeAttendeeById + * summary: Remove an existing attendee + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the attendee to delete + * tags: + * - attendees + * responses: + * 201: + * description: OK, attendee removed successfully + * 400: + * description: Bad request. Attendee id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await prisma.attendee.delete({ where: { id } }); + return { message: `Attendee with id: ${id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/attendees/[id]/_get.ts b/apps/api/v1/pages/api/attendees/[id]/_get.ts new file mode 100644 index 00000000000000..bf130f2f6604e2 --- /dev/null +++ b/apps/api/v1/pages/api/attendees/[id]/_get.ts @@ -0,0 +1,45 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaAttendeeReadPublic } from "~/lib/validations/attendee"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /attendees/{id}: + * get: + * operationId: getAttendeeById + * summary: Find an attendee + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the attendee to get + * tags: + * - attendees + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Attendee was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const attendee = await prisma.attendee.findUnique({ where: { id } }); + return { attendee: schemaAttendeeReadPublic.parse(attendee) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/attendees/[id]/_patch.ts b/apps/api/v1/pages/api/attendees/[id]/_patch.ts new file mode 100644 index 00000000000000..2fc29ebcb2aa09 --- /dev/null +++ b/apps/api/v1/pages/api/attendees/[id]/_patch.ts @@ -0,0 +1,77 @@ +import type { NextApiRequest } from "next"; +import type { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaAttendeeEditBodyParams, schemaAttendeeReadPublic } from "~/lib/validations/attendee"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /attendees/{id}: + * patch: + * operationId: editAttendeeById + * summary: Edit an existing attendee + * requestBody: + * description: Edit an existing attendee related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * format: email + * name: + * type: string + * timeZone: + * type: string + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the attendee to get + * tags: + * - attendees + * responses: + * 201: + * description: OK, attendee edited successfully + * 400: + * description: Bad request. Attendee body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ + +export async function patchHandler(req: NextApiRequest) { + const { query, body } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = schemaAttendeeEditBodyParams.parse(body); + await checkPermissions(req, data); + const attendee = await prisma.attendee.update({ where: { id }, data }); + return { attendee: schemaAttendeeReadPublic.parse(attendee) }; +} + +async function checkPermissions(req: NextApiRequest, body: z.infer) { + const { isSystemWideAdmin } = req; + if (isSystemWideAdmin) return; + const { userId } = req; + const { bookingId } = body; + if (bookingId) { + // Ensure that the booking the attendee is being added to belongs to the user + const booking = await prisma.booking.findFirst({ where: { id: bookingId, userId } }); + if (!booking) throw new HttpError({ statusCode: 403, message: "You don't have access to the booking" }); + } +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/attendees/[id]/index.ts b/apps/api/v1/pages/api/attendees/[id]/index.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/index.ts rename to apps/api/v1/pages/api/attendees/[id]/index.ts diff --git a/apps/api/v1/pages/api/attendees/_get.ts b/apps/api/v1/pages/api/attendees/_get.ts new file mode 100644 index 00000000000000..9f1f456766af36 --- /dev/null +++ b/apps/api/v1/pages/api/attendees/_get.ts @@ -0,0 +1,42 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaAttendeeReadPublic } from "~/lib/validations/attendee"; + +/** + * @swagger + * /attendees: + * get: + * operationId: listAttendees + * summary: Find all attendees + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - attendees + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No attendees were found + */ +async function handler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const args: Prisma.AttendeeFindManyArgs = isSystemWideAdmin ? {} : { where: { booking: { userId } } }; + const data = await prisma.attendee.findMany(args); + const attendees = data.map((attendee) => schemaAttendeeReadPublic.parse(attendee)); + if (!attendees) throw new HttpError({ statusCode: 404, message: "No attendees were found" }); + return { attendees }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/attendees/_post.ts b/apps/api/v1/pages/api/attendees/_post.ts new file mode 100644 index 00000000000000..03ca47a61409f8 --- /dev/null +++ b/apps/api/v1/pages/api/attendees/_post.ts @@ -0,0 +1,82 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "~/lib/validations/attendee"; + +/** + * @swagger + * /attendees: + * post: + * operationId: addAttendee + * summary: Creates a new attendee + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new attendee related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - bookingId + * - name + * - email + * - timeZone + * properties: + * bookingId: + * type: number + * email: + * type: string + * format: email + * name: + * type: string + * timeZone: + * type: string + * tags: + * - attendees + * responses: + * 201: + * description: OK, attendee created + * 400: + * description: Bad request. Attendee body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const body = schemaAttendeeCreateBodyParams.parse(req.body); + + if (!isSystemWideAdmin) { + const userBooking = await prisma.booking.findFirst({ + where: { userId, id: body.bookingId }, + select: { id: true }, + }); + // Here we make sure to only return attendee's of the user's own bookings. + if (!userBooking) throw new HttpError({ statusCode: 403, message: "Forbidden" }); + } + + const data = await prisma.attendee.create({ + data: { + email: body.email, + name: body.name, + timeZone: body.timeZone, + booking: { connect: { id: body.bookingId } }, + }, + }); + + return { + attendee: schemaAttendeeReadPublic.parse(data), + message: "Attendee created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/attendees/index.ts b/apps/api/v1/pages/api/attendees/index.ts similarity index 100% rename from apps/api/pages/api/attendees/index.ts rename to apps/api/v1/pages/api/attendees/index.ts diff --git a/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..245f5c9cb05272 --- /dev/null +++ b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts @@ -0,0 +1,21 @@ +import type { NextApiRequest } from "next"; + +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin, query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + /** Admins can skip the ownership verification */ + if (isSystemWideAdmin) return; + /** + * There's a caveat here. If the availability exists but the user doesn't own it, + * the user will see a 404 error which may or not be the desired behavior. + */ + await prisma.availability.findFirstOrThrow({ + where: { id, Schedule: { userId } }, + }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/availabilities/[id]/_delete.ts b/apps/api/v1/pages/api/availabilities/[id]/_delete.ts new file mode 100644 index 00000000000000..be891c76853ea4 --- /dev/null +++ b/apps/api/v1/pages/api/availabilities/[id]/_delete.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /availabilities/{id}: + * delete: + * operationId: removeAvailabilityById + * summary: Remove an existing availability + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the availability to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: integer + * description: Your API key + * tags: + * - availabilities + * externalDocs: + * url: https://docs.cal.com/docs/core-features/availability + * responses: + * 201: + * description: OK, availability removed successfully + * 400: + * description: Bad request. Availability id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await prisma.availability.delete({ where: { id } }); + return { message: `Availability with id: ${id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/availabilities/[id]/_get.ts b/apps/api/v1/pages/api/availabilities/[id]/_get.ts new file mode 100644 index 00000000000000..248aa1194993b0 --- /dev/null +++ b/apps/api/v1/pages/api/availabilities/[id]/_get.ts @@ -0,0 +1,50 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaAvailabilityReadPublic } from "~/lib/validations/availability"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /availabilities/{id}: + * get: + * operationId: getAvailabilityById + * summary: Find an availability + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the availability to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: integer + * description: Your API key + * tags: + * - availabilities + * externalDocs: + * url: https://docs.cal.com/docs/core-features/availability + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid + * 404: + * description: Availability not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const availability = await prisma.availability.findUnique({ + where: { id }, + include: { Schedule: { select: { userId: true } } }, + }); + return { availability: schemaAvailabilityReadPublic.parse(availability) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/availabilities/[id]/_patch.ts b/apps/api/v1/pages/api/availabilities/[id]/_patch.ts new file mode 100644 index 00000000000000..66fc949addc6d2 --- /dev/null +++ b/apps/api/v1/pages/api/availabilities/[id]/_patch.ts @@ -0,0 +1,87 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + schemaAvailabilityEditBodyParams, + schemaAvailabilityReadPublic, +} from "~/lib/validations/availability"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /availabilities/{id}: + * patch: + * operationId: editAvailabilityById + * summary: Edit an existing availability + * parameters: + * - in: query + * name: apiKey + * required: true + * description: Your API key + * schema: + * type: integer + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID of the availability to edit + * requestBody: + * description: Edit an existing availability related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * days: + * type: array + * description: Array of integers depicting weekdays + * items: + * type: integer + * enum: [0, 1, 2, 3, 4, 5] + * scheduleId: + * type: integer + * description: ID of schedule this availability is associated with + * startTime: + * type: string + * description: Start time of the availability + * endTime: + * type: string + * description: End time of the availability + * examples: + * availability: + * summary: An example of availability + * value: + * scheduleId: 123 + * days: [1,2,3,5] + * startTime: 1970-01-01T17:00:00.000Z + * endTime: 1970-01-01T17:00:00.000Z + * + * tags: + * - availabilities + * externalDocs: + * url: https://docs.cal.com/docs/core-features/availability + * responses: + * 201: + * description: OK, availability edited successfully + * 400: + * description: Bad request. Availability body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query, body } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = schemaAvailabilityEditBodyParams.parse(body); + const availability = await prisma.availability.update({ + where: { id }, + data, + include: { Schedule: { select: { userId: true } } }, + }); + return { availability: schemaAvailabilityReadPublic.parse(availability) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/availabilities/[id]/index.ts b/apps/api/v1/pages/api/availabilities/[id]/index.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/index.ts rename to apps/api/v1/pages/api/availabilities/[id]/index.ts diff --git a/apps/api/v1/pages/api/availabilities/_post.ts b/apps/api/v1/pages/api/availabilities/_post.ts new file mode 100644 index 00000000000000..1cd6b2e7e88376 --- /dev/null +++ b/apps/api/v1/pages/api/availabilities/_post.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + schemaAvailabilityCreateBodyParams, + schemaAvailabilityReadPublic, +} from "~/lib/validations/availability"; + +/** + * @swagger + * /availabilities: + * post: + * operationId: addAvailability + * summary: Creates a new availability + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Edit an existing availability related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - scheduleId + * - startTime + * - endTime + * properties: + * days: + * type: array + * description: Array of integers depicting weekdays + * items: + * type: integer + * enum: [0, 1, 2, 3, 4, 5] + * scheduleId: + * type: integer + * description: ID of schedule this availability is associated with + * startTime: + * type: string + * description: Start time of the availability + * endTime: + * type: string + * description: End time of the availability + * examples: + * availability: + * summary: An example of availability + * value: + * scheduleId: 123 + * days: [1,2,3,5] + * startTime: 1970-01-01T17:00:00.000Z + * endTime: 1970-01-01T17:00:00.000Z + * + * + * tags: + * - availabilities + * externalDocs: + * url: https://docs.cal.com/docs/core-features/availability + * responses: + * 201: + * description: OK, availability created + * 400: + * description: Bad request. Availability body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const data = schemaAvailabilityCreateBodyParams.parse(req.body); + await checkPermissions(req); + const availability = await prisma.availability.create({ + data, + include: { Schedule: { select: { userId: true } } }, + }); + req.statusCode = 201; + return { + availability: schemaAvailabilityReadPublic.parse(availability), + message: "Availability created successfully", + }; +} + +async function checkPermissions(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + if (isSystemWideAdmin) return; + const data = schemaAvailabilityCreateBodyParams.parse(req.body); + const schedule = await prisma.schedule.findFirst({ + where: { userId, id: data.scheduleId }, + }); + if (!schedule) + throw new HttpError({ statusCode: 401, message: "You can't add availabilities to this schedule" }); +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/availabilities/index.ts b/apps/api/v1/pages/api/availabilities/index.ts similarity index 100% rename from apps/api/pages/api/availabilities/index.ts rename to apps/api/v1/pages/api/availabilities/index.ts diff --git a/apps/api/v1/pages/api/availability/_get.ts b/apps/api/v1/pages/api/availability/_get.ts new file mode 100644 index 00000000000000..03b0ab42a235b4 --- /dev/null +++ b/apps/api/v1/pages/api/availability/_get.ts @@ -0,0 +1,252 @@ +import type { NextApiRequest } from "next"; +import { z } from "zod"; + +import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { availabilityUserSelect } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { stringOrNumber } from "@calcom/prisma/zod-utils"; + +/** + * @swagger + * /teams/{teamId}/availability: + * get: + * summary: Find team availability + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * example: "1234abcd5678efgh" + * description: Your API key + * - in: path + * name: teamId + * required: true + * schema: + * type: integer + * example: 123 + * description: ID of the team to fetch the availability for + * - in: query + * name: dateFrom + * schema: + * type: string + * format: date + * example: "2023-05-14 00:00:00" + * description: Start Date of the availability query + * - in: query + * name: dateTo + * schema: + * type: string + * format: date + * example: "2023-05-20 00:00:00" + * description: End Date of the availability query + * - in: query + * name: eventTypeId + * schema: + * type: integer + * example: 123 + * description: Event Type ID of the event type to fetch the availability for + * operationId: team-availability + * tags: + * - availability + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * example: + * busy: + * - start: "2023-05-14T10:00:00.000Z" + * end: "2023-05-14T11:00:00.000Z" + * title: "Team meeting between Alice and Bob" + * - start: "2023-05-15T14:00:00.000Z" + * end: "2023-05-15T15:00:00.000Z" + * title: "Project review between Carol and Dave" + * - start: "2023-05-16T09:00:00.000Z" + * end: "2023-05-16T10:00:00.000Z" + * - start: "2023-05-17T13:00:00.000Z" + * end: "2023-05-17T14:00:00.000Z" + * timeZone: "America/New_York" + * workingHours: + * - days: [1, 2, 3, 4, 5] + * startTime: 540 + * endTime: 1020 + * userId: 101 + * dateOverrides: + * - date: "2023-05-15" + * startTime: 600 + * endTime: 960 + * userId: 101 + * currentSeats: 4 + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Team not found | Team has no members + * + * /availability: + * get: + * summary: Find user availability + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * example: "1234abcd5678efgh" + * description: Your API key + * - in: query + * name: userId + * schema: + * type: integer + * example: 101 + * description: ID of the user to fetch the availability for + * - in: query + * name: username + * schema: + * type: string + * example: "alice" + * description: username of the user to fetch the availability for + * - in: query + * name: dateFrom + * schema: + * type: string + * format: date + * example: "2023-05-14 00:00:00" + * description: Start Date of the availability query + * - in: query + * name: dateTo + * schema: + * type: string + * format: date + * example: "2023-05-20 00:00:00" + * description: End Date of the availability query + * - in: query + * name: eventTypeId + * schema: + * type: integer + * example: 123 + * description: Event Type ID of the event type to fetch the availability for + * operationId: user-availability + * tags: + * - availability + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * example: + * busy: + * - start: "2023-05-14T10:00:00.000Z" + * end: "2023-05-14T11:00:00.000Z" + * title: "Team meeting between Alice and Bob" + * - start: "2023-05-15T14:00:00.000Z" + * end: "2023-05-15T15:00:00.000Z" + * title: "Project review between Carol and Dave" + * - start: "2023-05-16T09:00:00.000Z" + * end: "2023-05-16T10:00:00.000Z" + * - start: "2023-05-17T13:00:00.000Z" + * end: "2023-05-17T14:00:00.000Z" + * timeZone: "America/New_York" + * workingHours: + * - days: [1, 2, 3, 4, 5] + * startTime: 540 + * endTime: 1020 + * userId: 101 + * dateOverrides: + * - date: "2023-05-15" + * startTime: 600 + * endTime: 960 + * userId: 101 + * currentSeats: 4 + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: User not found + */ +interface MemberRoles { + [userId: number | string]: MembershipRole; +} + +const availabilitySchema = z + .object({ + userId: stringOrNumber.optional(), + teamId: stringOrNumber.optional(), + username: z.string().optional(), + dateFrom: z.string(), + dateTo: z.string(), + eventTypeId: stringOrNumber.optional(), + }) + .refine( + (data) => !!data.username || !!data.userId || !!data.teamId, + "Either username or userId or teamId should be filled in." + ); + +async function handler(req: NextApiRequest) { + const { isSystemWideAdmin, userId: reqUserId } = req; + const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query); + if (!teamId) + return getUserAvailability({ + username, + dateFrom, + dateTo, + eventTypeId, + userId, + returnDateOverrides: true, + bypassBusyCalendarTimes: false, + }); + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { members: true }, + }); + if (!team) throw new HttpError({ statusCode: 404, message: "teamId not found" }); + if (!team.members) throw new HttpError({ statusCode: 404, message: "team has no members" }); + const allMemberIds = team.members.reduce((allMemberIds: number[], member) => { + if (member.accepted) { + allMemberIds.push(member.userId); + } + return allMemberIds; + }, []); + const members = await prisma.user.findMany({ + where: { id: { in: allMemberIds } }, + select: availabilityUserSelect, + }); + const memberRoles: MemberRoles = team.members.reduce((acc: MemberRoles, membership) => { + acc[membership.userId] = membership.role; + return acc; + }, {} as MemberRoles); + // check if the user is a team Admin or Owner, if it is a team request, or a system Admin + const isUserAdminOrOwner = + memberRoles[reqUserId] == MembershipRole.ADMIN || + memberRoles[reqUserId] == MembershipRole.OWNER || + isSystemWideAdmin; + if (!isUserAdminOrOwner) throw new HttpError({ statusCode: 403, message: "Forbidden" }); + const availabilities = members.map(async (user) => { + return { + userId: user.id, + availability: await getUserAvailability({ + userId: user.id, + dateFrom, + dateTo, + eventTypeId, + returnDateOverrides: true, + bypassBusyCalendarTimes: false, + }), + }; + }); + const settled = await Promise.all(availabilities); + if (!settled) + throw new HttpError({ + statusCode: 401, + message: "We had an issue retrieving all your members availabilities", + }); + return settled; +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/availability/index.ts b/apps/api/v1/pages/api/availability/index.ts similarity index 100% rename from apps/api/pages/api/availability/index.ts rename to apps/api/v1/pages/api/availability/index.ts diff --git a/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..542f16265b5c42 --- /dev/null +++ b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + // Here we make sure to only return references of the user's own bookings if the user is not an admin. + if (isSystemWideAdmin) return; + // Find all references where the user has bookings + const bookingReference = await prisma.bookingReference.findFirst({ + where: { id, booking: { userId } }, + }); + if (!bookingReference) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/booking-references/[id]/_delete.ts b/apps/api/v1/pages/api/booking-references/[id]/_delete.ts new file mode 100644 index 00000000000000..0dbcf32525d9a1 --- /dev/null +++ b/apps/api/v1/pages/api/booking-references/[id]/_delete.ts @@ -0,0 +1,44 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /booking-references/{id}: + * delete: + * operationId: removeBookingReferenceById + * summary: Remove an existing booking reference + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking reference to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - booking-references + * responses: + * 201: + * description: OK, bookingReference removed successfully + * 400: + * description: Bad request. BookingReference id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await prisma.bookingReference.delete({ where: { id } }); + return { message: `BookingReference with id: ${id} deleted` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/booking-references/[id]/_get.ts b/apps/api/v1/pages/api/booking-references/[id]/_get.ts new file mode 100644 index 00000000000000..5ef19d92633be2 --- /dev/null +++ b/apps/api/v1/pages/api/booking-references/[id]/_get.ts @@ -0,0 +1,45 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-reference"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /booking-references/{id}: + * get: + * operationId: getBookingReferenceById + * summary: Find a booking reference + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking reference to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - booking-references + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: BookingReference was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const booking_reference = await prisma.bookingReference.findUniqueOrThrow({ where: { id } }); + return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/booking-references/[id]/_patch.ts b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts new file mode 100644 index 00000000000000..b699de7cd959a7 --- /dev/null +++ b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts @@ -0,0 +1,79 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + schemaBookingEditBodyParams, + schemaBookingReferenceReadPublic, +} from "~/lib/validations/booking-reference"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /booking-references/{id}: + * patch: + * operationId: editBookingReferenceById + * summary: Edit an existing booking reference + * requestBody: + * description: Edit an existing booking reference related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * meetingId: + * type: string + * meetingPassword: + * type: string + * externalCalendarId: + * type: string + * deleted: + * type: boolean + * credentialId: + * type: integer + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking reference to edit + * tags: + * - booking-references + * responses: + * 201: + * description: OK, BookingReference edited successfully + * 400: + * description: Bad request. BookingReference body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query, body, isSystemWideAdmin, userId } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = schemaBookingEditBodyParams.parse(body); + /* If user tries to update bookingId, we run extra checks */ + if (data.bookingId) { + const args: Prisma.BookingFindFirstOrThrowArgs = isSystemWideAdmin + ? /* If admin, we only check that the booking exists */ + { where: { id: data.bookingId } } + : /* For non-admins we make sure the booking belongs to the user */ + { where: { id: data.bookingId, userId } }; + await prisma.booking.findFirstOrThrow(args); + } + const booking_reference = await prisma.bookingReference.update({ where: { id }, data }); + return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/booking-references/[id]/index.ts b/apps/api/v1/pages/api/booking-references/[id]/index.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/index.ts rename to apps/api/v1/pages/api/booking-references/[id]/index.ts diff --git a/apps/api/v1/pages/api/booking-references/_get.ts b/apps/api/v1/pages/api/booking-references/_get.ts new file mode 100644 index 00000000000000..5794d3fe1db5bf --- /dev/null +++ b/apps/api/v1/pages/api/booking-references/_get.ts @@ -0,0 +1,41 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-reference"; + +/** + * @swagger + * /booking-references: + * get: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * operationId: listBookingReferences + * summary: Find all booking references + * tags: + * - booking-references + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No booking references were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const args: Prisma.BookingReferenceFindManyArgs = isSystemWideAdmin + ? {} + : { where: { booking: { userId } } }; + const data = await prisma.bookingReference.findMany(args); + return { booking_references: data.map((br) => schemaBookingReferenceReadPublic.parse(br)) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/booking-references/_post.ts b/apps/api/v1/pages/api/booking-references/_post.ts new file mode 100644 index 00000000000000..d551ac98907f2e --- /dev/null +++ b/apps/api/v1/pages/api/booking-references/_post.ts @@ -0,0 +1,87 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + schemaBookingCreateBodyParams, + schemaBookingReferenceReadPublic, +} from "~/lib/validations/booking-reference"; + +/** + * @swagger + * /booking-references: + * post: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * operationId: addBookingReference + * summary: Creates a new booking reference + * requestBody: + * description: Create a new booking reference related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - type + * - uid + * properties: + * type: + * type: string + * uid: + * type: string + * meetingId: + * type: string + * meetingPassword: + * type: string + * meetingUrl: + * type: string + * bookingId: + * type: boolean + * externalCalendarId: + * type: string + * deleted: + * type: boolean + * credentialId: + * type: integer + * tags: + * - booking-references + * responses: + * 201: + * description: OK, booking reference created + * 400: + * description: Bad request. BookingReference body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const body = schemaBookingCreateBodyParams.parse(req.body); + const args: Prisma.BookingFindFirstOrThrowArgs = isSystemWideAdmin + ? /* If admin, we only check that the booking exists */ + { where: { id: body.bookingId } } + : /* For non-admins we make sure the booking belongs to the user */ + { where: { id: body.bookingId, userId } }; + await prisma.booking.findFirstOrThrow(args); + + const data = await prisma.bookingReference.create({ + data: { + ...body, + bookingId: body.bookingId, + }, + }); + + return { + booking_reference: schemaBookingReferenceReadPublic.parse(data), + message: "Booking reference created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/booking-references/index.ts b/apps/api/v1/pages/api/booking-references/index.ts similarity index 100% rename from apps/api/pages/api/booking-references/index.ts rename to apps/api/v1/pages/api/booking-references/index.ts diff --git a/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..e198674602d56e --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts @@ -0,0 +1,88 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin, query } = req; + if (isSystemWideAdmin) { + return; + } + + const { id } = schemaQueryIdParseInt.parse(query); + if (isOrganizationOwnerOrAdmin) { + const booking = await prisma.booking.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (booking) { + const bookingUserId = booking.userId; + if (bookingUserId) { + const accessibleUsersIds = await getAccessibleUsers({ + adminUserId: userId, + memberUserIds: [bookingUserId], + }); + if (accessibleUsersIds.length > 0) return; + } + } + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + bookings: true, + }, + }); + + if (!user) throw new HttpError({ statusCode: 404, message: "User not found" }); + + const filteredBookings = user?.bookings?.filter((booking) => booking.id === id); + const userIsHost = !!filteredBookings?.length; + + const bookingsAsAttendee = prisma.booking.findMany({ + where: { + id, + attendees: { some: { email: user.email } }, + }, + }); + + const bookingsAsEventTypeOwner = prisma.booking.findMany({ + where: { + id, + eventType: { + owner: { id: userId }, + }, + }, + }); + + const bookingsAsTeamOwnerOrAdmin = prisma.booking.findMany({ + where: { + id, + eventType: { + team: { + members: { + some: { userId, role: { in: ["ADMIN", "OWNER"] }, accepted: true }, + }, + }, + }, + }, + }); + + const [resultOne, resultTwo, resultThree] = await Promise.all([ + bookingsAsAttendee, + bookingsAsEventTypeOwner, + bookingsAsTeamOwnerOrAdmin, + ]); + + const teamBookingsAsOwnerOrAdmin = [...resultOne, ...resultTwo, ...resultThree]; + const userHasTeamBookings = !!teamBookingsAsOwnerOrAdmin.length; + + if (!userIsHost && !userHasTeamBookings) + throw new HttpError({ statusCode: 403, message: "You are not authorized" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/bookings/[id]/_delete.ts b/apps/api/v1/pages/api/bookings/[id]/_delete.ts new file mode 100644 index 00000000000000..421302d405ab61 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/_delete.ts @@ -0,0 +1,79 @@ +import type { NextApiRequest } from "next"; + +import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; +import { defaultResponder } from "@calcom/lib/server"; + +import { bookingCancelSchema } from "~/lib/validations/booking"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}/cancel: + * delete: + * summary: Booking cancellation + * operationId: cancelBookingById + * + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking to cancel + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: allRemainingBookings + * required: false + * schema: + * type: boolean + * description: Delete all remaining bookings + * - in: query + * name: cancellationReason + * required: false + * schema: + * type: string + * description: The reason for cancellation of the booking + * tags: + * - bookings + * responses: + * 200: + * description: OK, booking cancelled successfully + * 400: + * description: | + * Bad request + * + * + * + * + * + * + * + * + * + * + * + * + * + *
MessageCause
Booking not foundThe provided id didn't correspond to any existing booking.
User not foundThe userId did not matched an existing user.
+ * 404: + * description: User not found + */ +async function handler(req: NextApiRequest) { + const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt + .merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true })) + .parse({ + ...req.query, + allRemainingBookings: req.query.allRemainingBookings === "true", + }); + + // Normalizing for universal handler + req.body = { id, allRemainingBookings, cancellationReason }; + return await handleCancelBooking(req); +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/bookings/[id]/_get.ts b/apps/api/v1/pages/api/bookings/[id]/_get.ts new file mode 100644 index 00000000000000..01eafee532e0cf --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/_get.ts @@ -0,0 +1,112 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaBookingReadPublic } from "~/lib/validations/booking"; +import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}: + * get: + * summary: Find a booking + * operationId: getBookingById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - bookings + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Booking" + * examples: + * booking: + * value: + * { + * "booking": { + * "id": 91, + * "userId": 5, + * "description": "", + * "eventTypeId": 7, + * "uid": "bFJeNb2uX8ANpT3JL5EfXw", + * "title": "60min between Pro Example and John Doe", + * "startTime": "2023-05-25T09:30:00.000Z", + * "endTime": "2023-05-25T10:30:00.000Z", + * "attendees": [ + * { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "timeZone": "Asia/Kolkata", + * "locale": "en" + * } + * ], + * "user": { + * "email": "pro@example.com", + * "name": "Pro Example", + * "timeZone": "Asia/Kolkata", + * "locale": "en" + * }, + * "payment": [ + * { + * "id": 1, + * "success": true, + * "paymentOption": "ON_BOOKING" + * } + * ], + * "metadata": {}, + * "status": "ACCEPTED", + * "responses": { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "location": { + * "optionValue": "", + * "value": "inPerson" + * } + * } + * } + * } + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Booking was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand); + const expand = Array.isArray(queryFilterForExpand) + ? queryFilterForExpand + : queryFilterForExpand + ? [queryFilterForExpand] + : []; + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { + attendees: true, + user: true, + payment: true, + eventType: expand.includes("team") ? { include: { team: true } } : false, + }, + }); + return { booking: schemaBookingReadPublic.parse(booking) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/bookings/[id]/_patch.ts b/apps/api/v1/pages/api/bookings/[id]/_patch.ts new file mode 100644 index 00000000000000..d26eb3c3b6c62a --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/_patch.ts @@ -0,0 +1,136 @@ +import type { NextApiRequest } from "next"; +import type { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; +import { schemaBookingEditBodyParams, schemaBookingReadPublic } from "~/lib/validations/booking"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}: + * patch: + * summary: Edit an existing booking + * operationId: editBookingById + * requestBody: + * description: Edit an existing booking related to one of your event-types + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * description: 'Booking event title' + * start: + * type: string + * format: date-time + * description: 'Start time of the Event' + * end: + * type: string + * format: date-time + * description: 'End time of the Event' + * status: + * type: string + * description: 'Acceptable values one of ["ACCEPTED", "PENDING", "CANCELLED", "REJECTED"]' + * description: + * type: string + * description: 'Description of the meeting' + * examples: + * editBooking: + * value: + * { + * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", + * "start": "2023-05-24T13:00:00.000Z", + * "end": "2023-05-24T13:30:00.000Z", + * "status": "CANCELLED" + * } + * + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking to edit + * tags: + * - bookings + * responses: + * 200: + * description: OK, booking edited successfully + * content: + * application/json: + * examples: + * bookings: + * value: + * { + * "booking": { + * "id": 11223344, + * "userId": 182, + * "description": null, + * "eventTypeId": 2323232, + * "uid": "stoSJtnh83PEL4rZmqdHe2", + * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", + * "startTime": "2023-05-24T13:00:00.000Z", + * "endTime": "2023-05-24T13:30:00.000Z", + * "metadata": {}, + * "status": "CANCELLED", + * "responses": { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "location": { + * "optionValue": "", + * "value": "inPerson" + * } + * } + * } + * } + * 400: + * description: Bad request. Booking body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query, body } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = schemaBookingEditBodyParams.parse(body); + await checkPermissions(req, data); + const booking = await prisma.booking.update({ where: { id }, data }); + return { booking: schemaBookingReadPublic.parse(booking) }; +} + +async function checkPermissions(req: NextApiRequest, body: z.infer) { + const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req; + if (body.userId && !isSystemWideAdmin && !isOrganizationOwnerOrAdmin) { + // Organizer has to be a cal user and we can't allow a booking to be transfered to some other cal user's name + throw new HttpError({ + statusCode: 403, + message: "Only admin can change the organizer of a booking", + }); + } + + if (body.userId && isOrganizationOwnerOrAdmin) { + const accessibleUsersIds = await getAccessibleUsers({ + adminUserId: userId, + memberUserIds: [body.userId], + }); + if (accessibleUsersIds.length === 0) { + throw new HttpError({ + statusCode: 403, + message: "Only admin can change the organizer of a booking", + }); + } + } +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/bookings/[id]/cancel.ts b/apps/api/v1/pages/api/bookings/[id]/cancel.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/cancel.ts rename to apps/api/v1/pages/api/bookings/[id]/cancel.ts diff --git a/apps/api/pages/api/bookings/[id]/index.ts b/apps/api/v1/pages/api/bookings/[id]/index.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/index.ts rename to apps/api/v1/pages/api/bookings/[id]/index.ts diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts new file mode 100644 index 00000000000000..3ec371dd984ac5 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest } from "next"; + +import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient"; +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { RecordingItemSchema } from "@calcom/prisma/zod-utils"; +import type { PartialReference } from "@calcom/types/EventManager"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}/recordings: + * get: + * summary: Find all Cal video recordings of that booking + * operationId: getRecordingsByBookingId + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking for which recordings need to be fetched. Recording download link is only valid for 12 hours and you would have to fetch the recordings again to get new download link + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - bookings + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/ArrayOfRecordings" + * examples: + * recordings: + * value: + * - id: "ad90a2e7-154f-49ff-a815-5da1db7bf899" + * room_name: "0n22w24AQ5ZFOtEKX2gX" + * start_ts: 1716215386 + * status: "finished" + * max_participants: 1 + * duration: 11 + * share_token: "x94YK-69Gnh7" + * download_link: "https://daily-meeting-recordings..." + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Booking was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { references: true }, + }); + + if (!booking) + throw new HttpError({ + statusCode: 404, + message: `No Booking found with booking id ${id}`, + }); + + const roomName = + booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? + undefined; + + if (!roomName) + throw new HttpError({ + statusCode: 404, + message: `No Cal Video reference found with booking id ${booking.id}`, + }); + + const recordings = await getRecordingsOfCalVideoByRoomName(roomName); + + if (!recordings || !("data" in recordings)) return []; + + const recordingWithDownloadLink = recordings.data.map((recording: RecordingItemSchema) => { + return getDownloadLinkOfCalVideoByRecordingId(recording.id) + .then((res) => ({ + ...recording, + download_link: res?.download_link, + })) + .catch((err) => ({ ...recording, download_link: null, error: err.message })); + }); + const res = await Promise.all(recordingWithDownloadLink); + return res; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts new file mode 100644 index 00000000000000..8d5bc44ed5ddb0 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +import authMiddleware from "../_auth-middleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts new file mode 100644 index 00000000000000..068e430b129dad --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts @@ -0,0 +1,92 @@ +import type { NextApiRequest } from "next"; + +import { + getTranscriptsAccessLinkFromRecordingId, + checkIfRoomNameMatchesInRecording, +} from "@calcom/core/videoClient"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { PartialReference } from "@calcom/types/EventManager"; + +import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}/transcripts/{recordingId}: + * get: + * summary: Find all Cal video transcripts of that recording + * operationId: getTranscriptsByRecordingId + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking for which transcripts need to be fetched. + * - in: path + * name: recordingId + * schema: + * type: string + * required: true + * description: ID of the recording(daily.co recording id) for which transcripts need to be fetched. + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - bookings + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Booking was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id, recordingId } = getTranscriptFromRecordingId.parse(query); + + await checkIfRecordingBelongsToBooking(id, recordingId); + + const transcriptsAccessLinks = await getTranscriptsAccessLinkFromRecordingId(recordingId); + + return transcriptsAccessLinks; +} + +const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => { + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { references: true }, + }); + + if (!booking) + throw new HttpError({ + statusCode: 404, + message: `No Booking found with booking id ${bookingId}`, + }); + + const roomName = + booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? + undefined; + + if (!roomName) + throw new HttpError({ + statusCode: 404, + message: `No Booking Reference with Daily Video found with booking id ${bookingId}`, + }); + + const canUserAccessRecordingId = await checkIfRoomNameMatchesInRecording(roomName, recordingId); + if (!canUserAccessRecordingId) { + throw new HttpError({ + statusCode: 403, + message: `This Recording Id ${recordingId} does not belong to booking ${bookingId}`, + }); + } +}; + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/index.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/index.ts new file mode 100644 index 00000000000000..3085d27a86745d --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/index.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +import authMiddleware from "../../_auth-middleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts new file mode 100644 index 00000000000000..36bd3638b68639 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts @@ -0,0 +1,71 @@ +import type { NextApiRequest } from "next"; + +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { PartialReference } from "@calcom/types/EventManager"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}/transcripts: + * get: + * summary: Find all Cal video transcripts of that booking + * operationId: getTranscriptsByBookingId + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking for which recordings need to be fetched. + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - bookings + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Booking was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { references: true }, + }); + + if (!booking) + throw new HttpError({ + statusCode: 404, + message: `No Booking found with booking id ${id}`, + }); + + const roomName = + booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? + undefined; + + if (!roomName) + throw new HttpError({ + statusCode: 404, + message: `No Cal Video reference found with booking id ${booking.id}`, + }); + + const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName); + + return transcripts; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts new file mode 100644 index 00000000000000..8d5bc44ed5ddb0 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +import authMiddleware from "../_auth-middleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/bookings/_get.ts b/apps/api/v1/pages/api/bookings/_get.ts new file mode 100644 index 00000000000000..e9ec08b4b72a4c --- /dev/null +++ b/apps/api/v1/pages/api/bookings/_get.ts @@ -0,0 +1,373 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; +import { + getAccessibleUsers, + retrieveOrgScopedAccessibleUsers, +} from "~/lib/utils/retrieveScopedAccessibleUsers"; +import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking"; +import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail"; +import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations"; +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +/** + * @swagger + * /bookings: + * get: + * summary: Find all bookings + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * example: 123456789abcdefgh + * - in: query + * name: userId + * required: false + * schema: + * oneOf: + * - type: integer + * example: 1 + * - type: array + * items: + * type: integer + * example: [2, 3, 4] + * - in: query + * name: attendeeEmail + * required: false + * schema: + * oneOf: + * - type: string + * format: email + * example: john.doe@example.com + * - type: array + * items: + * type: string + * format: email + * example: [john.doe@example.com, jane.doe@example.com] + * - in: query + * name: order + * required: false + * schema: + * type: string + * enum: [asc, desc] + * - in: query + * name: sortBy + * required: false + * schema: + * type: string + * enum: [createdAt, updatedAt] + * - in: query + * name: status + * required: false + * schema: + * type: string + * enum: [upcoming] + * description: Filter bookings by status, it will overwrite dateFrom and dateTo filters + * - in: query + * name: dateFrom + * required: false + * schema: + * type: string + * description: ISO 8601 date string to filter bookings by start time + * - in: query + * name: dateTo + * required: false + * schema: + * type: string + * description: ISO 8601 date string to filter bookings by end time + * operationId: listBookings + * tags: + * - bookings + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/ArrayOfBookings" + * examples: + * bookings: + * value: [ + * { + * "booking": { + * "id": 91, + * "userId": 5, + * "description": "", + * "eventTypeId": 7, + * "uid": "bFJeNb2uX8ANpT3JL5EfXw", + * "title": "60min between Pro Example and John Doe", + * "startTime": "2023-05-25T09:30:00.000Z", + * "endTime": "2023-05-25T10:30:00.000Z", + * "attendees": [ + * { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "timeZone": "Asia/Kolkata", + * "locale": "en" + * } + * ], + * "user": { + * "email": "pro@example.com", + * "name": "Pro Example", + * "timeZone": "Asia/Kolkata", + * "locale": "en" + * }, + * "payment": [ + * { + * "id": 1, + * "success": true, + * "paymentOption": "ON_BOOKING" + * } + * ], + * "metadata": {}, + * "status": "ACCEPTED", + * "responses": { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "location": { + * "optionValue": "", + * "value": "inPerson" + * } + * } + * } + * } + * ] + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No bookings were found + */ +type GetAdminArgsType = { + adminDidQueryUserIds?: boolean; + requestedUserIds: number[]; + userId: number; +}; + +/** + * Constructs the WHERE clause for Prisma booking findMany operation. + * + * @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee. + * @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls. + * @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it. + * @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array. + * + * @returns An object that represents the WHERE clause for the findMany/findUnique operation. + */ +function buildWhereClause( + userId: number | null, + attendeeEmails: string[], + userIds: number[] = [], + userEmails: string[] = [] +) { + const filterByAttendeeEmails = attendeeEmails.length > 0; + const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {}; + + let whereClause = {}; + + if (filterByAttendeeEmails) { + whereClause = { + AND: [ + userFilter, + { + attendees: { + some: { + email: { in: attendeeEmails }, + }, + }, + }, + ], + }; + } else { + whereClause = { + OR: [ + userFilter, + { + attendees: { + some: { + email: { in: userEmails }, + }, + }, + }, + ], + }; + } + + return whereClause; +} + +export async function handler(req: NextApiRequest) { + const { + userId, + isSystemWideAdmin, + isOrganizationOwnerOrAdmin, + pagination: { take, skip }, + } = req; + const { dateFrom, dateTo, order, sortBy, status } = schemaBookingGetParams.parse(req.query); + + const args: Prisma.BookingFindManyArgs = {}; + if (req.query.take && req.query.page) { + args.take = take; + args.skip = skip; + } + const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand); + const expand = Array.isArray(queryFilterForExpand) + ? queryFilterForExpand + : queryFilterForExpand + ? [queryFilterForExpand] + : []; + + args.include = { + attendees: true, + user: true, + payment: true, + eventType: expand.includes("team") ? { include: { team: true } } : false, + }; + + const queryFilterForAttendeeEmails = schemaQuerySingleOrMultipleAttendeeEmails.parse(req.query); + const attendeeEmails = Array.isArray(queryFilterForAttendeeEmails.attendeeEmail) + ? queryFilterForAttendeeEmails.attendeeEmail + : typeof queryFilterForAttendeeEmails.attendeeEmail === "string" + ? [queryFilterForAttendeeEmails.attendeeEmail] + : []; + const filterByAttendeeEmails = attendeeEmails.length > 0; + + /** Only admins can query other users */ + if (isSystemWideAdmin) { + if (req.query.userId || filterByAttendeeEmails) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const requestedUserIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + + const systemWideAdminArgs = { + adminDidQueryUserIds: !!req.query.userId, + requestedUserIds, + userId, + }; + const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs); + args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails); + } + } else if (isOrganizationOwnerOrAdmin) { + let requestedUserIds = [userId]; + if (req.query.userId || filterByAttendeeEmails) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + requestedUserIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + } + const orgWideAdminArgs = { + adminDidQueryUserIds: !!req.query.userId, + requestedUserIds, + userId, + }; + const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs); + args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails); + } else { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + }, + }); + if (!user) { + throw new HttpError({ message: "User not found", statusCode: 404 }); + } + args.where = buildWhereClause(userId, attendeeEmails, [], []); + } + + if (dateFrom) { + args.where = { + ...args.where, + startTime: { gte: dateFrom }, + }; + } + if (dateTo) { + args.where = { + ...args.where, + endTime: { lte: dateTo }, + }; + } + + if (sortBy === "updatedAt") { + args.orderBy = { + updatedAt: order, + }; + } + + if (sortBy === "createdAt") { + args.orderBy = { + createdAt: order, + }; + } + + if (status) { + switch (status) { + case "upcoming": + args.where = { + ...args.where, + startTime: { gte: new Date().toISOString() }, + }; + break; + default: + throw new HttpError({ message: "Invalid status", statusCode: 400 }); + } + } + + const data = await prisma.booking.findMany(args); + return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) }; +} + +const handleSystemWideAdminArgs = async ({ + adminDidQueryUserIds, + requestedUserIds, + userId, +}: GetAdminArgsType) => { + if (adminDidQueryUserIds) { + const users = await prisma.user.findMany({ + where: { id: { in: requestedUserIds } }, + select: { email: true }, + }); + const userEmails = users.map((u) => u.email); + + return { userId, userIds: requestedUserIds, userEmails }; + } + return { userId: null, userIds: [], userEmails: [] }; +}; + +const handleOrgWideAdminArgs = async ({ + adminDidQueryUserIds, + requestedUserIds, + userId, +}: GetAdminArgsType) => { + if (adminDidQueryUserIds) { + const accessibleUsersIds = await getAccessibleUsers({ + adminUserId: userId, + memberUserIds: requestedUserIds, + }); + + if (!accessibleUsersIds.length) throw new HttpError({ message: "No User found", statusCode: 404 }); + const users = await prisma.user.findMany({ + where: { id: { in: accessibleUsersIds } }, + select: { email: true }, + }); + const userEmails = users.map((u) => u.email); + return { userId, userIds: accessibleUsersIds, userEmails }; + } else { + const accessibleUsersIds = await retrieveOrgScopedAccessibleUsers({ + adminId: userId, + }); + + const users = await prisma.user.findMany({ + where: { id: { in: accessibleUsersIds } }, + select: { email: true }, + }); + const userEmails = users.map((u) => u.email); + return { userId, userIds: accessibleUsersIds, userEmails }; + } +}; + +export default withMiddleware("pagination")(defaultResponder(handler)); diff --git a/apps/api/v1/pages/api/bookings/_post.ts b/apps/api/v1/pages/api/bookings/_post.ts new file mode 100644 index 00000000000000..307591a9f24968 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/_post.ts @@ -0,0 +1,244 @@ +import type { NextApiRequest } from "next"; + +import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi"; +import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import { CreationSource } from "@calcom/prisma/enums"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; + +/** + * @swagger + * /bookings: + * post: + * summary: Creates a new booking + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * operationId: addBooking + * requestBody: + * description: Create a new booking related to one of your event-types + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - eventTypeId + * - start + * - responses + * - timeZone + * - language + * - metadata + * properties: + * eventTypeId: + * type: integer + * description: 'ID of the event type to book' + * start: + * type: string + * format: date-time + * description: 'Start time of the Event' + * end: + * type: string + * format: date-time + * description: 'End time of the Event' + * rescheduleUid: + * type: string + * format: UID + * description: 'Uid of the booking to reschedule' + * responses: + * type: object + * required: + * - name + * - email + * - location + * properties: + * name: + * type: string + * description: 'Attendee full name' + * email: + * type: string + * format: email + * description: 'Attendee email address' + * location: + * type: object + * properties: + * optionValue: + * type: string + * description: 'Option value for the location' + * value: + * type: string + * description: 'The meeting URL, Phone number or Address' + * description: 'Meeting location' + * metadata: + * type: object + * properties: {} + * description: 'Any metadata associated with the booking' + * timeZone: + * type: string + * description: 'TimeZone of the Attendee' + * language: + * type: string + * description: 'Language of the Attendee' + * title: + * type: string + * description: 'Booking event title' + * recurringEventId: + * type: integer + * description: 'Recurring event ID if the event is recurring' + * description: + * type: string + * description: 'Event description' + * status: + * type: string + * description: 'Acceptable values one of ["ACCEPTED", "PENDING", "CANCELLED", "REJECTED"]' + * seatsPerTimeSlot: + * type: integer + * description: 'The number of seats for each time slot' + * seatsShowAttendees: + * type: boolean + * description: 'Share Attendee information in seats' + * seatsShowAvailabilityCount: + * type: boolean + * description: 'Show the number of available seats' + * smsReminderNumber: + * type: number + * description: 'SMS reminder number' + * examples: + * New Booking example: + * value: + * { + * "eventTypeId": 2323232, + * "start": "2023-05-24T13:00:00.000Z", + * "end": "2023-05-24T13:30:00.000Z", + * "responses":{ + * "name": "Hello Hello", + * "email": "hello@gmail.com", + * "metadata": {}, + * "location": "Calcom HQ", + * }, + * "timeZone": "Europe/London", + * "language": "en", + * "title": "Debugging between Syed Ali Shahbaz and Hello Hello", + * "description": null, + * "status": "PENDING", + * "smsReminderNumber": null + * } + * + * tags: + * - bookings + * responses: + * 200: + * description: Booking(s) created successfully. + * content: + * application/json: + * examples: + * booking created successfully example: + * value: + * { + * "booking": { + * "id": 91, + * "userId": 5, + * "description": "", + * "eventTypeId": 7, + * "uid": "bFJeNb2uX8ANpT3JL5EfXw", + * "title": "60min between Pro Example and John Doe", + * "startTime": "2023-05-25T09:30:00.000Z", + * "endTime": "2023-05-25T10:30:00.000Z", + * "attendees": [ + * { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "timeZone": "Asia/Kolkata", + * "locale": "en" + * } + * ], + * "user": { + * "email": "pro@example.com", + * "name": "Pro Example", + * "timeZone": "Asia/Kolkata", + * "locale": "en" + * }, + * "payment": [ + * { + * "id": 1, + * "success": true, + * "paymentOption": "ON_BOOKING" + * } + * ], + * "metadata": {}, + * "status": "ACCEPTED", + * "responses": { + * "email": "john.doe@example.com", + * "name": "John Doe", + * "location": { + * "optionValue": "", + * "value": "inPerson" + * } + * } + * } + * } + * 400: + * description: | + * Bad request + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
MessageCause
Booking body is invalidMissing property on booking entity.
Invalid eventTypeIdThe provided eventTypeId does not exist.
Missing recurringCountThe eventType is recurring, and no recurringCount was passed.
Invalid recurringCountThe provided recurringCount is greater than the eventType recurring config
+ * 401: + * description: Authorization information is missing or invalid. + */ +async function handler(req: NextApiRequest) { + const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req; + req.body = { + ...req.body, + creationSource: CreationSource.API_V1, + }; + if (isSystemWideAdmin) req.userId = req.body.userId || userId; + + if (isOrganizationOwnerOrAdmin) { + const accessibleUsersIds = await getAccessibleUsers({ + adminUserId: userId, + memberUserIds: [req.body.userId || userId], + }); + const [requestedUserId] = accessibleUsersIds; + req.userId = requestedUserId || userId; + } + + try { + return await handleNewBooking(req, getBookingDataSchemaForApi); + } catch (error: unknown) { + const knownError = error as Error; + if (knownError?.message === ErrorCode.NoAvailableUsersFound) { + throw new HttpError({ statusCode: 400, message: knownError.message }); + } + + throw error; + } +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/bookings/index.ts b/apps/api/v1/pages/api/bookings/index.ts similarity index 100% rename from apps/api/pages/api/bookings/index.ts rename to apps/api/v1/pages/api/bookings/index.ts diff --git a/apps/api/v1/pages/api/connected-calendars/_get.ts b/apps/api/v1/pages/api/connected-calendars/_get.ts new file mode 100644 index 00000000000000..2478d1776109e8 --- /dev/null +++ b/apps/api/v1/pages/api/connected-calendars/_get.ts @@ -0,0 +1,145 @@ +import type { NextApiRequest } from "next"; + +import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars"; +import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import prisma from "@calcom/prisma"; + +import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery"; +import { schemaConnectedCalendarsReadPublic } from "~/lib/validations/connected-calendar"; + +/** + * @swagger + * /connected-calendars: + * get: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: false + * schema: + * type: number + * description: Admins can fetch connected calendars for other user e.g. &userId=1 or multiple users e.g. &userId=1&userId=2 + * summary: Fetch connected calendars + * tags: + * - connected-calendars + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * appId: + * type: string + * userId: + * type: number + * integration: + * type: string + * calendars: + * type: array + * items: + * type: object + * properties: + * externalId: + * type: string + * name: + * type: string + * primary: + * type: boolean + * readOnly: + * type: boolean + * examples: + * connectedCalendarExample: + * value: [ + * { + * "name": "Google Calendar", + * "appId": "google-calendar", + * "userId": 10, + * "integration": "google_calendar", + * "calendars": [ + * { + * "externalId": "alice@gmail.com", + * "name": "alice@gmail.com", + * "primary": true, + * "readOnly": false + * }, + * { + * "externalId": "addressbook#contacts@group.v.calendar.google.com", + * "name": "birthdays", + * "primary": false, + * "readOnly": true + * }, + * { + * "externalId": "en.latvian#holiday@group.v.calendar.google.com", + * "name": "Holidays in Narnia", + * "primary": false, + * "readOnly": true + * } + * ] + * } + * ] + * 401: + * description: Authorization information is missing or invalid. + * 403: + * description: Non admin user trying to fetch other user's connected calendars. + */ + +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + + if (!isSystemWideAdmin && req.query.userId) + throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + + const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; + + const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({ + ids: userIds, + }); + + return await getConnectedCalendars(usersWithCalendars); +} + +async function getConnectedCalendars(users: UserWithCalendars[]) { + const connectedDestinationCalendarsPromises = users.map((user) => + getConnectedDestinationCalendarsAndEnsureDefaultsInDb({ user, onboarding: false, prisma }).then( + (connectedCalendarsResult) => + connectedCalendarsResult.connectedCalendars.map((calendar) => ({ + userId: user.id, + ...calendar, + })) + ) + ); + const connectedDestinationCalendars = await Promise.all(connectedDestinationCalendarsPromises); + + const flattenedCalendars = connectedDestinationCalendars.flat(); + + const mapped = flattenedCalendars.map((calendar) => ({ + name: calendar.integration.name, + appId: calendar.integration.slug, + userId: calendar.userId, + integration: calendar.integration.type, + calendars: (calendar.calendars ?? []).map((c) => ({ + externalId: c.externalId, + name: c.name, + primary: c.primary ?? false, + readOnly: c.readOnly, + })), + })); + + return schemaConnectedCalendarsReadPublic.parse(mapped); +} + +export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/me/index.ts b/apps/api/v1/pages/api/connected-calendars/index.ts similarity index 100% rename from apps/api/pages/api/me/index.ts rename to apps/api/v1/pages/api/connected-calendars/index.ts diff --git a/apps/api/v1/pages/api/credential-sync/_delete.ts b/apps/api/v1/pages/api/credential-sync/_delete.ts new file mode 100644 index 00000000000000..7753110ffc92d7 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_delete.ts @@ -0,0 +1,60 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCredentialDeleteParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * delete: + * operationId: deleteUserAppCredential + * summary: Delete a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * - in: query + * name: credentialId + * required: true + * schema: + * type: string + * description: ID of the credential to update + * tags: + * - credentials + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { userId, credentialId } = schemaCredentialDeleteParams.parse(req.query); + + const credential = await prisma.credential.delete({ + where: { + id: credentialId, + userId, + }, + select: { + id: true, + appId: true, + }, + }); + + return { credentialDeleted: credential }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/_get.ts b/apps/api/v1/pages/api/credential-sync/_get.ts new file mode 100644 index 00000000000000..d465d080fef3d9 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_get.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCredentialGetParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * get: + * operationId: getUserAppCredentials + * summary: Get all app credentials for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * tags: + * - credentials + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { appSlug, userId } = schemaCredentialGetParams.parse(req.query); + + let credentials = await prisma.credential.findMany({ + where: { + userId, + ...(appSlug && { appId: appSlug }), + }, + select: { + id: true, + appId: true, + }, + }); + + // For apps we're transitioning to using the term slug to keep things consistent + credentials = credentials.map((credential) => { + return { + ...credential, + appSlug: credential.appId, + }; + }); + + return { credentials }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/_patch.ts b/apps/api/v1/pages/api/credential-sync/_patch.ts new file mode 100644 index 00000000000000..c4cc8109afd21d --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_patch.ts @@ -0,0 +1,85 @@ +import type { NextApiRequest } from "next"; + +import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCredentialPatchParams, schemaCredentialPatchBody } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * patch: + * operationId: updateUserAppCredential + * summary: Update a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * - in: query + * name: credentialId + * required: true + * schema: + * type: string + * description: ID of the credential to update + * tags: + * - credentials + * requestBody: + * description: Update a new credential + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - encryptedKey + * properties: + * encryptedKey: + * type: string + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { userId, credentialId } = schemaCredentialPatchParams.parse(req.query); + + const { encryptedKey } = schemaCredentialPatchBody.parse(req.body); + + const decryptedKey = JSON.parse( + symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + const key = OAuth2UniversalSchema.parse(decryptedKey); + + const credential = await prisma.credential.update({ + where: { + id: credentialId, + userId, + }, + data: { + key, + }, + select: { + id: true, + appId: true, + }, + }); + + return { credential }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/_post.ts b/apps/api/v1/pages/api/credential-sync/_post.ts new file mode 100644 index 00000000000000..32d74da2c10a62 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_post.ts @@ -0,0 +1,145 @@ +import type { NextApiRequest } from "next"; + +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; + +import { schemaCredentialPostBody, schemaCredentialPostParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * post: + * operationId: createUserAppCredential + * summary: Create a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * tags: + * - credentials + * requestBody: + * description: Create a new credential + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - encryptedKey + * - appSlug + * properties: + * encryptedKey: + * type: string + * appSlug: + * type: string + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + if (!req.body) { + throw new HttpError({ message: "Request body is missing", statusCode: 400 }); + } + + const { userId, createSelectedCalendar, createDestinationCalendar } = schemaCredentialPostParams.parse( + req.query + ); + + const { appSlug, encryptedKey } = schemaCredentialPostBody.parse(req.body); + + const decryptedKey = JSON.parse( + symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + const key = OAuth2UniversalSchema.parse(decryptedKey); + + // Need to get app type + const app = await prisma.app.findUnique({ + where: { slug: appSlug }, + select: { dirName: true, categories: true }, + }); + + if (!app) { + throw new HttpError({ message: "App not found", statusCode: 500 }); + } + + const createCalendarResources = + app.categories.some((category) => category === "calendar") && + (createSelectedCalendar || createDestinationCalendar); + + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + + const createdcredential = await prisma.credential.create({ + data: { + userId, + appId: appSlug, + key, + type: appMetadata.type, + }, + select: credentialForCalendarServiceSelect, + }); + // createdcredential.user.email; + // TODO: ^ Investigate why this select doesn't work. + const credential = await prisma.credential.findUniqueOrThrow({ + where: { + id: createdcredential.id, + }, + select: credentialForCalendarServiceSelect, + }); + // ^ Workaround for the select in `create` not working + + if (createCalendarResources) { + const calendar = await getCalendar(credential); + if (!calendar) throw new HttpError({ message: "Calendar missing for credential", statusCode: 500 }); + const calendars = await calendar.listCalendars(); + const calendarToCreate = calendars.find((calendar) => calendar.primary) || calendars[0]; + + if (createSelectedCalendar) { + await prisma.selectedCalendar.createMany({ + data: [ + { + userId, + integration: appMetadata.type, + externalId: calendarToCreate.externalId, + credentialId: credential.id, + }, + ], + skipDuplicates: true, + }); + } + if (createDestinationCalendar) { + await prisma.destinationCalendar.create({ + data: { + integration: appMetadata.type, + externalId: calendarToCreate.externalId, + credential: { connect: { id: credential.id } }, + primaryEmail: calendarToCreate.email || credential.user?.email, + user: { connect: { id: userId } }, + }, + }); + } + } + + return { credential: { id: credential.id, type: credential.type } }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/index.ts b/apps/api/v1/pages/api/credential-sync/index.ts new file mode 100644 index 00000000000000..8def51c2bd441c --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/index.ts @@ -0,0 +1,12 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware("verifyCredentialSyncEnabled")( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + }) +); diff --git a/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..05243a39226175 --- /dev/null +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + // Admins can just skip this check + if (isSystemWideAdmin) return; + // Check if the current user can access the event type of this input + const eventTypeCustomInput = await prisma.eventTypeCustomInput.findFirst({ + where: { id, eventType: { userId } }, + }); + if (!eventTypeCustomInput) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts new file mode 100644 index 00000000000000..08b636b959bbed --- /dev/null +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /custom-inputs/{id}: + * delete: + * summary: Remove an existing eventTypeCustomInput + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the eventTypeCustomInput to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - custom-inputs + * responses: + * 201: + * description: OK, eventTypeCustomInput removed successfully + * 400: + * description: Bad request. EventType id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await prisma.eventTypeCustomInput.delete({ where: { id } }); + return { message: `CustomInputEventType with id: ${id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/custom-inputs/[id]/_get.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_get.ts new file mode 100644 index 00000000000000..321aea5487de38 --- /dev/null +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_get.ts @@ -0,0 +1,44 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-custom-input"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /custom-inputs/{id}: + * get: + * summary: Find a eventTypeCustomInput + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the eventTypeCustomInput to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - custom-inputs + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: EventType was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = await prisma.eventTypeCustomInput.findUniqueOrThrow({ where: { id } }); + return { event_type_custom_input: schemaEventTypeCustomInputPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts new file mode 100644 index 00000000000000..ae863c3906efcd --- /dev/null +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts @@ -0,0 +1,87 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + schemaEventTypeCustomInputEditBodyParams, + schemaEventTypeCustomInputPublic, +} from "~/lib/validations/event-type-custom-input"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /custom-inputs/{id}: + * patch: + * summary: Edit an existing eventTypeCustomInput + * requestBody: + * description: Edit an existing eventTypeCustomInput for an event type + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * eventTypeId: + * type: integer + * description: 'ID of the event type to which the custom input is being added' + * label: + * type: string + * description: 'Label of the custom input' + * type: + * type: string + * description: 'Type of the custom input. The value is ENUM; one of [TEXT, TEXTLONG, NUMBER, BOOL, RADIO, PHONE]' + * options: + * type: object + * properties: + * label: + * type: string + * type: + * type: string + * description: 'Options for the custom input' + * required: + * type: boolean + * description: 'If the custom input is required before booking' + * placeholder: + * type: string + * description: 'Placeholder text for the custom input' + * + * examples: + * custom-inputs: + * summary: Example of patching an existing Custom Input + * value: + * required: true + * + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the eventTypeCustomInput to edit + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * + * tags: + * - custom-inputs + * responses: + * 201: + * description: OK, eventTypeCustomInput edited successfully + * 400: + * description: Bad request. EventType body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = schemaEventTypeCustomInputEditBodyParams.parse(req.body); + const result = await prisma.eventTypeCustomInput.update({ where: { id }, data }); + return { event_type_custom_input: schemaEventTypeCustomInputPublic.parse(result) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/custom-inputs/[id]/index.ts b/apps/api/v1/pages/api/custom-inputs/[id]/index.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/index.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/index.ts diff --git a/apps/api/v1/pages/api/custom-inputs/_get.ts b/apps/api/v1/pages/api/custom-inputs/_get.ts new file mode 100644 index 00000000000000..00259fa64943e4 --- /dev/null +++ b/apps/api/v1/pages/api/custom-inputs/_get.ts @@ -0,0 +1,40 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-custom-input"; + +/** + * @swagger + * /custom-inputs: + * get: + * summary: Find all eventTypeCustomInputs + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - custom-inputs + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No eventTypeCustomInputs were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const args: Prisma.EventTypeCustomInputFindManyArgs = isSystemWideAdmin + ? {} + : { where: { eventType: { userId } } }; + const data = await prisma.eventTypeCustomInput.findMany(args); + return { event_type_custom_inputs: data.map((v) => schemaEventTypeCustomInputPublic.parse(v)) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/custom-inputs/_post.ts b/apps/api/v1/pages/api/custom-inputs/_post.ts new file mode 100644 index 00000000000000..3c01c416de7cbe --- /dev/null +++ b/apps/api/v1/pages/api/custom-inputs/_post.ts @@ -0,0 +1,104 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + schemaEventTypeCustomInputBodyParams, + schemaEventTypeCustomInputPublic, +} from "~/lib/validations/event-type-custom-input"; + +/** + * @swagger + * /custom-inputs: + * post: + * summary: Creates a new eventTypeCustomInput + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new custom input for an event type + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - eventTypeId + * - label + * - type + * - required + * - placeholder + * properties: + * eventTypeId: + * type: integer + * description: 'ID of the event type to which the custom input is being added' + * label: + * type: string + * description: 'Label of the custom input' + * type: + * type: string + * description: 'Type of the custom input. The value is ENUM; one of [TEXT, TEXTLONG, NUMBER, BOOL, RADIO, PHONE]' + * options: + * type: object + * properties: + * label: + * type: string + * type: + * type: string + * description: 'Options for the custom input' + * required: + * type: boolean + * description: 'If the custom input is required before booking' + * placeholder: + * type: string + * description: 'Placeholder text for the custom input' + * + * examples: + * custom-inputs: + * summary: An example of custom-inputs + * value: + * eventTypeID: 1 + * label: "Phone Number" + * type: "PHONE" + * required: true + * placeholder: "100 101 1234" + * + * tags: + * - custom-inputs + * responses: + * 201: + * description: OK, eventTypeCustomInput created + * 400: + * description: Bad request. EventTypeCustomInput body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { eventTypeId, ...body } = schemaEventTypeCustomInputBodyParams.parse(req.body); + + if (!isSystemWideAdmin) { + /* We check that the user has access to the event type he's trying to add a custom input to. */ + const eventType = await prisma.eventType.findFirst({ + where: { id: eventTypeId, userId }, + }); + if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); + } + + const data = await prisma.eventTypeCustomInput.create({ + data: { ...body, eventType: { connect: { id: eventTypeId } } }, + }); + + return { + event_type_custom_input: schemaEventTypeCustomInputPublic.parse(data), + message: "EventTypeCustomInput created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/custom-inputs/index.ts b/apps/api/v1/pages/api/custom-inputs/index.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/index.ts rename to apps/api/v1/pages/api/custom-inputs/index.ts diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..7878b05b91a0d4 --- /dev/null +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts @@ -0,0 +1,33 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + if (isSystemWideAdmin) return; + const userEventTypes = await prisma.eventType.findMany({ + where: { userId }, + select: { id: true }, + }); + + const userEventTypeIds = userEventTypes.map((eventType) => eventType.id); + + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + AND: [ + { id }, + { + OR: [{ userId }, { eventTypeId: { in: userEventTypeIds } }], + }, + ], + }, + }); + if (!destinationCalendar) + throw new HttpError({ statusCode: 404, message: "Destination calendar not found" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts new file mode 100644 index 00000000000000..a356e2656b5d8c --- /dev/null +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /destination-calendars/{id}: + * delete: + * summary: Remove an existing destination calendar + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the destination calendar to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK, destinationCalendar removed successfully + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Destination calendar not found + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await prisma.destinationCalendar.delete({ where: { id } }); + return { message: `OK, Destination Calendar removed successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_get.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_get.ts new file mode 100644 index 00000000000000..6531539814516d --- /dev/null +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_get.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /destination-calendars/{id}: + * get: + * summary: Find a destination calendar + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the destination calendar to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Destination calendar not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const destinationCalendar = await prisma.destinationCalendar.findUnique({ + where: { id }, + }); + + return { destinationCalendar: schemaDestinationCalendarReadPublic.parse({ ...destinationCalendar }) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts new file mode 100644 index 00000000000000..064634b2f22ac4 --- /dev/null +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts @@ -0,0 +1,313 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import type { z } from "zod"; + +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { PrismaClient } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; + +import { + schemaDestinationCalendarEditBodyParams, + schemaDestinationCalendarReadPublic, +} from "~/lib/validations/destination-calendar"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /destination-calendars/{id}: + * patch: + * summary: Edit an existing destination calendar + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the destination calendar to edit + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new booking related to one of your event-types + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * integration: + * type: string + * description: 'The integration' + * externalId: + * type: string + * description: 'The external ID of the integration' + * eventTypeId: + * type: integer + * description: 'The ID of the eventType it is associated with' + * bookingId: + * type: integer + * description: 'The booking ID it is associated with' + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Destination calendar not found + */ +type DestinationCalendarType = { + userId?: number | null; + eventTypeId?: number | null; + credentialId: number | null; +}; + +type UserCredentialType = { + id: number; + appId: string | null; + type: string; + userId: number | null; + user: { + email: string; + } | null; + teamId: number | null; + key: Prisma.JsonValue; + invalid: boolean | null; +}; + +export async function patchHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin, query, body } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body); + const assignedUserId = isSystemWideAdmin ? parsedBody.userId || userId : userId; + + validateIntegrationInput(parsedBody); + const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma); + await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma }); + + const userCredentials = await getUserCredentials({ + credentialId: destinationCalendarObject.credentialId, + userId: assignedUserId, + prisma, + }); + const credentialId = await verifyCredentialsAndGetId({ + parsedBody, + userCredentials, + currentCredentialId: destinationCalendarObject.credentialId, + }); + // If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well + if (parsedBody.eventTypeId) parsedBody.userId = undefined; + const destinationCalendar = await prisma.destinationCalendar.update({ + where: { id }, + data: { ...parsedBody, credentialId }, + }); + return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) }; +} + +/** + * Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user + * + * @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown. + * @param userId - The user ID against which the credentials need to be verified. + * @param prisma - An instance of PrismaClient for database operations. + * + * @returns - An array containing the matching user credentials. + * + * @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database. + */ +async function getUserCredentials({ + credentialId, + userId, + prisma, +}: { + credentialId: number | null; + userId: number; + prisma: PrismaClient; +}) { + if (!credentialId) { + throw new HttpError({ + statusCode: 404, + message: `Destination calendar missing credential id`, + }); + } + const userCredentials = await prisma.credential.findMany({ + where: { id: credentialId, userId }, + select: credentialForCalendarServiceSelect, + }); + + if (!userCredentials || userCredentials.length === 0) { + throw new HttpError({ + statusCode: 400, + message: `Bad request, no associated credentials found`, + }); + } + return userCredentials; +} + +/** + * Verifies the provided credentials and retrieves the associated credential ID. + * + * This function checks if the `integration` and `externalId` properties from the parsed body are present. + * If both properties exist, it fetches the connected calendar credentials using the provided user credentials + * and checks for a matching external ID and integration from the list of connected calendars. + * + * If a match is found, it updates the `credentialId` with the one from the connected calendar. + * Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID. + * + * If the parsed body does not contain the necessary properties, the function + * returns the `credentialId` from the destination calendar object. + * + * @param parsedBody - The parsed body from the incoming request, validated against a predefined schema. + * Checked if it contain properties like `integration` and `externalId`. + * @param userCredentials - An array of user credentials used to fetch the connected calendar credentials. + * @param destinationCalendarObject - An object representing the destination calendar. Primarily used + * to fetch the default `credentialId`. + * + * @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar, + * or the provided destination calendar object in other cases. + * + * @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`. + */ +async function verifyCredentialsAndGetId({ + parsedBody, + userCredentials, + currentCredentialId, +}: { + parsedBody: z.infer; + userCredentials: UserCredentialType[]; + currentCredentialId: number | null; +}) { + if (parsedBody.integration && parsedBody.externalId) { + const calendarCredentials = getCalendarCredentials(userCredentials); + + const { connectedCalendars } = await getConnectedCalendars( + calendarCredentials, + [], + parsedBody.externalId + ); + const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly); + const calendar = eligibleCalendars?.find( + (c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration + ); + + if (!calendar?.credentialId) + throw new HttpError({ + statusCode: 400, + message: "Bad request, credential id invalid", + }); + return calendar?.credentialId; + } + return currentCredentialId; +} + +/** + * Validates the request for updating a destination calendar. + * + * This function checks the validity of the provided eventTypeId against the existing destination calendar object + * in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided. + * + * It also ensures that the eventTypeId, if provided, belongs to the assigned user. + * + * @param destinationCalendarObject - An object representing the destination calendar. + * @param parsedBody - The parsed body from the incoming request, validated against a predefined schema. + * @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user. + * @param prisma - An instance of PrismaClient for database operations. + * + * @throws HttpError - If the validation fails or inconsistencies are detected in the request data. + */ +async function validateRequestAndOwnership({ + destinationCalendarObject, + parsedBody, + assignedUserId, + prisma, +}: { + destinationCalendarObject: DestinationCalendarType; + parsedBody: z.infer; + assignedUserId: number; + prisma: PrismaClient; +}) { + if (parsedBody.eventTypeId) { + if (!destinationCalendarObject.eventTypeId) { + throw new HttpError({ + statusCode: 400, + message: `The provided destination calendar can not be linked to an event type`, + }); + } + + const userEventType = await prisma.eventType.findFirst({ + where: { id: parsedBody.eventTypeId }, + select: { userId: true }, + }); + + if (!userEventType || userEventType.userId !== assignedUserId) { + throw new HttpError({ + statusCode: 404, + message: `Event type with ID ${parsedBody.eventTypeId} not found`, + }); + } + } + + if (!parsedBody.eventTypeId) { + if (destinationCalendarObject.eventTypeId) { + throw new HttpError({ + statusCode: 400, + message: `The provided destination calendar can only be linked to an event type`, + }); + } + if (destinationCalendarObject.userId !== assignedUserId) { + throw new HttpError({ + statusCode: 403, + message: `Forbidden`, + }); + } + } +} + +/** + * Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`. + * + * If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status + * indicating that the desired destination calendar was not found is thrown. + * + * @param id - The ID of the destination calendar to be retrieved. + * @param prisma - An instance of PrismaClient for database operations. + * + * @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`. + * + * @throws HttpError - If no destination calendar matches the provided ID. + */ +async function getDestinationCalendar(id: number, prisma: PrismaClient) { + const destinationCalendarObject = await prisma.destinationCalendar.findFirst({ + where: { + id, + }, + select: { userId: true, eventTypeId: true, credentialId: true }, + }); + + if (!destinationCalendarObject) { + throw new HttpError({ + statusCode: 404, + message: `Destination calendar with ID ${id} not found`, + }); + } + + return destinationCalendarObject; +} + +function validateIntegrationInput(parsedBody: z.infer) { + if (parsedBody.integration && !parsedBody.externalId) { + throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" }); + } + if (!parsedBody.integration && parsedBody.externalId) { + throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" }); + } +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/event-types/[id]/index.ts b/apps/api/v1/pages/api/destination-calendars/[id]/index.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/index.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/index.ts diff --git a/apps/api/v1/pages/api/destination-calendars/_get.ts b/apps/api/v1/pages/api/destination-calendars/_get.ts new file mode 100644 index 00000000000000..a65bf90987ac87 --- /dev/null +++ b/apps/api/v1/pages/api/destination-calendars/_get.ts @@ -0,0 +1,59 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery"; +import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar"; + +/** + * @swagger + * /destination-calendars: + * get: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * summary: Find all destination calendars + * tags: + * - destination-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No destination calendars were found + */ +async function getHandler(req: NextApiRequest) { + const { userId } = req; + const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; + + const userEventTypes = await prisma.eventType.findMany({ + where: { userId: { in: userIds } }, + select: { id: true }, + }); + + const userEventTypeIds = userEventTypes.map((eventType) => eventType.id); + + const allDestinationCalendars = await prisma.destinationCalendar.findMany({ + where: { + OR: [{ userId: { in: userIds } }, { eventTypeId: { in: userEventTypeIds } }], + }, + }); + + if (allDestinationCalendars.length === 0) + new HttpError({ statusCode: 404, message: "No destination calendars were found" }); + + return { + destinationCalendars: allDestinationCalendars.map((destinationCalendar) => + schemaDestinationCalendarReadPublic.parse(destinationCalendar) + ), + }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/destination-calendars/_post.ts b/apps/api/v1/pages/api/destination-calendars/_post.ts new file mode 100644 index 00000000000000..1d8379335cf0da --- /dev/null +++ b/apps/api/v1/pages/api/destination-calendars/_post.ts @@ -0,0 +1,143 @@ +import type { NextApiRequest } from "next"; + +import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; + +import { + schemaDestinationCalendarReadPublic, + schemaDestinationCalendarCreateBodyParams, +} from "~/lib/validations/destination-calendar"; + +/** + * @swagger + * /destination-calendars: + * post: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * summary: Creates a new destination calendar + * requestBody: + * description: Create a new destination calendar for your events + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - integration + * - externalId + * - credentialId + * properties: + * integration: + * type: string + * description: 'The integration' + * externalId: + * type: string + * description: 'The external ID of the integration' + * eventTypeId: + * type: integer + * description: 'The ID of the eventType it is associated with' + * bookingId: + * type: integer + * description: 'The booking ID it is associated with' + * userId: + * type: integer + * description: 'The user it is associated with' + * tags: + * - destination-calendars + * responses: + * 201: + * description: OK, destination calendar created + * 400: + * description: Bad request. DestinationCalendar body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin, body } = req; + const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body); + await checkPermissions(req, userId); + + const assignedUserId = isSystemWideAdmin && parsedBody.userId ? parsedBody.userId : userId; + + /* Check if credentialId data matches the ownership and integration passed in */ + const userCredentials = await prisma.credential.findMany({ + where: { + type: parsedBody.integration, + userId: assignedUserId, + }, + select: credentialForCalendarServiceSelect, + }); + + if (userCredentials.length === 0) + throw new HttpError({ + statusCode: 400, + message: "Bad request, credential id invalid", + }); + + const calendarCredentials = getCalendarCredentials(userCredentials); + + const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId); + + const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly); + const calendar = eligibleCalendars?.find( + (c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration + ); + if (!calendar?.credentialId) + throw new HttpError({ + statusCode: 400, + message: "Bad request, credential id invalid", + }); + const credentialId = calendar.credentialId; + + if (parsedBody.eventTypeId) { + const eventType = await prisma.eventType.findFirst({ + where: { id: parsedBody.eventTypeId, userId: parsedBody.userId }, + }); + if (!eventType) + throw new HttpError({ + statusCode: 400, + message: "Bad request, eventTypeId invalid", + }); + parsedBody.userId = undefined; + } + + const destination_calendar = await prisma.destinationCalendar.create({ + data: { ...parsedBody, credentialId }, + }); + + return { + destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar), + message: "Destination calendar created successfully", + }; +} + +async function checkPermissions(req: NextApiRequest, userId: number) { + const { isSystemWideAdmin } = req; + const body = schemaDestinationCalendarCreateBodyParams.parse(req.body); + + /* Non-admin users can only create destination calendars for themselves */ + if (!isSystemWideAdmin && body.userId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `userId`", + }); + /* Admin users are required to pass in a userId */ + if (isSystemWideAdmin && !body.userId) + throw new HttpError({ statusCode: 400, message: "`userId` required" }); + /* User should only be able to create for their own destination calendars*/ + if (!isSystemWideAdmin && body.eventTypeId) { + const ownsEventType = await prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } }); + if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + } + // TODO:: Add support for team event types with validation +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/event-types/index.ts b/apps/api/v1/pages/api/destination-calendars/index.ts similarity index 100% rename from apps/api/pages/api/event-types/index.ts rename to apps/api/v1/pages/api/destination-calendars/index.ts diff --git a/apps/api/pages/api/docs.ts b/apps/api/v1/pages/api/docs.ts similarity index 81% rename from apps/api/pages/api/docs.ts rename to apps/api/v1/pages/api/docs.ts index fc320d011b9ed7..e0f33493d6cc1b 100644 --- a/apps/api/pages/api/docs.ts +++ b/apps/api/v1/pages/api/docs.ts @@ -11,8 +11,8 @@ const swaggerHandler = withSwagger({ { url: "https://api.cal.com/v1" }, ], externalDocs: { - url: "https://docs.cal.com", - description: "Find more info at our main docs: https://docs.cal.com/", + url: "https://docs.cal.com/docs", + description: "Find more info at our main docs: https://docs.cal.com/docs/", }, info: { title: `${pjson.name}: ${pjson.description}`, @@ -27,6 +27,37 @@ const swaggerHandler = withSwagger({ $ref: "#/components/schemas/Booking", }, }, + ArrayOfRecordings: { + type: "array", + items: { + $ref: "#/components/schemas/Recording", + }, + }, + Recording: { + properties: { + id: { + type: "string", + }, + room_name: { + type: "string", + }, + start_ts: { + type: "number", + }, + status: { + type: "string", + }, + max_participants: { + type: "number", + }, + duration: { + type: "number", + }, + download_link: { + type: "string", + }, + }, + }, Booking: { properties: { id: { @@ -57,6 +88,11 @@ const swaggerHandler = withSwagger({ type: "string", example: "Europe/London", }, + fromReschedule: { + type: "string", + nullable: true, + format: "uuid", + }, attendees: { type: "array", items: { diff --git a/apps/api/v1/pages/api/event-types/[id]/_delete.ts b/apps/api/v1/pages/api/event-types/[id]/_delete.ts new file mode 100644 index 00000000000000..efcc3c98efae3c --- /dev/null +++ b/apps/api/v1/pages/api/event-types/[id]/_delete.ts @@ -0,0 +1,69 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +import checkParentEventOwnership from "../_utils/checkParentEventOwnership"; +import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission"; + +/** + * @swagger + * /event-types/{id}: + * delete: + * operationId: removeEventTypeById + * summary: Remove an existing eventType + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the eventType to delete + * tags: + * - event-types + * externalDocs: + * url: https://docs.cal.com/docs/core-features/event-types + * responses: + * 201: + * description: OK, eventType removed successfully + * 400: + * description: Bad request. EventType id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + await checkPermissions(req); + await prisma.eventType.delete({ where: { id } }); + return { message: `Event Type with id: ${id} deleted successfully` }; +} + +async function checkPermissions(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + if (isSystemWideAdmin) return; + + const eventType = await prisma.eventType.findFirst({ where: { id } }); + + if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); + + /** Only event type owners or team owners for team events can delete it */ + if (eventType.teamId) return await checkTeamEventEditPermission(req, { teamId: eventType.teamId }); + + if (eventType.parentId) return await checkParentEventOwnership(req); + + if (eventType.userId && eventType.userId !== userId) + throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/event-types/[id]/_get.ts b/apps/api/v1/pages/api/event-types/[id]/_get.ts new file mode 100644 index 00000000000000..af70bffe173fa9 --- /dev/null +++ b/apps/api/v1/pages/api/event-types/[id]/_get.ts @@ -0,0 +1,103 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; +import { checkPermissions as canAccessTeamEventOrThrow } from "~/pages/api/teams/[teamId]/_auth-middleware"; + +import getCalLink from "../_utils/getCalLink"; + +/** + * @swagger + * /event-types/{id}: + * get: + * operationId: getEventTypeById + * summary: Find a eventType + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: id + * example: 4 + * schema: + * type: integer + * required: true + * description: ID of the eventType to get + * tags: + * - event-types + * externalDocs: + * url: https://docs.cal.com/docs/core-features/event-types + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: EventType was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const eventType = await prisma.eventType.findUnique({ + where: { id }, + include: { + customInputs: true, + hashedLink: { select: { link: true } }, + team: { select: { slug: true } }, + hosts: { select: { userId: true, isFixed: true } }, + owner: { select: { username: true, id: true } }, + children: { select: { id: true, userId: true } }, + }, + }); + await checkPermissions(req, eventType); + + const link = eventType ? getCalLink(eventType) : null; + // user.defaultScheduleId doesn't work the same for team events. + if (!eventType?.scheduleId && eventType?.userId && !eventType?.teamId) { + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: eventType.userId, + }, + select: { + defaultScheduleId: true, + }, + }); + eventType.scheduleId = user.defaultScheduleId; + } + + // TODO: eventType when not found should be a 404 + // but API consumers may depend on the {} behaviour. + return { event_type: schemaEventTypeReadPublic.parse({ ...eventType, link }) }; +} + +type BaseEventTypeCheckPermissions = { + userId: number | null; + teamId: number | null; +}; + +async function checkPermissions( + req: NextApiRequest, + eventType: (T & Partial>) | null +) { + if (req.isSystemWideAdmin) return true; + if (eventType?.teamId) { + req.query.teamId = String(eventType.teamId); + await canAccessTeamEventOrThrow(req, { + in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], + }); + return true; + } + if (eventType?.userId === req.userId) return true; // is owner. + throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/event-types/[id]/_patch.ts b/apps/api/v1/pages/api/event-types/[id]/_patch.ts new file mode 100644 index 00000000000000..99185cba537e6e --- /dev/null +++ b/apps/api/v1/pages/api/event-types/[id]/_patch.ts @@ -0,0 +1,249 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import type { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { SchedulingType } from "@calcom/prisma/enums"; + +import type { schemaEventTypeBaseBodyParams } from "~/lib/validations/event-type"; +import { schemaEventTypeEditBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; +import ensureOnlyMembersAsHosts from "~/pages/api/event-types/_utils/ensureOnlyMembersAsHosts"; + +import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission"; + +/** + * @swagger + * /event-types/{id}: + * patch: + * operationId: editEventTypeById + * summary: Edit an existing eventType + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the eventType to edit + * requestBody: + * description: Create a new event-type related to your user or team + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * length: + * type: integer + * description: Duration of the event type in minutes + * metadata: + * type: object + * description: Metadata relating to event type. Pass {} if empty + * title: + * type: string + * description: Title of the event type + * slug: + * type: string + * description: Unique slug for the event type + * scheduleId: + * type: number + * description: The ID of the schedule for this event type + * hosts: + * type: array + * items: + * type: object + * properties: + * userId: + * type: number + * isFixed: + * type: boolean + * description: Host MUST be available for any slot to be bookable. + * hidden: + * type: boolean + * description: If the event type should be hidden from your public booking page + * position: + * type: integer + * description: The position of the event type on the public booking page + * teamId: + * type: integer + * description: Team ID if the event type should belong to a team + * periodType: + * type: string + * enum: [UNLIMITED, ROLLING, RANGE] + * description: To decide how far into the future an invitee can book an event with you + * periodStartDate: + * type: string + * format: date-time + * description: Start date of bookable period (Required if periodType is 'range') + * periodEndDate: + * type: string + * format: date-time + * description: End date of bookable period (Required if periodType is 'range') + * periodDays: + * type: integer + * description: Number of bookable days (Required if periodType is rolling) + * periodCountCalendarDays: + * type: boolean + * description: If calendar days should be counted for period days + * requiresConfirmation: + * type: boolean + * description: If the event type should require your confirmation before completing the booking + * recurringEvent: + * type: object + * description: If the event should recur every week/month/year with the selected frequency + * properties: + * interval: + * type: integer + * count: + * type: integer + * freq: + * type: integer + * disableGuests: + * type: boolean + * description: If the event type should disable adding guests to the booking + * hideCalendarNotes: + * type: boolean + * description: If the calendar notes should be hidden from the booking + * minimumBookingNotice: + * type: integer + * description: Minimum time in minutes before the event is bookable + * beforeEventBuffer: + * type: integer + * description: Number of minutes of buffer time before a Cal Event + * afterEventBuffer: + * type: integer + * description: Number of minutes of buffer time after a Cal Event + * schedulingType: + * type: string + * description: The type of scheduling if a Team event. Required for team events only + * enum: [ROUND_ROBIN, COLLECTIVE] + * price: + * type: integer + * description: Price of the event type booking + * currency: + * type: string + * description: Currency acronym. Eg- usd, eur, gbp, etc. + * slotInterval: + * type: integer + * description: The intervals of available bookable slots in minutes + * successRedirectUrl: + * type: string + * format: url + * description: A valid URL where the booker will redirect to, once the booking is completed successfully + * description: + * type: string + * description: Description of the event type + * seatsPerTimeSlot: + * type: integer + * description: 'The number of seats for each time slot' + * seatsShowAttendees: + * type: boolean + * description: 'Share Attendee information in seats' + * seatsShowAvailabilityCount: + * type: boolean + * description: 'Show the number of available seats' + * locations: + * type: array + * description: A list of all available locations for the event type + * items: + * type: array + * items: + * oneOf: + * - type: object + * properties: + * type: + * type: string + * enum: ['integrations:daily'] + * - type: object + * properties: + * type: + * type: string + * enum: ['attendeeInPerson'] + * - type: object + * properties: + * type: + * type: string + * enum: ['inPerson'] + * address: + * type: string + * displayLocationPublicly: + * type: boolean + * - type: object + * properties: + * type: + * type: string + * enum: ['link'] + * link: + * type: string + * displayLocationPublicly: + * type: boolean + * example: + * event-type: + * summary: An example of event type PATCH request + * value: + * length: 60 + * requiresConfirmation: true + * tags: + * - event-types + * externalDocs: + * url: https://docs.cal.com/docs/core-features/event-types + * responses: + * 201: + * description: OK, eventType edited successfully + * 400: + * description: Bad request. EventType body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query, body } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const { + hosts = [], + bookingLimits, + durationLimits, + /** FIXME: Updating event-type children from API not supported for now */ + children: _, + ...parsedBody + } = schemaEventTypeEditBodyParams.parse(body); + + const data: Prisma.EventTypeUpdateArgs["data"] = { + ...parsedBody, + bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits, + durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits, + }; + + if (hosts) { + await ensureOnlyMembersAsHosts(req, parsedBody); + data.hosts = { + deleteMany: {}, + create: hosts.map((host) => ({ + ...host, + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + })), + }; + } + await checkPermissions(req, parsedBody); + const eventType = await prisma.eventType.update({ where: { id }, data }); + return { event_type: schemaEventTypeReadPublic.parse(eventType) }; +} + +async function checkPermissions(req: NextApiRequest, body: z.infer) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + if (isSystemWideAdmin) return; + /** Only event type owners can modify it */ + const eventType = await prisma.eventType.findFirst({ where: { id, userId } }); + if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); + await checkTeamEventEditPermission(req, body); +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/v1/pages/api/event-types/[id]/index.ts b/apps/api/v1/pages/api/event-types/[id]/index.ts new file mode 100644 index 00000000000000..26d7389880b9c0 --- /dev/null +++ b/apps/api/v1/pages/api/event-types/[id]/index.ts @@ -0,0 +1,15 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + return defaultHandler({ + GET: import("./_get"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/event-types/_get.ts b/apps/api/v1/pages/api/event-types/_get.ts new file mode 100644 index 00000000000000..07d80d3201b1bc --- /dev/null +++ b/apps/api/v1/pages/api/event-types/_get.ts @@ -0,0 +1,136 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { PrismaClient } from "@calcom/prisma"; + +import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; +import { schemaQuerySlug } from "~/lib/validations/shared/querySlug"; +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +import getCalLink from "./_utils/getCalLink"; + +/** + * @swagger + * /event-types: + * get: + * summary: Find all event types + * operationId: listEventTypes + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: query + * name: slug + * schema: + * type: string + * required: false + * description: Slug to filter event types by + * tags: + * - event-types + * externalDocs: + * url: https://docs.cal.com/docs/core-features/event-types + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No event types were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; + const { slug } = schemaQuerySlug.parse(req.query); + const shouldUseUserId = !isSystemWideAdmin || !slug || !!req.query.userId; + // When user is admin and no query params are provided we should return all event types. + // But currently we return only the event types of the user. Not changing this for backwards compatibility. + const data = await prisma.eventType.findMany({ + where: { + userId: shouldUseUserId ? { in: userIds } : undefined, + slug: slug, // slug will be undefined if not provided in query + }, + include: { + customInputs: true, + hashedLink: { select: { link: true } }, + team: { select: { slug: true } }, + hosts: { select: { userId: true, isFixed: true } }, + owner: { select: { username: true, id: true } }, + children: { select: { id: true, userId: true } }, + }, + }); + // this really should return [], but backwards compatibility.. + if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" }); + return { + event_types: (await defaultScheduleId<(typeof data)[number]>({ eventTypes: data, prisma, userIds })).map( + (eventType) => { + const link = getCalLink(eventType); + return schemaEventTypeReadPublic.parse({ ...eventType, link }); + } + ), + }; +} +// TODO: Extract & reuse. +function extractUserIdsFromQuery({ isSystemWideAdmin, query }: NextApiRequest) { + /** Guard: Only admins can query other users */ + if (!isSystemWideAdmin) { + throw new HttpError({ statusCode: 401, message: "ADMIN required" }); + } + const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query); + return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds]; +} + +type DefaultScheduleIdEventTypeBase = { + scheduleId: number | null; + userId: number | null; +}; +// If an eventType is given w/o a scheduleId +// Then we associate the default user schedule id to the eventType +async function defaultScheduleId({ + prisma, + eventTypes, + userIds, +}: { + prisma: PrismaClient; + eventTypes: (T & Partial>)[]; + userIds: number[]; +}) { + // there is no event types without a scheduleId, skip the user query + if (eventTypes.every((eventType) => eventType.scheduleId)) return eventTypes; + + const users = await prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + defaultScheduleId: true, + }, + }); + + if (!users.length) { + return eventTypes; + } + + const defaultScheduleIds = users.reduce((result, user) => { + result[user.id] = user.defaultScheduleId; + return result; + }, {} as { [x: number]: number | null }); + + return eventTypes.map((eventType) => { + // realistically never happens, userId should't be null on personal event types. + if (!eventType.userId) return eventType; + return { + ...eventType, + scheduleId: eventType.scheduleId || defaultScheduleIds[eventType.userId], + }; + }); +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/event-types/_post.ts b/apps/api/v1/pages/api/event-types/_post.ts new file mode 100644 index 00000000000000..4873c1432deebe --- /dev/null +++ b/apps/api/v1/pages/api/event-types/_post.ts @@ -0,0 +1,338 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/client"; + +import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type"; +import { canUserAccessTeamWithRole } from "~/pages/api/teams/[teamId]/_auth-middleware"; + +import checkParentEventOwnership from "./_utils/checkParentEventOwnership"; +import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission"; +import checkUserMembership from "./_utils/checkUserMembership"; +import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts"; + +/** + * @swagger + * /event-types: + * post: + * summary: Creates a new event type + * operationId: addEventType + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * requestBody: + * description: Create a new event-type related to your user or team + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - title + * - slug + * - length + * - metadata + * properties: + * length: + * type: integer + * description: Duration of the event type in minutes + * metadata: + * type: object + * description: Metadata relating to event type. Pass {} if empty + * title: + * type: string + * description: Title of the event type + * slug: + * type: string + * description: Unique slug for the event type + * hosts: + * type: array + * items: + * type: object + * properties: + * userId: + * type: number + * isFixed: + * type: boolean + * description: Host MUST be available for any slot to be bookable. + * hidden: + * type: boolean + * description: If the event type should be hidden from your public booking page + * scheduleId: + * type: number + * description: The ID of the schedule for this event type + * position: + * type: integer + * description: The position of the event type on the public booking page + * teamId: + * type: integer + * description: Team ID if the event type should belong to a team + * periodType: + * type: string + * enum: [UNLIMITED, ROLLING, RANGE] + * description: To decide how far into the future an invitee can book an event with you + * periodStartDate: + * type: string + * format: date-time + * description: Start date of bookable period (Required if periodType is 'range') + * periodEndDate: + * type: string + * format: date-time + * description: End date of bookable period (Required if periodType is 'range') + * periodDays: + * type: integer + * description: Number of bookable days (Required if periodType is rolling) + * periodCountCalendarDays: + * type: boolean + * description: If calendar days should be counted for period days + * requiresConfirmation: + * type: boolean + * description: If the event type should require your confirmation before completing the booking + * recurringEvent: + * type: object + * description: If the event should recur every week/month/year with the selected frequency + * properties: + * interval: + * type: integer + * count: + * type: integer + * freq: + * type: integer + * disableGuests: + * type: boolean + * description: If the event type should disable adding guests to the booking + * hideCalendarNotes: + * type: boolean + * description: If the calendar notes should be hidden from the booking + * minimumBookingNotice: + * type: integer + * description: Minimum time in minutes before the event is bookable + * beforeEventBuffer: + * type: integer + * description: Number of minutes of buffer time before a Cal Event + * afterEventBuffer: + * type: integer + * description: Number of minutes of buffer time after a Cal Event + * schedulingType: + * type: string + * description: The type of scheduling if a Team event. Required for team events only + * enum: [ROUND_ROBIN, COLLECTIVE, MANAGED] + * price: + * type: integer + * description: Price of the event type booking + * parentId: + * type: integer + * description: EventTypeId of the parent managed event + * currency: + * type: string + * description: Currency acronym. Eg- usd, eur, gbp, etc. + * slotInterval: + * type: integer + * description: The intervals of available bookable slots in minutes + * successRedirectUrl: + * type: string + * format: url + * description: A valid URL where the booker will redirect to, once the booking is completed successfully + * description: + * type: string + * description: Description of the event type + * locations: + * type: array + * description: A list of all available locations for the event type + * items: + * type: array + * items: + * oneOf: + * - type: object + * properties: + * type: + * type: string + * enum: ['integrations:daily'] + * - type: object + * properties: + * type: + * type: string + * enum: ['attendeeInPerson'] + * - type: object + * properties: + * type: + * type: string + * enum: ['inPerson'] + * address: + * type: string + * displayLocationPublicly: + * type: boolean + * - type: object + * properties: + * type: + * type: string + * enum: ['link'] + * link: + * type: string + * displayLocationPublicly: + * type: boolean + * examples: + * event-type: + * summary: An example of an individual event type POST request + * value: + * title: Hello World + * slug: hello-world + * length: 30 + * hidden: false + * position: 0 + * eventName: null + * timeZone: null + * scheduleId: 5 + * periodType: UNLIMITED + * periodStartDate: 2023-02-15T08:46:16.000Z + * periodEndDate: 2023-0-15T08:46:16.000Z + * periodDays: null + * periodCountCalendarDays: false + * requiresConfirmation: false + * recurringEvent: null + * disableGuests: false + * hideCalendarNotes: false + * minimumBookingNotice: 120 + * beforeEventBuffer: 0 + * afterEventBuffer: 0 + * price: 0 + * currency: usd + * slotInterval: null + * successRedirectUrl: null + * description: A test event type + * metadata: { + * apps: { + * stripe: { + * price: 0, + * enabled: false, + * currency: usd + * } + * } + * } + * team-event-type: + * summary: An example of a team event type POST request + * value: + * title: "Tennis class" + * slug: "tennis-class-{{$guid}}" + * length: 60 + * hidden: false + * position: 0 + * teamId: 3 + * eventName: null + * timeZone: null + * periodType: "UNLIMITED" + * periodStartDate: null + * periodEndDate: null + * periodDays: null + * periodCountCalendarDays: null + * requiresConfirmation: true + * recurringEvent: + * interval: 2 + * count: 10 + * freq: 2 + * disableGuests: false + * hideCalendarNotes: false + * minimumBookingNotice: 120 + * beforeEventBuffer: 0 + * afterEventBuffer: 0 + * schedulingType: "COLLECTIVE" + * price: 0 + * currency: "usd" + * slotInterval: null + * successRedirectUrl: null + * description: null + * locations: + * - address: "London" + * type: "inPerson" + * metadata: {} + * tags: + * - event-types + * externalDocs: + * url: https://docs.cal.com/docs/core-features/event-types + * responses: + * 201: + * description: OK, event type created + * 400: + * description: Bad request. EventType body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin, body } = req; + + const { + hosts = [], + bookingLimits, + durationLimits, + /** FIXME: Adding event-type children from API not supported for now */ + children: _, + ...parsedBody + } = schemaEventTypeCreateBodyParams.parse(body || {}); + + let data: Prisma.EventTypeCreateArgs["data"] = { + ...parsedBody, + userId: !!parsedBody.teamId ? null : userId, + users: !!parsedBody.teamId ? undefined : { connect: { id: userId } }, + bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits, + durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits, + }; + + await checkPermissions(req); + + if (parsedBody.parentId) { + await checkParentEventOwnership(req); + await checkUserMembership(req); + } + + if (isSystemWideAdmin && parsedBody.userId && !parsedBody.teamId) { + data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } }; + } + + await checkTeamEventEditPermission(req, parsedBody); + await ensureOnlyMembersAsHosts(req, parsedBody); + + if (hosts) { + data.hosts = { createMany: { data: hosts } }; + } + + const eventType = await prisma.eventType.create({ data, include: { hosts: true } }); + + return { + event_type: schemaEventTypeReadPublic.parse(eventType), + message: "Event type created successfully", + }; +} + +async function checkPermissions(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + const body = schemaEventTypeCreateBodyParams.parse(req.body); + /* Non-admin users can only create event types for themselves */ + if (!isSystemWideAdmin && body.userId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `userId`", + }); + if ( + body.teamId && + !isSystemWideAdmin && + !(await canUserAccessTeamWithRole(req.userId, isSystemWideAdmin, body.teamId, { + in: [MembershipRole.OWNER, MembershipRole.ADMIN], + })) + ) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `teamId`", + }); + /* Admin users are required to pass in a userId or teamId */ + if (isSystemWideAdmin && !body.userId && !body.teamId) + throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" }); +} + +export default defaultResponder(postHandler); diff --git a/apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts b/apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts new file mode 100644 index 00000000000000..38e9fe78c25c0a --- /dev/null +++ b/apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts @@ -0,0 +1,54 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +/** + * Checks if a user, identified by the provided userId, has ownership (or admin rights) over + * the team associated with the event type identified by the parentId. + * + * @param req - The current request + * + * @throws {HttpError} If the parent event type is not found, + * if the parent event type doesn't belong to any team, + * or if the user doesn't have ownership or admin rights to the associated team. + */ +export default async function checkParentEventOwnership(req: NextApiRequest) { + const { userId, body } = req; + /** These are already parsed upstream, we can assume they're good here. */ + const parentId = Number(body.parentId); + const parentEventType = await prisma.eventType.findUnique({ + where: { id: parentId }, + select: { teamId: true }, + }); + + if (!parentEventType) { + throw new HttpError({ + statusCode: 404, + message: "Parent event type not found.", + }); + } + + if (!parentEventType.teamId) { + throw new HttpError({ + statusCode: 400, + message: "This event type is not capable of having children", + }); + } + + const teamMember = await prisma.membership.findFirst({ + where: { + teamId: parentEventType.teamId, + userId: userId, + role: { in: ["ADMIN", "OWNER"] }, + accepted: true, + }, + }); + + if (!teamMember) { + throw new HttpError({ + statusCode: 403, + message: "User is not authorized to access the team to which the parent event type belongs.", + }); + } +} diff --git a/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts new file mode 100644 index 00000000000000..edba1dcea4da4e --- /dev/null +++ b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest } from "next"; +import type { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import type { schemaEventTypeCreateBodyParams } from "~/lib/validations/event-type"; + +export default async function checkTeamEventEditPermission( + req: NextApiRequest, + body: Pick, "teamId" | "userId"> +) { + if (body.teamId) { + const membership = await prisma.membership.findFirst({ + where: { + userId: req.userId, + teamId: body.teamId, + accepted: true, + role: { in: ["ADMIN", "OWNER"] }, + }, + }); + + if (!membership) { + throw new HttpError({ + statusCode: 403, + message: "No permission to operate on event-type for this team", + }); + } + } +} diff --git a/apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts b/apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts new file mode 100644 index 00000000000000..176d5a93f7dae3 --- /dev/null +++ b/apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts @@ -0,0 +1,58 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +/** + * Checks if a user, identified by the provided userId, is a member of the team associated + * with the event type identified by the parentId. + * + * @param req - The current request + * + * @throws {HttpError} If the event type is not found, + * if the event type doesn't belong to any team, + * or if the user isn't a member of the associated team. + */ +export default async function checkUserMembership(req: NextApiRequest) { + const { body } = req; + /** These are already parsed upstream, we can assume they're good here. */ + const parentId = Number(body.parentId); + const userId = Number(body.userId); + const parentEventType = await prisma.eventType.findUnique({ + where: { + id: parentId, + }, + select: { + teamId: true, + }, + }); + + if (!parentEventType) { + throw new HttpError({ + statusCode: 404, + message: "Event type not found.", + }); + } + + if (!parentEventType.teamId) { + throw new HttpError({ + statusCode: 400, + message: "This event type is not capable of having children.", + }); + } + + const teamMember = await prisma.membership.findFirst({ + where: { + teamId: parentEventType.teamId, + userId: userId, + accepted: true, + }, + }); + + if (!teamMember) { + throw new HttpError({ + statusCode: 400, + message: "User is not a team member.", + }); + } +} diff --git a/apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts b/apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts similarity index 87% rename from apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts rename to apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts index 301c5307a1f9d9..f3b29a934ed4ec 100644 --- a/apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts +++ b/apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts @@ -1,6 +1,8 @@ import type { NextApiRequest } from "next"; import type { z } from "zod"; +import prisma from "@calcom/prisma"; + import type { schemaEventTypeCreateBodyParams } from "~/lib/validations/event-type"; export default async function ensureOnlyMembersAsHosts( @@ -8,7 +10,7 @@ export default async function ensureOnlyMembersAsHosts( body: Pick, "hosts" | "teamId"> ) { if (body.teamId && body.hosts && body.hosts.length > 0) { - const teamMemberCount = await req.prisma.membership.count({ + const teamMemberCount = await prisma.membership.count({ where: { teamId: body.teamId, userId: { in: body.hosts.map((host) => host.userId) }, diff --git a/apps/api/v1/pages/api/event-types/_utils/getCalLink.ts b/apps/api/v1/pages/api/event-types/_utils/getCalLink.ts new file mode 100644 index 00000000000000..1877e3547f418a --- /dev/null +++ b/apps/api/v1/pages/api/event-types/_utils/getCalLink.ts @@ -0,0 +1,16 @@ +import { WEBSITE_URL } from "@calcom/lib/constants"; + +export default function getCalLink(eventType: { + team?: { slug: string | null } | null; + owner?: { username: string | null } | null; + users?: { username: string | null }[]; + slug: string; +}) { + return `${WEBSITE_URL}/${ + eventType?.team + ? `team/${eventType?.team?.slug}` + : eventType?.owner + ? eventType.owner.username + : eventType?.users?.[0]?.username + }/${eventType?.slug}`; +} diff --git a/apps/api/pages/api/memberships/index.ts b/apps/api/v1/pages/api/event-types/index.ts similarity index 100% rename from apps/api/pages/api/memberships/index.ts rename to apps/api/v1/pages/api/event-types/index.ts diff --git a/apps/api/v1/pages/api/index.ts b/apps/api/v1/pages/api/index.ts new file mode 100644 index 00000000000000..9f0dfa482973ea --- /dev/null +++ b/apps/api/v1/pages/api/index.ts @@ -0,0 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function CalcomApi(_: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ message: "Welcome to Cal.com API - docs are at https://developer.cal.com/api" }); +} diff --git a/apps/api/v1/pages/api/invites/_post.ts b/apps/api/v1/pages/api/invites/_post.ts new file mode 100644 index 00000000000000..eabe62ba694f15 --- /dev/null +++ b/apps/api/v1/pages/api/invites/_post.ts @@ -0,0 +1,74 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { CreationSource } from "@calcom/prisma/enums"; +import { createContext } from "@calcom/trpc/server/createContext"; +import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; +import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema"; +import { ZInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema"; +import type { UserProfile } from "@calcom/types/UserProfile"; + +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; + +async function postHandler(req: NextApiRequest, res: NextApiResponse) { + const data = ZInviteMemberInputSchema.parse(req.body); + await checkPermissions(req, data); + + async function sessionGetter() { + return { + user: { + id: req.userId, + username: "", + profile: { + id: null, + organizationId: null, + organization: null, + username: "", + upId: "", + } satisfies UserProfile, + }, + hasValidLicense: true, + expires: "", + upId: "", + }; + } + + const ctx = await createContext({ req, res }, sessionGetter); + try { + const caller = viewerTeamsRouter.createCaller(ctx); + await caller.inviteMember({ + role: data.role, + language: data.language, + teamId: data.teamId, + usernameOrEmail: data.usernameOrEmail, + creationSource: CreationSource.API_V1, + }); + + return { success: true, message: `${data.usernameOrEmail} has been invited.` }; + } catch (cause) { + if (cause instanceof TRPCError) { + const statusCode = getHTTPStatusCodeFromError(cause); + throw new HttpError({ statusCode, message: cause.message }); + } + + throw cause; + } +} + +async function checkPermissions(req: NextApiRequest, body: TInviteMemberInputSchema) { + const { userId, isSystemWideAdmin } = req; + if (isSystemWideAdmin) return; + // To prevent auto-accepted invites, limit it to ADMIN users + if (!isSystemWideAdmin && "accepted" in body) + throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" }); + // Only team OWNERS and ADMINS can add other members + const membership = await prisma.membership.findFirst({ + where: { userId, teamId: body.teamId, role: { in: ["ADMIN", "OWNER"] } }, + }); + if (!membership) throw new HttpError({ statusCode: 403, message: "You can't add members to this team" }); +} + +export default defaultResponder(postHandler); diff --git a/apps/api/v1/pages/api/invites/index.ts b/apps/api/v1/pages/api/invites/index.ts new file mode 100644 index 00000000000000..3ccafb7e556e5d --- /dev/null +++ b/apps/api/v1/pages/api/invites/index.ts @@ -0,0 +1,9 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware()( + defaultHandler({ + POST: import("./_post"), + }) +); diff --git a/apps/api/v1/pages/api/me/_get.ts b/apps/api/v1/pages/api/me/_get.ts new file mode 100644 index 00000000000000..d7887a15da00de --- /dev/null +++ b/apps/api/v1/pages/api/me/_get.ts @@ -0,0 +1,18 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaUserReadPublic } from "~/lib/validations/user"; + +async function handler({ userId }: NextApiRequest) { + const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + return { + user: schemaUserReadPublic.parse({ + ...data, + avatar: data.avatarUrl, + }), + }; +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/slots/index.ts b/apps/api/v1/pages/api/me/index.ts similarity index 100% rename from apps/api/pages/api/slots/index.ts rename to apps/api/v1/pages/api/me/index.ts diff --git a/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..f14317eae39305 --- /dev/null +++ b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts @@ -0,0 +1,18 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { membershipIdSchema } from "~/lib/validations/membership"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { teamId } = membershipIdSchema.parse(req.query); + // Admins can just skip this check + if (isSystemWideAdmin) return; + // Only team members can modify a membership + const membership = await prisma.membership.findFirst({ where: { userId, teamId } }); + if (!membership) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/memberships/[id]/_delete.ts b/apps/api/v1/pages/api/memberships/[id]/_delete.ts new file mode 100644 index 00000000000000..e6c39eb4821efd --- /dev/null +++ b/apps/api/v1/pages/api/memberships/[id]/_delete.ts @@ -0,0 +1,103 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { membershipIdSchema } from "~/lib/validations/membership"; + +/** + * @swagger + * /memberships/{userId}_{teamId}: + * delete: + * summary: Remove an existing membership + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Numeric userId of the membership to get + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: Numeric teamId of the membership to get + * tags: + * - memberships + * responses: + * 201: + * description: OK, membership removed successfuly + * 400: + * description: Bad request. Membership id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const userId_teamId = membershipIdSchema.parse(query); + await checkPermissions(req); + await prisma.membership.delete({ where: { userId_teamId } }); + return { message: `Membership with id: ${query.id} deleted successfully` }; +} + +async function checkPermissions(req: NextApiRequest) { + const { isSystemWideAdmin, userId, query } = req; + const userId_teamId = membershipIdSchema.parse(query); + // Admin User can do anything including deletion of Admin Team Member in any team + if (isSystemWideAdmin) { + return; + } + + // Owner can delete Admin and Member + // Admin Team Member can delete Member + // Member can't delete anyone + const PRIVILEGE_ORDER = ["OWNER", "ADMIN", "MEMBER"]; + + const memberShipToBeDeleted = await prisma.membership.findUnique({ + where: { userId_teamId }, + }); + + if (!memberShipToBeDeleted) { + throw new HttpError({ statusCode: 404, message: "Membership not found" }); + } + + // If a user is deleting their own membership, then they can do it + if (userId === memberShipToBeDeleted.userId) { + return; + } + + const currentUserMembership = await prisma.membership.findUnique({ + where: { + userId_teamId: { + userId, + teamId: memberShipToBeDeleted.teamId, + }, + }, + }); + + if (!currentUserMembership) { + // Current User isn't a member of the team + throw new HttpError({ statusCode: 403, message: "You are not a member of the team" }); + } + + if ( + PRIVILEGE_ORDER.indexOf(memberShipToBeDeleted.role) === -1 || + PRIVILEGE_ORDER.indexOf(currentUserMembership.role) === -1 + ) { + throw new HttpError({ statusCode: 400, message: "Invalid role" }); + } + + // If Role that is being deleted comes before the current User's Role, or it's the same ROLE, throw error + if ( + PRIVILEGE_ORDER.indexOf(memberShipToBeDeleted.role) <= PRIVILEGE_ORDER.indexOf(currentUserMembership.role) + ) { + throw new HttpError({ + statusCode: 403, + message: "You don't have the appropriate role to delete this membership", + }); + } +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/memberships/[id]/_get.ts b/apps/api/v1/pages/api/memberships/[id]/_get.ts new file mode 100644 index 00000000000000..c66a09cf135650 --- /dev/null +++ b/apps/api/v1/pages/api/memberships/[id]/_get.ts @@ -0,0 +1,47 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { membershipIdSchema, schemaMembershipPublic } from "~/lib/validations/membership"; + +/** + * @swagger + * /memberships/{userId}_{teamId}: + * get: + * summary: Find a membership by userID and teamID + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Numeric userId of the membership to get + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: Numeric teamId of the membership to get + * tags: + * - memberships + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Membership was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const userId_teamId = membershipIdSchema.parse(query); + const args: Prisma.MembershipFindUniqueOrThrowArgs = { where: { userId_teamId } }; + // Just in case the user want to get more info about the team itself + if (req.query.include === "team") args.include = { team: true }; + const data = await prisma.membership.findUniqueOrThrow(args); + return { membership: schemaMembershipPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/memberships/[id]/_patch.ts b/apps/api/v1/pages/api/memberships/[id]/_patch.ts new file mode 100644 index 00000000000000..f44e573922b790 --- /dev/null +++ b/apps/api/v1/pages/api/memberships/[id]/_patch.ts @@ -0,0 +1,76 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { + membershipEditBodySchema, + membershipIdSchema, + schemaMembershipPublic, +} from "~/lib/validations/membership"; + +/** + * @swagger + * /memberships/{userId}_{teamId}: + * patch: + * summary: Edit an existing membership + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Numeric userId of the membership to get + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: Numeric teamId of the membership to get + * tags: + * - memberships + * responses: + * 201: + * description: OK, membership edited successfully + * 400: + * description: Bad request. Membership body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query } = req; + const userId_teamId = membershipIdSchema.parse(query); + const data = membershipEditBodySchema.parse(req.body); + const args: Prisma.MembershipUpdateArgs = { where: { userId_teamId }, data }; + + await checkPermissions(req); + + const result = await prisma.membership.update(args); + return { membership: schemaMembershipPublic.parse(result) }; +} + +async function checkPermissions(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { userId: queryUserId, teamId } = membershipIdSchema.parse(req.query); + const data = membershipEditBodySchema.parse(req.body); + // Admins can just skip this check + if (isSystemWideAdmin) return; + // Only the invited user can accept the invite + if ("accepted" in data && queryUserId !== userId) + throw new HttpError({ + statusCode: 403, + message: "Only the invited user can accept the invite", + }); + // Only team OWNERS and ADMINS can modify `role` + if ("role" in data) { + const membership = await prisma.membership.findFirst({ + where: { userId, teamId, role: { in: ["ADMIN", "OWNER"] } }, + }); + if (!membership || (membership.role !== "OWNER" && req.body.role === "OWNER")) + throw new HttpError({ statusCode: 403, message: "Forbidden" }); + } +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/memberships/[id]/index.ts b/apps/api/v1/pages/api/memberships/[id]/index.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/index.ts rename to apps/api/v1/pages/api/memberships/[id]/index.ts diff --git a/apps/api/v1/pages/api/memberships/_get.ts b/apps/api/v1/pages/api/memberships/_get.ts new file mode 100644 index 00000000000000..c175718a8b41e9 --- /dev/null +++ b/apps/api/v1/pages/api/memberships/_get.ts @@ -0,0 +1,78 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaMembershipPublic } from "~/lib/validations/membership"; +import { + schemaQuerySingleOrMultipleTeamIds, + schemaQuerySingleOrMultipleUserIds, +} from "~/lib/validations/shared/queryUserId"; + +/** + * @swagger + * /memberships: + * get: + * summary: Find all memberships + * tags: + * - memberships + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No memberships were found + */ +async function getHandler(req: NextApiRequest) { + const args: Prisma.MembershipFindManyArgs = { + where: { + /** Admins can query multiple users */ + userId: { in: getUserIds(req) }, + /** Admins can query multiple teams as well */ + teamId: { in: getTeamIds(req) }, + }, + }; + // Just in case the user want to get more info about the team itself + if (req.query.include === "team") args.include = { team: true }; + + const data = await prisma.membership.findMany(args); + return { memberships: data.map((v) => schemaMembershipPublic.parse(v)) }; +} + +/** + * Returns requested users IDs only if admin, otherwise return only current user ID + */ +function getUserIds(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + /** Only admins can query other users */ + if (!isSystemWideAdmin && req.query.userId) + throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isSystemWideAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + return userIds; + } + // Return all memberships for ADMIN, limit to current user to non-admins + return isSystemWideAdmin ? undefined : [userId]; +} + +/** + * Returns requested teams IDs only if admin + */ +function getTeamIds(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + /** Only admins can query other teams */ + if (!isSystemWideAdmin && req.query.teamId) + throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isSystemWideAdmin && req.query.teamId) { + const query = schemaQuerySingleOrMultipleTeamIds.parse(req.query); + const teamIds = Array.isArray(query.teamId) ? query.teamId : [query.teamId]; + return teamIds; + } + return undefined; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/memberships/_post.ts b/apps/api/v1/pages/api/memberships/_post.ts new file mode 100644 index 00000000000000..9c4b1af12ef483 --- /dev/null +++ b/apps/api/v1/pages/api/memberships/_post.ts @@ -0,0 +1,53 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/validations/membership"; + +/** + * @swagger + * /memberships: + * post: + * summary: Creates a new membership + * tags: + * - memberships + * responses: + * 201: + * description: OK, membership created + * 400: + * description: Bad request. Membership body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const data = membershipCreateBodySchema.parse(req.body); + const args: Prisma.MembershipCreateArgs = { data }; + + await checkPermissions(req); + + const result = await prisma.membership.create(args); + + return { + membership: schemaMembershipPublic.parse(result), + message: "Membership created successfully", + }; +} + +async function checkPermissions(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + if (isSystemWideAdmin) return; + const body = membershipCreateBodySchema.parse(req.body); + // To prevent auto-accepted invites, limit it to ADMIN users + if (!isSystemWideAdmin && "accepted" in body) + throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" }); + // Only team OWNERS and ADMINS can add other members + const membership = await prisma.membership.findFirst({ + where: { userId, teamId: body.teamId, role: { in: ["ADMIN", "OWNER"] } }, + }); + if (!membership) throw new HttpError({ statusCode: 403, message: "You can't add members to this team" }); +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/schedules/index.ts b/apps/api/v1/pages/api/memberships/index.ts similarity index 100% rename from apps/api/pages/api/schedules/index.ts rename to apps/api/v1/pages/api/memberships/index.ts diff --git a/apps/api/v1/pages/api/payments/[id].ts b/apps/api/v1/pages/api/payments/[id].ts new file mode 100644 index 00000000000000..0b621cb86d8b6d --- /dev/null +++ b/apps/api/v1/pages/api/payments/[id].ts @@ -0,0 +1,69 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; +import type { PaymentResponse } from "~/lib/types"; +import { schemaPaymentPublic } from "~/lib/validations/payment"; +import { + schemaQueryIdParseInt, + withValidQueryIdTransformParseInt, +} from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /payments/{id}: + * get: + * summary: Find a payment + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the payment to get + * tags: + * - payments + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Payment was not found + */ +export async function paymentById( + { method, query, userId }: NextApiRequest, + res: NextApiResponse +) { + const safeQuery = schemaQueryIdParseInt.safeParse(query); + if (safeQuery.success && method === "GET") { + const userWithBookings = await prisma.user.findUnique({ + where: { id: userId }, + include: { bookings: true }, + }); + await prisma.payment + .findUnique({ where: { id: safeQuery.data.id } }) + .then((data) => schemaPaymentPublic.parse(data)) + .then((payment) => { + if (!userWithBookings?.bookings.map((b) => b.id).includes(payment.bookingId)) { + res.status(401).json({ message: "Unauthorized" }); + } else { + res.status(200).json({ payment }); + } + }) + .catch((error: Error) => + res.status(404).json({ + message: `Payment with id: ${safeQuery.data.id} not found`, + error, + }) + ); + } +} +export default withMiddleware("HTTP_GET")(withValidQueryIdTransformParseInt(paymentById)); diff --git a/apps/api/v1/pages/api/payments/index.ts b/apps/api/v1/pages/api/payments/index.ts new file mode 100644 index 00000000000000..d556245753e058 --- /dev/null +++ b/apps/api/v1/pages/api/payments/index.ts @@ -0,0 +1,44 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import prisma from "@calcom/prisma"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; +import type { PaymentsResponse } from "~/lib/types"; +import { schemaPaymentPublic } from "~/lib/validations/payment"; + +/** + * @swagger + * /payments: + * get: + * summary: Find all payments + * tags: + * - payments + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No payments were found + */ +async function allPayments({ userId }: NextApiRequest, res: NextApiResponse) { + const userWithBookings = await prisma.user.findUnique({ + where: { id: userId }, + include: { bookings: true }, + }); + if (!userWithBookings) throw new Error("No user found"); + const bookings = userWithBookings.bookings; + const bookingIds = bookings.map((booking) => booking.id); + const data = await prisma.payment.findMany({ where: { bookingId: { in: bookingIds } } }); + const payments = data.map((payment) => schemaPaymentPublic.parse(payment)); + + if (payments) res.status(200).json({ payments }); + else + (error: Error) => + res.status(404).json({ + message: "No Payments were found", + error, + }); +} +// NO POST FOR PAYMENTS FOR NOW +export default withMiddleware("HTTP_GET")(allPayments); diff --git a/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..ef44111c2ea268 --- /dev/null +++ b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdParseInt.parse(req.query); + // Admins can just skip this check + if (isSystemWideAdmin) return; + // Check if the current user can access the schedule + const schedule = await prisma.schedule.findFirst({ + where: { id, userId }, + }); + if (!schedule) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/schedules/[id]/_delete.ts b/apps/api/v1/pages/api/schedules/[id]/_delete.ts new file mode 100644 index 00000000000000..0b2d0198ba9edc --- /dev/null +++ b/apps/api/v1/pages/api/schedules/[id]/_delete.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /schedules/{id}: + * delete: + * operationId: removeScheduleById + * summary: Remove an existing schedule + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the schedule to delete + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * tags: + * - schedules + * responses: + * 201: + * description: OK, schedule removed successfully + * 400: + * description: Bad request. Schedule id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + /* If we're deleting any default user schedule, we unset it */ + await prisma.user.updateMany({ where: { defaultScheduleId: id }, data: { defaultScheduleId: undefined } }); + + await prisma.schedule.delete({ where: { id } }); + return { message: `Schedule with id: ${id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/schedules/[id]/_get.ts b/apps/api/v1/pages/api/schedules/[id]/_get.ts new file mode 100644 index 00000000000000..8e1a2df5af83d3 --- /dev/null +++ b/apps/api/v1/pages/api/schedules/[id]/_get.ts @@ -0,0 +1,82 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaSchedulePublic } from "~/lib/validations/schedule"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /schedules/{id}: + * get: + * operationId: getScheduleById + * summary: Find a schedule + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the schedule to get + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * tags: + * - schedules + * responses: + * 200: + * description: OK + * content: + * application/json: + * examples: + * schedule: + * value: + * { + * "schedule": { + * "id": 12345, + * "userId": 182, + * "name": "Sample Schedule", + * "timeZone": "Asia/Calcutta", + * "availability": [ + * { + * "id": 111, + * "eventTypeId": null, + * "days": [0, 1, 2, 3, 4, 6], + * "startTime": "00:00:00", + * "endTime": "23:45:00" + * }, + * { + * "id": 112, + * "eventTypeId": null, + * "days": [5], + * "startTime": "00:00:00", + * "endTime": "12:00:00" + * }, + * { + * "id": 113, + * "eventTypeId": null, + * "days": [5], + * "startTime": "15:00:00", + * "endTime": "23:45:00" + * } + * ] + * } + * } + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Schedule was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = await prisma.schedule.findUniqueOrThrow({ where: { id }, include: { availability: true } }); + return { schedule: schemaSchedulePublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/schedules/[id]/_patch.ts b/apps/api/v1/pages/api/schedules/[id]/_patch.ts new file mode 100644 index 00000000000000..c9236eff8496a2 --- /dev/null +++ b/apps/api/v1/pages/api/schedules/[id]/_patch.ts @@ -0,0 +1,102 @@ +import type { NextApiRequest } from "next"; +import type { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaSchedulePublic, schemaSingleScheduleBodyParams } from "~/lib/validations/schedule"; +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /schedules/{id}: + * patch: + * operationId: editScheduleById + * summary: Edit an existing schedule + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the schedule to edit + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * requestBody: + * description: Edit an existing schedule + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: Name of the schedule + * timeZone: + * type: string + * description: The timezone for this schedule + * examples: + * schedule: + * value: + * { + * "name": "Updated Schedule", + * "timeZone": "Asia/Calcutta" + * } + * tags: + * - schedules + * responses: + * 200: + * description: OK, schedule edited successfully + * content: + * application/json: + * examples: + * schedule: + * value: + * { + * "schedule": { + * "id": 12345, + * "userId": 1, + * "name": "Total Testing Part 2", + * "timeZone": "Asia/Calcutta", + * "availability": [ + * { + * "id": 4567, + * "eventTypeId": null, + * "days": [1, 2, 3, 4, 5], + * "startTime": "09:00:00", + * "endTime": "17:00:00" + * } + * ] + * } + * } + * 400: + * description: Bad request. Schedule body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ + +export async function patchHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + const data = schemaSingleScheduleBodyParams.parse(req.body); + await checkPermissions(req, data); + const result = await prisma.schedule.update({ where: { id }, data, include: { availability: true } }); + return { schedule: schemaSchedulePublic.parse(result) }; +} + +async function checkPermissions(req: NextApiRequest, body: z.infer) { + const { isSystemWideAdmin } = req; + if (isSystemWideAdmin) return; + if (body.userId) { + throw new HttpError({ statusCode: 403, message: "Non admin cannot change the owner of a schedule" }); + } + //_auth-middleware takes care of verifying the ownership of schedule. +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/schedules/[id]/index.ts b/apps/api/v1/pages/api/schedules/[id]/index.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/index.ts rename to apps/api/v1/pages/api/schedules/[id]/index.ts diff --git a/apps/api/v1/pages/api/schedules/_get.ts b/apps/api/v1/pages/api/schedules/_get.ts new file mode 100644 index 00000000000000..d70c64aecaaf9a --- /dev/null +++ b/apps/api/v1/pages/api/schedules/_get.ts @@ -0,0 +1,100 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaSchedulePublic } from "~/lib/validations/schedule"; +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +export const schemaUserIds = z + .union([z.string(), z.array(z.string())]) + .transform((val) => (Array.isArray(val) ? val.map((v) => parseInt(v, 10)) : [parseInt(val, 10)])); + +/** + * @swagger + * /schedules: + * get: + * operationId: listSchedules + * summary: Find all schedules + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * tags: + * - schedules + * responses: + * 200: + * description: OK + * content: + * application/json: + * examples: + * schedules: + * value: + * { + * "schedules": [ + * { + * "id": 1234, + * "userId": 5678, + * "name": "Sample Schedule 1", + * "timeZone": "America/Chicago", + * "availability": [ + * { + * "id": 987, + * "eventTypeId": null, + * "days": [1, 2, 3, 4, 5], + * "startTime": "09:00:00", + * "endTime": "23:00:00" + * } + * ] + * }, + * { + * "id": 2345, + * "userId": 6789, + * "name": "Sample Schedule 2", + * "timeZone": "Europe/Amsterdam", + * "availability": [ + * { + * "id": 876, + * "eventTypeId": null, + * "days": [1, 2, 3, 4, 5], + * "startTime": "09:00:00", + * "endTime": "17:00:00" + * } + * ] + * } + * ] + * } + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No schedules were found + */ + +async function handler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const args: Prisma.ScheduleFindManyArgs = isSystemWideAdmin ? {} : { where: { userId } }; + args.include = { availability: true }; + + if (!isSystemWideAdmin && req.query.userId) + throw new HttpError({ + statusCode: 401, + message: "Unauthorized: Only admins can query other users", + }); + + if (isSystemWideAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + args.where = { userId: { in: userIds } }; + if (Array.isArray(query.userId)) args.orderBy = { userId: "asc" }; + } + const data = await prisma.schedule.findMany(args); + return { schedules: data.map((s) => schemaSchedulePublic.parse(s)) }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/schedules/_post.ts b/apps/api/v1/pages/api/schedules/_post.ts new file mode 100644 index 00000000000000..8b8de204726f84 --- /dev/null +++ b/apps/api/v1/pages/api/schedules/_post.ts @@ -0,0 +1,114 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCreateScheduleBodyParams, schemaSchedulePublic } from "~/lib/validations/schedule"; + +/** + * @swagger + * /schedules: + * post: + * operationId: addSchedule + * summary: Creates a new schedule + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * requestBody: + * description: Create a new schedule + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - timeZone + * properties: + * name: + * type: string + * description: Name of the schedule + * timeZone: + * type: string + * description: The timeZone for this schedule + * examples: + * schedule: + * value: + * { + * "name": "Sample Schedule", + * "timeZone": "Asia/Calcutta" + * } + * tags: + * - schedules + * responses: + * 200: + * description: OK, schedule created + * content: + * application/json: + * examples: + * schedule: + * value: + * { + * "schedule": { + * "id": 79471, + * "userId": 182, + * "name": "Total Testing", + * "timeZone": "Asia/Calcutta", + * "availability": [ + * { + * "id": 337917, + * "eventTypeId": null, + * "days": [1, 2, 3, 4, 5], + * "startTime": "09:00:00", + * "endTime": "17:00:00" + * } + * ] + * }, + * "message": "Schedule created successfully" + * } + * 400: + * description: Bad request. Schedule body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ + +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const body = schemaCreateScheduleBodyParams.parse(req.body); + let args: Prisma.ScheduleCreateArgs = { data: { ...body, userId } }; + + /* If ADMIN we create the schedule for selected user */ + if (isSystemWideAdmin && body.userId) args = { data: { ...body, userId: body.userId } }; + + if (!isSystemWideAdmin && body.userId) + throw new HttpError({ statusCode: 403, message: "ADMIN required for `userId`" }); + + // We create default availabilities for the schedule + args.data.availability = { + createMany: { + data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE).map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + })), + }, + }; + // We include the recently created availability + args.include = { availability: true }; + + const data = await prisma.schedule.create(args); + + return { + schedule: schemaSchedulePublic.parse(data), + message: "Schedule created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/selected-calendars/index.ts b/apps/api/v1/pages/api/schedules/index.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/index.ts rename to apps/api/v1/pages/api/schedules/index.ts diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..09ce0c39407a70 --- /dev/null +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { userId: queryUserId } = selectedCalendarIdSchema.parse(req.query); + // Admins can just skip this check + if (isSystemWideAdmin) return; + // Check if the current user requesting is the same as the one being requested + if (userId !== queryUserId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts new file mode 100644 index 00000000000000..1658ffec1cc9c9 --- /dev/null +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts @@ -0,0 +1,58 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; + +import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; + +/** + * @swagger + * /selected-calendars/{userId}_{integration}_{externalId}: + * delete: + * operationId: removeSelectedCalendarById + * summary: Remove a selected calendar + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: userId of the selected calendar to get + * - in: path + * name: externalId + * schema: + * type: integer + * required: true + * description: externalId of the selected-calendar to get + * - in: path + * name: integration + * schema: + * type: string + * required: true + * description: integration of the selected calendar to get + * tags: + * - selected-calendars + * responses: + * 201: + * description: OK, selected-calendar removed successfully + * 400: + * description: Bad request. SelectedCalendar id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const userId_integration_externalId = selectedCalendarIdSchema.parse(query); + await SelectedCalendarRepository.deleteUserLevel({ + where: userId_integration_externalId, + }); + return { message: `Selected Calendar with id: ${query.id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts new file mode 100644 index 00000000000000..f65e6ecd2085c2 --- /dev/null +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts @@ -0,0 +1,58 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; + +import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; + +/** + * @swagger + * /selected-calendars/{userId}_{integration}_{externalId}: + * get: + * operationId: getSelectedCalendarById + * summary: Find a selected calendar by providing the compoundId(userId_integration_externalId) separated by `_` + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: userId of the selected calendar to get + * - in: path + * name: externalId + * schema: + * type: string + * required: true + * description: externalId of the selected calendar to get + * - in: path + * name: integration + * schema: + * type: string + * required: true + * description: integration of the selected calendar to get + * tags: + * - selected-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: SelectedCalendar was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const userId_integration_externalId = selectedCalendarIdSchema.parse(query); + const data = await SelectedCalendarRepository.findUserLevelUniqueOrThrow({ + where: userId_integration_externalId, + }); + return { selected_calendar: schemaSelectedCalendarPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts new file mode 100644 index 00000000000000..6de282fd5873d7 --- /dev/null +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts @@ -0,0 +1,76 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import type { UpdateArguments } from "@calcom/lib/server/repository/selectedCalendar"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import prisma from "@calcom/prisma"; + +import { + schemaSelectedCalendarPublic, + schemaSelectedCalendarUpdateBodyParams, + selectedCalendarIdSchema, +} from "~/lib/validations/selected-calendar"; + +/** + * @swagger + * /selected-calendars/{userId}_{integration}_{externalId}: + * patch: + * operationId: editSelectedCalendarById + * summary: Edit a selected calendar + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: userId of the selected calendar to get + * - in: path + * name: externalId + * schema: + * type: string + * required: true + * description: externalId of the selected calendar to get + * - in: path + * name: integration + * schema: + * type: string + * required: true + * description: integration of the selected calendar to get + * tags: + * - selected-calendars + * responses: + * 201: + * description: OK, selected-calendar edited successfully + * 400: + * description: Bad request. SelectedCalendar body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query, isSystemWideAdmin } = req; + const userId_integration_externalId = selectedCalendarIdSchema.parse(query); + const { userId: bodyUserId, ...data } = schemaSelectedCalendarUpdateBodyParams.parse(req.body); + const args: UpdateArguments = { where: { ...userId_integration_externalId }, data }; + + if (!isSystemWideAdmin && bodyUserId) + throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isSystemWideAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: bodyUserId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = bodyUserId; + } + + const result = await SelectedCalendarRepository.updateUserLevel(args); + return { selected_calendar: schemaSelectedCalendarPublic.parse(result) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/selected-calendars/[id]/index.ts b/apps/api/v1/pages/api/selected-calendars/[id]/index.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/index.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/index.ts diff --git a/apps/api/v1/pages/api/selected-calendars/_get.ts b/apps/api/v1/pages/api/selected-calendars/_get.ts new file mode 100644 index 00000000000000..5b7264e6cd1362 --- /dev/null +++ b/apps/api/v1/pages/api/selected-calendars/_get.ts @@ -0,0 +1,53 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import type { FindManyArgs } from "@calcom/lib/server/repository/selectedCalendar"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; + +import { schemaSelectedCalendarPublic } from "~/lib/validations/selected-calendar"; +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; + +/** + * @swagger + * /selected-calendars: + * get: + * operationId: listSelectedCalendars + * summary: Find all selected calendars + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * tags: + * - selected-calendars + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No selected calendars were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + /* Admin gets all selected calendar by default, otherwise only the user's ones */ + const args: FindManyArgs = isSystemWideAdmin ? {} : { where: { userId } }; + + /** Only admins can query other users */ + if (!isSystemWideAdmin && req.query.userId) + throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isSystemWideAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + args.where = { userId: { in: userIds } }; + if (Array.isArray(query.userId)) args.orderBy = { userId: "asc" }; + } + + const data = await SelectedCalendarRepository.findManyUserLevel(args); + return { selected_calendars: data.map((v) => schemaSelectedCalendarPublic.parse(v)) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/selected-calendars/_post.ts b/apps/api/v1/pages/api/selected-calendars/_post.ts new file mode 100644 index 00000000000000..8ff0f31eea8611 --- /dev/null +++ b/apps/api/v1/pages/api/selected-calendars/_post.ts @@ -0,0 +1,79 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import prisma from "@calcom/prisma"; + +import { + schemaSelectedCalendarBodyParams, + schemaSelectedCalendarPublic, +} from "~/lib/validations/selected-calendar"; + +/** + * @swagger + * /selected-calendars: + * post: + * summary: Creates a new selected calendar + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * requestBody: + * description: Create a new selected calendar + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - integration + * - externalId + * properties: + * integration: + * type: string + * description: The integration name + * externalId: + * type: string + * description: The external ID of the integration + * tags: + * - selected-calendars + * responses: + * 201: + * description: OK, selected calendar created + * 400: + * description: Bad request. SelectedCalendar body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { userId: bodyUserId, ...body } = schemaSelectedCalendarBodyParams.parse(req.body); + const args: { + data: Prisma.SelectedCalendarUncheckedCreateInput; + } = { + data: { ...body, userId }, + }; + + if (!isSystemWideAdmin && bodyUserId) + throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isSystemWideAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: bodyUserId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = bodyUserId; + } + + const data = await SelectedCalendarRepository.create(args.data); + + return { + selected_calendar: schemaSelectedCalendarPublic.parse(data), + message: "Selected Calendar created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/teams/index.ts b/apps/api/v1/pages/api/selected-calendars/index.ts similarity index 100% rename from apps/api/pages/api/teams/index.ts rename to apps/api/v1/pages/api/selected-calendars/index.ts diff --git a/apps/api/v1/pages/api/slots/_get.test.ts b/apps/api/v1/pages/api/slots/_get.test.ts new file mode 100644 index 00000000000000..29d531f133d80c --- /dev/null +++ b/apps/api/v1/pages/api/slots/_get.test.ts @@ -0,0 +1,92 @@ +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import handler from "./_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +function buildMockData() { + prismock.user.create({ + data: { + id: 1, + username: "test", + name: "Test User", + email: "test@example.com", + }, + }); + prismock.eventType.create({ + data: { + id: 1, + slug: "test", + length: 30, + title: "Test Event Type", + userId: 1, + }, + }); +} + +describe("GET /api/slots", () => { + describe("Errors", () => { + test("Missing required data", async () => { + const { req, res } = createMocks({ + method: "GET", + }); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toMatchInlineSnapshot(` + { + "message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required", + } + `); + }); + }); + + describe("Success", () => { + describe("Regular event-type", () => { + test("Returns and event type available slots", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { + eventTypeId: 1, + startTime: dayjs().format(), + endTime: dayjs().add(1, "day").format(), + usernameList: "test", + }, + prisma: prismock, + }); + buildMockData(); + await handler(req, res); + console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); + const response = JSON.parse(res._getData()); + expect(response.slots).toEqual(expect.objectContaining({})); + }); + test("Returns and event type available slots with passed timeZone", async () => { + const { req, res } = createMocks({ + method: "GET", + query: { + eventTypeId: 1, + startTime: dayjs().format(), + endTime: dayjs().add(1, "day").format(), + usernameList: "test", + timeZone: "UTC", + }, + prisma: prismock, + }); + buildMockData(); + await handler(req, res); + console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); + const response = JSON.parse(res._getData()); + expect(response.slots).toEqual(expect.objectContaining({})); + }); + }); + }); +}); diff --git a/apps/api/v1/pages/api/slots/_get.ts b/apps/api/v1/pages/api/slots/_get.ts new file mode 100644 index 00000000000000..16dc7f8aaf82cd --- /dev/null +++ b/apps/api/v1/pages/api/slots/_get.ts @@ -0,0 +1,63 @@ +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import dayjs from "@calcom/dayjs"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { isSupportedTimeZone } from "@calcom/lib/date-fns"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import { createContext } from "@calcom/trpc/server/createContext"; +import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types"; +import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util"; + +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; + +// Apply plugins +dayjs.extend(utc); +dayjs.extend(timezone); + +let isColdStart = true; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (isColdStart && IS_PRODUCTION) { + console.log("Cold start of /v1/slots detected"); + isColdStart = false; + } + + const { usernameList, isTeamEvent, ...rest } = req.query; + const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true"; + let slugs = usernameList; + if (!Array.isArray(usernameList)) { + slugs = usernameList ? [usernameList] : undefined; + } + const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest }); + const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false; + const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input }); + const slotsInProvidedTimeZone = timeZoneSupported + ? Object.keys(availableSlots.slots).reduce( + (acc: Record, date) => { + acc[date] = availableSlots.slots[date].map((slot) => ({ + ...slot, + time: dayjs(slot.time).tz(input.timeZone).format(), + })); + return acc; + }, + {} + ) + : availableSlots.slots; + + return { slots: slotsInProvidedTimeZone }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (cause) { + if (cause instanceof TRPCError) { + const statusCode = getHTTPStatusCodeFromError(cause); + throw new HttpError({ statusCode, message: cause.message }); + } + throw cause; + } +} + +export default defaultResponder(handler); diff --git a/apps/api/pages/api/teams/[teamId]/event-types/index.ts b/apps/api/v1/pages/api/slots/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/event-types/index.ts rename to apps/api/v1/pages/api/slots/index.ts diff --git a/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts new file mode 100644 index 00000000000000..2ca975a09db709 --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts @@ -0,0 +1,48 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { teamId } = schemaQueryTeamId.parse(req.query); + /** Admins can skip the ownership verification */ + if (isSystemWideAdmin) return; + /** Non-members will see a 404 error which may or not be the desired behavior. */ + await prisma.team.findFirstOrThrow({ + where: { id: teamId, members: { some: { userId } } }, + }); +} + +export async function checkPermissions( + req: NextApiRequest, + role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER +) { + const { userId, isSystemWideAdmin } = req; + const { teamId } = schemaQueryTeamId.parse({ + teamId: req.query.teamId, + version: req.query.version, + apiKey: req.query.apiKey, + }); + return canUserAccessTeamWithRole(userId, isSystemWideAdmin, teamId, role); +} + +export async function canUserAccessTeamWithRole( + userId: number, + isSystemWideAdmin: boolean, + teamId: number, + role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER +) { + const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } }; + /** If not ADMIN then we check if the actual user belongs to team and matches the required role */ + if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } }; + const team = await prisma.team.findFirst(args); + if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: OWNER or ADMIN role required` }); + return team; +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/teams/[teamId]/_delete.ts b/apps/api/v1/pages/api/teams/[teamId]/_delete.ts new file mode 100644 index 00000000000000..dfe2ad21edf6d6 --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/_delete.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; + +import { checkPermissions } from "./_auth-middleware"; + +/** + * @swagger + * /teams/{teamId}: + * delete: + * operationId: removeTeamById + * summary: Remove an existing team + * parameters: + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: ID of the team to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - teams + * responses: + * 201: + * description: OK, team removed successfully + * 400: + * description: Bad request. Team id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { teamId } = schemaQueryTeamId.parse(query); + await checkPermissions(req); + await prisma.team.delete({ where: { id: teamId } }); + return { message: `Team with id: ${teamId} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/teams/[teamId]/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/_get.ts new file mode 100644 index 00000000000000..2e76910f9eb991 --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/_get.ts @@ -0,0 +1,49 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; +import { schemaTeamReadPublic } from "~/lib/validations/team"; + +/** + * @swagger + * /teams/{teamId}: + * get: + * operationId: getTeamById + * summary: Find a team + * parameters: + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: ID of the team to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - teams + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Team was not found + */ +export async function getHandler(req: NextApiRequest) { + const { isSystemWideAdmin, userId } = req; + const { teamId } = schemaQueryTeamId.parse(req.query); + const where: Prisma.TeamWhereInput = { id: teamId }; + // Non-admins can only query the teams they're part of + if (!isSystemWideAdmin) where.members = { some: { userId } }; + const data = await prisma.team.findFirstOrThrow({ where }); + return { team: schemaTeamReadPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts new file mode 100644 index 00000000000000..057c4cb9a8b6e4 --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts @@ -0,0 +1,144 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; +import { schemaTeamReadPublic, schemaTeamUpdateBodyParams } from "~/lib/validations/team"; + +/** + * @swagger + * /teams/{teamId}: + * patch: + * operationId: editTeamById + * summary: Edit an existing team + * parameters: + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: ID of the team to edit + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new custom input for an event type + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: Name of the team + * slug: + * type: string + * description: A unique slug that works as path for the team public page + * tags: + * - teams + * responses: + * 201: + * description: OK, team edited successfully + * 400: + * description: Bad request. Team body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { body, userId } = req; + const data = schemaTeamUpdateBodyParams.parse(body); + const { teamId } = schemaQueryTeamId.parse(req.query); + + /** Only OWNERS and ADMINS can edit teams */ + const _team = await prisma.team.findFirst({ + include: { members: true }, + where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } }, + }); + if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" }); + + const slugAlreadyExists = await prisma.team.findFirst({ + where: { + slug: { + mode: "insensitive", + equals: data.slug, + }, + }, + }); + + if (slugAlreadyExists && data.slug !== _team.slug) + throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); + + // Check if parentId is related to this user + if (data.parentId && data.parentId === teamId) { + throw new HttpError({ + statusCode: 400, + message: "Bad request: Parent id cannot be the same as the team id.", + }); + } + if (data.parentId) { + const parentTeam = await prisma.team.findFirst({ + where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } }, + }); + if (!parentTeam) + throw new HttpError({ + statusCode: 401, + message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.", + }); + } + + let paymentUrl; + if (_team.slug === null && data.slug) { + data.metadata = { + ...(_team.metadata as Prisma.JsonObject), + requestedSlug: data.slug, + }; + delete data.slug; + if (IS_TEAM_BILLING_ENABLED) { + const checkoutSession = await purchaseTeamOrOrgSubscription({ + teamId: _team.id, + seatsUsed: _team.members.length, + userId, + pricePerSeat: null, + }); + if (!checkoutSession.url) + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed retrieving a checkout session URL.", + }); + paymentUrl = checkoutSession.url; + } + } + + // TODO: Perhaps there is a better fix for this? + const cloneData: typeof data & { + metadata: NonNullable | undefined; + bookingLimits: NonNullable | undefined; + } = { + ...data, + smsLockReviewedByAdmin: false, + bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits, + metadata: data.metadata === null ? {} : data.metadata || undefined, + }; + const team = await prisma.team.update({ where: { id: teamId }, data: cloneData }); + const result = { + team: schemaTeamReadPublic.parse(team), + paymentUrl, + }; + if (!paymentUrl) { + delete result.paymentUrl; + } + return result; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/teams/[teamId]/availability/index.ts b/apps/api/v1/pages/api/teams/[teamId]/availability/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/availability/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/availability/index.ts diff --git a/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts new file mode 100644 index 00000000000000..01ec13cc8b8a65 --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts @@ -0,0 +1,73 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import { z } from "zod"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; + +const querySchema = z.object({ + teamId: z.coerce.number(), +}); + +/** + * @swagger + * /teams/{teamId}/event-types: + * get: + * summary: Find all event types that belong to teamId + * operationId: listEventTypesByTeamId + * parameters: + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API Key + * - in: path + * name: teamId + * schema: + * type: number + * required: true + * tags: + * - event-types + * externalDocs: + * url: https://docs.cal.com/docs/core-features/event-types + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No event types were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + + const { teamId } = querySchema.parse(req.query); + + const args: Prisma.EventTypeFindManyArgs = { + where: { + team: isSystemWideAdmin + ? { + id: teamId, + } + : { + id: teamId, + members: { some: { userId } }, + }, + }, + include: { + customInputs: true, + team: { select: { slug: true } }, + hosts: { select: { userId: true, isFixed: true } }, + owner: { select: { username: true, id: true } }, + children: { select: { id: true, userId: true } }, + }, + }; + + const data = await prisma.eventType.findMany(args); + return { event_types: data.map((eventType) => schemaEventTypeReadPublic.parse(eventType)) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts new file mode 100644 index 00000000000000..c53e4b8ef39a6c --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts @@ -0,0 +1,9 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware()( + defaultHandler({ + GET: import("./_get"), + }) +); diff --git a/apps/api/pages/api/teams/[teamId]/index.ts b/apps/api/v1/pages/api/teams/[teamId]/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/index.ts diff --git a/apps/api/v1/pages/api/teams/[teamId]/publish.ts b/apps/api/v1/pages/api/teams/[teamId]/publish.ts new file mode 100644 index 00000000000000..d23eb57b38d627 --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/publish.ts @@ -0,0 +1,60 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; +import { createContext } from "@calcom/trpc/server/createContext"; +import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; +import type { UserProfile } from "@calcom/types/UserProfile"; + +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +import authMiddleware, { checkPermissions } from "./_auth-middleware"; + +const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => { + await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] }); + async function sessionGetter() { + return { + user: { + id: req.userId, + username: "" /* Not used in this context */, + role: req.isSystemWideAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER, + profile: { + id: null, + organizationId: null, + organization: null, + username: "", + upId: "", + } satisfies UserProfile, + }, + hasValidLicense: true /* To comply with TS signature */, + expires: "" /* Not used in this context */, + upId: "", + }; + } + /** @see https://trpc.io/docs/server-side-calls */ + const ctx = await createContext({ req, res }, sessionGetter); + try { + const caller = viewerTeamsRouter.createCaller(ctx); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await caller.publish(req.query as any /* Let tRPC handle this */); + } catch (cause) { + if (cause instanceof TRPCError) { + const statusCode = getHTTPStatusCodeFromError(cause); + throw new HttpError({ statusCode, message: cause.message }); + } + throw cause; + } +}; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/teams/_get.ts b/apps/api/v1/pages/api/teams/_get.ts new file mode 100644 index 00000000000000..4b28a07b394a53 --- /dev/null +++ b/apps/api/v1/pages/api/teams/_get.ts @@ -0,0 +1,41 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaTeamsReadPublic } from "~/lib/validations/team"; + +/** + * @swagger + * /teams: + * get: + * operationId: listTeams + * summary: Find all teams + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - teams + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No teams were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const where: Prisma.TeamWhereInput = {}; + // If user is not ADMIN, return only his data. + if (!isSystemWideAdmin) where.members = { some: { userId } }; + const data = await prisma.team.findMany({ where }); + return { teams: schemaTeamsReadPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/teams/_post.ts b/apps/api/v1/pages/api/teams/_post.ts new file mode 100644 index 00000000000000..3e8b116070f539 --- /dev/null +++ b/apps/api/v1/pages/api/teams/_post.ts @@ -0,0 +1,249 @@ +import type { NextApiRequest } from "next"; + +import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { getDubCustomer } from "@calcom/features/auth/lib/dub"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { schemaMembershipPublic } from "~/lib/validations/membership"; +import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validations/team"; + +/** + * @swagger + * /teams: + * post: + * operationId: addTeam + * summary: Creates a new team + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new custom input for an event type + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - slug + * - hideBookATeamMember + * - brandColor + * - darkBrandColor + * - timeZone + * - weekStart + * - isPrivate + * properties: + * name: + * type: string + * description: Name of the team + * slug: + * type: string + * description: A unique slug that works as path for the team public page + * hideBookATeamMember: + * type: boolean + * description: Flag to hide or show the book a team member option + * brandColor: + * type: string + * description: Primary brand color for the team + * darkBrandColor: + * type: string + * description: Dark variant of the primary brand color for the team + * timeZone: + * type: string + * description: Time zone of the team + * weekStart: + * type: string + * description: Starting day of the week for the team + * isPrivate: + * type: boolean + * description: Flag indicating if the team is private + * ownerId: + * type: number + * description: ID of the team owner - only admins can set this. + * parentId: + * type: number + * description: ID of the parent organization. + * tags: + * - teams + * responses: + * 201: + * description: OK, team created + * 400: + * description: Bad request. Team body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { body, userId, isSystemWideAdmin } = req; + const { ownerId, ...data } = schemaTeamCreateBodyParams.parse(body); + + await checkPermissions(req); + + const effectiveUserId = isSystemWideAdmin && ownerId ? ownerId : userId; + + if (data.slug) { + const alreadyExist = await prisma.team.findFirst({ + where: { + slug: { + mode: "insensitive", + equals: data.slug, + }, + }, + }); + if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); + } + + // Check if parentId is related to this user and is an organization + if (data.parentId) { + const parentTeam = await prisma.team.findFirst({ + where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } }, + }); + if (!parentTeam) + throw new HttpError({ + statusCode: 401, + message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.", + }); + + if (parentTeam.parentId) + throw new HttpError({ + statusCode: 400, + message: "parentId must be of an organization, not a team.", + }); + } + + // TODO: Perhaps there is a better fix for this? + const cloneData: typeof data & { + metadata: NonNullable | undefined; + bookingLimits: NonNullable | undefined; + } = { + ...data, + smsLockReviewedByAdmin: false, + bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits || undefined, + metadata: data.metadata === null ? {} : data.metadata || undefined, + }; + + if (!IS_TEAM_BILLING_ENABLED || data.parentId) { + const team = await prisma.team.create({ + data: { + ...cloneData, + members: { + create: { userId: effectiveUserId, role: MembershipRole.OWNER, accepted: true }, + }, + }, + include: { members: true }, + }); + + req.statusCode = 201; + + return { + team: schemaTeamReadPublic.parse(team), + owner: schemaMembershipPublic.parse(team.members[0]), + message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`, + }; + } + + const pendingPaymentTeam = await prisma.team.create({ + data: { + ...cloneData, + pendingPayment: true, + }, + }); + + const checkoutSession = await generateTeamCheckoutSession({ + pendingPaymentTeamId: pendingPaymentTeam.id, + ownerId: effectiveUserId, + }); + + return { + message: + "Your team will be created once we receive your payment. Please complete the payment using the payment link.", + paymentLink: checkoutSession.url, + pendingTeam: { + ...schemaTeamReadPublic.parse(pendingPaymentTeam), + }, + }; +} + +async function checkPermissions(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + const body = schemaTeamCreateBodyParams.parse(req.body); + + /* Non-admin users can only create teams for themselves */ + if (!isSystemWideAdmin && body.ownerId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `ownerId`", + }); +} + +const generateTeamCheckoutSession = async ({ + pendingPaymentTeamId, + ownerId, +}: { + pendingPaymentTeamId: number; + ownerId: number; +}) => { + const [customer, dubCustomer] = await Promise.all([ + getStripeCustomerIdFromUserId(ownerId), + getDubCustomer(ownerId.toString()), + ]); + + const session = await stripe.checkout.sessions.create({ + customer, + mode: "subscription", + ...(dubCustomer?.discount?.couponId + ? { + discounts: [ + { + coupon: + process.env.NODE_ENV !== "production" && dubCustomer.discount.couponTestId + ? dubCustomer.discount.couponTestId + : dubCustomer.discount.couponId, + }, + ], + } + : { allow_promotion_codes: true }), + success_url: `${WEBAPP_URL}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${WEBAPP_URL}/settings/my-account/profile`, + line_items: [ + { + /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ + price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID, + /**Initially it will be just the team owner */ + quantity: 1, + }, + ], + customer_update: { + address: "auto", + }, + // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode + automatic_tax: { + enabled: IS_PRODUCTION, + }, + metadata: { + pendingPaymentTeamId, + ownerId, + dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe + }, + }); + + if (!session.url) + throw new HttpError({ + statusCode: 500, + message: "Failed generating a checkout session URL.", + }); + + return session; +}; + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/users/index.ts b/apps/api/v1/pages/api/teams/index.ts similarity index 100% rename from apps/api/pages/api/users/index.ts rename to apps/api/v1/pages/api/teams/index.ts diff --git a/apps/api/v1/pages/api/users/[userId]/_delete.ts b/apps/api/v1/pages/api/users/[userId]/_delete.ts new file mode 100644 index 00000000000000..436c1fdf0e7e22 --- /dev/null +++ b/apps/api/v1/pages/api/users/[userId]/_delete.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest } from "next"; + +import { deleteUser } from "@calcom/features/users/lib/userDeletionService"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; + +/** + * @swagger + * /users/{userId}: + * delete: + * summary: Remove an existing user + * operationId: removeUserById + * parameters: + * - in: path + * name: userId + * example: 1 + * schema: + * type: integer + * required: true + * description: ID of the user to delete + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API key + * tags: + * - users + * responses: + * 201: + * description: OK, user removed successfuly + * 400: + * description: Bad request. User id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + const query = schemaQueryUserId.parse(req.query); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isSystemWideAdmin && query.userId !== req.userId) + throw new HttpError({ statusCode: 403, message: "Forbidden" }); + + const user = await prisma.user.findUnique({ + where: { id: query.userId }, + select: { + id: true, + email: true, + metadata: true, + }, + }); + if (!user) throw new HttpError({ statusCode: 404, message: "User not found" }); + + await deleteUser(user); + + return { message: `User with id: ${user.id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/users/[userId]/_get.ts b/apps/api/v1/pages/api/users/[userId]/_get.ts new file mode 100644 index 00000000000000..19b9a6c293a613 --- /dev/null +++ b/apps/api/v1/pages/api/users/[userId]/_get.ts @@ -0,0 +1,55 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; +import { schemaUserReadPublic } from "~/lib/validations/user"; + +/** + * @swagger + * /users/{userId}: + * get: + * summary: Find a user, returns your user if regular user. + * operationId: getUserById + * parameters: + * - in: path + * name: userId + * example: 4 + * schema: + * type: integer + * required: true + * description: ID of the user to get + * - in: query + * name: apiKey + * schema: + * type: string + * required: true + * description: Your API key + * tags: + * - users + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: User was not found + */ +export async function getHandler(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + + const query = schemaQueryUserId.parse(req.query); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isSystemWideAdmin && query.userId !== req.userId) + throw new HttpError({ statusCode: 403, message: "Forbidden" }); + const data = await prisma.user.findUnique({ where: { id: query.userId } }); + const user = schemaUserReadPublic.parse({ + ...data, + avatar: data?.avatarUrl, + }); + return { user }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/users/[userId]/_patch.ts b/apps/api/v1/pages/api/users/[userId]/_patch.ts new file mode 100644 index 00000000000000..cd7ecceaeee4e6 --- /dev/null +++ b/apps/api/v1/pages/api/users/[userId]/_patch.ts @@ -0,0 +1,141 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import { uploadAvatar } from "@calcom/lib/server/avatar"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; +import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user"; + +/** + * @swagger + * /users/{userId}: + * patch: + * summary: Edit an existing user + * operationId: editUserById + * parameters: + * - in: path + * name: userId + * example: 4 + * schema: + * type: integer + * required: true + * description: ID of the user to edit + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Edit an existing attendee related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * format: email + * description: Email that belongs to the user being edited + * username: + * type: string + * description: Username for the user being edited + * brandColor: + * description: The user's brand color + * type: string + * darkBrandColor: + * description: The user's brand color for dark mode + * type: string + * weekStart: + * description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY] + * type: string + * timeZone: + * description: The user's time zone + * type: string + * hideBranding: + * description: Remove branding from the user's calendar page + * type: boolean + * theme: + * description: Default theme for the user. Acceptable values are one of [DARK, LIGHT] + * type: string + * timeFormat: + * description: The user's time format. Acceptable values are one of [TWELVE, TWENTY_FOUR] + * type: string + * locale: + * description: The user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI] + * type: string + * avatar: + * description: The user's avatar, in base64 format + * type: string + * examples: + * user: + * summary: An example of USER + * value: + * email: email@example.com + * username: johndoe + * weekStart: MONDAY + * brandColor: #555555 + * darkBrandColor: #111111 + * timeZone: EUROPE/PARIS + * theme: LIGHT + * timeFormat: TWELVE + * locale: FR + * tags: + * - users + * responses: + * 200: + * description: OK, user edited successfully + * 400: + * description: Bad request. User body is invalid. + * 401: + * description: Authorization information is missing or invalid. + * 403: + * description: Insufficient permissions to access resource. + */ +export async function patchHandler(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + const query = schemaQueryUserId.parse(req.query); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isSystemWideAdmin && query.userId !== req.userId) + throw new HttpError({ statusCode: 403, message: "Forbidden" }); + + const { avatar, ...body }: { avatar?: string | undefined } & Prisma.UserUpdateInput = + await schemaUserEditBodyParams.parseAsync(req.body); + // disable role or branding changes unless admin. + if (!isSystemWideAdmin) { + if (body.role) body.role = undefined; + if (body.hideBranding) body.hideBranding = undefined; + } + + const userSchedules = await prisma.schedule.findMany({ + where: { userId: query.userId }, + }); + const userSchedulesIds = userSchedules.map((schedule) => schedule.id); + // @note: here we make sure user can only make as default his own scheudles + if (body.defaultScheduleId && !userSchedulesIds.includes(Number(body.defaultScheduleId))) { + throw new HttpError({ + statusCode: 400, + message: "Bad request: Invalid default schedule id", + }); + } + + if (avatar) { + body.avatarUrl = await uploadAvatar({ + userId: query.userId, + avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar), + }); + } + + const data = await prisma.user.update({ + where: { id: query.userId }, + data: body, + }); + const user = schemaUserReadPublic.parse(data); + return { user }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/users/[userId]/availability/index.ts b/apps/api/v1/pages/api/users/[userId]/availability/index.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/availability/index.ts rename to apps/api/v1/pages/api/users/[userId]/availability/index.ts diff --git a/apps/api/pages/api/users/[userId]/index.ts b/apps/api/v1/pages/api/users/[userId]/index.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/index.ts rename to apps/api/v1/pages/api/users/[userId]/index.ts diff --git a/apps/api/v1/pages/api/users/_get.ts b/apps/api/v1/pages/api/users/_get.ts new file mode 100644 index 00000000000000..81761e90ccec7b --- /dev/null +++ b/apps/api/v1/pages/api/users/_get.ts @@ -0,0 +1,70 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; +import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail"; +import { schemaUsersReadPublic } from "~/lib/validations/user"; + +/** + * @swagger + * /users: + * get: + * operationId: listUsers + * summary: Find all users. + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: email + * required: false + * schema: + * type: array + * items: + * type: string + * format: email + * style: form + * explode: true + * description: The email address or an array of email addresses to filter by + * tags: + * - users + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No users were found + */ +export async function getHandler(req: NextApiRequest) { + const { + userId, + isSystemWideAdmin, + pagination: { take, skip }, + } = req; + const where: Prisma.UserWhereInput = {}; + // If user is not ADMIN, return only his data. + if (!isSystemWideAdmin) where.id = userId; + + if (req.query.email) { + const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query); + where.email = { + in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email], + }; + } + + const [total, data] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ where, take, skip }), + ]); + const users = schemaUsersReadPublic.parse(data); + return { users, total }; +} + +export default withMiddleware("pagination")(defaultResponder(getHandler)); diff --git a/apps/api/v1/pages/api/users/_post.ts b/apps/api/v1/pages/api/users/_post.ts new file mode 100644 index 00000000000000..8331005acd1eb2 --- /dev/null +++ b/apps/api/v1/pages/api/users/_post.ts @@ -0,0 +1,101 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { CreationSource } from "@calcom/prisma/enums"; + +import { schemaUserCreateBodyParams } from "~/lib/validations/user"; + +/** + * @swagger + * /users: + * post: + * operationId: addUser + * summary: Creates a new user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new user + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - username + * properties: + * email: + * type: string + * format: email + * description: Email that belongs to the user being edited + * username: + * type: string + * description: Username for the user being created + * brandColor: + * description: The new user's brand color + * type: string + * darkBrandColor: + * description: The new user's brand color for dark mode + * type: string + * hideBranding: + * description: Remove branding from the user's calendar page + * type: boolean + * weekStart: + * description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY] + * type: string + * timeZone: + * description: The new user's time zone. Eg- 'EUROPE/PARIS' + * type: string + * theme: + * description: Default theme for the new user. Acceptable values are one of [DARK, LIGHT] + * type: string + * timeFormat: + * description: The new user's time format. Acceptable values are one of [TWELVE, TWENTY_FOUR] + * type: string + * locale: + * description: The new user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI] + * type: string + * avatar: + * description: The user's avatar, in base64 format + * type: string + * examples: + * user: + * summary: An example of USER + * value: + * email: 'email@example.com' + * username: 'johndoe' + * weekStart: 'MONDAY' + * brandColor: '#555555' + * darkBrandColor: '#111111' + * timeZone: 'EUROPE/PARIS' + * theme: 'LIGHT' + * timeFormat: 'TWELVE' + * locale: 'FR' + * tags: + * - users + * responses: + * 201: + * description: OK, user created + * 400: + * description: Bad request. user body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { isSystemWideAdmin } = req; + // If user is not ADMIN, return unauthorized. + if (!isSystemWideAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" }); + const data = await schemaUserCreateBodyParams.parseAsync(req.body); + const user = await prisma.user.create({ data: { ...data, creationSource: CreationSource.API_V1 } }); + req.statusCode = 201; + return { user }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/pages/api/webhooks/index.ts b/apps/api/v1/pages/api/users/index.ts similarity index 100% rename from apps/api/pages/api/webhooks/index.ts rename to apps/api/v1/pages/api/users/index.ts diff --git a/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts new file mode 100644 index 00000000000000..5598f3ed8b0aa5 --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdAsString.parse(req.query); + // Admins can just skip this check + if (isSystemWideAdmin) return; + // Check if the current user can access the webhook + const webhook = await prisma.webhook.findFirst({ + where: { id, appId: null, OR: [{ userId }, { eventType: { team: { members: { some: { userId } } } } }] }, + }); + if (!webhook) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/apps/api/v1/pages/api/webhooks/[id]/_delete.ts b/apps/api/v1/pages/api/webhooks/[id]/_delete.ts new file mode 100644 index 00000000000000..e91bd3b7d9e3cb --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/[id]/_delete.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; + +/** + * @swagger + * /webhooks/{id}: + * delete: + * summary: Remove an existing hook + * operationId: removeWebhookById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Numeric ID of the hooks to delete + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - webhooks + * externalDocs: + * url: https://docs.cal.com/docs/core-features/webhooks + * responses: + * 201: + * description: OK, hook removed successfully + * 400: + * description: Bad request. hook id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdAsString.parse(query); + await prisma.webhook.delete({ where: { id } }); + return { message: `Webhook with id: ${id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/apps/api/v1/pages/api/webhooks/[id]/_get.ts b/apps/api/v1/pages/api/webhooks/[id]/_get.ts new file mode 100644 index 00000000000000..9bfd6a097552cd --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/[id]/_get.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; +import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; + +/** + * @swagger + * /webhooks/{id}: + * get: + * summary: Find a webhook + * operationId: getWebhookById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Numeric ID of the webhook to get + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - webhooks + * externalDocs: + * url: https://docs.cal.com/docs/core-features/webhooks + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Webhook was not found + */ +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdAsString.parse(query); + const data = await prisma.webhook.findUniqueOrThrow({ where: { id } }); + return { webhook: schemaWebhookReadPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/webhooks/[id]/_patch.ts b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts new file mode 100644 index 00000000000000..a35e83b7d2f5e5 --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts @@ -0,0 +1,106 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; +import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/validations/webhook"; + +/** + * @swagger + * /webhooks/{id}: + * patch: + * summary: Edit an existing webhook + * operationId: editWebhookById + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Numeric ID of the webhook to edit + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Edit an existing webhook + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * subscriberUrl: + * type: string + * format: uri + * description: The URL to subscribe to this webhook + * eventTriggers: + * type: string + * enum: [BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, MEETING_ENDED] + * description: The events which should trigger this webhook call + * active: + * type: boolean + * description: Whether the webhook is active and should trigger on associated trigger events + * payloadTemplate: + * type: string + * description: The template of the webhook's payload + * eventTypeId: + * type: number + * description: The event type ID if this webhook should be associated with only that event type + * secret: + * type: string + * description: The secret to verify the authenticity of the received payload + * tags: + * - webhooks + * externalDocs: + * url: https://docs.cal.com/docs/core-features/webhooks + * responses: + * 201: + * description: OK, webhook edited successfully + * 400: + * description: Bad request. Webhook body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { query, userId, isSystemWideAdmin } = req; + const { id } = schemaQueryIdAsString.parse(query); + const { + eventTypeId, + userId: bodyUserId, + eventTriggers, + ...data + } = schemaWebhookEditBodyParams.parse(req.body); + const args: Prisma.WebhookUpdateArgs = { where: { id }, data }; + + if (eventTypeId) { + const where: Prisma.EventTypeWhereInput = { id: eventTypeId }; + if (!isSystemWideAdmin) where.userId = userId; + await prisma.eventType.findFirstOrThrow({ where }); + args.data.eventTypeId = eventTypeId; + } + + if (!isSystemWideAdmin && bodyUserId) + throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isSystemWideAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: bodyUserId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = bodyUserId; + } + + if (eventTriggers) { + const eventTriggersSet = new Set(eventTriggers); + args.data.eventTriggers = Array.from(eventTriggersSet); + } + + const result = await prisma.webhook.update(args); + return { webhook: schemaWebhookReadPublic.parse(result) }; +} + +export default defaultResponder(patchHandler); diff --git a/apps/api/pages/api/webhooks/[id]/index.ts b/apps/api/v1/pages/api/webhooks/[id]/index.ts similarity index 100% rename from apps/api/pages/api/webhooks/[id]/index.ts rename to apps/api/v1/pages/api/webhooks/[id]/index.ts diff --git a/apps/api/v1/pages/api/webhooks/_get.ts b/apps/api/v1/pages/api/webhooks/_get.ts new file mode 100644 index 00000000000000..437d70e98e46b6 --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/_get.ts @@ -0,0 +1,56 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; +import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; + +/** + * @swagger + * /webhooks: + * get: + * summary: Find all webhooks + * operationId: listWebhooks + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - webhooks + * externalDocs: + * url: https://docs.cal.com/docs/core-features/webhooks + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No webhooks were found + */ +async function getHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const args: Prisma.WebhookFindManyArgs = isSystemWideAdmin + ? {} + : { where: { OR: [{ eventType: { userId } }, { userId }] } }; + + /** Only admins can query other users */ + if (!isSystemWideAdmin && req.query.userId) + throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isSystemWideAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + args.where = { OR: [{ eventType: { userId: { in: userIds } } }, { userId: { in: userIds } }] }; + if (Array.isArray(query.userId)) args.orderBy = { userId: "asc", eventType: { userId: "asc" } }; + } + + const data = await prisma.webhook.findMany(args); + return { webhooks: data.map((v) => schemaWebhookReadPublic.parse(v)) }; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/webhooks/_post.ts b/apps/api/v1/pages/api/webhooks/_post.ts new file mode 100644 index 00000000000000..7e752004f231fc --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/_post.ts @@ -0,0 +1,110 @@ +import type { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; +import { v4 as uuidv4 } from "uuid"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/validations/webhook"; + +/** + * @swagger + * /webhooks: + * post: + * summary: Creates a new webhook + * operationId: addWebhook + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * requestBody: + * description: Create a new webhook + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - subscriberUrl + * - eventTriggers + * - active + * properties: + * subscriberUrl: + * type: string + * format: uri + * description: The URL to subscribe to this webhook + * eventTriggers: + * type: string + * enum: [BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, MEETING_ENDED] + * description: The events which should trigger this webhook call + * active: + * type: boolean + * description: Whether the webhook is active and should trigger on associated trigger events + * payloadTemplate: + * type: string + * description: The template of the webhook's payload + * eventTypeId: + * type: number + * description: The event type ID if this webhook should be associated with only that event type + * secret: + * type: string + * description: The secret to verify the authenticity of the received payload + * tags: + * - webhooks + * externalDocs: + * url: https://docs.cal.com/docs/core-features/webhooks + * responses: + * 201: + * description: OK, webhook created + * 400: + * description: Bad request. webhook body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { userId, isSystemWideAdmin } = req; + const { + eventTypeId, + userId: bodyUserId, + eventTriggers, + ...body + } = schemaWebhookCreateBodyParams.parse(req.body); + const args: Prisma.WebhookCreateArgs = { data: { id: uuidv4(), ...body } }; + + // If no event type, we assume is for the current user. If admin we run more checks below... + if (!eventTypeId) args.data.userId = userId; + + if (eventTypeId) { + const where: Prisma.EventTypeWhereInput = { id: eventTypeId }; + if (!isSystemWideAdmin) where.userId = userId; + await prisma.eventType.findFirstOrThrow({ where }); + args.data.eventTypeId = eventTypeId; + } + + if (!isSystemWideAdmin && bodyUserId) + throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` }); + + if (isSystemWideAdmin && bodyUserId) { + const where: Prisma.UserWhereInput = { id: bodyUserId }; + await prisma.user.findFirstOrThrow({ where }); + args.data.userId = bodyUserId; + } + + if (eventTriggers) { + const eventTriggersSet = new Set(eventTriggers); + args.data.eventTriggers = Array.from(eventTriggersSet); + } + + const data = await prisma.webhook.create(args); + + return { + webhook: schemaWebhookReadPublic.parse(data), + message: "Webhook created successfully", + }; +} + +export default defaultResponder(postHandler); diff --git a/apps/api/v1/pages/api/webhooks/index.ts b/apps/api/v1/pages/api/webhooks/index.ts new file mode 100644 index 00000000000000..2a15abfa5bdad4 --- /dev/null +++ b/apps/api/v1/pages/api/webhooks/index.ts @@ -0,0 +1,10 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware()( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + }) +); diff --git a/apps/api/scripts/vercel-deploy.sh b/apps/api/v1/scripts/vercel-deploy.sh similarity index 100% rename from apps/api/scripts/vercel-deploy.sh rename to apps/api/v1/scripts/vercel-deploy.sh diff --git a/packages/trpc/server/routers/loggedInViewer/avatar.schema.ts b/apps/api/v1/sentry.client.config.ts similarity index 100% rename from packages/trpc/server/routers/loggedInViewer/avatar.schema.ts rename to apps/api/v1/sentry.client.config.ts diff --git a/apps/api/test/README.md b/apps/api/v1/test/README.md similarity index 100% rename from apps/api/test/README.md rename to apps/api/v1/test/README.md diff --git a/apps/api/v1/test/docker-compose.yml b/apps/api/v1/test/docker-compose.yml new file mode 100644 index 00000000000000..de160dc7d9c578 --- /dev/null +++ b/apps/api/v1/test/docker-compose.yml @@ -0,0 +1,12 @@ +# The containers that compose the project +services: + db: + image: postgres:13 + restart: always + container_name: integration-tests-prisma + ports: + - "5433:5432" + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests diff --git a/apps/api/test/jest-resolver.js b/apps/api/v1/test/jest-resolver.js similarity index 100% rename from apps/api/test/jest-resolver.js rename to apps/api/v1/test/jest-resolver.js diff --git a/apps/api/test/jest-setup.js b/apps/api/v1/test/jest-setup.js similarity index 100% rename from apps/api/test/jest-setup.js rename to apps/api/v1/test/jest-setup.js diff --git a/apps/api/v1/test/lib/attendees/_post.test.ts b/apps/api/v1/test/lib/attendees/_post.test.ts new file mode 100644 index 00000000000000..dc4c0c3105bae2 --- /dev/null +++ b/apps/api/v1/test/lib/attendees/_post.test.ts @@ -0,0 +1,115 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test } from "vitest"; + +import handler from "../../../pages/api/attendees/_post"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("POST /api/attendees", () => { + describe("Errors", () => { + test("Returns 403 if user is not admin and has no booking", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + bookingId: 1, + email: "test@example.com", + name: "Test User", + timeZone: "UTC", + }, + }); + + prismaMock.booking.findFirst.mockResolvedValue(null); + + req.userId = 123; + // req.isAdmin = false; + await handler(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData()).message).toBe("Forbidden"); + }); + + test("Returns 200 if user is admin", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + bookingId: 1, + email: "test@example.com", + name: "Test User", + timeZone: "UTC", + }, + }); + + const attendeeData = { + id: 1, + email: "test@example.com", + name: "Test User", + timeZone: "UTC", + bookingId: 1, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + prismaMock.attendee.create.mockResolvedValue(attendeeData); + req.isSystemWideAdmin = true; + req.userId = 123; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData); + expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully"); + }); + + test("Returns 200 if user is not admin but has a booking", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + bookingId: 1, + email: "test@example.com", + name: "Test User", + timeZone: "UTC", + }, + }); + + const userBooking = { id: 1 }; + + prismaMock.booking.findFirst.mockResolvedValue(userBooking); + + const attendeeData = { + id: 1, + email: "test@example.com", + name: "Test User", + timeZone: "UTC", + bookingId: 1, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + prismaMock.attendee.create.mockResolvedValue(attendeeData); + + req.userId = 123; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData); + expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully"); + }); + + test("Returns 400 if request body is invalid", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + // Missing required fields + }, + }); + + req.userId = 123; + await handler(req, res); + + expect(res.statusCode).toBe(400); + }); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts b/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts new file mode 100644 index 00000000000000..5554d6411d2a4a --- /dev/null +++ b/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts @@ -0,0 +1,87 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, it, expect } from "vitest"; + +import prisma from "@calcom/prisma"; + +import handler from "../../../../pages/api/bookings/[id]/_patch"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("PATCH /api/bookings", () => { + it("Returns 403 when user has no permission to the booking", async () => { + const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } }); + const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } }); + const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } }); + + const { req, res } = createMocks({ + method: "PATCH", + body: { + title: booking.title, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + userId: memberUser.id, + }, + query: { + id: booking.id, + }, + }); + + req.userId = memberUser.id; + + await handler(req, res); + expect(res.statusCode).toBe(403); + }); + + it("Allows PATCH when user is system-wide admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } }); + const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } }); + const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } }); + + const { req, res } = createMocks({ + method: "PATCH", + body: { + title: booking.title, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + userId: proUser.id, + }, + query: { + id: booking.id, + }, + }); + + req.userId = adminUser.id; + req.isSystemWideAdmin = true; + + await handler(req, res); + expect(res.statusCode).toBe(200); + }); + + it("Allows PATCH when user is org-wide admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } }); + const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } }); + + const { req, res } = createMocks({ + method: "PATCH", + body: { + title: booking.title, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + userId: memberUser.id, + }, + query: { + id: booking.id, + }, + }); + + req.userId = adminUser.id; + req.isOrganizationOwnerOrAdmin = true; + + await handler(req, res); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts new file mode 100644 index 00000000000000..7a0cc731bd9563 --- /dev/null +++ b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts @@ -0,0 +1,136 @@ +import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { + getRecordingsOfCalVideoByRoomName, + getDownloadLinkOfCalVideoByRecordingId, +} from "@calcom/core/videoClient"; +import { buildBooking } from "@calcom/lib/test/builder"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; + +import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware"; +import handler from "../../../../../pages/api/bookings/[id]/recordings/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +const adminUserId = 1; +const memberUserId = 10; + +vi.mock("@calcom/core/videoClient", () => { + return { + getRecordingsOfCalVideoByRoomName: vi.fn(), + getDownloadLinkOfCalVideoByRecordingId: vi.fn(), + }; +}); + +vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => { + return { + getAccessibleUsers: vi.fn(), + }; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +const mockGetRecordingsAndDownloadLink = () => { + const download_link = "https://URL"; + const recordingItem = { + id: "TEST_ID", + room_name: "0n22w24AQ5ZFOtEKX2gX", + start_ts: 1716215386, + status: "finished", + max_participants: 1, + duration: 11, + share_token: "TEST_TOKEN", + }; + + vi.mocked(getRecordingsOfCalVideoByRoomName).mockResolvedValue({ data: [recordingItem], total_count: 1 }); + + vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({ + download_link, + }); + + return [{ ...recordingItem, download_link }]; +}; + +describe("GET /api/bookings/[id]/recordings", () => { + test("Returns recordings if user is system-wide admin", async () => { + const userId = 2; + + const bookingId = 1111; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId, + references: [ + { + id: 1, + type: "daily_video", + uid: "17OHkCH53pBa03FhxMbw", + meetingId: "17OHkCH53pBa03FhxMbw", + meetingPassword: "password", + meetingUrl: "https://URL", + }, + ], + }) + ); + + const mockedRecordings = mockGetRecordingsAndDownloadLink(); + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual(mockedRecordings); + }); + + test("Allows GET recordings when user is org-wide admin", async () => { + const bookingId = 3333; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId: memberUserId, + references: [ + { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" }, + ], + }) + ); + + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.userId = adminUserId; + req.isOrganizationOwnerOrAdmin = true; + const mockedRecordings = mockGetRecordingsAndDownloadLink(); + vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]); + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts new file mode 100644 index 00000000000000..c6320fc9e711b0 --- /dev/null +++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts @@ -0,0 +1,129 @@ +import prismaMock from "../../../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { + getTranscriptsAccessLinkFromRecordingId, + checkIfRoomNameMatchesInRecording, +} from "@calcom/core/videoClient"; +import { buildBooking } from "@calcom/lib/test/builder"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; + +import authMiddleware from "../../../../../../pages/api/bookings/[id]/_auth-middleware"; +import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[recordingId]/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +vi.mock("@calcom/core/videoClient", () => { + return { + getTranscriptsAccessLinkFromRecordingId: vi.fn(), + checkIfRoomNameMatchesInRecording: vi.fn(), + }; +}); + +vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => { + return { + getAccessibleUsers: vi.fn(), + }; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +const mockGetTranscripts = () => { + const downloadLinks = [{ format: "json", link: "https://URL1" }]; + + vi.mocked(getTranscriptsAccessLinkFromRecordingId).mockResolvedValue(downloadLinks); + vi.mocked(checkIfRoomNameMatchesInRecording).mockResolvedValue(true); + + return downloadLinks; +}; + +const recordingId = "abc-xyz"; + +describe("GET /api/bookings/[id]/transcripts/[recordingId]", () => { + test("Returns transcripts if user is system-wide admin", async () => { + const adminUserId = 1; + const userId = 2; + + const bookingId = 1111; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId, + references: [ + { + id: 1, + type: "daily_video", + uid: "17OHkCH53pBa03FhxMbw", + meetingId: "17OHkCH53pBa03FhxMbw", + meetingPassword: "password", + meetingUrl: "https://URL", + }, + ], + }) + ); + + const mockedTranscripts = mockGetTranscripts(); + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + recordingId, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual(mockedTranscripts); + }); + + test("Allows GET transcripts when user is org-wide admin", async () => { + const adminUserId = 1; + const memberUserId = 10; + const bookingId = 3333; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId: memberUserId, + references: [ + { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" }, + ], + }) + ); + + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + recordingId, + }, + }); + + req.userId = adminUserId; + req.isOrganizationOwnerOrAdmin = true; + mockGetTranscripts(); + + vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]); + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts new file mode 100644 index 00000000000000..a821935bd853d6 --- /dev/null +++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts @@ -0,0 +1,120 @@ +import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; +import { buildBooking } from "@calcom/lib/test/builder"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; + +import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware"; +import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +vi.mock("@calcom/core/videoClient", () => { + return { + getAllTranscriptsAccessLinkFromRoomName: vi.fn(), + }; +}); + +vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => { + return { + getAccessibleUsers: vi.fn(), + }; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +const mockGetTranscripts = () => { + const downloadLinks = ["https://URL1", "https://URL2"]; + + vi.mocked(getAllTranscriptsAccessLinkFromRoomName).mockResolvedValue(downloadLinks); + + return downloadLinks; +}; + +describe("GET /api/bookings/[id]/transcripts", () => { + test("Returns transcripts if user is system-wide admin", async () => { + const adminUserId = 1; + const userId = 2; + + const bookingId = 1111; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId, + references: [ + { + id: 1, + type: "daily_video", + uid: "17OHkCH53pBa03FhxMbw", + meetingId: "17OHkCH53pBa03FhxMbw", + meetingPassword: "password", + meetingUrl: "https://URL", + }, + ], + }) + ); + + const mockedTranscripts = mockGetTranscripts(); + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual(mockedTranscripts); + }); + + test("Allows GET transcripts when user is org-wide admin", async () => { + const adminUserId = 1; + const memberUserId = 10; + const bookingId = 3333; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId: memberUserId, + references: [ + { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" }, + ], + }) + ); + + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.userId = adminUserId; + req.isOrganizationOwnerOrAdmin = true; + mockGetTranscripts(); + + vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]); + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/_auth-middleware.integration-test.ts b/apps/api/v1/test/lib/bookings/_auth-middleware.integration-test.ts new file mode 100644 index 00000000000000..7b449700828e1e --- /dev/null +++ b/apps/api/v1/test/lib/bookings/_auth-middleware.integration-test.ts @@ -0,0 +1,334 @@ +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, it, expect, test } from "vitest"; + +import { MembershipRole } from "@calcom/prisma/enums"; + +import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("Booking ownership and access in Middleware", () => { + const adminUserId = 1; + const ownerUserId = 2; + const memberUserId = 3; + const orgOwnerUserId = 4; + const adminUserEmail = "admin@example.com"; + const ownerUserEmail = "owner@example.com"; + const memberUserEmail = "member@example.com"; + const orgOwnerUserEmail = "org-owner@example.com"; + // mock user data + function buildMockData() { + //Create Users + prismock.user.create({ + data: { + id: adminUserId, + username: "admin", + name: "Admin User", + email: adminUserEmail, + }, + }); + prismock.user.create({ + data: { + id: ownerUserId, + username: "owner", + name: "Owner User", + email: ownerUserEmail, + }, + }); + prismock.user.create({ + data: { + id: orgOwnerUserId, + username: "org-owner", + name: "Org Owner", + email: orgOwnerUserEmail, + }, + }); + prismock.user.create({ + data: { + id: memberUserId, + username: "member", + name: "Member User", + email: memberUserEmail, + bookings: { + create: { + id: 2, + uid: "2", + title: "Booking 2", + eventTypeId: 1, + startTime: "2024-08-30T06:45:00.000Z", + endTime: "2024-08-30T07:45:00.000Z", + attendees: { + create: { + name: "Member User", + email: memberUserEmail, + timeZone: "UTC", + }, + }, + }, + }, + }, + }); + //create team + prismock.team.create({ + data: { + id: 1, + name: "Team 1", + slug: "team1", + members: { + createMany: { + data: [ + { + userId: adminUserId, + role: MembershipRole.ADMIN, + accepted: true, + }, + { + userId: ownerUserId, + role: MembershipRole.OWNER, + accepted: true, + }, + { + userId: memberUserId, + role: MembershipRole.MEMBER, + accepted: true, + }, + ], + }, + }, + }, + }); + //create Org + prismock.team.create({ + data: { + id: 2, + name: "Org", + slug: "org", + isOrganization: true, + children: { + connect: { + id: 1, + }, + }, + members: { + createMany: { + data: [ + { + userId: orgOwnerUserId, + role: MembershipRole.OWNER, + accepted: true, + }, + { + userId: memberUserId, + role: MembershipRole.MEMBER, + accepted: true, + }, + { + userId: ownerUserId, + role: MembershipRole.MEMBER, + accepted: true, + }, + { + userId: adminUserId, + role: MembershipRole.MEMBER, + accepted: true, + }, + ], + }, + }, + }, + }); + //create eventTypes + prismock.eventType.create({ + data: { + id: 1, + title: "Event 1", + slug: "event", + length: 60, + bookings: { + connect: { + id: 2, + }, + }, + }, + }); + prismock.eventType.create({ + data: { + id: 2, + title: "Event 2", + slug: "event", + length: 60, + teamId: 1, + bookings: { + connect: { + id: 1, + }, + }, + }, + }); + //link eventType to teams + prismock.eventType.update({ + where: { + id: 1, + }, + data: { + owner: { + connect: { + id: ownerUserId, + }, + }, + }, + }); + prismock.eventType.update({ + where: { + id: 2, + }, + data: { + team: { + connect: { + id: 1, + }, + }, + }, + }); + //link team to org + prismock.team.update({ + where: { + id: 1, + }, + data: { + parentId: 2, + }, + }); + // Call Prisma to create booking with attendees + prismock.booking.create({ + data: { + id: 1, + uid: "1", + title: "Booking 1", + userId: 1, + startTime: "2024-08-30T06:45:00.000Z", + endTime: "2024-08-30T07:45:00.000Z", + eventTypeId: 2, + attendees: { + create: { + name: "Admin User", + email: adminUserEmail, + timeZone: "UTC", + }, + }, + }, + }); + } + + test("should not throw error for bookings where user is an attendee", async () => { + const { req } = createMocks({ + method: "GET", + query: { + id: 2, + }, + prisma: prismock, + }); + buildMockData(); + req.userId = memberUserId; + await expect(authMiddleware(req)).resolves.not.toThrow(); + }); + + test("should throw error for bookings where user is not an attendee", async () => { + const { req } = createMocks({ + method: "GET", + query: { + id: 1, + }, + prisma: prismock, + }); + buildMockData(); + req.userId = memberUserId; + + await expect(authMiddleware(req)).rejects.toThrow(); + }); + + test("should not throw error for booking where user is the event type owner", async () => { + const { req } = createMocks({ + method: "GET", + query: { + id: 2, + }, + prisma: prismock, + }); + buildMockData(); + req.userId = ownerUserId; + await expect(authMiddleware(req)).resolves.not.toThrow(); + }); + + test("should not throw error for booking where user is team owner or admin", async () => { + const { req: req1 } = createMocks({ + method: "GET", + query: { + id: 1, + }, + prisma: prismock, + }); + const { req: req2 } = createMocks({ + method: "GET", + query: { + id: 1, + }, + prisma: prismock, + }); + buildMockData(); + + req1.userId = adminUserId; + req2.userId = ownerUserId; + + await expect(authMiddleware(req1)).resolves.not.toThrow(); + await expect(authMiddleware(req2)).resolves.not.toThrow(); + }); + test("should throw error for booking where user is not team owner or admin", async () => { + const { req } = createMocks({ + method: "GET", + query: { + id: 1, + }, + prisma: prismock, + }); + buildMockData(); + + req.userId = memberUserId; + + await expect(authMiddleware(req)).rejects.toThrow(); + }); + test("should not throw error when user is system-wide admin", async () => { + const { req } = createMocks({ + method: "GET", + query: { + id: 2, + }, + prisma: prismock, + }); + buildMockData(); + req.userId = adminUserId; + req.isSystemWideAdmin = true; + + await authMiddleware(req); + }); + + it("should throw error when user is org-wide admin", async () => { + const { req } = createMocks({ + method: "GET", + query: { + id: 1, + }, + prisma: prismock, + }); + buildMockData(); + req.userId = orgOwnerUserId; + req.isOrganizationOwnerOrAdmin = true; + + await authMiddleware(req); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/_get.integration-test.ts b/apps/api/v1/test/lib/bookings/_get.integration-test.ts new file mode 100644 index 00000000000000..4d915a634a7ef3 --- /dev/null +++ b/apps/api/v1/test/lib/bookings/_get.integration-test.ts @@ -0,0 +1,213 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, it } from "vitest"; + +import prisma from "@calcom/prisma"; + +import { handler } from "../../../pages/api/bookings/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +const DefaultPagination = { + take: 10, + skip: 0, +}; + +describe("GET /api/bookings", async () => { + const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } }); + const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } }); + + it("Does not return bookings of other users when user has no permission", async () => { + const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } }); + + const { req } = createMocks({ + method: "GET", + query: { + userId: proUser.id, + }, + pagination: DefaultPagination, + }); + + req.userId = memberUser.id; + + const responseData = await handler(req); + const groupedUsers = new Set(responseData.bookings.map((b) => b.userId)); + + expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined(); + expect(groupedUsers.size).toBe(1); + expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id); + }); + + it("Returns bookings for regular user", async () => { + const { req } = createMocks({ + method: "GET", + pagination: DefaultPagination, + }); + + req.userId = proUser.id; + + const responseData = await handler(req); + expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined(); + expect(responseData.bookings.find((b) => b.userId !== proUser.id)).toBeUndefined(); + }); + + it("Returns bookings for specified user when accessed by system-wide admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const { req } = createMocks({ + method: "GET", + pagination: DefaultPagination, + query: { + userId: proUser.id, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUser.id; + + const responseData = await handler(req); + expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined(); + expect(responseData.bookings.find((b) => b.userId !== proUser.id)).toBeUndefined(); + }); + + it("Returns bookings for all users when accessed by system-wide admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const { req } = createMocks({ + method: "GET", + pagination: { + take: 100, + skip: 0, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUser.id; + + const responseData = await handler(req); + const groupedUsers = new Set(responseData.bookings.map((b) => b.userId)); + expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined(); + expect(groupedUsers.size).toBeGreaterThan(2); + }); + + it("Returns bookings for org users when accessed by org admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const { req } = createMocks({ + method: "GET", + pagination: DefaultPagination, + }); + + req.userId = adminUser.id; + req.isOrganizationOwnerOrAdmin = true; + + const responseData = await handler(req); + const groupedUsers = new Set(responseData.bookings.map((b) => b.userId)); + expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeUndefined(); + expect(groupedUsers.size).toBeGreaterThanOrEqual(2); + }); + + describe("Upcoming bookings feature", () => { + it("Returns only upcoming bookings when status=upcoming for regular user", async () => { + const { req } = createMocks({ + method: "GET", + query: { + status: "upcoming", + }, + pagination: DefaultPagination, + }); + + req.userId = proUser.id; + + const responseData = await handler(req); + responseData.bookings.forEach((booking) => { + expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime()); + }); + }); + + it("Returns all bookings when status not specified for regular user", async () => { + const { req } = createMocks({ + method: "GET", + pagination: DefaultPagination, + }); + + req.userId = proUser.id; + + const responseData = await handler(req); + expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined(); + + const { req: req2 } = createMocks({ + method: "GET", + pagination: DefaultPagination, + }); + + req2.userId = proUser.id; + + const responseData2 = await handler(req2); + expect(responseData2.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined(); + }); + + it("Returns only upcoming bookings when status=upcoming for system-wide admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const { req } = createMocks({ + method: "GET", + query: { + status: "upcoming", + }, + pagination: { + take: 100, + skip: 0, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUser.id; + + const responseData = await handler(req); + responseData.bookings.forEach((booking) => { + expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime()); + }); + }); + + it("Returns only upcoming bookings when status=upcoming for org admin", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const { req } = createMocks({ + method: "GET", + query: { + status: "upcoming", + }, + pagination: DefaultPagination, + }); + + req.userId = adminUser.id; + req.isOrganizationOwnerOrAdmin = true; + + const responseData = await handler(req); + responseData.bookings.forEach((booking) => { + expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime()); + }); + }); + }); + + describe("Expand feature to add relational data in return payload", () => { + it("Returns only team data when expand=team is set", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const { req } = createMocks({ + method: "GET", + query: { + expand: "team", + }, + pagination: DefaultPagination, + }); + + req.userId = adminUser.id; + req.isOrganizationOwnerOrAdmin = true; + + const responseData = await handler(req); + console.log("bookings=>", responseData.bookings); + responseData.bookings.forEach((booking) => { + if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1"); + if (booking.id === 19) expect(booking.eventType?.team).toBe(null); + }); + }); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts new file mode 100644 index 00000000000000..9aa9b516d73ef2 --- /dev/null +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -0,0 +1,333 @@ +// TODO: Fix tests (These test were never running due to the vitest workspace config) +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi } from "vitest"; + +import dayjs from "@calcom/dayjs"; +import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder"; +import prisma from "@calcom/prisma"; +import type { Booking } from "@calcom/prisma/client"; +import { CreationSource } from "@calcom/prisma/enums"; + +import handler from "../../../pages/api/bookings/_post"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; +vi.mock("@calcom/features/webhooks/lib/sendPayload"); +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: (key: string) => key, + }; +}); + +describe.skipIf(true)("POST /api/bookings", () => { + describe("Errors", () => { + test("Missing required data", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: + "invalid_type in 'eventTypeId': Required; invalid_type in 'title': Required; invalid_type in 'startTime': Required; invalid_type in 'startTime': Required; invalid_type in 'endTime': Required; invalid_type in 'endTime': Required", + }) + ); + }); + + test("Invalid eventTypeId", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue(null); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: + "'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required", + }) + ); + }); + + test("Missing recurringCount", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: + "'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required", + }) + ); + }); + + test("Invalid recurringCount", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + recurringCount: 15, + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: + "'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required", + }) + ); + }); + + test("No available users", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + name: "test", + start: dayjs().format(), + end: dayjs().add(1, "day").format(), + eventTypeId: 2, + email: "test@example.com", + location: "Cal.com Video", + timeZone: "America/Montevideo", + language: "en", + customInputs: [], + metadata: {}, + userId: 4, + }, + prisma, + }); + + prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType()); + + await handler(req, res); + console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); + + expect(res._getStatusCode()).toBe(500); + expect(JSON.parse(res._getData())).toEqual( + expect.objectContaining({ + message: ErrorCode.NoAvailableUsersFound, + }) + ); + }); + }); + + describe("Success", () => { + describe("Regular event-type", () => { + let createdBooking: Booking; + test("Creates one single booking", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + name: "test", + start: dayjs().format(), + end: dayjs().add(1, "day").format(), + eventTypeId: 2, + email: "test@example.com", + location: "Cal.com Video", + timeZone: "America/Montevideo", + language: "en", + customInputs: [], + metadata: {}, + userId: 4, + }, + prisma, + }); + + prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType()); + prismaMock.booking.findMany.mockResolvedValue([]); + + await handler(req, res); + console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); + createdBooking = JSON.parse(res._getData()); + expect(prismaMock.booking.create).toHaveBeenCalledTimes(1); + }); + + test("Reschedule created booking", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + name: "testReschedule", + start: dayjs().add(2, "day").format(), + end: dayjs().add(3, "day").format(), + eventTypeId: 2, + email: "test@example.com", + location: "Cal.com Video", + timeZone: "America/Montevideo", + language: "en", + customInputs: [], + metadata: {}, + userId: 4, + rescheduleUid: createdBooking.uid, + }, + prisma, + }); + + prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType()); + prismaMock.booking.findMany.mockResolvedValue([]); + + await handler(req, res); + console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) }); + const rescheduledBooking = JSON.parse(res._getData()) as Booking; + expect(prismaMock.booking.create).toHaveBeenCalledTimes(1); + expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid); + const previousBooking = await prisma.booking.findUnique({ + where: { uid: createdBooking.uid }, + }); + expect(previousBooking?.status).toBe("cancelled"); + }); + + test("Creates source as api_v1", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + name: "test", + start: dayjs().format(), + end: dayjs().add(1, "day").format(), + eventTypeId: 2, + email: "test@example.com", + location: "Cal.com Video", + timeZone: "America/Montevideo", + language: "en", + customInputs: [], + metadata: {}, + userId: 4, + }, + prisma, + }); + + prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType()); + prismaMock.booking.findMany.mockResolvedValue([]); + + await handler(req, res); + createdBooking = JSON.parse(res._getData()); + expect(createdBooking.creationSource).toEqual(CreationSource.API_V1); + expect(prismaMock.booking.create).toHaveBeenCalledTimes(1); + }); + }); + + describe("Recurring event-type", () => { + test("Creates multiple bookings", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + recurringCount: 12, + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + Array.from(Array(12).keys()).map(async () => { + prismaMock.booking.create.mockResolvedValue(buildBooking()); + }); + + prismaMock.webhook.findMany.mockResolvedValue([]); + + await handler(req, res); + const data = JSON.parse(res._getData()); + + expect(prismaMock.booking.create).toHaveBeenCalledTimes(12); + expect(res._getStatusCode()).toBe(201); + expect(data.message).toEqual("Bookings created successfully."); + expect(data.bookings.length).toEqual(12); + }); + }); + test("Notifies multiple bookings", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test", + eventTypeId: 2, + startTime: dayjs().toDate(), + endTime: dayjs().add(1, "day").toDate(), + recurringCount: 12, + }, + prisma, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }) + ); + + const createdAt = new Date(); + Array.from(Array(12).keys()).map(async () => { + prismaMock.booking.create.mockResolvedValue(buildBooking({ createdAt })); + }); + + const mockedWebhooks = [ + buildWebhook({ + subscriberUrl: "http://mockedURL1.com", + createdAt, + eventTypeId: 1, + secret: "secret1", + }), + buildWebhook({ + subscriberUrl: "http://mockedURL2.com", + createdAt, + eventTypeId: 2, + secret: "secret2", + }), + ]; + prismaMock.webhook.findMany.mockResolvedValue(mockedWebhooks); + + await handler(req, res); + const data = JSON.parse(res._getData()); + + expect(sendPayload).toHaveBeenCalledTimes(24); + expect(data.message).toEqual("Bookings created successfully."); + expect(data.bookings.length).toEqual(12); + }); + }); +}); diff --git a/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts b/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts new file mode 100644 index 00000000000000..5b3143df670cfa --- /dev/null +++ b/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts @@ -0,0 +1,96 @@ +import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, beforeEach, vi } from "vitest"; + +import { buildEventType } from "@calcom/lib/test/builder"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import handler from "../../../../pages/api/event-types/[id]/_delete"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("DELETE /api/event-types/[id]", () => { + const eventTypeId = 1234567; + const teamId = 9999; + const adminUser = 1111; + const memberUser = 2222; + beforeEach(() => { + vi.resetAllMocks(); + // Mocking membership.findFirst + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + prismaMock.membership.findFirst.mockImplementation(({ where }) => { + const { userId, teamId, accepted, role } = where; + const mockData = [ + { userId: 1111, teamId: teamId, accepted: true, role: MembershipRole.ADMIN }, + { userId: 2222, teamId: teamId, accepted: true, role: MembershipRole.MEMBER }, + ]; + // Return the correct user based on the query conditions + return mockData.find( + (membership) => + membership.userId === userId && + membership.teamId === teamId && + membership.accepted === accepted && + role.in.includes(membership.role) + ); + }); + + // Mocking eventType.findFirst + prismaMock.eventType.findFirst.mockResolvedValue( + buildEventType({ + id: eventTypeId, + teamId, + }) + ); + + // Mocking team.findUnique + prismaMock.team.findUnique.mockResolvedValue({ + id: teamId, + members: [ + { userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId }, + { userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId }, + ], + }); + }); + + describe("Error", async () => { + test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => { + const { req, res } = createMocks({ + method: "DELETE", + body: {}, + query: { + id: eventTypeId, + }, + }); + + // Assign userId to the request objects + req.userId = memberUser; + + await handler(req, res); + expect(res.statusCode).toBe(403); // Check if the deletion was successful + }); + }); + + describe("Success", async () => { + test("Removes event type if user is owner of team associated with event type", async () => { + // Mocks for DELETE request + const { req, res } = createMocks({ + method: "DELETE", + body: {}, + query: { + id: eventTypeId, + }, + }); + + // Assign userId to the request objects + req.userId = adminUser; + + await handler(req, res); + expect(res.statusCode).toBe(200); // Check if the deletion was successful + }); + }); +}); diff --git a/apps/api/v1/test/lib/event-types/[id]/_get.test.ts b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts new file mode 100644 index 00000000000000..836246bbb99637 --- /dev/null +++ b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts @@ -0,0 +1,143 @@ +import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test } from "vitest"; + +import { buildEventType } from "@calcom/lib/test/builder"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import handler from "../../../../pages/api/event-types/[id]/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("GET /api/event-types/[id]", () => { + describe("Errors", () => { + test("Returns 403 if user not admin/team member/event owner", async () => { + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: 123456, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: 123456, + userId: 444444, + }) + ); + + req.userId = 333333; + await handler(req, res); + + expect(res.statusCode).toBe(403); + }); + }); + + describe("Success", async () => { + test("Returns event type if user is admin", async () => { + const eventTypeId = 123456; + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: eventTypeId, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: eventTypeId, + }) + ); + + req.isSystemWideAdmin = true; + req.userId = 333333; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId); + }); + + test("Returns event type if user is in team associated with event type", async () => { + const eventTypeId = 123456; + const teamId = 9999; + const userId = 333333; + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: eventTypeId, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: eventTypeId, + teamId, + }) + ); + + prismaMock.team.findFirst.mockResolvedValue({ + id: teamId, + members: [ + { + userId, + }, + ], + }); + + req.isSystemWideAdmin = false; + req.userId = userId; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId); + expect(prismaMock.team.findFirst).toHaveBeenCalledWith({ + where: { + id: teamId, + members: { + some: { + userId: req.userId, + role: { + in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], + }, + }, + }, + }, + }); + }); + + test("Returns event type if user is the event type owner", async () => { + const eventTypeId = 123456; + const userId = 333333; + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: eventTypeId, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: eventTypeId, + userId, + scheduleId: 1111, + }) + ); + + req.isSystemWideAdmin = false; + req.userId = userId; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId); + expect(prismaMock.team.findFirst).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/v1/test/lib/event-types/_post.test.ts b/apps/api/v1/test/lib/event-types/_post.test.ts new file mode 100644 index 00000000000000..4b467fe04f641f --- /dev/null +++ b/apps/api/v1/test/lib/event-types/_post.test.ts @@ -0,0 +1,160 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { buildEventType } from "@calcom/lib/test/builder"; + +import handler from "../../../pages/api/event-types/_post"; +import checkParentEventOwnership from "../../../pages/api/event-types/_utils/checkParentEventOwnership"; +import checkTeamEventEditPermission from "../../../pages/api/event-types/_utils/checkTeamEventEditPermission"; +import checkUserMembership from "../../../pages/api/event-types/_utils/checkUserMembership"; +import ensureOnlyMembersAsHosts from "../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts"; +import { canUserAccessTeamWithRole } from "../../../pages/api/teams/[teamId]/_auth-middleware"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +const adminUserId = 1; +const memberUserId = 10; + +vi.mock("../../../pages/api/teams/[teamId]/_auth-middleware", () => ({ + canUserAccessTeamWithRole: vi.fn(), +})); + +vi.mock("../../../pages/api/event-types/_utils/checkUserMembership", () => ({ + default: vi.fn(), +})); +vi.mock("../../../pages/api/event-types/_utils/checkParentEventOwnership", () => ({ + default: vi.fn(), +})); +vi.mock("../../../pages/api/event-types/_utils/checkTeamEventEditPermission", () => ({ + default: vi.fn(), +})); +vi.mock("../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts", () => ({ + default: vi.fn(), +})); + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("POST /api/event-types", () => { + describe("Errors", () => { + test("should throw 401 if a non-admin user tries to create an event type with userId", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "Tennis class", + slug: "tennis-class-{{$guid}}", + length: 60, + hidden: true, + userId: memberUserId, + }, + }); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `userId`"); + }); + test("should throw 401 if not system-wide admin and user cannot access teamId with required roles", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "Tennis class", + slug: "tennis-class-{{$guid}}", + length: 60, + hidden: true, + teamId: 9999, + }, + }); + req.userId = memberUserId; + // @ts-expect-error - Return type is wrong + vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `teamId`"); + }); + test("should throw 400 if system-wide admin but neither userId nor teamId provided", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "Tennis class", + slug: "tennis-class-{{$guid}}", + length: 60, + hidden: true, + }, + }); + req.isSystemWideAdmin = true; + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData()).message).toBe("`userId` or `teamId` required"); + }); + }); + + describe("Success", () => { + test("should call checkParentEventOwnership and checkUserMembership if parentId is present", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + title: "Tennis class", + slug: "tennis-class-{{$guid}}", + length: 60, + hidden: true, + userId: memberUserId, + parentId: 9999, + teamId: 9999, + }, + }); + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + // @ts-expect-error - Return type is wrong + vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false); + + await handler(req, res); + expect(checkParentEventOwnership).toHaveBeenCalled(); + expect(checkUserMembership).toHaveBeenCalled(); + }); + + test("should create event type successfully if all conditions are met", async () => { + const eventTypeTest = buildEventType(); + const { req, res } = createMocks({ + method: "POST", + body: { + title: "test title", + slug: "test-slug", + length: 60, + hidden: true, + userId: memberUserId, + parentId: 9999, + teamId: 9999, + }, + }); + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + // @ts-expect-error - Return type is wrong + vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => true); + vi.mocked(checkParentEventOwnership).mockImplementationOnce(async () => undefined); + vi.mocked(checkUserMembership).mockImplementationOnce(async () => undefined); + vi.mocked(checkTeamEventEditPermission).mockImplementationOnce(async () => undefined); + vi.mocked(ensureOnlyMembersAsHosts).mockImplementationOnce(async () => undefined); + + prismaMock.eventType.create.mockResolvedValue(eventTypeTest); + + await handler(req, res); + const data = JSON.parse(res._getData()); + + expect(prismaMock.eventType.create).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(200); + expect(data.message).toBe("Event type created successfully"); + }); + }); +}); diff --git a/apps/api/v1/test/lib/middleware/addRequestId.test.ts b/apps/api/v1/test/lib/middleware/addRequestId.test.ts new file mode 100644 index 00000000000000..d2879a24e80a49 --- /dev/null +++ b/apps/api/v1/test/lib/middleware/addRequestId.test.ts @@ -0,0 +1,36 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, vi, it, expect, afterEach } from "vitest"; + +import { addRequestId } from "../../../lib/helpers/addRequestid"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("Adds a request ID", () => { + it("Should attach a request ID to the request", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + const middleware = { + fn: addRequestId, + }; + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + expect(res.statusCode).toBe(200); + expect(res.getHeader("Calcom-Response-ID")).toBeDefined(); + }); +}); diff --git a/apps/api/v1/test/lib/middleware/httpMethods.test.ts b/apps/api/v1/test/lib/middleware/httpMethods.test.ts new file mode 100644 index 00000000000000..2fc536c46cff81 --- /dev/null +++ b/apps/api/v1/test/lib/middleware/httpMethods.test.ts @@ -0,0 +1,53 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, vi, it, expect, afterEach } from "vitest"; + +import { httpMethod } from "../../../lib/helpers/httpMethods"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("HTTP Methods function only allows the correct HTTP Methods", () => { + it("Should allow the passed in Method", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + const middleware = { + fn: httpMethod("POST"), + }; + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + expect(res.statusCode).toBe(200); + }); + it("Should allow the passed in Method", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + const middleware = { + fn: httpMethod("GET"), + }; + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + expect(res.statusCode).toBe(405); + }); +}); diff --git a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts new file mode 100644 index 00000000000000..e306dec5920dfb --- /dev/null +++ b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts @@ -0,0 +1,218 @@ +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService"; +import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService"; +import prisma from "@calcom/prisma"; +import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; + +import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys"; + +import { verifyApiKey } from "../../../lib/helpers/verifyApiKey"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("Verify API key", () => { + let service: ILicenseKeyService; + + beforeEach(async () => { + service = await LicenseKeyService.create(); + + vi.spyOn(service, "checkLicense"); + }); + + it("should throw an error if the api key is not valid", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + const middleware = { + fn: verifyApiKey, + }; + + vi.mocked(service.checkLicense).mockResolvedValue(false); + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + + expect(res.statusCode).toBe(401); + }); + + it("should throw an error if no api key is provided", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + }); + + const middleware = { + fn: verifyApiKey, + }; + + vi.mocked(service.checkLicense).mockResolvedValue(true); + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + + expect(res.statusCode).toBe(401); + }); + + it("should set correct permissions for system-wide admin", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + query: { + apiKey: "cal_test_key", + }, + prisma, + }); + const hashedKey = hashAPIKey("test_key"); + await prismock.apiKey.create({ + data: { + hashedKey, + user: { + create: { + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + locked: false, + }, + }, + }, + }); + + const middleware = { + fn: verifyApiKey, + }; + + vi.mocked(service.checkLicense).mockResolvedValue(true); + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + + expect(req.isSystemWideAdmin).toBe(true); + expect(req.isOrganizationOwnerOrAdmin).toBe(false); + }); + + it("should set correct permissions for org-level admin", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + query: { + apiKey: "cal_test_key", + }, + prisma, + }); + const hashedKey = hashAPIKey("test_key"); + await prismock.apiKey.create({ + data: { + hashedKey, + user: { + create: { + email: "org-admin@acme.com", + role: UserPermissionRole.USER, + locked: false, + teams: { + create: { + accepted: true, + role: MembershipRole.OWNER, + team: { + create: { + name: "ACME", + isOrganization: true, + organizationSettings: { + create: { + isAdminAPIEnabled: true, + orgAutoAcceptEmail: "acme.com", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const middleware = { + fn: verifyApiKey, + }; + + vi.mocked(service.checkLicense).mockResolvedValue(true); + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + + expect(req.isSystemWideAdmin).toBe(false); + expect(req.isOrganizationOwnerOrAdmin).toBe(true); + }); + + it("should return 403 if user is locked or blocked", async () => { + const { req, res } = createMocks({ + method: "POST", + body: {}, + query: { + apiKey: "cal_test_key", + }, + prisma, + }); + const hashedKey = hashAPIKey("test_key"); + await prismock.apiKey.create({ + data: { + hashedKey, + user: { + create: { + email: "locked@example.com", + role: UserPermissionRole.USER, + locked: true, + }, + }, + }, + }); + + const middleware = { + fn: verifyApiKey, + }; + + vi.mocked(service.checkLicense).mockResolvedValue(true); + + const serverNext = vi.fn((next: void) => Promise.resolve(next)); + const middlewareSpy = vi.spyOn(middleware, "fn"); + + await middleware.fn(req, res, serverNext); + + expect(middlewareSpy).toBeCalled(); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ error: "You are not authorized to perform this request." }); + expect(serverNext).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/v1/test/lib/selected-calendars/_post.test.ts b/apps/api/v1/test/lib/selected-calendars/_post.test.ts new file mode 100644 index 00000000000000..8975e2548966b8 --- /dev/null +++ b/apps/api/v1/test/lib/selected-calendars/_post.test.ts @@ -0,0 +1,139 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test } from "vitest"; + +import { HttpError } from "@calcom/lib/http-error"; + +import handler from "../../../pages/api/selected-calendars/_post"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("POST /api/selected-calendars", () => { + describe("Errors", () => { + test("Returns 403 if non-admin user tries to set userId in body", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + integration: "google", + externalId: "ext123", + userId: 444444, + }, + }); + + req.userId = 333333; + + try { + await handler(req, res); + } catch (e) { + expect(e).toBeInstanceOf(HttpError); + expect((e as HttpError).statusCode).toBe(403); + expect((e as HttpError).message).toBe("ADMIN required for userId"); + } + }); + + test("Returns 400 if request body is invalid", async () => { + const { req, res } = createMocks({ + method: "POST", + body: { + integration: "google", + }, + }); + + req.userId = 333333; + req.isSystemWideAdmin = true; + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res._getData()).message).toBe("invalid_type in 'externalId': Required"); + }); + }); + + describe("Success", () => { + test("Creates selected calendar if user is admin and sets bodyUserId", async () => { + const { req, res } = createMocks({ + method: "POST", + query: { + apiKey: "validApiKey", + }, + body: { + integration: "google", + externalId: "ext123", + userId: 444444, + }, + }); + + req.userId = 333333; + req.isSystemWideAdmin = true; + + prismaMock.user.findFirstOrThrow.mockResolvedValue({ + id: 444444, + } as any); + + prismaMock.selectedCalendar.create.mockResolvedValue({ + credentialId: 1, + integration: "google", + externalId: "ext123", + userId: 444444, + id: "xxx-xxx", + eventTypeId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + }); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData.selected_calendar.credentialId).toBe(1); + expect(responseData.message).toBe("Selected Calendar created successfully"); + }); + + test("Creates selected calendar if user is non-admin and does not set bodyUserId", async () => { + const { req, res } = createMocks({ + method: "POST", + query: { + apiKey: "validApiKey", + }, + body: { + integration: "google", + externalId: "ext123", + }, + }); + + req.userId = 333333; + + prismaMock.selectedCalendar.create.mockResolvedValue({ + id: "xxx-xxx", + credentialId: 1, + integration: "google", + externalId: "ext123", + userId: 333333, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + domainWideDelegationCredentialId: null, + eventTypeId: null, + error: null, + }); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData.selected_calendar.credentialId).toBe(1); + expect(responseData.message).toBe("Selected Calendar created successfully"); + }); + }); +}); diff --git a/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts new file mode 100644 index 00000000000000..2136617d23a4a3 --- /dev/null +++ b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts @@ -0,0 +1,94 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, it, expect } from "vitest"; + +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "../../../lib/utils/isAdmin"; +import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("isAdmin guard", () => { + it("Returns false when user does not exist in the system", async () => { + const { req } = createMocks({ + method: "POST", + body: {}, + }); + + req.userId = 0; + req.user = undefined; + + const { isAdmin, scope } = await isAdminGuard(req); + + expect(isAdmin).toBe(false); + expect(scope).toBe(null); + }); + + it("Returns false when org user is a member", async () => { + const { req } = createMocks({ + method: "POST", + body: {}, + }); + + const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } }); + + req.userId = memberUser.id; + req.user = memberUser; + + const { isAdmin, scope } = await isAdminGuard(req); + + expect(isAdmin).toBe(false); + expect(scope).toBe(null); + }); + + it("Returns system-wide admin when user is marked as such", async () => { + const { req } = createMocks({ + method: "POST", + body: {}, + }); + + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } }); + + req.userId = adminUser.id; + req.user = adminUser; + + const { isAdmin, scope } = await isAdminGuard(req); + + expect(isAdmin).toBe(true); + expect(scope).toBe(ScopeOfAdmin.SystemWide); + }); + + it("Returns org-wide admin when user is set as such & admin API access is granted", async () => { + const { req } = createMocks({ + method: "POST", + body: {}, + }); + + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + + req.userId = adminUser.id; + req.user = adminUser; + + const { isAdmin, scope } = await isAdminGuard(req); + expect(isAdmin).toBe(true); + expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin); + }); + + it("Returns no admin when user is set as org admin but admin API access is revoked", async () => { + const { req } = createMocks({ + method: "POST", + body: {}, + }); + + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } }); + + req.userId = adminUser.id; + req.user = adminUser; + + const { isAdmin } = await isAdminGuard(req); + expect(isAdmin).toBe(false); + }); +}); diff --git a/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts new file mode 100644 index 00000000000000..9a79a7663bff6b --- /dev/null +++ b/apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts @@ -0,0 +1,91 @@ +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, beforeEach } from "vitest"; + +import { WatchlistSeverity } from "@calcom/prisma/enums"; + +import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked"; + +describe("isLockedOrBlocked", () => { + beforeEach(async () => { + await prismock.watchlist.createMany({ + data: [ + { + type: "DOMAIN", + value: "spam.com", + createdById: 1, + }, + { + type: "DOMAIN", + value: "blocked.com", + severity: WatchlistSeverity.CRITICAL, + createdById: 1, + }, + ], + }); + }); + + it("should return false if no user in request", async () => { + const req = { userId: null, user: null } as any; + const result = await isLockedOrBlocked(req); + expect(result).toBe(false); + }); + + it("should return false if user has no email", async () => { + const req = { userId: 123, user: { email: null } } as any; + const result = await isLockedOrBlocked(req); + expect(result).toBe(false); + }); + + it("should return true if user is locked", async () => { + const req = { + userId: 123, + user: { + locked: true, + email: "test@example.com", + }, + } as any; + + const result = await isLockedOrBlocked(req); + expect(result).toBe(true); + }); + + it("should return true if user email domain is watchlisted", async () => { + const req = { + userId: 123, + user: { + locked: false, + email: "test@blocked.com", + }, + } as any; + + const result = await isLockedOrBlocked(req); + expect(result).toBe(true); + }); + + it("should return false if user is not locked and email domain is not watchlisted", async () => { + const req = { + userId: 123, + user: { + locked: false, + email: "test@example.com", + }, + } as any; + + const result = await isLockedOrBlocked(req); + expect(result).toBe(false); + }); + + it("should handle email domains case-insensitively", async () => { + const req = { + userId: 123, + user: { + locked: false, + email: "test@BLOCKED.COM", + }, + } as any; + + const result = await isLockedOrBlocked(req); + expect(result).toBe(true); + }); +}); diff --git a/apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts b/apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts new file mode 100644 index 00000000000000..d670defc721ffd --- /dev/null +++ b/apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; + +import prisma from "@calcom/prisma"; + +import { + getAccessibleUsers, + retrieveOrgScopedAccessibleUsers, +} from "../../../lib/utils/retrieveScopedAccessibleUsers"; + +describe("retrieveScopedAccessibleUsers tests", () => { + describe("getAccessibleUsers", () => { + it("Does not return members when only admin user ID is supplied", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const accessibleUserIds = await getAccessibleUsers({ + memberUserIds: [], + adminUserId: adminUser.id, + }); + + expect(accessibleUserIds.length).toBe(0); + }); + + it("Does not return members when admin user ID is not an admin of the user", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } }); + const memberOneUser = await prisma.user.findFirstOrThrow({ + where: { email: "member1-acme@example.com" }, + }); + const accessibleUserIds = await getAccessibleUsers({ + memberUserIds: [memberOneUser.id], + adminUserId: adminUser.id, + }); + + expect(accessibleUserIds.length).toBe(0); + }); + + it("Returns members when admin user ID is supplied and members IDs are supplied", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); + const memberOneUser = await prisma.user.findFirstOrThrow({ + where: { email: "member1-acme@example.com" }, + }); + const memberTwoUser = await prisma.user.findFirstOrThrow({ + where: { email: "member2-acme@example.com" }, + }); + const accessibleUserIds = await getAccessibleUsers({ + memberUserIds: [memberOneUser.id, memberTwoUser.id], + adminUserId: adminUser.id, + }); + + expect(accessibleUserIds.length).toBe(2); + expect(accessibleUserIds).toContain(memberOneUser.id); + expect(accessibleUserIds).toContain(memberTwoUser.id); + }); + }); + + describe("retrieveOrgScopedAccessibleUsers", () => { + it("Does not return members when admin user ID is an admin of an org", async () => { + const memberOneUser = await prisma.user.findFirstOrThrow({ + where: { email: "member1-acme@example.com" }, + }); + + const accessibleUserIds = await retrieveOrgScopedAccessibleUsers({ + adminId: memberOneUser.id, + }); + + expect(accessibleUserIds.length).toBe(0); + }); + + it("Returns members when admin user ID is an admin of an org", async () => { + const adminUser = await prisma.user.findFirstOrThrow({ + where: { email: "owner1-acme@example.com" }, + }); + + const accessibleUserIds = await retrieveOrgScopedAccessibleUsers({ + adminId: adminUser.id, + }); + + const memberOneUser = await prisma.user.findFirstOrThrow({ + where: { email: "member1-acme@example.com" }, + }); + + const memberTwoUser = await prisma.user.findFirstOrThrow({ + where: { email: "member2-acme@example.com" }, + }); + + expect(accessibleUserIds.length).toBe(11); + expect(accessibleUserIds).toContain(memberOneUser.id); + expect(accessibleUserIds).toContain(memberTwoUser.id); + expect(accessibleUserIds).toContain(adminUser.id); + }); + }); +}); diff --git a/apps/api/v1/tsconfig.json b/apps/api/v1/tsconfig.json new file mode 100644 index 00000000000000..ba4060d01ff433 --- /dev/null +++ b/apps/api/v1/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@calcom/tsconfig/nextjs.json", + "compilerOptions": { + "strict": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "~/*": ["*"], + "@prisma/client/*": ["@calcom/prisma/client/*"] + }, + "experimentalDecorators": true + }, + "include": [ + "next-env.d.ts", + "./next.d.ts", + "**/*.ts", + "**/*.tsx", + "../../../packages/types/*.d.ts", + "../../../packages/types/next-auth.d.ts" + ], + "exclude": ["node_modules", "templates", "auth"] +} diff --git a/apps/api/v1/vercel.json b/apps/api/v1/vercel.json new file mode 100644 index 00000000000000..09d28d9434a33f --- /dev/null +++ b/apps/api/v1/vercel.json @@ -0,0 +1,7 @@ +{ + "functions": { + "pages/api/slots/*.ts": { + "memory": 512 + } + } +} diff --git a/apps/api/v2/.dockerignore b/apps/api/v2/.dockerignore new file mode 100644 index 00000000000000..569ce539708a55 --- /dev/null +++ b/apps/api/v2/.dockerignore @@ -0,0 +1,2 @@ +**/node_modules +**/dist \ No newline at end of file diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example new file mode 100644 index 00000000000000..66b00d53f0485b --- /dev/null +++ b/apps/api/v2/.env.example @@ -0,0 +1,34 @@ +NODE_ENV= +API_PORT= +API_URL= +DATABASE_READ_URL= +DATABASE_WRITE_URL= +LOG_LEVEL= +NEXTAUTH_SECRET= +DATABASE_URL= +JWT_SECRET= +SENTRY_DSN= + +# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2 +NEXT_PUBLIC_SENTRY_DSN= + +# Stripe Billing +STRIPE_PRICE_ID_STARTER= +STRIPE_PRICE_ID_STARTER_OVERAGE= +STRIPE_PRICE_ID_ESSENTIALS= +STRIPE_PRICE_ID_ESSENTIALS_OVERAGE= +STRIPE_PRICE_ID_ENTERPRISE= +STRIPE_PRICE_ID_ENTERPRISE_OVERAGE= +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= + +WEB_APP_URL=http://localhost:3000/ +CALCOM_LICENSE_KEY= +API_KEY_PREFIX=cal_ +GET_LICENSE_KEY_URL="https://console.cal.com/api/license" +IS_E2E=false +DOCS_URL= + +# Axiom logging +AXIOM_DATASET= +AXIOM_TOKEN= diff --git a/apps/api/v2/.eslintrc.js b/apps/api/v2/.eslintrc.js new file mode 100644 index 00000000000000..4408591ef198ba --- /dev/null +++ b/apps/api/v2/.eslintrc.js @@ -0,0 +1,31 @@ +module.exports = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: "tsconfig.json", + tsconfigRootDir: __dirname, + sourceType: "module", + }, + plugins: ["@typescript-eslint/eslint-plugin"], + extends: ["plugin:@typescript-eslint/recommended"], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: [".eslintrc.js", "next-i18next.config.js"], + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + overrides: [ + { + files: ["./src/**/*.controller.ts"], + excludedFiles: "*.spec.js", + rules: { + "@typescript-eslint/explicit-function-return-type": "error", + }, + }, + ], +}; diff --git a/apps/api/v2/.gitignore b/apps/api/v2/.gitignore new file mode 100644 index 00000000000000..0cf21bfcd21152 --- /dev/null +++ b/apps/api/v2/.gitignore @@ -0,0 +1,44 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.* +!.env.example +!.env.appStore.example \ No newline at end of file diff --git a/apps/api/v2/.prettierrc.js b/apps/api/v2/.prettierrc.js new file mode 100644 index 00000000000000..de9853706fbf7c --- /dev/null +++ b/apps/api/v2/.prettierrc.js @@ -0,0 +1,7 @@ +const rootConfig = require("../../../packages/config/prettier-preset"); + +module.exports = { + ...rootConfig, + importOrder: ["^./instrument", ...rootConfig.importOrder], + importOrderParserPlugins: ["typescript", "decorators-legacy"], +}; diff --git a/apps/api/v2/Dockerfile b/apps/api/v2/Dockerfile new file mode 100644 index 00000000000000..b95d060b44bb4a --- /dev/null +++ b/apps/api/v2/Dockerfile @@ -0,0 +1,29 @@ +FROM node:18-alpine as build + +ARG DATABASE_DIRECT_URL +ARG DATABASE_URL + +WORKDIR /calcom + +RUN set -eux; +RUN ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3 + +ENV NODE_ENV="production" +ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL} +ENV DATABASE_URL=${DATABASE_URL} + +COPY . . + +RUN yarn install + +# Build prisma schema and make sure that it is linked to v2 node_modules +RUN yarn workspace @calcom/api-v2 run generate-schemas +RUN rm -rf apps/api/v2/node_modules +RUN yarn install + +RUN yarn workspace @calcom/api-v2 run build + +EXPOSE 80 + +CMD [ "yarn", "workspace", "@calcom/api-v2", "start:prod"] diff --git a/apps/api/v2/README.md b/apps/api/v2/README.md new file mode 100644 index 00000000000000..e67f17751e6859 --- /dev/null +++ b/apps/api/v2/README.md @@ -0,0 +1,93 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +Cal.com is using the [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ yarn install +``` + +## Prisma setup + +```bash +$ yarn prisma generate +``` + +## Env setup + +Copy `.env.example` to `.env` and fill values. + +## Add license Key to deployments table in DB + +id, logo theme licenseKey agreedLicenseAt +1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611' + +your CALCOM_LICENSE_KEY env var need to contain the same value + +.env +CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb + +## Running the app + +```bash +# development +$ yarn run start + +# watch mode +$ yarn run start:dev + +# production mode +$ yarn run start:prod +``` + +## Test + +```bash +# unit tests +$ yarn run test + +# e2e tests +$ yarn run test:e2e + +# test coverage +$ yarn run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/apps/api/v2/docker-compose.yaml b/apps/api/v2/docker-compose.yaml new file mode 100644 index 00000000000000..5b6af6cdd9a4f5 --- /dev/null +++ b/apps/api/v2/docker-compose.yaml @@ -0,0 +1,12 @@ +services: + redis: + image: redis:latest + container_name: redis_container + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + +volumes: + redis_data: diff --git a/apps/api/v2/jest-e2e.json b/apps/api/v2/jest-e2e.json new file mode 100644 index 00000000000000..cbf895ce74648b --- /dev/null +++ b/apps/api/v2/jest-e2e.json @@ -0,0 +1,17 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "moduleNameMapper": { + "@/(.*)": "/src/$1", + "test/(.*)": "/test/$1" + }, + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "setupFiles": ["/test/setEnvVars.ts", "jest-date-mock"], + "reporters": ["default", "jest-summarizing-reporter"], + "workerIdleMemoryLimit": "512MB", + "maxWorkers": 8 +} diff --git a/apps/api/v2/jest.config.json b/apps/api/v2/jest.config.json new file mode 100644 index 00000000000000..a7b6a8c8885869 --- /dev/null +++ b/apps/api/v2/jest.config.json @@ -0,0 +1,14 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "moduleNameMapper": { + "@/(.*)": "/src/$1", + "test/(.*)": "/test/$1" + }, + "testEnvironment": "node", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.ts$": "ts-jest" + }, + "setupFiles": ["/test/setEnvVars.ts"] +} diff --git a/apps/api/v2/nest-cli.json b/apps/api/v2/nest-cli.json new file mode 100644 index 00000000000000..45d207286db5a1 --- /dev/null +++ b/apps/api/v2/nest-cli.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "entryFile": "./apps/api/v2/src/main.js", + "compilerOptions": { + "deleteOutDir": true, + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true } + } + ] + } +} diff --git a/apps/api/v2/next-i18next.config.js b/apps/api/v2/next-i18next.config.js new file mode 100644 index 00000000000000..a07cf209817826 --- /dev/null +++ b/apps/api/v2/next-i18next.config.js @@ -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; diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json new file mode 100644 index 00000000000000..accf796d837624 --- /dev/null +++ b/apps/api/v2/package.json @@ -0,0 +1,105 @@ +{ + "name": "@calcom/api-v2", + "version": "0.0.1", + "description": "Platform API for Cal.com", + "author": "Cal.com Inc.", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "yarn dev:build && nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch", + "dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ", + "dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch", + "dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch", + "start:debug": "nest start --debug --watch", + "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", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json", + "test:e2e:local": "yarn test:e2e --maxWorkers=4", + "test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch", + "prisma": "yarn workspace @calcom/prisma prisma", + "generate-schemas": "yarn prisma generate && yarn prisma format", + "copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts" + }, + "dependencies": { + "@axiomhq/winston": "^1.2.0", + "@calcom/platform-constants": "*", + "@calcom/platform-enums": "*", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.94", + "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", + "@calcom/platform-types": "*", + "@calcom/platform-utils": "*", + "@calcom/prisma": "*", + "@golevelup/ts-jest": "^0.4.0", + "@microsoft/microsoft-graph-types-beta": "^0.42.0-preview", + "@nest-lab/throttler-storage-redis": "1.0.0", + "@nestjs/bull": "^10.1.1", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.2", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", + "@nestjs/throttler": "6.2.1", + "@sentry/nestjs": "^8.37.1", + "@sentry/node": "^8.8.0", + "@sentry/profiling-node": "^8.37.1", + "body-parser": "^1.20.2", + "bull": "^4.12.4", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "cookie-parser": "^1.4.6", + "dotenv": "^16.3.1", + "fs-extra": "^11.2.0", + "googleapis": "^84.0.0", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "jsforce": "^1.11.0", + "luxon": "^3.4.4", + "nest-winston": "^1.9.4", + "next-auth": "^4.22.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "qs-stringify": "^1.2.1", + "querystring": "^0.2.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "stripe": "^15.3.0", + "uuid": "^8.3.2", + "winston": "^3.13.0", + "winston-transport": "^4.7.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.6", + "@types/express": "^4.17.21", + "@types/fs-extra": "^11.0.4", + "@types/jest": "^29.5.10", + "@types/luxon": "^3.3.7", + "@types/node": "^20.3.1", + "@types/passport-jwt": "^3.0.13", + "@types/supertest": "^2.0.12", + "jest": "^29.7.0", + "jest-date-mock": "^1.0.10", + "prettier": "^2.8.6", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.1.0", + "typescript": "^4.9.4" + }, + "prisma": { + "schema": "../../../packages/prisma/schema.prisma" + } +} diff --git a/apps/api/v2/scripts/docker-start.ts b/apps/api/v2/scripts/docker-start.ts new file mode 100644 index 00000000000000..c71470dfc16744 --- /dev/null +++ b/apps/api/v2/scripts/docker-start.ts @@ -0,0 +1,35 @@ +import { execSync } from "child_process"; + +function checkCommandExists(command: string): boolean { + try { + execSync(`${command} --version`, { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +} + +try { + // Check if docker is installed + if (!checkCommandExists("docker")) { + throw new Error("Docker is not installed"); + } + + // Try docker compose first (new syntax) + try { + execSync("docker compose version", { stdio: "ignore" }); + console.log("Starting containers with docker compose..."); + execSync("docker compose up -d", { stdio: "inherit" }); + } catch (e) { + // Fall back to docker-compose if the above fails + if (checkCommandExists("docker-compose")) { + console.log("Starting containers with docker-compose..."); + execSync("docker-compose up -d", { stdio: "inherit" }); + } else { + throw new Error("Neither 'docker compose' nor 'docker-compose' command is available"); + } + } +} catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : "Unknown error occurred"}`); + process.exit(1); +} diff --git a/apps/api/v2/src/app.controller.ts b/apps/api/v2/src/app.controller.ts new file mode 100644 index 00000000000000..82295bce9105b7 --- /dev/null +++ b/apps/api/v2/src/app.controller.ts @@ -0,0 +1,12 @@ +import { getEnv } from "@/env"; +import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common"; +import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; + +@Controller() +export class AppController { + @Get("health") + @Version(VERSION_NEUTRAL) + getHealth(): "OK" { + return "OK"; + } +} diff --git a/apps/api/v2/src/app.e2e-spec.ts b/apps/api/v2/src/app.e2e-spec.ts new file mode 100644 index 00000000000000..18a23020db2b68 --- /dev/null +++ b/apps/api/v2/src/app.e2e-spec.ts @@ -0,0 +1,388 @@ +import { AppModule } from "@/app.module"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { CustomThrottlerGuard } from "@/lib/throttler-guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { TestingModule } from "@nestjs/testing"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client"; + +describe("AppController", () => { + describe("Rate limiting", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let rateLimitRepositoryFixture: RateLimitRepositoryFixture; + const userEmail = `app-rate-limits-user-${randomString()}@api.com`; + let user: User; + + let organization: Team; + let oAuthClient: PlatformOAuthClient; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + + let apiKeyString: string; + + let rateLimit: RateLimit; + let apiKeyStringWithRateLimit: string; + + let apiKeyStringWithMultipleLimits: string; + let firstRateLimitWithMultipleLimits: RateLimit; + let secondRateLimitWithMultipleLimits: RateLimit; + + const mockDefaultLimit = 5; + const mockDefaultTtl = 2500; + const mockDefaultBlockDuration = 5000; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], + }).compile(); + + jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit); + jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl); + jest + .spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration") + .mockReturnValue(mockDefaultBlockDuration); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = `cal_test_${keyString}`; + + rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef); + const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey( + user.id, + null + ); + apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`; + rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000); + + const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } = + await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`; + firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit( + "short", + apiKeyWithMultipleLimits.id, + 1000, + 2, + 2000 + ); + secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit( + "long", + apiKeyWithMultipleLimits.id, + 2000, + 3, + 4000 + ); + + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + organization = await organizationsRepositoryFixture.create({ + name: `app-rate-limits-organization-${randomString()}`, + }); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + oAuthClient = await createOAuthClient(organization.id); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: user.id } }, + organization: { connect: { id: organization.id } }, + }); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 1023, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it( + "api key with default rate limit - should enforce rate limits and reset after block duration", + async () => { + const limit = mockDefaultLimit; + const blockDuration = mockDefaultBlockDuration; + + for (let i = 1; i <= limit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyString}` }) + .expect(200); + + expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString()); + expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + } + + const blockedResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyString}`) + .expect(429); + + expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0"); + expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual( + blockDuration / 1000 + ); + + await new Promise((resolve) => setTimeout(resolve, blockDuration)); + + const afterBlockResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyString}`) + .expect(200); + + expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString()); + expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + }, + 15 * 1000 + ); + + it( + "api key with custom rate limit - should enforce rate limits and reset after block duration", + async () => { + const limit = rateLimit.limit; + const blockDuration = rateLimit.blockDuration; + const name = rateLimit.name; + + for (let i = 1; i <= limit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` }) + .expect(200); + + expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString()); + expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString()); + expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0); + } + + const blockedResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`) + .expect(429); + + expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString()); + expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0"); + expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual( + blockDuration / 1000 + ); + + await new Promise((resolve) => setTimeout(resolve, blockDuration)); + + const afterBlockResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`) + .expect(200); + + expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString()); + expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString()); + expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0); + }, + 15 * 1000 + ); + + it( + "api key with multiple rate limits - should enforce both short and long rate limits", + async () => { + const shortLimit = firstRateLimitWithMultipleLimits.limit; + const longLimit = secondRateLimitWithMultipleLimits.limit; + const shortName = firstRateLimitWithMultipleLimits.name; + const longName = secondRateLimitWithMultipleLimits.name; + const shortBlock = firstRateLimitWithMultipleLimits.blockDuration; + const longBlock = secondRateLimitWithMultipleLimits.blockDuration; + + let requestsMade = 0; + // note(Lauris): exhaust short limit to have remaining 0 for it + for (let i = 1; i <= shortLimit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + + requestsMade++; + + expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString()); + expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString()); + expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0); + + expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString()); + expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString()); + expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0); + } + + // note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it + for (let i = requestsMade; i < longLimit; i++) { + const responseAfterShortLimit = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + + requestsMade++; + + expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe( + shortLimit.toString() + ); + expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0"); + expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan( + 0 + ); + + expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString()); + expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe( + (longLimit - requestsMade).toString() + ); + expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0); + } + + // note(Lauris): both have remaining 0 so now exceed both + const blockedResponseLong = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(429); + + expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString()); + expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0"); + expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual( + firstRateLimitWithMultipleLimits.blockDuration / 1000 + ); + + expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString()); + expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0"); + expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual( + secondRateLimitWithMultipleLimits.blockDuration / 1000 + ); + + // note(Lauris): wait for short limit to reset + await new Promise((resolve) => setTimeout(resolve, shortBlock)); + const responseAfterShortLimitReload = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe( + shortLimit.toString() + ); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe( + (shortLimit - 1).toString() + ); + expect( + Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`]) + ).toBeGreaterThan(0); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe( + longLimit.toString() + ); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe( + (longLimit - requestsMade).toString() + ); + expect( + Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`]) + ).toBeGreaterThan(0); + + // note(Lauris): wait for long limit to reset + await new Promise((resolve) => setTimeout(resolve, longBlock)); + const responseAfterLongLimitReload = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe( + shortLimit.toString() + ); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe( + (shortLimit - 1).toString() + ); + expect( + Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`]) + ).toBeGreaterThan(0); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe( + longLimit.toString() + ); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe( + (longLimit - 1).toString() + ); + expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan( + 0 + ); + }, + 30 * 1000 + ); + + it( + "non api key with default rate limit - should enforce rate limits and reset after block duration", + async () => { + const limit = mockDefaultLimit; + const blockDuration = mockDefaultBlockDuration; + + for (let i = 1; i <= limit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(200); + + expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString()); + expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + } + + const blockedResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(429); + + expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0"); + expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual( + blockDuration / 1000 + ); + + await new Promise((resolve) => setTimeout(resolve, blockDuration)); + + const afterBlockResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(200); + + expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString()); + expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + }, + 15 * 1000 + ); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userEmail); + await organizationsRepositoryFixture.delete(organization.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts new file mode 100644 index 00000000000000..b9bd76cfd7a38f --- /dev/null +++ b/apps/api/v2/src/app.module.ts @@ -0,0 +1,104 @@ +import appConfig from "@/config/app"; +import { CustomThrottlerGuard } from "@/lib/throttler-guard"; +import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware"; +import { RedirectsMiddleware } from "@/middleware/app.redirects.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"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { RedisService } from "@/modules/redis/redis.service"; +import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; +import { BullModule } from "@nestjs/bull"; +import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core"; +import { seconds, ThrottlerModule } from "@nestjs/throttler"; +import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup"; + +import { AppController } from "./app.controller"; + +@Module({ + imports: [ + SentryModule.forRoot(), + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [appConfig], + }), + + RedisModule, + BullModule.forRoot({ + redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`, + }), + ThrottlerModule.forRootAsync({ + imports: [RedisModule], + inject: [RedisService], + useFactory: (redisService: RedisService) => ({ + // note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one + // entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule + // without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc. + // So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits. + throttlers: [ + { + name: "dummy", + ttl: seconds(60), + limit: 120, + }, + ], + storage: new ThrottlerStorageRedisService(redisService.redis), + }), + }), + PrismaModule, + EndpointsModule, + AuthModule, + JwtModule, + ], + controllers: [AppController], + providers: [ + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + { + provide: ThrottlerStorageRedisService, + useFactory: (redisService: RedisService) => { + return new ThrottlerStorageRedisService(redisService.redis); + }, + inject: [RedisService], + }, + { + provide: APP_INTERCEPTOR, + useClass: ResponseInterceptor, + }, + { + provide: APP_GUARD, + useClass: CustomThrottlerGuard, + }, + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer + .apply(RawBodyMiddleware) + .forRoutes({ + path: "/api/v2/billing/webhook", + method: RequestMethod.POST, + }) + .apply(JsonBodyMiddleware) + .forRoutes("*") + .apply(RequestIdMiddleware) + .forRoutes("*") + .apply(AppLoggerMiddleware) + .forRoutes("*") + .apply(RedirectsMiddleware) + .forRoutes("/") + .apply(RewriterMiddleware) + .forRoutes("/"); + } +} diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts new file mode 100644 index 00000000000000..cbcff9b57b2a5b --- /dev/null +++ b/apps/api/v2/src/app.ts @@ -0,0 +1,83 @@ +import "./instrument"; + +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import type { ValidationError } from "@nestjs/common"; +import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common"; +import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import * as cookieParser from "cookie-parser"; +import { Request } from "express"; +import helmet from "helmet"; + +import { + API_VERSIONS, + VERSION_2024_04_15, + API_VERSIONS_ENUM, + CAL_API_VERSION_HEADER, + X_CAL_CLIENT_ID, + X_CAL_SECRET_KEY, + X_CAL_PLATFORM_EMBED, +} from "@calcom/platform-constants"; + +import { TRPCExceptionFilter } from "./filters/trpc-exception.filter"; + +export const bootstrap = (app: NestExpressApplication): NestExpressApplication => { + app.enableShutdownHooks(); + + app.enableVersioning({ + type: VersioningType.CUSTOM, + extractor: (request: unknown) => { + const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined; + if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) { + return headerVersion; + } + return VERSION_2024_04_15; + }, + defaultVersion: VERSION_2024_04_15, + }); + + app.use(helmet()); + + app.enableCors({ + origin: "*", + methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"], + allowedHeaders: [ + X_CAL_CLIENT_ID, + X_CAL_SECRET_KEY, + X_CAL_PLATFORM_EMBED, + CAL_API_VERSION_HEADER, + "Accept", + "Authorization", + "Content-Type", + "Origin", + ], + maxAge: 86_400, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + validationError: { + target: true, + value: true, + }, + exceptionFactory(errors: ValidationError[]) { + return new BadRequestException({ errors }); + }, + }) + ); + + // Exception filters, new filters go at the bottom, keep the order + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new PrismaExceptionFilter()); + app.useGlobalFilters(new ZodExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalFilters(new TRPCExceptionFilter()); + + app.use(cookieParser()); + + return app; +}; diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts new file mode 100644 index 00000000000000..79861bbdf314a9 --- /dev/null +++ b/apps/api/v2/src/config/app.ts @@ -0,0 +1,43 @@ +import { getEnv } from "@/env"; + +import type { AppConfig } from "./type"; + +const loadConfig = (): AppConfig => { + return { + env: { + type: getEnv("NODE_ENV", "development"), + }, + api: { + port: Number(getEnv("API_PORT", "5555")), + path: getEnv("API_URL", "http://localhost"), + url: `${getEnv("API_URL", "http://localhost")}${ + process.env.API_PORT && getEnv("NODE_ENV", "development") === "development" + ? `:${Number(getEnv("API_PORT", "5555"))}` + : "" + }/v2`, + keyPrefix: getEnv("API_KEY_PREFIX", "cal_"), + licenseKey: getEnv("CALCOM_LICENSE_KEY", ""), + licenseKeyUrl: getEnv("GET_LICENSE_KEY_URL", "https://console.cal.com/api/license"), + }, + db: { + readUrl: getEnv("DATABASE_READ_URL"), + writeUrl: getEnv("DATABASE_WRITE_URL"), + redisUrl: getEnv("REDIS_URL"), + }, + next: { + authSecret: getEnv("NEXTAUTH_SECRET"), + }, + stripe: { + apiKey: getEnv("STRIPE_API_KEY"), + webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"), + teamMonthlyPriceId: getEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "set-team-monthly-price-in-your-env"), + isTeamBillingEnabled: getEnv("IS_TEAM_BILLING_ENABLED", true), + }, + app: { + baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"), + }, + e2e: getEnv("IS_E2E", false), + }; +}; + +export default loadConfig; diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts new file mode 100644 index 00000000000000..cd2fe252bfce33 --- /dev/null +++ b/apps/api/v2/src/config/type.ts @@ -0,0 +1,31 @@ +export type AppConfig = { + env: { + type: "production" | "development"; + }; + api: { + port: number; + path: string; + url: string; + keyPrefix: string; + licenseKey: string; + licenseKeyUrl: string; + }; + db: { + readUrl: string; + writeUrl: string; + redisUrl: string; + }; + next: { + authSecret: string; + }; + stripe: { + apiKey: string; + webhookSecret: string; + teamMonthlyPriceId: string; + isTeamBillingEnabled: boolean; + }; + app: { + baseUrl: string; + }; + e2e: boolean; +}; diff --git a/apps/api/v2/src/ee/LICENSE b/apps/api/v2/src/ee/LICENSE new file mode 100644 index 00000000000000..a8c6744758303a --- /dev/null +++ b/apps/api/v2/src/ee/LICENSE @@ -0,0 +1,42 @@ +The Cal.com Commercial License (the “Commercial License”) +Copyright (c) 2020-present Cal.com, Inc + +With regard to the Cal.com Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Cal.com Subscription Terms available +at https://cal.com/terms, or other agreements governing +the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"), +and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription") +for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence, +you are free to modify this Software and publish patches to the Software. You agree +that Cal.com and/or its licensors (as applicable) retain all right, title and interest in +and to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Commercial Subscription for the correct number of hosts. +Notwithstanding the foregoing, you may copy and modify the Software for development +and testing purposes, without requiring a subscription. You agree that Cal.com and/or +its licensors (as applicable) retain all right, title and interest in and to all such +modifications. You are not granted any other rights beyond what is expressly stated herein. +Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +This Commercial License applies only to the part of this Software that is not distributed under +the AGPLv3 license. Any part of this Software distributed under the MIT license or which +is served client-side as an image, font, cascading stylesheet (CSS), file which produces +or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or +in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall +be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Cal.com Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/apps/api/v2/src/ee/README.md b/apps/api/v2/src/ee/README.md new file mode 100644 index 00000000000000..44bff8c9c04b5f --- /dev/null +++ b/apps/api/v2/src/ee/README.md @@ -0,0 +1,18 @@ + + + +# Enterprise Edition of API + +Welcome to the Enterprise Edition ("/ee") of the Cal.com API. + +Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All "Multiplayer APIs" are under a commercial license. + +The [/ee](https://github.com/calcom/cal.com/tree/main/apps/api/v2/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://cal.com/pricing) plan and enterprise-grade features for [Enterprise](https://cal.com/enterprise) such as SSO, SAML, OIDC, SCIM, SIEM and much more or [Platform](https://cal.com/platform) plan to build a marketplace. + +> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calcom/cal.com)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://console.cal.com/) first❗_ diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts new file mode 100644 index 00000000000000..de0b63ddf377cb --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts @@ -0,0 +1,17 @@ +import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { BillingModule } from "@/modules/billing/billing.module"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, RedisModule, TokensModule, BillingModule], + providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository], + controllers: [BookingsController_2024_04_15], +}) +export class BookingsModule_2024_04_15 {} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts new file mode 100644 index 00000000000000..cc31378ffdc921 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts @@ -0,0 +1,358 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output"; +import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { handleNewBooking } from "@calcom/platform-libraries"; +import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types"; + +describe("Bookings Endpoints 2024-04-15", () => { + describe("User Authenticated", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let apiKeyString: string; + + const userEmail = `bookings-2024-04-15-user-${randomString()}@api.com`; + let user: User; + + let eventTypeId: number; + + let createdBooking: Awaited>; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = keyString; + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `bookings-2024-04-15-schedule-${randomString()}-${describe.name}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { + title: `bookings-2024-04-15-event-type-${randomString()}-${describe.name}`, + slug: `bookings-2024-04-15-event-type-${randomString()}-${describe.name}`, + length: 60, + }, + user.id + ); + eventTypeId = event.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a booking", async () => { + const bookingStart = "2040-05-21T09:30:00.000Z"; + const bookingEnd = "2040-05-21T10:30:00.000Z"; + const bookingEventTypeId = eventTypeId; + const bookingTimeZone = "Europe/London"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingMetadata = { + timeFormat: "12", + meetingType: "organizer-phone", + }; + const bookingResponses = { + name: "tester", + email: "tester@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test", + guests: [], + }; + + const body: CreateBookingInput_2024_04_15 = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: bookingMetadata, + hashedLink: bookingHashedLink, + responses: bookingResponses, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toEqual(userEmail); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.uid).toBeDefined(); + expect(responseBody.data.startTime).toEqual(bookingStart); + expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId); + expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone); + expect(responseBody.data.metadata).toEqual(bookingMetadata); + + createdBooking = responseBody.data; + }); + }); + + it("should fail to create a booking with no_available_users_found_error", async () => { + const bookingStart = "2040-05-21T09:30:00.000Z"; + const bookingEnd = "2040-05-21T10:30:00.000Z"; + const bookingEventTypeId = eventTypeId; + const bookingTimeZone = "Europe/London"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingMetadata = { + timeFormat: "12", + meetingType: "organizer-phone", + }; + const bookingResponses = { + name: "tester", + email: "tester@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test", + guests: [], + }; + + const body: CreateBookingInput_2024_04_15 = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: bookingMetadata, + hashedLink: bookingHashedLink, + responses: bookingResponses, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .expect(400) + .then(async (response) => { + const responseBody: ApiErrorResponse = response.body; + expect(responseBody.error.message).toEqual("no_available_users_found_error"); + }); + }); + + it("should create a booking with api key to get owner id", async () => { + const bookingStart = "2040-05-22T09:30:00.000Z"; + const bookingEnd = "2040-05-22T10:30:00.000Z"; + const bookingEventTypeId = eventTypeId; + const bookingTimeZone = "Europe/London"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingMetadata = { + timeFormat: "12", + meetingType: "organizer-phone", + }; + const bookingResponses = { + name: "tester", + email: "tester@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test", + guests: [], + }; + + const body: CreateBookingInput_2024_04_15 = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: bookingMetadata, + hashedLink: bookingHashedLink, + responses: bookingResponses, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toEqual(userEmail); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.uid).toBeDefined(); + expect(responseBody.data.startTime).toEqual(bookingStart); + expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId); + expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone); + expect(responseBody.data.metadata).toEqual(bookingMetadata); + + createdBooking = responseBody.data; + }); + }); + + describe("should reschedule a booking", () => { + it("should reschedule with updated start time, end time & metadata", async () => { + const newBookingStart = "2040-05-21T12:30:00.000Z"; + const newBookingEnd = "2040-05-21T13:30:00.000Z"; + const bookingEventTypeId = eventTypeId; + const bookingTimeZone = "Europe/London"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const newBookingMetadata = { + timeFormat: "24", + meetingType: "attendee-phone", + }; + const bookingResponses = { + name: "tester", + email: "tester@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test", + guests: [], + }; + + const body: CreateBookingInput_2024_04_15 = { + rescheduleUid: createdBooking.uid, + start: newBookingStart, + end: newBookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: newBookingMetadata, + hashedLink: bookingHashedLink, + responses: bookingResponses, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toEqual(userEmail); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.uid).toBeDefined(); + expect(responseBody.data.startTime).toEqual(newBookingStart); + expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId); + expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone); + expect(responseBody.data.metadata).toEqual(newBookingMetadata); + + createdBooking = responseBody.data; + }); + }); + }); + + it("should get bookings", async () => { + return request(app.getHttpServer()) + .get("/v2/bookings?filters[status]=upcoming") + .then((response) => { + const responseBody: GetBookingsOutput_2024_04_15 = response.body; + + expect(responseBody.data.bookings.length).toEqual(2); + const fetchedBooking = responseBody.data.bookings.find( + (booking) => booking.id === createdBooking.id + ); + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(fetchedBooking).toBeDefined(); + + if (fetchedBooking) { + expect(fetchedBooking.id).toEqual(createdBooking.id); + expect(fetchedBooking.uid).toEqual(createdBooking.uid); + expect(fetchedBooking.startTime).toEqual(createdBooking.startTime); + expect(fetchedBooking.endTime).toEqual(createdBooking.endTime); + expect(fetchedBooking.user?.email).toEqual(userEmail); + } + }); + }); + + it("should get booking", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdBooking.uid}`) + .then((response) => { + const responseBody: GetBookingOutput_2024_04_15 = response.body; + const bookingInfo = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(bookingInfo?.id).toBeDefined(); + expect(bookingInfo?.uid).toBeDefined(); + expect(bookingInfo?.id).toEqual(createdBooking.id); + expect(bookingInfo?.uid).toEqual(createdBooking.uid); + expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(bookingInfo?.startTime).toEqual(createdBooking.startTime); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts new file mode 100644 index 00000000000000..20f621e6ca82c9 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -0,0 +1,451 @@ +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input"; +import { MarkNoShowInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/mark-no-show.input"; +import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output"; +import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; +import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output"; +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { + Controller, + Post, + Logger, + Req, + InternalServerErrorException, + Body, + Headers, + HttpException, + Param, + Get, + Query, + NotFoundException, + UseGuards, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; +import { User } from "@prisma/client"; +import { CreationSource } from "@prisma/client"; +import { Request } from "express"; +import { NextApiRequest } from "next/types"; +import { v4 as uuidv4 } from "uuid"; + +import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants"; +import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants"; +import { + handleNewRecurringBooking, + handleNewBooking, + BookingResponse, + HttpError, + handleInstantMeeting, + handleMarkNoShow, + getAllUserBookings, + getBookingInfo, + handleCancelBooking, + getBookingForReschedule, + ErrorCode, +} from "@calcom/platform-libraries"; +import { + GetBookingsInput_2024_04_15, + CancelBookingInput_2024_04_15, + Status_2024_04_15, +} from "@calcom/platform-types"; +import { ApiResponse } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +type BookingRequest = Request & { + userId?: number; +}; + +type OAuthRequestParams = { + platformClientId: string; + platformRescheduleUrl: string; + platformCancelUrl: string; + platformBookingUrl: string; + platformBookingLocation?: string; + arePlatformEmailsEnabled: boolean; +}; + +const DEFAULT_PLATFORM_PARAMS = { + platformClientId: "", + platformCancelUrl: "", + platformRescheduleUrl: "", + platformBookingUrl: "", + arePlatformEmailsEnabled: false, + platformBookingLocation: undefined, +}; + +@Controller({ + path: "/v2/bookings", + version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14], +}) +@UseGuards(PermissionsGuard) +@DocsExcludeController(true) +export class BookingsController_2024_04_15 { + private readonly logger = new Logger("BookingsController_2024_04_15"); + + constructor( + private readonly oAuthFlowService: OAuthFlowService, + private readonly prismaReadService: PrismaReadService, + private readonly oAuthClientRepository: OAuthClientRepository, + private readonly billingService: BillingService, + private readonly config: ConfigService, + private readonly apiKeyRepository: ApiKeyRepository + ) {} + + @Get("/") + @UseGuards(ApiAuthGuard) + @Permissions([BOOKING_READ]) + @ApiQuery({ name: "filters[status]", enum: Status_2024_04_15, required: true }) + @ApiQuery({ name: "limit", type: "number", required: false }) + @ApiQuery({ name: "cursor", type: "number", required: false }) + async getBookings( + @GetUser() user: User, + @Query() queryParams: GetBookingsInput_2024_04_15 + ): Promise { + const { filters, cursor, limit } = queryParams; + const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"]; + const bookings = await getAllUserBookings({ + bookingListingByStatus: [bookingListingByStatus], + skip: cursor ?? 0, + take: limit ?? 10, + filters, + ctx: { + user: { email: user.email, id: user.id }, + prisma: this.prismaReadService.prisma as unknown as PrismaClient, + }, + }); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } + + @Get("/:bookingUid") + async getBooking(@Param("bookingUid") bookingUid: string): Promise { + const { bookingInfo } = await getBookingInfo(bookingUid); + + if (!bookingInfo) { + throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); + } + + return { + status: SUCCESS_STATUS, + data: bookingInfo, + }; + } + + @Get("/:bookingUid/reschedule") + async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise> { + const booking = await getBookingForReschedule(bookingUid); + + if (!booking) { + throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); + } + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Post("/") + async createBooking( + @Req() req: BookingRequest, + @Body() body: CreateBookingInput_2024_04_15, + @Headers(X_CAL_CLIENT_ID) clientId?: string, + @Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string + ): Promise>> { + const oAuthClientId = clientId?.toString(); + const { orgSlug, locationUrl } = body; + req.headers["x-cal-force-slug"] = orgSlug; + try { + const booking = await handleNewBooking( + await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed) + ); + if (booking.userId && booking.uid && booking.startTime) { + void (await this.billingService.increaseUsageByUserId(booking.userId, { + uid: booking.uid, + startTime: booking.startTime, + fromReschedule: booking.fromReschedule, + })); + } + return { + status: SUCCESS_STATUS, + data: booking, + }; + } catch (err) { + this.handleBookingErrors(err); + } + throw new InternalServerErrorException("Could not create booking."); + } + + @Post("/:bookingId/cancel") + async cancelBooking( + @Req() req: BookingRequest, + @Param("bookingId") bookingId: string, + @Body() _: CancelBookingInput_2024_04_15, + @Headers(X_CAL_CLIENT_ID) clientId?: string, + @Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string + ): Promise> { + const oAuthClientId = clientId?.toString(); + if (bookingId) { + try { + req.body.id = parseInt(bookingId); + const res = await handleCancelBooking( + await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed) + ); + if (!res.onlyRemovedAttendee) { + void (await this.billingService.cancelUsageByBookingUid(res.bookingUid)); + } + return { + status: SUCCESS_STATUS, + data: { + bookingId: res.bookingId, + bookingUid: res.bookingUid, + onlyRemovedAttendee: res.onlyRemovedAttendee, + }, + }; + } catch (err) { + this.handleBookingErrors(err); + } + } else { + throw new NotFoundException("Booking ID is required."); + } + throw new InternalServerErrorException("Could not cancel booking."); + } + + @Post("/:bookingUid/mark-no-show") + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard) + async markNoShow( + @GetUser("id") userId: number, + @Body() body: MarkNoShowInput_2024_04_15, + @Param("bookingUid") bookingUid: string + ): Promise { + try { + const markNoShowResponse = await handleMarkNoShow({ + bookingUid: bookingUid, + attendees: body.attendees, + noShowHost: body.noShowHost, + userId, + }); + + return { status: SUCCESS_STATUS, data: markNoShowResponse }; + } catch (err) { + this.handleBookingErrors(err, "no-show"); + } + throw new InternalServerErrorException("Could not mark no show."); + } + + @Post("/recurring") + async createRecurringBooking( + @Req() req: BookingRequest, + @Body() _: CreateRecurringBookingInput_2024_04_15[], + @Headers(X_CAL_CLIENT_ID) clientId?: string, + @Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string + ): Promise> { + const oAuthClientId = clientId?.toString(); + try { + const recurringEventId = uuidv4(); + for (const recurringEvent of req.body) { + if (!recurringEvent.recurringEventId) { + recurringEvent.recurringEventId = recurringEventId; + } + } + + const createdBookings: BookingResponse[] = await handleNewRecurringBooking( + await this.createNextApiRecurringBookingRequest(req, oAuthClientId, undefined, isEmbed) + ); + + createdBookings.forEach(async (booking) => { + if (booking.userId && booking.uid && booking.startTime) { + void (await this.billingService.increaseUsageByUserId(booking.userId, { + uid: booking.uid, + startTime: booking.startTime, + })); + } + }); + + return { + status: SUCCESS_STATUS, + data: createdBookings, + }; + } catch (err) { + this.handleBookingErrors(err, "recurring"); + } + throw new InternalServerErrorException("Could not create recurring booking."); + } + + @Post("/instant") + async createInstantBooking( + @Req() req: BookingRequest, + @Body() _: CreateBookingInput_2024_04_15, + @Headers(X_CAL_CLIENT_ID) clientId?: string, + @Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string + ): Promise>>> { + const oAuthClientId = clientId?.toString(); + req.userId = (await this.getOwnerId(req)) ?? -1; + try { + const instantMeeting = await handleInstantMeeting( + await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed) + ); + + if (instantMeeting.userId && instantMeeting.bookingUid) { + const now = new Date(); + // add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed + now.setSeconds(now.getSeconds() + 10); + void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, { + uid: instantMeeting.bookingUid, + startTime: now, + })); + } + + return { + status: SUCCESS_STATUS, + data: instantMeeting, + }; + } catch (err) { + this.handleBookingErrors(err, "instant"); + } + throw new InternalServerErrorException("Could not create instant booking."); + } + + private async getOwnerId(req: Request): Promise { + try { + const bearerToken = req.get("Authorization")?.replace("Bearer ", ""); + if (bearerToken) { + if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) { + const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix")); + const apiKeyHash = hashAPIKey(strippedApiKey); + const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); + return keyData?.userId; + } else { + // Access Token + return this.oAuthFlowService.getOwnerId(bearerToken); + } + } + } catch (err) { + this.logger.error(err); + } + } + + private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise { + const res = { ...DEFAULT_PLATFORM_PARAMS }; + + if (isEmbed) { + // embed should ignore oauth client settings and enable emails by default + return { ...res, arePlatformEmailsEnabled: true }; + } + + try { + const client = await this.oAuthClientRepository.getOAuthClient(clientId); + // fetch oAuthClient from db and use data stored in db to set these values + if (client) { + res.platformClientId = clientId; + res.platformCancelUrl = client.bookingCancelRedirectUri ?? ""; + res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? ""; + res.platformBookingUrl = client.bookingRedirectUri ?? ""; + res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false; + } + return res; + } catch (err) { + this.logger.error(err); + return res; + } + } + + private async createNextApiBookingRequest( + req: BookingRequest, + oAuthClientId?: string, + platformBookingLocation?: string, + isEmbed?: string + ): Promise { + const requestId = req.get("X-Request-Id"); + const clone = { ...req }; + const userId = (await this.getOwnerId(req)) ?? -1; + const oAuthParams = oAuthClientId + ? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed)) + : DEFAULT_PLATFORM_PARAMS; + this.logger.log(`createNextApiBookingRequest_2024_04_15`, { + requestId, + ownerId: userId, + platformBookingLocation, + oAuthClientId, + ...oAuthParams, + }); + Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation }); + clone.body = { + ...clone.body, + noEmail: !oAuthParams.arePlatformEmailsEnabled, + creationSource: CreationSource.API_V2, + }; + return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; + } + + private async createNextApiRecurringBookingRequest( + req: BookingRequest, + oAuthClientId?: string, + platformBookingLocation?: string, + isEmbed?: string + ): Promise { + const clone = { ...req }; + const userId = (await this.getOwnerId(req)) ?? -1; + const oAuthParams = oAuthClientId + ? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed)) + : DEFAULT_PLATFORM_PARAMS; + const requestId = req.get("X-Request-Id"); + this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, { + requestId, + ownerId: userId, + platformBookingLocation, + oAuthClientId, + ...oAuthParams, + }); + Object.assign(clone, { + userId, + ...oAuthParams, + platformBookingLocation, + noEmail: !oAuthParams.arePlatformEmailsEnabled, + creationSource: CreationSource.API_V2, + }); + return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; + } + + private handleBookingErrors( + err: Error | HttpError | unknown, + type?: "recurring" | `instant` | "no-show" + ): void { + const errMsg = + type === "no-show" + ? `Error while marking no-show.` + : `Error while creating ${type ? type + " " : ""}booking.`; + if (err instanceof HttpError) { + const httpError = err as HttpError; + throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); + } + + if (err instanceof Error) { + const error = err as Error; + if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) { + throw new HttpException(error.message, 400); + } + throw new InternalServerErrorException(error?.message ?? errMsg); + } + + throw new InternalServerErrorException(errMsg); + } + + private transformToBoolean(v?: string): boolean { + return v && typeof v === "string" ? v.toLowerCase() === "true" : false; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts new file mode 100644 index 00000000000000..675b94cf698c6f --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -0,0 +1,172 @@ +import { ApiProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsTimeZone, + IsNumber, + IsString, + IsOptional, + IsArray, + IsObject, + IsEmail, + ValidateNested, +} from "class-validator"; + +class Location { + @IsString() + @ApiProperty() + optionValue!: string; + + @IsString() + @ApiProperty() + value!: string; +} + +class Response { + @IsString() + @ApiProperty() + name!: string; + + @IsEmail() + @ApiProperty() + email!: string; + + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: [String] }) + guests!: string[]; + + @IsOptional() + @ValidateNested() + @Type(() => Location) + @ApiPropertyOptional({ type: Location }) + location?: Location; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + notes?: string; +} + +export class CreateBookingInput_2024_04_15 { + @IsString() + @IsOptional() + @ApiPropertyOptional() + end?: string; + + @IsString() + @ApiProperty() + start!: string; + + @IsNumber() + @ApiProperty() + eventTypeId!: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + eventTypeSlug?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + rescheduleUid?: string; + + @IsTimeZone() + @ApiProperty() + timeZone!: string; + + @Transform(({ value }: { value: string | string[] }) => { + return typeof value === "string" ? [value] : value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ApiPropertyOptional({ type: [String] }) + user?: string[]; + + @IsString() + @ApiProperty() + language!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + bookingUid?: string; + + @IsObject() + @ApiProperty({ type: Object }) + metadata!: Record; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + hasHashedBookingLink?: boolean; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + hashedLink!: string | null; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + seatReferenceUid?: string; + + @Type(() => Response) + @ApiProperty({ type: Response }) + responses!: Response; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + orgSlug?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + locationUrl?: string; + + // note(rajiv): after going through getUrlSearchParamsToForward.ts we found out + // that the below properties were not being included inside of handleNewBooking :- cc @morgan + // cal.salesforce.rrSkipToAccountLookupField, cal.rerouting & cal.isTestPreviewLink + // hence no input values have been setup for them in CreateBookingInput_2024_04_15 + @IsArray() + @Type(() => Number) + @IsOptional() + @ApiHideProperty() + routedTeamMemberIds?: number[]; + + @IsNumber() + @IsOptional() + @ApiHideProperty() + routingFormResponseId?: number; + + @IsBoolean() + @IsOptional() + @ApiHideProperty() + skipContactOwner?: boolean; + + @IsBoolean() + @IsOptional() + @ApiHideProperty() + _shouldServeCache?: boolean; + + @IsBoolean() + @IsOptional() + @ApiHideProperty() + _isDryRun?: boolean; + + // reroutingFormResponses is similar to rescheduling which can only be done by the organiser + // won't really be necessary here in our usecase though :- cc @Hariom + @IsObject() + @IsOptional() + @ApiHideProperty() + reroutingFormResponses?: Record< + string, + { + value: (string | number | string[]) & (string | number | string[] | undefined); + label?: string | undefined; + } + >; +} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-recurring-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-recurring-booking.input.ts new file mode 100644 index 00000000000000..7692748fbe8e76 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-recurring-booking.input.ts @@ -0,0 +1,35 @@ +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator"; + +import type { AppsStatus } from "@calcom/platform-libraries"; + +export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 { + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + noEmail?: boolean; + + @IsOptional() + @IsNumber() + @ApiPropertyOptional() + recurringCount?: number; + + @IsOptional() + @ApiPropertyOptional({ type: [Object] }) + appsStatus?: AppsStatus[] | undefined; + + @IsOptional() + @ApiPropertyOptional({ type: [Object] }) + allRecurringDates?: Record[]; + + @IsOptional() + @IsNumber() + @ApiPropertyOptional() + currentRecurringIndex?: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + recurringEventId?: string; +} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/mark-no-show.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/mark-no-show.input.ts new file mode 100644 index 00000000000000..8d8348e1249fb9 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/mark-no-show.input.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator"; + +class Attendee { + @IsEmail() + @ApiProperty() + email!: string; + + @IsBoolean() + @ApiProperty() + noShow!: boolean; +} + +export class MarkNoShowInput_2024_04_15 { + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + noShowHost?: boolean; + + @ValidateNested() + @Type(() => Attendee) + @IsArray() + @IsOptional() + @ApiPropertyOptional({ type: [Attendee] }) + attendees?: Attendee[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-booking.output.ts new file mode 100644 index 00000000000000..bf27fa2a18992f --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-booking.output.ts @@ -0,0 +1,213 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsString, + IsEnum, + IsInt, + IsOptional, + IsObject, + ValidateNested, + IsArray, + IsUrl, + IsDateString, + IsEmail, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Metadata { + @IsUrl() + @ApiProperty() + videoCallUrl!: string; +} + +class Location { + @IsString() + @ApiProperty() + optionValue!: string; + + @IsString() + @ApiProperty() + value!: string; +} + +class Response { + @IsString() + @ApiProperty() + name!: string; + + @IsEmail() + @ApiProperty() + email!: string; + + @IsString() + @ApiProperty() + notes!: string; + + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: [String] }) + guests!: string[]; + + @ValidateNested() + @Type(() => Location) + @ApiProperty({ type: Location }) + location!: Location; +} + +class User { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @IsEmail() + @ApiProperty() + email!: string; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + username!: string | null; + + @IsString() + @ApiProperty() + timeZone!: string; +} + +class Attendee { + @IsString() + @ApiProperty() + name!: string; + + @IsEmail() + @ApiProperty() + email!: string; + + @IsString() + @ApiProperty() + timeZone!: string; +} + +class EventType { + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + eventName!: string | null; + + @IsString() + @ApiProperty() + slug!: string; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + timeZone!: string | null; +} + +class GetBookingData_2024_04_15 { + @IsString() + @ApiProperty() + title!: string; + + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty() + uid!: string; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + description!: string | null; + + @IsObject() + @ApiProperty({ type: Object }) + customInputs!: any; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + smsReminderNumber!: string | null; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + recurringEventId!: string | null; + + @IsDateString() + @ApiProperty() + startTime!: Date; + + @IsDateString() + @ApiProperty() + endTime!: Date; + + @IsUrl() + @ApiProperty({ type: String, nullable: true }) + location!: string | null; + + @IsString() + @ApiProperty() + status!: string; + + @Type(() => Metadata) + @ApiProperty({ type: Metadata }) + metadata!: Metadata | any; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + cancellationReason!: string | null; + + @ValidateNested() + @Type(() => Response) + @ApiProperty({ type: Response }) + responses!: Response | any; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + rejectionReason!: string | null; + + @IsString() + @IsEmail() + @ApiProperty({ type: String, nullable: true }) + userPrimaryEmail!: string | null; + + @ValidateNested() + @Type(() => User) + @ApiProperty({ type: User, nullable: true }) + user!: User | null; + + @ValidateNested() + @Type(() => Attendee) + @IsArray() + @ApiProperty({ type: [Attendee] }) + attendees!: Attendee[]; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + eventTypeId!: number | null; + + @ValidateNested() + @Type(() => EventType) + @ApiProperty({ type: EventType, nullable: true }) + eventType!: EventType | null; +} + +export class GetBookingOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: GetBookingData_2024_04_15, + }) + @ValidateNested() + @Type(() => GetBookingData_2024_04_15) + data!: GetBookingData_2024_04_15; +} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-bookings.output.ts b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-bookings.output.ts new file mode 100644 index 00000000000000..91270c24fb4f14 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-bookings.output.ts @@ -0,0 +1,283 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsString, + IsEnum, + IsInt, + IsBoolean, + IsUrl, + IsOptional, + IsObject, + ValidateNested, + IsArray, + IsDateString, + IsEmail, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +enum Status { + CANCELLED = "CANCELLED", + REJECTED = "REJECTED", + ACCEPTED = "ACCEPTED", + PENDING = "PENDING", + AWAITING_HOST = "AWAITING_HOST", +} + +class Attendee { + @IsInt() + @ApiProperty() + id!: number; + + @IsEmail() + @ApiProperty() + email!: string; + + @IsString() + @ApiProperty() + name!: string; + + @IsString() + @ApiProperty() + timeZone!: string; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + locale!: string | null; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + bookingId!: number | null; +} + +class EventType { + @IsOptional() + @IsString() + @ApiPropertyOptional() + slug?: string; + + @IsOptional() + @IsInt() + @ApiPropertyOptional() + id?: number; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + eventName?: string | null; + + @IsInt() + @ApiProperty() + price!: number; + + @IsOptional() + @ApiPropertyOptional() + recurringEvent?: any; + + @IsString() + @ApiProperty() + currency!: string; + + @IsObject() + @ApiProperty() + metadata!: any; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional({ type: Boolean, nullable: true }) + seatsShowAttendees?: boolean | null; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional({ type: Boolean, nullable: true }) + seatsShowAvailabilityCount?: boolean | null; + + @IsOptional() + @ApiPropertyOptional({ nullable: true }) + team?: any | null; +} + +class Reference { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty() + type!: string; + + @IsString() + @ApiProperty() + uid!: string; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + meetingId?: string | null; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + thirdPartyRecurringEventId?: string | null; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + meetingPassword!: string | null; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + meetingUrl?: string | null; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + bookingId!: number | null; + + @IsEmail() + @ApiProperty({ type: String, nullable: true }) + externalCalendarId!: string | null; + + @IsOptional() + @ApiPropertyOptional() + deleted?: any; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + credentialId!: number | null; +} + +class User { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @IsEmail() + @ApiProperty() + email!: string; +} + +class GetBookingsDataEntry { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty() + title!: string; + + @IsOptional() + @IsEmail() + @ApiProperty({ type: String, nullable: true }) + userPrimaryEmail?: string | null; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + description!: string | null; + + @IsObject() + @ApiProperty({ type: Object }) + customInputs!: object | any; + + @IsDateString() + @ApiProperty() + startTime!: string; + + @IsDateString() + @ApiProperty() + endTime!: string; + + @ValidateNested({ each: true }) + @Type(() => Attendee) + @IsArray() + @ApiProperty({ type: [Attendee] }) + attendees!: Attendee[]; + + @ApiProperty() + metadata!: any; + + @IsString() + @ApiProperty() + uid!: string; + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + recurringEventId!: string | null; + + @IsUrl() + @ApiProperty({ type: String, nullable: true }) + location!: string | null; + + @ValidateNested() + @Type(() => EventType) + @ApiProperty({ type: EventType }) + eventType!: EventType; + + @IsEnum(Status) + @ApiProperty({ enum: Status, type: String }) + status!: Status; + + @IsBoolean() + @ApiProperty({ type: Boolean }) + paid!: boolean; + + @IsArray() + @ApiProperty() + payment!: any[]; + + @ValidateNested() + @Type(() => Reference) + @IsArray() + @ApiProperty({ type: [Reference] }) + references!: Reference[]; + + @IsBoolean() + @ApiProperty({ type: Boolean }) + isRecorded!: boolean; + + @IsArray() + @ApiProperty() + seatsReferences!: any[]; + + @ValidateNested() + @Type(() => User) + @ApiProperty({ type: User }) + user!: User | null; + + @IsOptional() + @ApiPropertyOptional() + rescheduled?: any; +} + +class GetBookingsData_2024_04_15 { + @ValidateNested() + @Type(() => GetBookingsDataEntry) + @IsArray() + @ApiProperty({ type: [GetBookingsDataEntry] }) + bookings!: GetBookingsDataEntry[]; + + @IsArray() + @ApiProperty() + recurringInfo!: any[]; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + nextCursor!: number | null; +} + +export class GetBookingsOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS], type: String }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: GetBookingsData_2024_04_15, + }) + @ValidateNested() + @Type(() => GetBookingsData_2024_04_15) + data!: GetBookingsData_2024_04_15; +} diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/outputs/mark-no-show.output.ts b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/mark-no-show.output.ts new file mode 100644 index 00000000000000..653a0afeea1a09 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/mark-no-show.output.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Attendee { + @IsEmail() + @ApiProperty() + email!: string; + + @IsBoolean() + @ApiProperty() + noShow!: boolean; +} + +class HandleMarkNoShowData_2024_04_15 { + @IsString() + @ApiProperty() + message!: string; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + noShowHost?: boolean; + + @ValidateNested() + @Type(() => Attendee) + @IsArray() + @IsOptional() + @ApiPropertyOptional({ type: [Attendee] }) + attendees?: Attendee[]; +} + +export class MarkNoShowOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: HandleMarkNoShowData_2024_04_15, + }) + @ValidateNested() + @Type(() => HandleMarkNoShowData_2024_04_15) + data!: HandleMarkNoShowData_2024_04_15; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts new file mode 100644 index 00000000000000..bd24568f4f29e3 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -0,0 +1,37 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller"; +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service"; +import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { BillingModule } from "@/modules/billing/billing.module"; +import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module"; +import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule, BookingSeatModule], + providers: [ + TokensRepository, + OAuthFlowService, + OAuthClientRepository, + BookingsService_2024_08_13, + InputBookingsService_2024_08_13, + OutputBookingsService_2024_08_13, + BookingsRepository_2024_08_13, + EventTypesRepository_2024_06_14, + BookingSeatRepository, + ApiKeyRepository, + ], + controllers: [BookingsController_2024_08_13], + exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13], +}) +export class BookingsModule_2024_08_13 {} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts new file mode 100644 index 00000000000000..03b9d0410fc98f --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts @@ -0,0 +1,162 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class BookingsRepository_2024_08_13 { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getById(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { + id, + }, + }); + } + + async getByIdsWithAttendeesAndUserAndEvent(ids: number[]) { + return this.dbRead.prisma.booking.findMany({ + where: { + id: { + in: ids, + }, + }, + include: { + attendees: true, + user: true, + eventType: true, + }, + }); + } + + async getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids: number[]) { + return this.dbRead.prisma.booking.findMany({ + where: { + id: { + in: ids, + }, + }, + include: { + attendees: { + include: { + bookingSeat: true, + }, + }, + user: true, + eventType: true, + }, + }); + } + + async getByUid(bookingUid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + }); + } + + async getByUidWithUser(bookingUid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + include: { + user: true, + }, + }); + } + + async getByIdWithAttendeesAndUserAndEvent(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { + id, + }, + include: { + attendees: true, + user: true, + eventType: true, + }, + }); + } + + async getByIdWithAttendeesWithBookingSeatAndUserAndEvent(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { + id, + }, + include: { + attendees: { + include: { + bookingSeat: true, + }, + }, + user: true, + eventType: true, + }, + }); + } + + async getByUidWithAttendeesAndUserAndEvent(uid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid, + }, + include: { + attendees: true, + user: true, + eventType: true, + }, + }); + } + + async getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid, + }, + include: { + attendees: { + include: { + bookingSeat: true, + }, + }, + user: true, + eventType: true, + }, + }); + } + + async getRecurringByUid(uid: string) { + return this.dbRead.prisma.booking.findMany({ + where: { + recurringEventId: uid, + }, + }); + } + + async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) { + return this.dbRead.prisma.booking.findMany({ + where: { + recurringEventId: uid, + }, + include: { + attendees: true, + user: true, + eventType: true, + }, + }); + } + + async getByFromReschedule(fromReschedule: string) { + return this.dbRead.prisma.booking.findFirst({ + where: { + fromReschedule, + }, + include: { + attendees: true, + user: true, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts new file mode 100644 index 00000000000000..91d24e8d26f19a --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -0,0 +1,338 @@ +import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output"; +import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Post, + Logger, + Body, + UseGuards, + Req, + Get, + Param, + Query, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { + ApiOperation, + ApiTags as DocsTags, + ApiHeader, + getSchemaPath, + ApiBody, + ApiExtraModels, +} from "@nestjs/swagger"; +import { User } from "@prisma/client"; +import { Request } from "express"; + +import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + CancelBookingInput, + CancelBookingInputPipe, + GetBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + RescheduleBookingInput, + RescheduleBookingInputPipe, +} from "@calcom/platform-types"; +import { + CreateBookingInputPipe, + CreateBookingInput, + GetBookingsInput_2024_08_13, + ReassignToUserBookingInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + DeclineBookingInput_2024_08_13, +} from "@calcom/platform-types"; + +@Controller({ + path: "/v2/bookings", + version: VERSION_2024_08_13_VALUE, +}) +@UseGuards(PermissionsGuard) +@DocsTags("Bookings") +@ApiHeader({ + name: "cal-api-version", + description: `Must be set to \`2024-08-13\``, + example: VERSION_2024_08_13, + required: true, +}) +export class BookingsController_2024_08_13 { + private readonly logger = new Logger("BookingsController_2024_08_13"); + + constructor(private readonly bookingsService: BookingsService_2024_08_13) {} + + @Post("/") + @ApiOperation({ + summary: "Create a booking", + description: ` + POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except: + If eventTypeId in the request body is id of a regular event, then regular booking is created. + + If it is an id of a recurring event type, then recurring booking is created. + + Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible. + + For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body. + + The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone. + `, + }) + @ApiBody({ + schema: { + oneOf: [ + { $ref: getSchemaPath(CreateBookingInput_2024_08_13) }, + { $ref: getSchemaPath(CreateInstantBookingInput_2024_08_13) }, + { $ref: getSchemaPath(CreateRecurringBookingInput_2024_08_13) }, + ], + }, + description: + "Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13", + }) + @ApiExtraModels( + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13 + ) + async createBooking( + @Body(new CreateBookingInputPipe()) + body: CreateBookingInput, + @Req() request: Request + ): Promise { + const booking = await this.bookingsService.createBooking(request, body); + + if (Array.isArray(booking)) { + await this.bookingsService.billBookings(booking); + } else { + await this.bookingsService.billBooking(booking); + } + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Get("/:bookingUid") + @UseGuards(BookingUidGuard) + @ApiOperation({ + summary: "Get a booking", + description: `\`:bookingUid\` can be + + 1. uid of a normal booking + + 2. uid of one of the recurring booking recurrences + + 3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`, + }) + async getBooking(@Param("bookingUid") bookingUid: string): Promise { + const booking = await this.bookingsService.getBooking(bookingUid); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Get("/") + @UseGuards(ApiAuthGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @Permissions([BOOKING_READ]) + @ApiOperation({ summary: "Get all bookings" }) + async getBookings( + @Query() queryParams: GetBookingsInput_2024_08_13, + @GetUser() user: User + ): Promise { + const bookings = await this.bookingsService.getBookings(queryParams, user); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } + + @Post("/:bookingUid/reschedule") + @UseGuards(BookingUidGuard) + @ApiOperation({ + summary: "Reschedule a booking", + description: + "Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.", + }) + async rescheduleBooking( + @Param("bookingUid") bookingUid: string, + @Body(new RescheduleBookingInputPipe()) + body: RescheduleBookingInput, + @Req() request: Request + ): Promise { + const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body); + await this.bookingsService.billRescheduledBooking(newBooking, bookingUid); + + return { + status: SUCCESS_STATUS, + data: newBooking, + }; + } + + @Post("/:bookingUid/cancel") + @UseGuards(BookingUidGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Cancel a booking", + description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences. + For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call + like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`, + }) + async cancelBooking( + @Req() request: Request, + @Param("bookingUid") bookingUid: string, + @Body(new CancelBookingInputPipe()) + body: CancelBookingInput + ): Promise { + const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body); + + return { + status: SUCCESS_STATUS, + data: cancelledBooking, + }; + } + + @Post("/:bookingUid/mark-absent") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Mark a booking absence" }) + async markNoShow( + @Param("bookingUid") bookingUid: string, + @Body() body: MarkAbsentBookingInput_2024_08_13, + @GetUser("id") ownerId: number + ): Promise { + const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Post("/:bookingUid/reassign") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Automatically reassign booking to a new host" }) + async reassignBooking( + @Param("bookingUid") bookingUid: string, + @GetUser() user: UserWithProfile + ): Promise { + const booking = await this.bookingsService.reassignBooking(bookingUid, user); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Post("/:bookingUid/reassign/:userId") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Reassign a booking to a specific user" }) + async reassignBookingToUser( + @Param("bookingUid") bookingUid: string, + @Param("userId") userId: number, + @GetUser("id") reassignedById: number, + @Body() body: ReassignToUserBookingInput_2024_08_13 + ): Promise { + const booking = await this.bookingsService.reassignBookingToUser( + bookingUid, + userId, + reassignedById, + body + ); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Post("/:bookingUid/confirm") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Confirm booking that requires a confirmation" }) + async confirmBooking( + @Param("bookingUid") bookingUid: string, + @GetUser() user: UserWithProfile + ): Promise { + const booking = await this.bookingsService.confirmBooking(bookingUid, user); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Post("/:bookingUid/decline") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Decline booking that requires a confirmation" }) + async declineBooking( + @Param("bookingUid") bookingUid: string, + @Body() body: DeclineBookingInput_2024_08_13, + @GetUser() user: UserWithProfile + ): Promise { + const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/api-key-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/api-key-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..61fb2097491542 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/api-key-bookings.e2e-spec.ts @@ -0,0 +1,300 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + AttendeeScheduledEmail, + OrganizerScheduledEmail, + AttendeeRescheduledEmail, + OrganizerRescheduledEmail, + AttendeeCancelledEmail, + OrganizerCancelledEmail, +} from "@calcom/platform-libraries"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, + CancelBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; +}); + +jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; +}); + +jest + .spyOn(AttendeeRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); + +describe("Bookings Endpoints 2024-08-13", () => { + describe("With api key", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let apiKeyString: string; + + const userEmail = `api-key-bookings-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + let eventTypeId: number; + const eventTypeSlug = `api-key-bookings-2024-08-13-event-type-${randomString()}`; + + let createdBooking: BookingOutput_2024_08_13; + let rescheduledBooking: BookingOutput_2024_08_13; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ + name: `api-key-bookings-organization-${randomString()}`, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = keyString; + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `api-key-bookings-e2e-api-key-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { + title: `api-key-bookings-e2e-api-key-bookings-2024-08-13-event-type-${randomString()}`, + slug: eventTypeSlug, + length: 60, + }, + user.id + ); + eventTypeId = event.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a booking with api key", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Key", + email: "mr_key@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(eventTypeId); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + createdBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should reschedule a booking with api key", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdBooking.uid}/reschedule`) + .send(body) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.reschedulingReason).toEqual(body.reschedulingReason); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString()); + expect(data.rescheduledFromUid).toEqual(createdBooking.uid); + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(createdBooking.status); + expect(data.duration).toEqual(createdBooking.duration); + expect(data.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(data.attendees[0]).toEqual(createdBooking.attendees[0]); + expect(data.location).toEqual(createdBooking.location); + expect(data.absentHost).toEqual(createdBooking.absentHost); + expect(data.metadata).toEqual(createdBooking.metadata); + expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + rescheduledBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should cancel booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid); + expect(booking).toBeDefined(); + expect(booking?.status).toEqual("ACCEPTED"); + + return request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBooking.uid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("cancelled"); + expect(data.cancellationReason).toEqual(body.cancellationReason); + expect(data.start).toEqual(rescheduledBooking.start); + expect(data.end).toEqual(rescheduledBooking.end); + expect(data.duration).toEqual(rescheduledBooking.duration); + expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId); + expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]); + expect(data.location).toEqual(rescheduledBooking.location); + expect(data.absentHost).toEqual(rescheduledBooking.absentHost); + + const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid); + expect(cancelledBooking).toBeDefined(); + expect(cancelledBooking?.status).toEqual("CANCELLED"); + expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts new file mode 100644 index 00000000000000..8b6af23ac2920e --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts @@ -0,0 +1,540 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + CreateBookingInput_2024_08_13, + GetBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { BookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { Booking, PlatformOAuthClient, Team, User, EventType } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Booking fields", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `booking-fields-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + let bookingWithSplitName: Booking; + const splitName = { + firstName: "Oldie", + lastName: "Goldie", + }; + + let seatedBookingWithSplitName: Booking; + + let eventTypeId: number; + let seatedEvent: EventType; + let eventTypeWithBookingFields: EventType; + const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`; + const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`; + const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `booking-fields-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `booking-fields-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { + title: `booking-fields-2024-08-13-event-type-${randomString()}`, + slug: eventTypeSlug, + length: 60, + }, + user.id + ); + + eventTypeId = event.id; + + seatedEvent = await eventTypesRepositoryFixture.create( + { + title: `booking-fields-2024-08-13-event-type-${randomString()}`, + slug: seatedEventTypeSlug, + length: 60, + seatsPerTimeSlot: 3, + }, + user.id + ); + + eventTypeWithBookingFields = await eventTypesRepositoryFixture.create( + { + title: `booking-fields-2024-08-13-event-type-${randomString()}`, + slug: eventTypeWithBookingFieldsSlug, + length: 60, + bookingFields: [ + { + name: "name", + type: "name", + label: "", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + variant: "fullName", + editable: "system", + required: true, + placeholder: "", + defaultLabel: "your_name", + variantsConfig: { + variants: { + fullName: { + fields: [ + { + name: "fullName", + type: "text", + label: "your_name", + required: true, + placeholder: "", + }, + ], + }, + firstAndLastName: { + fields: [ + { + name: "firstName", + type: "text", + label: "name", + required: true, + placeholder: "lauris", + }, + { + name: "lastName", + type: "text", + label: "surname", + required: true, + placeholder: "skraucis", + }, + ], + }, + }, + }, + disableOnPrefill: false, + }, + { + name: "email", + type: "email", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system", + required: true, + defaultLabel: "email_address", + }, + { + name: "location", + type: "radioInput", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system", + required: false, + defaultLabel: "location", + getOptionsAt: "locations", + optionsInputs: { + phone: { + type: "phone", + required: true, + placeholder: "", + }, + somewhereElse: { + type: "text", + required: true, + placeholder: "", + }, + attendeeInPerson: { + type: "address", + required: true, + placeholder: "", + }, + }, + hideWhenJustOneOption: true, + }, + { + name: "title", + type: "text", + hidden: true, + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: true, + defaultLabel: "what_is_this_meeting_about", + defaultPlaceholder: "", + }, + { + name: "notes", + type: "textarea", + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_notes", + defaultPlaceholder: "share_additional_notes", + }, + { + name: "guests", + type: "multiemail", + hidden: false, + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_guests", + defaultPlaceholder: "email", + }, + { + name: "rescheduleReason", + type: "textarea", + views: [ + { + id: "reschedule", + label: "Reschedule View", + }, + ], + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "reason_for_reschedule", + defaultPlaceholder: "reschedule_placeholder", + }, + { + name: "favorite-movie", + type: "text", + label: "favorite movie", + sources: [ + { + id: "user", + type: "user", + label: "User", + fieldRequired: true, + }, + ], + editable: "user", + required: true, + placeholder: "matrix", + disableOnPrefill: false, + }, + ], + }, + user.id + ); + + bookingWithSplitName = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: user.id, + }, + }, + startTime: new Date(Date.UTC(2020, 0, 8, 12, 0, 0)), + endTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)), + title: "peer coding lets goo", + uid: "booking", + eventType: { + connect: { + id: eventTypeId, + }, + }, + location: "integrations:daily", + customInputs: {}, + metadata: {}, + responses: { + name: splitName, + email: "oldie@gmail.com", + }, + attendees: { + create: { + email: "oldie@gmail.com", + name: "Oldie Goldie", + locale: "lv", + timeZone: "Europe/Rome", + }, + }, + }); + + seatedBookingWithSplitName = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: user.id, + }, + }, + startTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)), + endTime: new Date(Date.UTC(2020, 0, 8, 15, 0, 0)), + title: "peer coding lets goo", + uid: "seated-booking", + eventType: { + connect: { + id: seatedEvent.id, + }, + }, + location: "integrations:daily", + customInputs: {}, + metadata: {}, + responses: { + name: splitName, + email: "oldie@gmail.com", + }, + attendees: { + create: { + email: "oldie@gmail.com", + name: "Oldie Goldie", + locale: "lv", + timeZone: "Europe/Rome", + bookingSeat: { + create: { + referenceUid: "unique-seat-uid", + data: { + responses: { + email: "oldie@gmail.com", + name: { + firstName: "Oldie", + lastName: "Goldie", + }, + }, + }, + metadata: {}, + booking: { + connect: { + uid: "seated-booking", + }, + }, + }, + }, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("get individual booking", () => { + it("should should get a seated booking with split name responses", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${bookingWithSplitName.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.attendees[0].name).toEqual(`${splitName.firstName} ${splitName.lastName}`); + expect(data.bookingFieldsResponses.name).toEqual(splitName); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should should get a seated booking with split name responses", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${seatedBookingWithSplitName.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + // eslint-disable-next-line + // @ts-ignore + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + console.log("asap data", JSON.stringify(data, null, 2)); + expect(data.attendees[0].name).toEqual(`${splitName.firstName} ${splitName.lastName}`); + expect(data.attendees[0].bookingFieldsResponses.name).toEqual(splitName); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + describe("make booking", () => { + it("should not be able to book an event type with custom required booking fields if they are missing in bookingFieldsResponses", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: eventTypeWithBookingFields.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + return request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should be able to book an event type with custom required booking fields", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: eventTypeWithBookingFields.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + "favorite-movie": "lord of the rings", + }, + }; + return request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.bookingFieldsResponses["favorite-movie"]).toEqual("lord of the rings"); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/confirm-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/confirm-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..541f4a47c04e16 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/confirm-bookings.e2e-spec.ts @@ -0,0 +1,256 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Bookings confirmation", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `confirm-bookings-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + let eventTypeId: number; + const eventTypeSlug = `confirm-bookings-2024-08-13-event-type-${randomString()}`; + + let createdBooking1: Booking; + let createdBooking2: Booking; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `confirm-bookings-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `confirm-bookings-2024-08-13-${randomString()}-schedule`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { + title: `confirm-bookings-2024-08-13-${randomString()}-event-type`, + slug: `confirm-bookings-2024-08-13-${randomString()}-event-type-slug`, + length: 60, + }, + user.id + ); + eventTypeId = event.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should confirm a booking", async () => { + const status = "PENDING"; + createdBooking1 = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: user.id, + }, + }, + startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)), + endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)), + title: "peer coding", + uid: "peer-coding-one", + eventType: { + connect: { + id: eventTypeId, + }, + }, + location: "via 10, rome, italy", + customInputs: {}, + metadata: {}, + responses: { + name: "Bob", + email: "bob@gmail.com", + }, + attendees: { + create: { + email: "bob@gmail.com", + name: "Bob", + locale: "it", + timeZone: "Europe/Rome", + }, + }, + status, + }); + expect(createdBooking1.status).toEqual("PENDING"); + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdBooking1.uid}/confirm`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.uid).toBeDefined(); + expect(data.status).toEqual("accepted"); + + const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid); + expect(dbBooking?.status).toEqual("ACCEPTED"); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should decline a booking", async () => { + const status = "PENDING"; + createdBooking2 = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: user.id, + }, + }, + startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)), + endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)), + title: "peer coding", + uid: "peer-coding-two", + eventType: { + connect: { + id: eventTypeId, + }, + }, + location: "via 10, rome, italy", + customInputs: {}, + metadata: {}, + responses: { + name: "Bob", + email: "bob@gmail.com", + }, + attendees: { + create: { + email: "bob@gmail.com", + name: "Bob", + locale: "it", + timeZone: "Europe/Rome", + }, + }, + status, + }); + expect(createdBooking2.status).toEqual("PENDING"); + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdBooking2.uid}/decline`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.uid).toBeDefined(); + expect(data.status).toEqual("rejected"); + + const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid); + expect(dbBooking?.status).toEqual("REJECTED"); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts new file mode 100644 index 00000000000000..2d9e7b3f7b6d50 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/confirm-emails.e2e-spec.ts @@ -0,0 +1,487 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + OrganizerScheduledEmail, + AttendeeScheduledEmail, + OrganizerRescheduledEmail, + AttendeeRescheduledEmail, + OrganizerCancelledEmail, + AttendeeCancelledEmail, + AttendeeRequestEmail, + OrganizerRequestEmail, + AttendeeDeclinedEmail, +} from "@calcom/platform-libraries"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, + GetBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +jest + .spyOn(AttendeeScheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerScheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeRequestEmail.prototype, "getHtmlRequestEmail") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerRequestEmail.prototype, "getHtmlRequestEmail") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeDeclinedEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); + +type EmailSetup = { + user: User; + eventTypeId: number; + createdBookingUid: string; + rescheduledBookingUid: string; +}; + +describe("Bookings Endpoints 2024-08-13 confirm emails", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + + let emailsEnabledSetup: EmailSetup; + let emailsDisabledSetup: EmailSetup; + + const authEmail = `confirm-emails-2024-08-13-admin-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + authEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `confirm-emails-2024-08-13-organization-${randomString()}`, + }); + + await setupEnabledEmails(); + await setupDisabledEmails(); + + await userRepositoryFixture.create({ + email: authEmail, + organization: { + connect: { + id: organization.id, + }, + }, + role: "ADMIN", + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function setupEnabledEmails() { + const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true); + + const user = await userRepositoryFixture.create({ + email: `confirm-emails-2024-08-13-user-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsEnabled.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `confirm-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: "peer coding", + slug: `confirm-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + requiresConfirmation: true, + }, + user.id + ); + + emailsEnabledSetup = { + user, + eventTypeId: event.id, + createdBookingUid: "", + rescheduledBookingUid: "", + }; + } + + async function setupDisabledEmails() { + const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false); + + const user = await userRepositoryFixture.create({ + email: `confirm-emails-2024-08-13-user-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsDisabled.id, + }, + }, + }); + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `confirm-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { + title: "peer coding", + slug: `confirm-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + requiresConfirmation: true, + }, + user.id + ); + + emailsDisabledSetup = { + user, + eventTypeId: event.id, + createdBookingUid: "", + rescheduledBookingUid: "", + }; + } + + async function createOAuthClient(organizationId: number, emailsEnabled: boolean) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + areEmailsEnabled: emailsEnabled, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("OAuth client managed user bookings - emails disabled", () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + + it("should not send an email when creating a booking that requires confirmation", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled(); + emailsDisabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should not send an email when confirming a booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/confirm`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid; + }); + }); + + it("should not send an email when creating a booking that requires confirmation", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled(); + emailsDisabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should not send an email when declining a booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/decline`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeDeclinedEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid; + }); + }); + }); + + describe("OAuth client managed user bookings - emails enabled", () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + + it("should send an email when creating a booking that requires confirmation", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + emailsEnabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should send an email when confirming a booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; + }); + }); + + it("should send an email when creating a booking that requires confirmation", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(responseBody.data.status).toEqual("pending"); + expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled(); + emailsEnabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should send an email when declining a booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; + }); + }); + }); + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.user.email); + await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.user.email); + await userRepositoryFixture.deleteByEmail(authEmail); + await bookingsRepositoryFixture.deleteAllBookings( + emailsEnabledSetup.user.id, + emailsEnabledSetup.user.email + ); + await bookingsRepositoryFixture.deleteAllBookings( + emailsDisabledSetup.user.id, + emailsDisabledSetup.user.email + ); + await app.close(); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts new file mode 100644 index 00000000000000..d386680d3a7c49 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts @@ -0,0 +1,989 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { createParamDecorator, INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + OrganizerScheduledEmail, + AttendeeScheduledEmail, + OrganizerRescheduledEmail, + AttendeeRescheduledEmail, + OrganizerCancelledEmail, + AttendeeCancelledEmail, + OrganizerReassignedEmail, + AttendeeUpdatedEmail, +} from "@calcom/platform-libraries"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +jest + .spyOn(AttendeeScheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerScheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerReassignedEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeUpdatedEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); + +type EmailSetup = { + team: Team; + member1: User; + member2: User; + collectiveEventType: { + id: number; + createdBookingUid: string; + rescheduledBookingUid: string; + }; + roundRobinEventType: { + id: number; + createdBookingUid: string; + rescheduledBookingUid: string; + currentHostId: number; + }; +}; + +describe("Bookings Endpoints 2024-08-13 team emails", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + + let emailsEnabledSetup: EmailSetup; + let emailsDisabledSetup: EmailSetup; + + const authEmail = "team-emails-2024-08-13-user-admin@example.com"; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + authEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ name: `team-emails-organization-${randomString()}` }); + + await setupEnabledEmails(); + await setupDisabledEmails(); + + await userRepositoryFixture.create({ + email: authEmail, + organization: { + connect: { + id: organization.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function setupEnabledEmails() { + const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true); + + const team = await teamRepositoryFixture.create({ + name: `team-emails-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClientEmailsEnabled.id, + }, + }, + }); + + const member1 = await userRepositoryFixture.create({ + email: `team-emails-2024-08-13-member1-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsEnabled.id, + }, + }, + }); + + const member2 = await userRepositoryFixture.create({ + email: `team-emails-2024-08-13-member2-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsEnabled.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `team-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(member1.id, userSchedule); + await schedulesService.createUserSchedule(member2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${member1.id}`, + username: member1.email, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: member1.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${member2.id}`, + username: member2.email, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: member2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: member1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: member2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team.id }, + }, + title: `team-emails-2024-08-13-event-type-${randomString()}`, + slug: `team-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: member1.id, + }, + }, + eventType: { + connect: { + id: collectiveEvent.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: member2.id, + }, + }, + eventType: { + connect: { + id: collectiveEvent.id, + }, + }, + }); + + const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team.id }, + }, + title: `team-emails-2024-08-13-event-type-${randomString()}`, + slug: `team-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: false, + bookingFields: [], + locations: [], + }); + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: member1.id, + }, + }, + eventType: { + connect: { + id: roundRobinEvent.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: member2.id, + }, + }, + eventType: { + connect: { + id: roundRobinEvent.id, + }, + }, + }); + + emailsEnabledSetup = { + team, + member1: member1, + member2: member2, + collectiveEventType: { + id: collectiveEvent.id, + createdBookingUid: "", + rescheduledBookingUid: "", + }, + roundRobinEventType: { + id: roundRobinEvent.id, + createdBookingUid: "", + rescheduledBookingUid: "", + currentHostId: 0, + }, + }; + } + + async function setupDisabledEmails() { + const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false); + + const team = await teamRepositoryFixture.create({ + name: `team-emails-2024-08-13-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClientEmailsDisabled.id, + }, + }, + }); + + const member1 = await userRepositoryFixture.create({ + email: `team-emails-2024-08-13-member1-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsDisabled.id, + }, + }, + }); + + const member2 = await userRepositoryFixture.create({ + email: `team-emails-2024-08-13-member2-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsDisabled.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `team-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(member1.id, userSchedule); + await schedulesService.createUserSchedule(member2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${member1.id}`, + username: member1.email, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: member1.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${member2.id}`, + username: member2.email, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: member2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: member1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: member2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team.id }, + }, + title: `team-emails-2024-08-13-event-type-${randomString()}`, + slug: `team-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: member1.id, + }, + }, + eventType: { + connect: { + id: collectiveEvent.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: member2.id, + }, + }, + eventType: { + connect: { + id: collectiveEvent.id, + }, + }, + }); + + const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team.id }, + }, + title: `team-emails-2024-08-13-event-type-${randomString()}`, + slug: `team-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: false, + bookingFields: [], + locations: [], + }); + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: member1.id, + }, + }, + eventType: { + connect: { + id: roundRobinEvent.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: member2.id, + }, + }, + eventType: { + connect: { + id: roundRobinEvent.id, + }, + }, + }); + + emailsDisabledSetup = { + team, + member1: member1, + member2: member2, + collectiveEventType: { + id: collectiveEvent.id, + createdBookingUid: "", + rescheduledBookingUid: "", + }, + roundRobinEventType: { + id: roundRobinEvent.id, + createdBookingUid: "", + rescheduledBookingUid: "", + currentHostId: 0, + }, + }; + } + + async function createOAuthClient(organizationId: number, emailsEnabled: boolean) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + areEmailsEnabled: emailsEnabled, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + describe("OAuth client team bookings - emails disabled", () => { + describe("book", () => { + it("should not send an email when booking collective event", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.collectiveEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should not send an email when booking round robin event", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.roundRobinEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid; + emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + describe("reschedule", () => { + it("should not send an email when rescheduling collective booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid; + }); + }); + + it("should not send an email when rescheduling round robin booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid; + emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; + }); + }); + }); + + describe("reassign", () => { + it("should not send an email when manually reassigning round robin booking", async () => { + const reassignToId = + emailsDisabledSetup.roundRobinEventType.currentHostId === emailsDisabledSetup.member1.id + ? emailsDisabledSetup.member2.id + : emailsDisabledSetup.member1.id; + + return request(app.getHttpServer()) + .post( + `/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId; + }); + }); + + it("should not send an email when automatically reassigning round robin booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); + }); + }); + }); + + describe("cancel", () => { + it("should not send an email when cancelling a collective booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + }); + }); + }); + + it("should not send an email when cancelling a round robin booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + }); + }); + }); + + describe("OAuth client team bookings - emails enabled", () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + + describe("book", () => { + it("should send an email when booking collective event", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.collectiveEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + emailsEnabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should send an email when booking round robin event", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.roundRobinEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + emailsEnabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid; + emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + describe("reschedule", () => { + it("should send an email when rescheduling collective booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + emailsEnabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid; + }); + }); + + it("should send an email when rescheduling round robin booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid; + emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; + }); + }); + }); + + describe("reassign", () => { + it("should send an email when manually reassigning round robin booking", async () => { + const reassignToId = + emailsEnabledSetup.roundRobinEventType.currentHostId === emailsEnabledSetup.member1.id + ? emailsEnabledSetup.member2.id + : emailsEnabledSetup.member1.id; + + return request(app.getHttpServer()) + .post( + `/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled(); + emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId; + }); + }); + + it("should send an email when automatically reassigning round robin booking", async () => { + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled(); + expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); + }); + + describe("cancel", () => { + it("should send an email when cancelling a collective booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); + + it("should send an email when cancelling round robin booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); + }); + }); + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(authEmail); + await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email); + await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member2.email); + await bookingsRepositoryFixture.deleteAllBookings( + emailsEnabledSetup.member1.id, + emailsEnabledSetup.member1.email + ); + await bookingsRepositoryFixture.deleteAllBookings( + emailsDisabledSetup.member1.id, + emailsDisabledSetup.member2.email + ); + await app.close(); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/user-emails.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/user-emails.e2e-spec.ts new file mode 100644 index 00000000000000..b45fe865d2a88e --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/user-emails.e2e-spec.ts @@ -0,0 +1,493 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + OrganizerScheduledEmail, + AttendeeScheduledEmail, + OrganizerRescheduledEmail, + AttendeeRescheduledEmail, + OrganizerCancelledEmail, + AttendeeCancelledEmail, +} from "@calcom/platform-libraries"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +jest + .spyOn(AttendeeScheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerScheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerRescheduledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(AttendeeCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); +jest + .spyOn(OrganizerCancelledEmail.prototype, "getHtml") + .mockImplementation(() => Promise.resolve("

email

")); + +type EmailSetup = { + user: User; + eventTypeId: number; + recurringEventTypeId: number; + createdBookingUid: string; + rescheduledBookingUid: string; +}; + +describe("Bookings Endpoints 2024-08-13 user emails", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + + let emailsEnabledSetup: EmailSetup; + let emailsDisabledSetup: EmailSetup; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .overrideGuard(ApiAuthGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `user-emails-2024-08-13-organization-${randomString()}`, + }); + + await setupEnabledEmails(); + await setupDisabledEmails(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function setupEnabledEmails() { + const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true); + + const user = await userRepositoryFixture.create({ + email: `user-emails-2024-08-13-user-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsEnabled.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `user-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `user-emails-2024-08-13-event-type-${randomString()}`, + slug: `user-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + }, + user.id + ); + + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `user-emails-2024-08-13-recurring-event-type-${randomString()}`, + slug: `user-emails-2024-08-13-recurring-event-type-${randomString()}`, + length: 60, + recurringEvent: { freq: 2, count: 3, interval: 1 }, + }, + user.id + ); + + emailsEnabledSetup = { + user, + eventTypeId: event.id, + recurringEventTypeId: recurringEvent.id, + createdBookingUid: "", + rescheduledBookingUid: "", + }; + } + + async function setupDisabledEmails() { + const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false); + + const user = await userRepositoryFixture.create({ + email: `user-emails-2024-08-13-user-${randomString()}@api.com`, + platformOAuthClients: { + connect: { + id: oAuthClientEmailsDisabled.id, + }, + }, + }); + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `user-emails-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { + title: `user-emails-2024-08-13-event-type-${randomString()}`, + slug: `user-emails-2024-08-13-event-type-${randomString()}`, + length: 60, + }, + user.id + ); + + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `user-emails-2024-08-13-recurring-event-type-${randomString()}`, + slug: `user-emails-2024-08-13-recurring-event-type-${randomString()}`, + length: 60, + recurringEvent: { freq: 2, count: 3, interval: 1 }, + }, + user.id + ); + + emailsDisabledSetup = { + user, + eventTypeId: event.id, + recurringEventTypeId: recurringEvent.id, + createdBookingUid: "", + rescheduledBookingUid: "", + }; + } + + async function createOAuthClient(organizationId: number, emailsEnabled: boolean) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + areEmailsEnabled: emailsEnabled, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + describe("OAuth client managed user bookings - emails disabled", () => { + it("should not send an email when creating a booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should not send an email when creating a recurring booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.recurringEventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsRecurringBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + } else { + throw new Error("Invalid response data - expected booking array but received single booking"); + } + }); + }); + + it("should not send an email when rescheduling a booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid; + }); + }); + + it("should not send an email when cancelling a booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsDisabledSetup.rescheduledBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + }); + }); + }); + + describe("OAuth client managed user bookings - emails enabled", () => { + it("should send an email when creating a booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + emailsEnabledSetup.createdBookingUid = responseBody.data.uid; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should send an email when creating a recurring booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.recurringEventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + if (responseDataIsRecurringBooking(responseBody.data)) { + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + } else { + throw new Error("Invalid response data - expected booking array but received single booking"); + } + }); + }); + + it("should send an email when rescheduling a booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + + emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid; + }); + }); + + it("should send an email when cancelling a booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${emailsEnabledSetup.rescheduledBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + }); + }); + }); + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.user.email); + await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.user.email); + await bookingsRepositoryFixture.deleteAllBookings( + emailsEnabledSetup.user.id, + emailsEnabledSetup.user.email + ); + await bookingsRepositoryFixture.deleteAllBookings( + emailsDisabledSetup.user.id, + emailsDisabledSetup.user.email + ); + await app.close(); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..e710a5bd9cfa7f --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reassign-bookings.e2e-spec.ts @@ -0,0 +1,338 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { Booking, User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Reassign bookings", () => { + let app: INestApplication; + let organization: Team; + let team: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`; + const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`; + let teamUser1: User; + let teamUser2: User; + + let teamRoundRobinEventTypeId: number; + + let roundRobinBooking: Booking; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamUserEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await organizationsRepositoryFixture.create({ + name: `reassign-bookings-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + team = await teamRepositoryFixture.create({ + name: `reassign-bookings-2024-08-13-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser1 = await userRepositoryFixture.create({ + email: teamUserEmail, + locale: "it", + name: `reassign-bookings-2024-08-13-user1-${randomString()}`, + }); + + teamUser2 = await userRepositoryFixture.create({ + email: teamUserEmail2, + locale: "it", + name: `reassign-bookings-2024-08-13-user2-${randomString()}`, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `reassign-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(teamUser1.id, userSchedule); + await schedulesService.createUserSchedule(teamUser2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser1.id}`, + username: teamUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser1.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser2.id}`, + username: teamUserEmail2, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + team: { connect: { id: team.id } }, + user: { connect: { id: teamUser1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + team: { connect: { id: team.id } }, + user: { connect: { id: teamUser2.id } }, + accepted: true, + }); + + const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team.id }, + }, + users: { + connect: [{ id: teamUser1.id }, { id: teamUser2.id }], + }, + title: `reassign-bookings-2024-08-13-event-type-${randomString()}`, + slug: `reassign-bookings-2024-08-13-event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: false, + bookingFields: [], + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + }); + + teamRoundRobinEventTypeId = team1EventType.id; + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: teamUser1.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: teamUser2.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + roundRobinBooking = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: teamUser1.id, + }, + }, + startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)), + endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)), + title: "round robin coding lets goo", + uid: "round-robin-coding", + eventType: { + connect: { + id: teamRoundRobinEventTypeId, + }, + }, + location: "via 10, rome, italy", + customInputs: {}, + metadata: {}, + responses: { + name: "Bob", + email: "bob@gmail.com", + }, + attendees: { + create: { + email: "bob@gmail.com", + name: "Bob", + locale: "it", + timeZone: "Europe/Rome", + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should reassign round robin booking", async () => { + const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid); + expect(booking?.userId).toEqual(teamUser1.id); + + return request(app.getHttpServer()) + .post(`/v2/bookings/${roundRobinBooking.uid}/reassign`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: ReassignBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data; + expect(data.bookingUid).toEqual(roundRobinBooking.uid); + expect(data.reassignedTo).toEqual({ + id: teamUser2.id, + name: teamUser2.name, + email: teamUser2.email, + }); + + const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid); + expect(reassigned?.userId).toEqual(teamUser2.id); + }); + }); + + it("should reassign round robin booking to a specific user", async () => { + const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid); + expect(booking?.userId).toEqual(teamUser2.id); + + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: teamRoundRobinEventTypeId, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Madrid", + language: "es", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: ReassignBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data; + expect(data.bookingUid).toEqual(roundRobinBooking.uid); + expect(data.reassignedTo).toEqual({ + id: teamUser1.id, + name: teamUser1.name, + email: teamUser1.email, + }); + + const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid); + expect(reassigned?.userId).toEqual(teamUser1.id); + }); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(teamUser1.email); + await userRepositoryFixture.deleteByEmail(teamUserEmail2); + await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email); + await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/recurring-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/recurring-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..cd17ec46bebf43 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/recurring-bookings.e2e-spec.ts @@ -0,0 +1,722 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries"; +import { + CreateRecurringBookingInput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Creating recurring bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `recurring-bookings-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + const maxRecurrenceCount = 3; + let recurringEventTypeId: number; + const recurringEventSlug = `recurring-bookings-2024-08-13-event-type-${randomString()}`; + let createdRecurringBooking: RecurringBookingOutput_2024_08_13[]; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `recurring-bookings-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `recurring-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `recurring-bookings-2024-08-13-event-type-${randomString()}`, + slug: recurringEventSlug, + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => { + const recurrenceCount = 1000; + + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount - 1; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount - 1); + + const firstBooking = data[0]; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + + const firstBooking = data[0]; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.meetingUrl).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toBeDefined(); + expect(thirdBooking.uid).toBeDefined(); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual("accepted"); + expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString()); + expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString()); + expect(thirdBooking.duration).toEqual(60); + expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(thirdBooking.recurringBookingUid).toBeDefined(); + expect(thirdBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(thirdBooking.location).toEqual(body.location); + expect(thirdBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + afterEach(async () => { + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + describe("Recurring bookings cancel all subsequent bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `recurring-bookings-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + const maxRecurrenceCount = 4; + let recurringEventTypeId: number; + + let recurringBooking: RecurringBookingOutput_2024_08_13[]; + + beforeAll(async () => { + jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `recurring-bookings-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `recurring-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const recurringEventSlug = `recurring-bookings-2024-08-13-event-type-${randomString()}`; + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `recurring-bookings-2024-08-13-event-type-${randomString()}`, + slug: recurringEventSlug, + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should create a recurring booking", async () => { + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + recurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should cancel all recurrences after a specific recurrence", async () => { + const recurringBookingUid = recurringBooking[2].recurringBookingUid; + const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid); + for (const booking of bookings) { + expect(booking.status).toEqual("ACCEPTED"); + } + + // note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences + // aka cancel 3rd and 4th recurrences. + const thirdRecurrenceUid = recurringBooking[2].uid; + const fourthRecurringUid = recurringBooking[3].uid; + const body: CancelBookingInput_2024_08_13 = { + cancelSubsequentBookings: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${thirdRecurrenceUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + + const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid); + expect(firstRecurrence).toBeDefined(); + expect(firstRecurrence?.status).toEqual("accepted"); + + const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid); + expect(secondRecurrence).toBeDefined(); + expect(secondRecurrence?.status).toEqual("accepted"); + + const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid); + expect(thirdRecurrence).toBeDefined(); + expect(thirdRecurrence?.status).toEqual("cancelled"); + + const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid); + expect(fourthRecurrence).toBeDefined(); + expect(fourthRecurrence?.status).toEqual("cancelled"); + + const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid); + + const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid); + expect(bookingFirst?.status).toEqual("ACCEPTED"); + const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid); + expect(bookingSecond?.status).toEqual("ACCEPTED"); + const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid); + expect(bookingThird?.status).toEqual("CANCELLED"); + const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid); + expect(bookingFourth?.status).toEqual("CANCELLED"); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `recurring-bookings-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + const maxRecurrenceCount = 4; + let recurringEventTypeId: number; + + let recurringBooking: RecurringBookingOutput_2024_08_13[]; + + beforeAll(async () => { + jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () { + return "Mocked Email Content"; + }); + + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `recurring-bookings-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `recurring-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const recurringEventSlug = `recurring-bookings-2024-08-13-event-type-${randomString()}`; + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `recurring-bookings-2024-08-13-event-type-${randomString()}`, + slug: recurringEventSlug, + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should create a recurring booking", async () => { + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(maxRecurrenceCount); + recurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should cancel all remaning recurrences", async () => { + const recurringBookingUid = recurringBooking[0].recurringBookingUid; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${recurringBookingUid}/cancel`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + for (const booking of data) { + expect(booking.status).toEqual("cancelled"); + } + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..bb5ab98d825f31 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts @@ -0,0 +1,1071 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import { DateTime } from "luxon"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + CancelSeatedBookingInput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + GetBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RescheduleSeatedBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { + CreateBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Seated bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `seated-bookings-user-${randomString()}@api.com`; + let user: User; + + let seatedEventTypeId: number; + let recurringSeatedEventTypeId: number; + const maxRecurrenceCount = 3; + + const seatedEventSlug = `seated-bookings-event-type-${randomString()}`; + const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`; + + let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13; + let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[]; + + const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`; + const nameAttendeeOne = `Attendee One ${randomString()}`; + const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`; + const nameAttendeeTwo = `Attendee Two ${randomString()}`; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `seated-bookings-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `seated-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const seatedEvent = await eventTypesRepositoryFixture.create( + { + title: `seated-bookings-2024-08-13-event-type-${randomString()}`, + slug: seatedEventSlug, + length: 60, + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + }, + user.id + ); + seatedEventTypeId = seatedEvent.id; + + const recurringSeatedEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`, + slug: recurringSeatedEventSlug, + length: 60, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + }, + user.id + ); + recurringSeatedEventTypeId = recurringSeatedEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should book an event type with seats for the first time", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + attendee: { + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + const seatUid = data.seatUid; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should book an event type with seats for the second time", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + attendee: { + name: nameAttendeeTwo, + email: emailAttendeeTwo, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "Rust", + }, + metadata: { + userId: "200", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + const seatUid = data.seatUid; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(2); + // note(Lauris): first attendee is from previous test request + const firstAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeOne); + expect(firstAttendee).toEqual({ + name: createdSeatedBooking.attendees[0].name, + email: createdSeatedBooking.attendees[0].email, + timeZone: createdSeatedBooking.attendees[0].timeZone, + language: createdSeatedBooking.attendees[0].language, + absent: false, + seatUid: createdSeatedBooking.seatUid, + bookingFieldsResponses: { + name: createdSeatedBooking.attendees[0].name, + ...createdSeatedBooking.attendees[0].bookingFieldsResponses, + }, + metadata: createdSeatedBooking.attendees[0].metadata, + }); + const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo); + expect(secondAttendee).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should book a recurring event type with seats", async () => { + const recurrenceCount = 2; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(), + eventTypeId: recurringSeatedEventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + recurrenceCount, + metadata: { + userId: "300", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) { + const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(recurrenceCount); + + const firstBooking = data[0]; + expect(firstBooking.seatUid).toBeDefined(); + const seatUid = firstBooking.seatUid; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(body.start); + expect(firstBooking.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(firstBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedEventSlug, + }); + expect(firstBooking.attendees.length).toEqual(1); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.seatUid).toBeDefined(); + const secondSeatUid = secondBooking.seatUid; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO(); + expect(secondBooking.start).toEqual(expectedStart); + expect(secondBooking.end).toEqual( + DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(secondBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedEventSlug, + }); + expect(secondBooking.attendees.length).toEqual(1); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid: secondSeatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + createdRecurringSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get a booking for an event type with seats", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdSeatedBooking.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + const expected = structuredClone(createdSeatedBooking); + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete expected.seatUid; + expect(data).toEqual(expected); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get a booking for a recurring event type with seats", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) { + const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data; + const expected = structuredClone(createdRecurringSeatedBooking); + for (const booking of expected) { + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete booking.seatUid; + } + expect(data).toEqual(expected); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get a specific recurrence of a booking for a recurring event type with seats", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + const expected = structuredClone(createdRecurringSeatedBooking[0]); + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete expected.seatUid; + expect(data).toEqual(expected); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should get all seated bookings", async () => { + return request(app.getHttpServer()) + .get("/v2/bookings?sortCreated=asc") + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(3); + + const seatedBooking = responseBody.data[0]; + const seatedBookingExpected = structuredClone(createdSeatedBooking); + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete seatedBookingExpected.seatUid; + expect(seatedBooking).toEqual(seatedBookingExpected); + + const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]]; + const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking); + for (const booking of recurringSeatedBookingsExpected) { + // note(Lauris): seatUid in get response resides only in each attendee object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete booking.seatUid; + } + expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected); + }); + }); + + it("should reschedule seated booking", async () => { + const body: RescheduleSeatedBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(), + seatUid: createdSeatedBooking.seatUid, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + const seatUid = data.seatUid; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(1); + const attendee = createdSeatedBooking.attendees.find((a) => a.seatUid === body.seatUid); + expect(data.attendees[0]).toEqual({ + name: attendee?.name, + email: attendee?.email, + timeZone: attendee?.timeZone, + language: attendee?.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: attendee?.name, + ...attendee?.bookingFieldsResponses, + }, + metadata: attendee?.metadata, + }); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + createdSeatedBooking = data; + } else { + throw new Error("Invalid response data - expected booking but received array response"); + } + }); + }); + + it("should cancel seated booking", async () => { + const body: CancelSeatedBookingInput_2024_08_13 = { + seatUid: createdSeatedBooking.seatUid, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsGetSeatedBooking(responseBody.data)) { + const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("cancelled"); + expect(data.start).toEqual(createdSeatedBooking.start); + expect(data.end).toEqual(createdSeatedBooking.end); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeId); + expect(data.eventType).toEqual({ + id: seatedEventTypeId, + slug: seatedEventSlug, + }); + expect(data.attendees.length).toEqual(0); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + } else { + throw new Error("Invalid response data - expected booking but received array response"); + } + }); + }); + + function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 { + return data.hasOwnProperty("seatUid"); + } + + function responseDataIsCreateRecurringSeatedBooking( + data: any + ): data is CreateRecurringSeatedBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 { + return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid")); + } + + function responseDataIsGetRecurringSeatedBooking( + data: any + ): data is GetRecurringSeatedBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + describe("Recurring seated bookings creation", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `seated-bookings-user-${randomString()}@api.com`; + let user: User; + + let seatedEventTypeId: number; + let recurringSeatedEventTypeId: number; + const maxRecurrenceCount = 3; + + const seatedEventSlug = `seated-bookings-event-type-${randomString()}`; + const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`; + + let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13; + let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[]; + + const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`; + const nameAttendeeOne = `Attendee One ${randomString()}`; + const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`; + const nameAttendeeTwo = `Attendee Two ${randomString()}`; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `seated-bookings-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `seated-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const seatedEvent = await eventTypesRepositoryFixture.create( + { + title: `seated-bookings-2024-08-13-event-type-${randomString()}`, + slug: seatedEventSlug, + length: 60, + seatsPerTimeSlot: 3, + }, + user.id + ); + seatedEventTypeId = seatedEvent.id; + + const recurringSeatedEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`, + slug: recurringSeatedEventSlug, + length: 60, + seatsPerTimeSlot: 3, + recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringSeatedEventTypeId = recurringSeatedEvent.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should book a recurring event type with seats for the first time", async () => { + const recurrenceCount = 2; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(), + eventTypeId: recurringSeatedEventTypeId, + attendee: { + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + metadata: { + userId: "1000", + }, + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) { + const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(recurrenceCount); + + const firstBooking = data[0]; + expect(firstBooking.seatUid).toBeDefined(); + const seatUid = firstBooking.seatUid; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(body.start); + expect(firstBooking.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(firstBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedEventSlug, + }); + expect(firstBooking.attendees.length).toEqual(1); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.seatUid).toBeDefined(); + const secondSeatUid = secondBooking.seatUid; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO(); + expect(secondBooking.start).toEqual(expectedStart); + expect(secondBooking.end).toEqual( + DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(secondBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedEventSlug, + }); + expect(secondBooking.attendees.length).toEqual(1); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid: secondSeatUid, + bookingFieldsResponses: { + name: body.attendee.name, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + createdRecurringSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should book a recurring event type with seats for the second time", async () => { + const recurrenceCount = 2; + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(), + eventTypeId: recurringSeatedEventTypeId, + attendee: { + name: nameAttendeeTwo, + email: emailAttendeeTwo, + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + metadata: { + userId: "2000", + }, + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) { + const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(recurrenceCount); + + const firstBooking = data[0]; + expect(firstBooking.seatUid).toBeDefined(); + const seatUid = firstBooking.seatUid; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(body.start); + expect(firstBooking.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(firstBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedEventSlug, + }); + expect(firstBooking.attendees.length).toEqual(2); + const firstAttendee = firstBooking.attendees.find( + (attendee) => attendee.name === nameAttendeeOne + ); + expect(firstAttendee).toEqual({ + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid: createdRecurringSeatedBooking[0].seatUid, + bookingFieldsResponses: { + name: nameAttendeeOne, + ...body.bookingFieldsResponses, + }, + metadata: createdRecurringSeatedBooking[0].attendees[0].metadata, + }); + const secondAttendee = firstBooking.attendees.find( + (attendee) => attendee.name === nameAttendeeTwo + ); + expect(secondAttendee).toEqual({ + name: nameAttendeeTwo, + email: emailAttendeeTwo, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid, + bookingFieldsResponses: { + name: nameAttendeeTwo, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.seatUid).toBeDefined(); + const secondSeatUid = secondBooking.seatUid; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO(); + expect(secondBooking.start).toEqual(expectedStart); + expect(secondBooking.end).toEqual( + DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId); + expect(secondBooking.eventType).toEqual({ + id: recurringSeatedEventTypeId, + slug: recurringSeatedEventSlug, + }); + expect(secondBooking.attendees.length).toEqual(2); + expect(secondBooking.attendees[0]).toEqual({ + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid: createdRecurringSeatedBooking[1].seatUid, + bookingFieldsResponses: { + name: nameAttendeeOne, + ...body.bookingFieldsResponses, + }, + metadata: createdRecurringSeatedBooking[0].attendees[0].metadata, + }); + expect(secondBooking.attendees[1]).toEqual({ + name: nameAttendeeTwo, + email: emailAttendeeTwo, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + seatUid: secondSeatUid, + bookingFieldsResponses: { + name: nameAttendeeTwo, + ...body.bookingFieldsResponses, + }, + metadata: body.metadata, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + createdRecurringSeatedBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + function responseDataIsCreateRecurringSeatedBooking( + data: any + ): data is CreateRecurringSeatedBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 { + return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid")); + } + + function responseDataIsGetRecurringSeatedBooking( + data: any + ): data is GetRecurringSeatedBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..23769c99499238 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts @@ -0,0 +1,648 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Team bookings", () => { + let app: INestApplication; + let organization: Team; + let team1: Team; + let team2: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const teamUserEmail = `team-bookings-user1-${randomString()}@api.com`; + const teamUserEmail2 = `team-bookings-user2-${randomString()}@api.com`; + let teamUser: User; + let teamUser2: User; + + let team1EventTypeId: number; + let team2EventTypeId: number; + let phoneOnlyEventTypeId: number; + + const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`; + const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`; + const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamUserEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await organizationsRepositoryFixture.create({ + name: `team-bookings-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + team1 = await teamRepositoryFixture.create({ + name: `team-bookings-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + team2 = await teamRepositoryFixture.create({ + name: `team-bookings-team2-${randomString()}`, + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser = await userRepositoryFixture.create({ + email: teamUserEmail, + locale: "it", + name: "orgUser1team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser2 = await userRepositoryFixture.create({ + email: teamUserEmail2, + locale: "es", + name: "orgUser2team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `team-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(teamUser.id, userSchedule); + await schedulesService.createUserSchedule(teamUser2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser.id}`, + username: teamUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser2.id}`, + username: teamUserEmail2, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team2.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamUser2.id } }, + team: { connect: { id: team2.id } }, + accepted: true, + }); + + const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: `team-bookings-2024-08-13-event-type-${randomString()}`, + slug: team1EventTypeSlug, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team1EventTypeId = team1EventType.id; + + const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team1.id }, + }, + title: `team-bookings-2024-08-13-event-type-${randomString()}`, + slug: phoneOnlyEventTypeSlug, + length: 15, + assignAllTeamMembers: false, + hosts: { + connectOrCreate: [ + { + where: { + userId_eventTypeId: { + userId: teamUser.id, + eventTypeId: team1EventTypeId, + }, + }, + create: { + userId: teamUser.id, + isFixed: true, + }, + }, + ], + }, + bookingFields: [ + { + name: "name", + type: "name", + label: "your name", + sources: [{ id: "default", type: "default", label: "Default" }], + variant: "fullName", + editable: "system", + required: true, + defaultLabel: "your_name", + variantsConfig: { + variants: { + fullName: { + fields: [{ name: "fullName", type: "text", label: "your name", required: true }], + }, + }, + }, + }, + { + name: "email", + type: "email", + label: "your email", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: false, + defaultLabel: "email_address", + }, + { + name: "attendeePhoneNumber", + type: "phone", + label: "phone_number", + sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }], + editable: "user", + required: true, + placeholder: "", + }, + { + name: "rescheduleReason", + type: "textarea", + views: [{ id: "reschedule", label: "Reschedule View" }], + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "reason_for_reschedule", + defaultPlaceholder: "reschedule_placeholder", + }, + ], + locations: [], + }); + + phoneOnlyEventTypeId = phoneOnlyEventType.id; + + const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team2.id }, + }, + title: `team-bookings-2024-08-13-event-type-${randomString()}`, + slug: team2EventTypeSlug, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team2EventTypeId = team2EventType.id; + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team2EventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser2.id, + }, + }, + eventType: { + connect: { + id: team2EventType.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("create team bookings", () => { + it("should create a team 1 booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: team1EventTypeId, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Madrid", + language: "es", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team1EventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should create a phone based booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(), + eventTypeId: phoneOnlyEventTypeId, + attendee: { + name: "alice", + phoneNumber: "+919876543210", + timeZone: "Europe/Madrid", + language: "es", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString()); + expect(data.duration).toEqual(15); + expect(data.eventTypeId).toEqual(phoneOnlyEventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: "919876543210@sms.cal.com", + phoneNumber: body.attendee.phoneNumber, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should create a team 2 booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: team2EventTypeId, + attendee: { + name: "bob", + email: "bob@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team2EventTypeId); + expect(data.attendees.length).toEqual(2); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.attendees[1]).toEqual({ + name: teamUser2.name, + email: teamUser2.email, + timeZone: teamUser2.timeZone, + language: teamUser2.locale, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + describe("get team bookings", () => { + it("should should get bookings by teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team1.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].eventTypeId).toEqual(team1EventTypeId); + }); + }); + + it("should should get bookings by teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team2.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].eventTypeId).toEqual(team2EventTypeId); + }); + }); + + it("should get bookings by teamId and eventTypeId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team2.id}&eventTypeId=${team2EventTypeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].eventTypeId).toEqual(team2EventTypeId); + }); + }); + + it("should not get bookings by teamId and non existing eventTypeId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(0); + }); + }); + + it("should should get bookings by teamIds", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamIds=${team1.id},${team2.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined(); + expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined(); + }); + }); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(teamUser.email); + await userRepositoryFixture.deleteByEmail(teamUserEmail2); + await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..0eaa6eb14b3a52 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts @@ -0,0 +1,1337 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import { advanceTo, clear } from "jest-date-mock"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { + CAL_API_VERSION_HEADER, + SUCCESS_STATUS, + VERSION_2024_08_13, + X_CAL_CLIENT_ID, +} from "@calcom/platform-constants"; +import { + GetBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + RecurringBookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("User bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `user-bookings-user-${randomString()}@api.com`; + let user: User; + + let eventTypeId: number; + const eventTypeSlug = `user-bookings-event-type-${randomString()}`; + let recurringEventTypeId: number; + const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`; + + let createdBooking: BookingOutput_2024_08_13; + let rescheduledBooking: BookingOutput_2024_08_13; + let createdRecurringBooking: RecurringBookingOutput_2024_08_13[]; + + let bookingInThePast: Booking; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `user-bookings-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `user-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `user-bookings-2024-08-13-event-type-${randomString()}`, + slug: eventTypeSlug, + length: 60, + }, + user.id + ); + eventTypeId = event.id; + + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: "peer coding recurring", + slug: recurringEventTypeSlug, + length: 60, + recurringEvent: { freq: 2, count: 3, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + bookingInThePast = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: user.id, + }, + }, + startTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)), + endTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)), + title: "peer coding lets goo", + uid: "booking-in-the-past", + eventType: { + connect: { + id: eventTypeId, + }, + }, + location: "integrations:daily", + customInputs: {}, + metadata: {}, + responses: { + name: "Oldie", + email: "oldie@gmail.com", + }, + attendees: { + create: { + email: "oldie@gmail.com", + name: "Oldie", + locale: "lv", + timeZone: "Europe/Rome", + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + describe("create bookings", () => { + describe("invalid metadata", () => { + it("should not be able to create a booking with metadata with more than 50 keys", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + key1: "1", + key2: "2", + key3: "3", + key4: "4", + key5: "5", + key6: "6", + key7: "7", + key8: "8", + key9: "9", + key10: "10", + key11: "11", + key12: "12", + key13: "13", + key14: "14", + key15: "15", + key16: "16", + key17: "17", + key18: "18", + key19: "19", + key20: "20", + key21: "21", + key22: "22", + key23: "23", + key24: "24", + key25: "25", + key26: "26", + key27: "27", + key28: "28", + key29: "29", + key30: "30", + key31: "31", + key32: "32", + key33: "33", + key34: "34", + key35: "35", + key36: "36", + key37: "37", + key38: "38", + key39: "39", + key40: "40", + key41: "41", + key42: "42", + key43: "43", + key44: "44", + key45: "45", + key46: "46", + key47: "47", + key48: "48", + key49: "49", + key50: "50", + key51: "51", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should not be able to create a booking with metadata with a key longer than 40 characters", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + aaaaaaaaaabbbbbbbbbbccccccccccdddddddddde: "1", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should not be able to create a booking with metadata with a value longer than 500 characters", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + key: `${"a".repeat(501)}`, + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + }); + + it("should create a booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + guests: ["bob@gmail.com"], + }; + + const beforeCreate = new Date(); + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const afterCreate = new Date(); + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(eventTypeId); + expect(data.eventType).toEqual({ + id: eventTypeId, + slug: eventTypeSlug, + }); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.location).toEqual(body.location); + expect(data.meetingUrl).toEqual(body.location); + expect(data.absentHost).toEqual(false); + expect(data.bookingFieldsResponses).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + ...body.bookingFieldsResponses, + guests: body.guests, + }); + expect(data.guests).toEqual(body.guests); + + // Check createdAt date is between the time of the request and after the request + const createdAtDate = new Date(data.createdAt); + expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + + expect(data.metadata).toEqual(body.metadata); + createdBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should create a recurring booking", async () => { + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + metadata: { + userId: "1000", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(3); + + const firstBooking = data[0]; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.location).toEqual(body.location); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + expect(firstBooking.metadata).toEqual(body.metadata); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + expect(secondBooking.metadata).toEqual(body.metadata); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toBeDefined(); + expect(thirdBooking.uid).toBeDefined(); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual("accepted"); + expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString()); + expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString()); + expect(thirdBooking.duration).toEqual(60); + expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(thirdBooking.recurringBookingUid).toBeDefined(); + expect(thirdBooking.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(thirdBooking.location).toEqual(body.location); + expect(thirdBooking.absentHost).toEqual(false); + expect(thirdBooking.metadata).toEqual(body.metadata); + + createdRecurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + describe("get individual booking", () => { + it("should should get a booking", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdBooking.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toEqual(createdBooking.id); + expect(data.uid).toEqual(createdBooking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual(createdBooking.status); + expect(data.start).toEqual(createdBooking.start); + expect(data.end).toEqual(createdBooking.end); + expect(data.duration).toEqual(createdBooking.duration); + expect(data.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(data.attendees[0]).toEqual(createdBooking.attendees[0]); + expect(data.location).toEqual(createdBooking.location); + expect(data.absentHost).toEqual(createdBooking.absentHost); + expect(data.createdAt).toEqual(createdBooking.createdAt); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should should get 1 recurrence of a recurring booking", async () => { + const recurrenceUid = createdRecurringBooking[0].uid; + return request(app.getHttpServer()) + .get(`/v2/bookings/${recurrenceUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurranceBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurranceBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toEqual(createdRecurringBooking[0].id); + expect(data.uid).toEqual(createdRecurringBooking[0].uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual(createdRecurringBooking[0].status); + expect(data.start).toEqual(createdRecurringBooking[0].start); + expect(data.end).toEqual(createdRecurringBooking[0].end); + expect(data.duration).toEqual(createdRecurringBooking[0].duration); + expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId); + expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid); + expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]); + expect(data.location).toEqual(createdRecurringBooking[0].location); + expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should should get all recurrences of the recurring bookings", async () => { + const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid; + return request(app.getHttpServer()) + .get(`/v2/bookings/${recurringBookingUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(3); + + const firstBooking = data[0]; + expect(firstBooking.id).toEqual(createdRecurringBooking[0].id); + expect(firstBooking.uid).toEqual(createdRecurringBooking[0].uid); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual(createdRecurringBooking[0].status); + expect(firstBooking.start).toEqual(createdRecurringBooking[0].start); + expect(firstBooking.end).toEqual(createdRecurringBooking[0].end); + expect(firstBooking.duration).toEqual(createdRecurringBooking[0].duration); + expect(firstBooking.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId); + expect(firstBooking.recurringBookingUid).toEqual(recurringBookingUid); + expect(firstBooking.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]); + expect(firstBooking.location).toEqual(createdRecurringBooking[0].location); + expect(firstBooking.absentHost).toEqual(createdRecurringBooking[0].absentHost); + + const secondBooking = data[1]; + expect(secondBooking.id).toEqual(createdRecurringBooking[1].id); + expect(secondBooking.uid).toEqual(createdRecurringBooking[1].uid); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual(createdRecurringBooking[1].status); + expect(secondBooking.start).toEqual(createdRecurringBooking[1].start); + expect(secondBooking.end).toEqual(createdRecurringBooking[1].end); + expect(secondBooking.duration).toEqual(createdRecurringBooking[1].duration); + expect(secondBooking.eventTypeId).toEqual(createdRecurringBooking[1].eventTypeId); + expect(secondBooking.recurringBookingUid).toEqual(recurringBookingUid); + expect(secondBooking.attendees[0]).toEqual(createdRecurringBooking[1].attendees[0]); + expect(secondBooking.location).toEqual(createdRecurringBooking[1].location); + expect(secondBooking.absentHost).toEqual(createdRecurringBooking[1].absentHost); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toEqual(createdRecurringBooking[2].id); + expect(thirdBooking.uid).toEqual(createdRecurringBooking[2].uid); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual(createdRecurringBooking[2].status); + expect(thirdBooking.start).toEqual(createdRecurringBooking[2].start); + expect(thirdBooking.end).toEqual(createdRecurringBooking[2].end); + expect(thirdBooking.duration).toEqual(createdRecurringBooking[2].duration); + expect(thirdBooking.eventTypeId).toEqual(createdRecurringBooking[2].eventTypeId); + expect(thirdBooking.recurringBookingUid).toEqual(recurringBookingUid); + expect(thirdBooking.attendees[0]).toEqual(createdRecurringBooking[2].attendees[0]); + expect(thirdBooking.location).toEqual(createdRecurringBooking[2].location); + expect(thirdBooking.absentHost).toEqual(createdRecurringBooking[2].absentHost); + + createdRecurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + describe("get bookings", () => { + it("should should get all bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(5); + }); + }); + + it("should should take bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?take=3`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should skip bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?skip=2`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should get upcoming bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=upcoming`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(4); + }); + }); + + it("should should get past bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=past`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(1); + }); + }); + + it("should should get upcoming and past bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=upcoming,past`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(5); + }); + }); + + it("should should get recurring booking recurrences", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=recurring`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should get bookings by attendee email", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?attendeeEmail=mr_proper@gmail.com`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(1); + }); + }); + + it("should should get bookings by attendee name", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?attendeeName=Mr Proper Recurring`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should get bookings by eventTypeId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + }); + }); + + it("should should get bookings by eventTypeIds", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeIds=${eventTypeId},${recurringEventTypeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(5); + }); + }); + + it("should should get bookings by after specified start time", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?afterStart=${createdRecurringBooking[1].start}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + }); + }); + + it("should should get bookings by before specified end time", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?afterStart=${createdRecurringBooking[0].start}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should sort bookings by start in descending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortStart=desc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(createdBooking.start); + expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); + }); + }); + + it("should should sort bookings by start in ascending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortStart=asc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); + expect(data[1].start).toEqual(createdBooking.start); + }); + }); + + it("should should sort bookings by end in descending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortEnd=desc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(createdBooking.start); + expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); + }); + }); + + it("should should sort bookings by end in ascending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortEnd=asc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); + expect(data[1].start).toEqual(createdBooking.start); + }); + }); + + it("should should sort bookings by created in descending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortCreated=desc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(createdBooking.start); + expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); + }); + }); + + it("should should sort bookings by created in ascending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortCreated=asc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); + expect(data[1].start).toEqual(createdBooking.start); + }); + }); + }); + + describe("reschedule bookings", () => { + it("should should reschedule normal booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + const beforeCreate = new Date(); + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdBooking.uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const afterCreate = new Date(); + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.reschedulingReason).toEqual(body.reschedulingReason); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString()); + expect(data.rescheduledFromUid).toEqual(createdBooking.uid); + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual(createdBooking.status); + expect(data.duration).toEqual(createdBooking.duration); + expect(data.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(data.attendees[0]).toEqual(createdBooking.attendees[0]); + expect(data.location).toEqual(createdBooking.location); + expect(data.absentHost).toEqual(createdBooking.absentHost); + expect(data.metadata).toEqual(createdBooking.metadata); + + // When a booking is rescheduled, a new booking is created and the old booking is cancelled. + // We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates. + const createdAtDate = new Date(data.createdAt); + expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + + rescheduledBooking = data; + }); + }); + + it("should set rescheduled booking status to cancelled", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdBooking.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.status).toEqual("cancelled"); + + createdBooking = data; + }); + }); + + it("should reschedule recurrence of a recurring booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars again", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual(createdRecurringBooking[0].status); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString()); + expect(data.duration).toEqual(createdRecurringBooking[0].duration); + expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid); + expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId); + expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]); + expect(data.location).toEqual(createdRecurringBooking[0].location); + expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost); + expect(data.metadata).toEqual(createdRecurringBooking[0].metadata); + + const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid); + expect(oldBooking).toBeDefined(); + expect(oldBooking?.status).toEqual("CANCELLED"); + }); + }); + + it("should get recurring booking recurrences after rescheduling one", async () => { + const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid; + return request(app.getHttpServer()) + .get(`/v2/bookings/${recurringBookingUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + const cancelled = data.find((booking) => booking.status === "cancelled"); + expect(cancelled).toBeDefined(); + const rescheduledNew = data.find( + (booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString() + ); + expect(rescheduledNew).toBeDefined(); + createdRecurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + describe("mark absent", () => { + beforeAll(() => { + advanceTo(new Date(2035, 0, 9, 15, 0, 0)); + }); + + afterAll(() => { + clear(); + }); + + it("should mark host absent", async () => { + const body: MarkAbsentBookingInput_2024_08_13 = { + host: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[1].uid}/mark-absent`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: MarkAbsentBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + const booking = createdRecurringBooking[1]; + expect(data.absentHost).toEqual(true); + + expect(data.id).toEqual(booking.id); + expect(data.uid).toEqual(booking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual(booking.status); + expect(data.start).toEqual(booking.start); + expect(data.end).toEqual(booking.end); + expect(data.duration).toEqual(booking.duration); + expect(data.eventTypeId).toEqual(booking.eventTypeId); + expect(data.attendees[0]).toEqual(booking.attendees[0]); + expect(data.location).toEqual(booking.location); + }); + }); + + it("should mark attendee absent", async () => { + const body: MarkAbsentBookingInput_2024_08_13 = { + attendees: [{ email: "mr_proper_recurring@gmail.com", absent: true }], + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[2].uid}/mark-absent`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: MarkAbsentBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + const booking = createdRecurringBooking[2]; + + expect(data.id).toEqual(booking.id); + expect(data.uid).toEqual(booking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual(booking.status); + expect(data.start).toEqual(booking.start); + expect(data.end).toEqual(booking.end); + expect(data.duration).toEqual(booking.duration); + expect(data.eventTypeId).toEqual(booking.eventTypeId); + expect(data.attendees[0].absent).toEqual(true); + expect(data.absentHost).toEqual(booking.absentHost); + expect(data.location).toEqual(booking.location); + }); + }); + }); + + describe("cancel bookings", () => { + it("should cancel booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid); + expect(booking).toBeDefined(); + expect(booking?.status).toEqual("ACCEPTED"); + + return request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBooking.uid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual("cancelled"); + expect(data.cancellationReason).toEqual(body.cancellationReason); + expect(data.start).toEqual(rescheduledBooking.start); + expect(data.end).toEqual(rescheduledBooking.end); + expect(data.duration).toEqual(rescheduledBooking.duration); + expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId); + expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]); + expect(data.location).toEqual(rescheduledBooking.location); + expect(data.absentHost).toEqual(rescheduledBooking.absentHost); + + const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid); + expect(cancelledBooking).toBeDefined(); + expect(cancelledBooking?.status).toEqual("CANCELLED"); + }); + }); + + it("should cancel recurring booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[1].recurringBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + + const firstBooking = data[0]; + expect(firstBooking.status).toEqual("cancelled"); + + const secondBooking = data[1]; + expect(secondBooking.status).toEqual("cancelled"); + + const thirdBooking = data[2]; + expect(thirdBooking.status).toEqual("cancelled"); + + const fourthBooking = data[3]; + expect(fourthBooking.status).toEqual("cancelled"); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { + return ( + !Array.isArray(data) && + typeof data === "object" && + data && + "id" in data && + "recurringBookingUid" in data + ); + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/variable-length-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/variable-length-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..3c316aed5c12bd --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/variable-length-bookings.e2e-spec.ts @@ -0,0 +1,261 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { EventType, User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("User bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = `variable-length-bookings-2024-08-13-user-${randomString()}@api.com`; + let user: User; + + let variableLengthEventType: EventType; + const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`; + let normalEventType: EventType; + const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ + name: `variable-length-bookings-2024-08-13-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `variable-length-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + variableLengthEventType = await eventTypesRepositoryFixture.create( + { + title: `variable-length-bookings-2024-08-13-event-type-${randomString()}`, + slug: VARIABLE_LENGTH_EVENT_TYPE_SLUG, + length: 15, + metadata: { multipleDuration: [15, 30, 60] }, + }, + user.id + ); + + normalEventType = await eventTypesRepositoryFixture.create( + { + title: `variable-length-bookings-2024-08-13-event-type-${randomString()}`, + slug: NORMAL_EVENT_TYPE_SLUG, + length: 15, + }, + user.id + ); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + describe("create bookings", () => { + it("should not be able to specify length of booking for non variable length event type", async () => { + const lengthInMinutes = 30; + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: normalEventType.id, + lengthInMinutes, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should create a booking with default length", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: variableLengthEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString() + ); + expect(data.duration).toEqual(variableLengthEventType.length); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should create a booking with specified length that is not default length", async () => { + const lengthInMinutes = 30; + const body: CreateBookingInput_2024_08_13 = { + lengthInMinutes, + start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(), + eventTypeId: variableLengthEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString()); + expect(data.duration).toEqual(lengthInMinutes); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-uid.guard.ts b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-uid.guard.ts new file mode 100644 index 00000000000000..05d508a4fb38e9 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-uid.guard.ts @@ -0,0 +1,16 @@ +import { Injectable, CanActivate, ExecutionContext, BadRequestException } from "@nestjs/common"; + +@Injectable() +export class BookingUidGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + const bookingUid = request.params.bookingUid; + + if (!bookingUid) { + throw new BadRequestException("Booking UID missing in the request path"); + } + + return true; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts new file mode 100644 index 00000000000000..09ee2e4a77e7a7 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { + BookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; + +@ApiExtraModels( + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13 +) +export class CancelBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + { $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) } }, + ], + description: + "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects", + }) + @Type(() => Object) + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13[] + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts new file mode 100644 index 00000000000000..058e97f656aeba --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; + +@ApiExtraModels( + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13 +) +export class CreateBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + { $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) } }, + ], + description: + "Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects", + }) + @Type(() => Object) + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13[] + | CreateSeatedBookingOutput_2024_08_13 + | CreateRecurringSeatedBookingOutput_2024_08_13[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/mark-absent.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/mark-absent.output.ts new file mode 100644 index 00000000000000..16dcaae6a1a7e4 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/mark-absent.output.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class MarkAbsentBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + ], + description: + "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object", + }) + @ValidateNested() + @Type(() => Object) + data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reassign-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reassign-booking.output.ts new file mode 100644 index 00000000000000..8c01e8777ecca6 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reassign-booking.output.ts @@ -0,0 +1,21 @@ +import { ApiProperty, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types"; + +export class ReassignBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [{ $ref: getSchemaPath(ReassignBookingOutputData_2024_08_13) }], + description: + "Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object", + }) + @ValidateNested() + @Type(() => Object) + data!: ReassignBookingOutputData_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts new file mode 100644 index 00000000000000..c32c52a3102c8a --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { + BookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; + +@ApiExtraModels( + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13 +) +export class RescheduleBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) }, + { $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) }, + ], + description: + "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object", + }) + @ValidateNested() + @Type(() => Object) + data!: + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | CreateSeatedBookingOutput_2024_08_13 + | CreateRecurringSeatedBookingOutput_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts new file mode 100644 index 00000000000000..1fba30a1541ee6 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -0,0 +1,550 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service"; +import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common"; +import { Request } from "express"; +import { z } from "zod"; + +import { + handleNewRecurringBooking, + getAllUserBookings, + handleInstantMeeting, + handleCancelBooking, + roundRobinReassignment, + roundRobinManualReassignment, + handleMarkNoShow, + confirmBookingHandler, +} from "@calcom/platform-libraries"; +import { handleNewBooking } from "@calcom/platform-libraries"; +import { + CreateBookingInput_2024_08_13, + CreateBookingInput, + CreateRecurringBookingInput_2024_08_13, + GetBookingsInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + ReassignToUserBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + RescheduleBookingInput, + CancelBookingInput, +} from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; +import { EventType } from "@calcom/prisma/client"; + +type CreatedBooking = { + hosts: { id: number }[]; + uid: string; + start: string; +}; + +const eventTypeBookingFieldSchema = z.object({ + name: z.string(), + required: z.boolean(), + editable: z.string(), +}); + +const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema); + +@Injectable() +export class BookingsService_2024_08_13 { + private readonly logger = new Logger("BookingsService"); + constructor( + private readonly inputService: InputBookingsService_2024_08_13, + private readonly outputService: OutputBookingsService_2024_08_13, + private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly bookingSeatRepository: BookingSeatRepository, + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly prismaReadService: PrismaReadService, + private readonly billingService: BillingService, + private readonly usersService: UsersService, + private readonly usersRepository: UsersRepository + ) {} + + async createBooking(request: Request, body: CreateBookingInput) { + try { + if ("instant" in body && body.instant) { + return await this.createInstantBooking(request, body); + } + + const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId); + const isRecurring = !!eventType?.recurringEvent; + const isSeated = !!eventType?.seatsPerTimeSlot; + + await this.hasRequiredBookingFieldsResponses(body, eventType); + + if (isRecurring && isSeated) { + return await this.createRecurringSeatedBooking(request, body); + } + if (isRecurring && !isSeated) { + return await this.createRecurringBooking(request, body); + } + if (isSeated) { + return await this.createSeatedBooking(request, body); + } + + return await this.createRegularBooking(request, body); + } catch (error) { + if (error instanceof Error) { + if (error.message === "no_available_users_found_error") { + throw new BadRequestException("User either already has booking at this time or is not available"); + } + } + throw error; + } + } + + async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) { + const bookingFields = body.bookingFieldsResponses; + if (!bookingFields || !eventType || !eventType.bookingFields) { + return true; + } + + // note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee + const eventTypeBookingFields = eventTypeBookingFieldsSchema + .parse(eventType.bookingFields) + .filter((field) => !field.editable.startsWith("system")); + + for (const field of eventTypeBookingFields) { + if (field.required && !(field.name in bookingFields)) { + throw new BadRequestException(` + Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. + You can fetch the event type with ID ${eventType.id} to see the required fields.`); + } + } + + return true; + } + + async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createBookingRequest(request, body); + const booking = await handleInstantMeeting(bookingRequest); + + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent( + booking.bookingId + ); + if (!databaseBooking) { + throw new Error(`Booking with id=${booking.bookingId} was not found in the database`); + } + + return this.outputService.getOutputBooking(databaseBooking); + } + + async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); + const bookings = await handleNewRecurringBooking(bookingRequest); + const ids = bookings.map((booking) => booking.id || 0); + return this.outputService.getOutputRecurringBookings(ids); + } + + async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); + const bookings = await handleNewRecurringBooking(bookingRequest); + return this.outputService.getOutputCreateRecurringSeatedBookings( + bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })) + ); + } + + async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createBookingRequest(request, body); + const booking = await handleNewBooking(bookingRequest); + + if (!booking.uid) { + throw new Error("Booking missing uid"); + } + + const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(booking.uid); + if (!databaseBooking) { + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); + } + + return this.outputService.getOutputBooking(databaseBooking); + } + + async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createBookingRequest(request, body); + const booking = await handleNewBooking(bookingRequest); + + if (!booking.uid) { + throw new Error("Booking missing uid"); + } + + const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent( + booking.uid + ); + if (!databaseBooking) { + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); + } + + return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || ""); + } + + async getBooking(uid: string) { + const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid); + + if (booking) { + const isRecurring = !!booking.recurringEventId; + const isSeated = !!booking.eventType?.seatsPerTimeSlot; + + if (isRecurring && !isSeated) { + return this.outputService.getOutputRecurringBooking(booking); + } + if (isRecurring && isSeated) { + return this.outputService.getOutputRecurringSeatedBooking(booking); + } + if (isSeated) { + return this.outputService.getOutputSeatedBooking(booking); + } + return this.outputService.getOutputBooking(booking); + } + + const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid); + if (!recurringBooking.length) { + throw new NotFoundException(`Booking with uid=${uid} was not found in the database`); + } + const ids = recurringBooking.map((booking) => booking.id); + const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot; + if (isRecurringSeated) { + return this.outputService.getOutputRecurringSeatedBookings(ids); + } + + return this.outputService.getOutputRecurringBookings(ids); + } + + async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) { + const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({ + bookingListingByStatus: queryParams.status || [], + skip: queryParams.skip ?? 0, + // note(Lauris): we substract -1 because getAllUSerBookings child function adds +1 for some reason + take: queryParams.take ? queryParams.take - 1 : 100, + filters: this.inputService.transformGetBookingsFilters(queryParams), + ctx: { + user, + prisma: this.prismaReadService.prisma as unknown as PrismaClient, + }, + sort: this.inputService.transformGetBookingsSort(queryParams), + }); + // note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query, + // because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids. + const ids = fetchedBookings.bookings.map((booking) => booking.id); + const bookings = await this.bookingsRepository.getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids); + + const bookingMap = new Map(bookings.map((booking) => [booking.id, booking])); + const orderedBookings = ids.map((id) => bookingMap.get(id)); + + const formattedBookings: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + )[] = []; + for (const booking of orderedBookings) { + if (!booking) { + continue; + } + + const formatted = { + ...booking, + eventType: booking.eventType, + eventTypeId: booking.eventTypeId, + startTime: new Date(booking.startTime), + endTime: new Date(booking.endTime), + absentHost: !!booking.noShowHost, + }; + + const isRecurring = !!formatted.recurringEventId; + const isSeated = !!formatted.eventType?.seatsPerTimeSlot; + if (isRecurring && !isSeated) { + formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted)); + } else if (isRecurring && isSeated) { + formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted)); + } else if (isSeated) { + formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted)); + } else { + formattedBookings.push(this.outputService.getOutputBooking(formatted)); + } + } + + return formattedBookings; + } + + async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) { + try { + const bookingRequest = await this.inputService.createRescheduleBookingRequest( + request, + bookingUid, + body + ); + const booking = await handleNewBooking(bookingRequest); + if (!booking.uid) { + throw new Error("Booking missing uid"); + } + + const databaseBooking = + await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid); + if (!databaseBooking) { + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); + } + + const isRecurring = !!databaseBooking.recurringEventId; + const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot; + + if (isRecurring && !isSeated) { + return this.outputService.getOutputRecurringBooking(databaseBooking); + } + if (isRecurring && isSeated) { + return this.outputService.getOutputCreateRecurringSeatedBooking( + databaseBooking, + booking?.seatReferenceUid || "" + ); + } + if (isSeated) { + return this.outputService.getOutputCreateSeatedBooking( + databaseBooking, + booking.seatReferenceUid || "" + ); + } + return this.outputService.getOutputBooking(databaseBooking); + } catch (error) { + if (error instanceof Error) { + if (error.message === "no_available_users_found_error") { + throw new BadRequestException("User either already has booking at this time or is not available"); + } + } + throw error; + } + } + + async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) { + if (this.inputService.isCancelSeatedBody(body)) { + const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid); + + if (!seat) { + throw new BadRequestException( + "Invalid seatUid: this seat does not exist or has already been cancelled." + ); + } + + if (seat && bookingUid !== seat.booking.uid) { + throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking."); + } + } + + const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body); + const res = await handleCancelBooking(bookingRequest); + if (!res.onlyRemovedAttendee) { + await this.billingService.cancelUsageByBookingUid(res.bookingUid); + } + + if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) { + return this.getAllRecurringBookingsByIndividualUid(bookingUid); + } + + return this.getBooking(bookingUid); + } + + private async getAllRecurringBookingsByIndividualUid(bookingUid: string) { + const booking = await this.bookingsRepository.getByUid(bookingUid); + const recurringBookingUid = booking?.recurringEventId; + if (!recurringBookingUid) { + throw new BadRequestException( + `Booking with bookingUid=${bookingUid} is not part of a recurring booking.` + ); + } + + return await this.getBooking(recurringBookingUid); + } + + async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) { + const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body); + const bookingBefore = await this.bookingsRepository.getByUid(bookingUid); + const platformClientParams = bookingBefore?.eventTypeId + ? await this.inputService.getOAuthClientParams(bookingBefore.eventTypeId) + : undefined; + + await handleMarkNoShow({ + bookingUid, + attendees: bodyTransformed.attendees, + noShowHost: bodyTransformed.noShowHost, + userId: bookingOwnerId, + platformClientParams, + }); + + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); + + if (!booking) { + throw new Error(`Booking with uid=${bookingUid} was not found in the database`); + } + + const isRecurring = !!booking.recurringEventId; + if (isRecurring) { + return this.outputService.getOutputRecurringBooking(booking); + } + return this.outputService.getOutputBooking(booking); + } + + async billBookings(bookings: CreatedBooking[]) { + for (const booking of bookings) { + await this.billBooking(booking); + } + } + + async billBooking(booking: CreatedBooking) { + const hostId = booking.hosts?.[0]?.id; + if (!hostId) { + this.logger.error(`Booking with uid=${booking.uid} has no host`); + return; + } + + await this.billingService.increaseUsageByUserId(hostId, { + uid: booking.uid, + startTime: new Date(booking.start), + }); + } + + async billRescheduledBooking(newBooking: CreatedBooking, oldBookingUid: string) { + const hostId = newBooking.hosts[0].id; + if (!hostId) { + this.logger.error(`Booking with uid=${newBooking.uid} has no host`); + return; + } + + await this.billingService.increaseUsageByUserId(hostId, { + uid: newBooking.uid, + startTime: new Date(newBooking.start), + fromReschedule: oldBookingUid, + }); + } + + async reassignBooking(bookingUid: string, requestUser: UserWithProfile) { + const booking = await this.bookingsRepository.getByUid(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); + } + + const platformClientParams = booking.eventTypeId + ? await this.inputService.getOAuthClientParams(booking.eventTypeId) + : undefined; + + const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; + + const profile = this.usersService.getUserMainProfile(requestUser); + + await roundRobinReassignment({ + bookingId: booking.id, + orgId: profile?.organizationId || null, + emailsEnabled, + platformClientParams, + }); + + const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid); + if (!reassigned) { + throw new NotFoundException(`Reassigned booking with uid=${bookingUid} was not found in the database`); + } + + return this.outputService.getOutputReassignedBooking(reassigned); + } + + async reassignBookingToUser( + bookingUid: string, + newUserId: number, + reassignedById: number, + body: ReassignToUserBookingInput_2024_08_13 + ) { + const booking = await this.bookingsRepository.getByUid(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); + } + + const user = await this.usersRepository.findByIdWithProfile(newUserId); + if (!user) { + throw new NotFoundException(`User with id=${newUserId} was not found in the database`); + } + + const platformClientParams = booking.eventTypeId + ? await this.inputService.getOAuthClientParams(booking.eventTypeId) + : undefined; + + const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; + + const profile = this.usersService.getUserMainProfile(user); + + const reassigned = await roundRobinManualReassignment({ + bookingId: booking.id, + newUserId, + orgId: profile?.organizationId || null, + reassignReason: body.reason, + reassignedById, + emailsEnabled, + platformClientParams, + }); + + return this.outputService.getOutputReassignedBooking(reassigned); + } + + async confirmBooking(bookingUid: string, requestUser: UserWithProfile) { + const booking = await this.bookingsRepository.getByUid(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); + } + + const platformClientParams = booking.eventTypeId + ? await this.inputService.getOAuthClientParams(booking.eventTypeId) + : undefined; + + const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; + + await confirmBookingHandler({ + ctx: { + user: requestUser, + }, + input: { + bookingId: booking.id, + confirmed: true, + recurringEventId: booking.recurringEventId, + emailsEnabled, + platformClientParams, + }, + }); + + return this.getBooking(bookingUid); + } + + async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) { + const booking = await this.bookingsRepository.getByUid(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); + } + + const platformClientParams = booking.eventTypeId + ? await this.inputService.getOAuthClientParams(booking.eventTypeId) + : undefined; + + const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; + + await confirmBookingHandler({ + ctx: { + user: requestUser, + }, + input: { + bookingId: booking.id, + confirmed: false, + recurringEventId: booking.recurringEventId, + reason, + emailsEnabled, + platformClientParams, + }, + }); + + return this.getBooking(bookingUid); + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts new file mode 100644 index 00000000000000..9f4b56ff932688 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -0,0 +1,576 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { + bookingResponsesSchema, + seatedBookingDataSchema, +} from "@/ee/bookings/2024-08-13/services/output.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { CreationSource } from "@prisma/client"; +import { Request } from "express"; +import { DateTime } from "luxon"; +import { NextApiRequest } from "next/types"; +import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; + +import { EventTypeMetaDataSchema } from "@calcom/platform-libraries"; +import { + CancelBookingInput, + CancelBookingInput_2024_08_13, + CancelSeatedBookingInput_2024_08_13, + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + GetBookingsInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + RescheduleBookingInput, + RescheduleBookingInput_2024_08_13, + RescheduleSeatedBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { EventType, PlatformOAuthClient } from "@calcom/prisma/client"; + +type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams; + +type OAuthRequestParams = { + platformClientId: string; + platformRescheduleUrl: string; + platformCancelUrl: string; + platformBookingUrl: string; + platformBookingLocation?: string; + arePlatformEmailsEnabled: boolean; +}; + +export enum Frequency { + "YEARLY", + "MONTHLY", + "WEEKLY", + "DAILY", + "HOURLY", + "MINUTELY", + "SECONDLY", +} + +const recurringEventSchema = z.object({ + dtstart: z.string().optional(), + interval: z.number().int(), + count: z.number().int(), + freq: z.nativeEnum(Frequency), + until: z.string().optional(), +}); + +@Injectable() +export class InputBookingsService_2024_08_13 { + private readonly logger = new Logger("InputBookingsService_2024_08_13"); + + constructor( + private readonly oAuthFlowService: OAuthFlowService, + private readonly oAuthClientRepository: OAuthClientRepository, + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly config: ConfigService, + private readonly apiKeyRepository: ApiKeyRepository, + private readonly bookingSeatRepository: BookingSeatRepository + ) {} + + async createBookingRequest( + request: Request, + body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13 + ): Promise { + const bodyTransformed = await this.transformInputCreateBooking(body); + const oAuthClientParams = await this.getOAuthClientParams(body.eventTypeId); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + + const location = request.body.location || request.body.meetingUrl; + this.logger.log(`createBookingRequest_2024_04_15`, { + requestId: request.get("X-Request-Id"), + ownerId: userId, + location, + oAuthClientParams, + }); + + if (oAuthClientParams) { + Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location }); + newRequest.body = { + ...bodyTransformed, + noEmail: !oAuthClientParams.arePlatformEmailsEnabled, + creationSource: CreationSource.API_V2, + }; + } else { + Object.assign(newRequest, { userId, platformBookingLocation: location }); + newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 }; + } + + return newRequest as unknown as BookingRequest; + } + + async getOAuthClientParams(eventTypeId: number) { + const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId); + + let oAuthClient: PlatformOAuthClient | null = null; + if (eventType?.userId) { + oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId); + } else if (eventType?.teamId) { + oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId); + } + + if (oAuthClient) { + return { + platformClientId: oAuthClient.id, + platformCancelUrl: oAuthClient.bookingCancelRedirectUri, + platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri, + platformBookingUrl: oAuthClient.bookingRedirectUri, + arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled, + }; + } + + return undefined; + } + + async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) { + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam( + inputBooking.eventTypeId + ); + + if (!eventType) { + throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`); + } + + this.validateBookingLengthInMinutes(inputBooking, eventType); + + const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length; + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone( + inputBooking.attendee.timeZone + ); + const endTime = startTime.plus({ minutes: lengthInMinutes }); + + const guests = inputBooking.guests; + + return { + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: inputBooking.eventTypeId, + timeZone: inputBooking.attendee.timeZone, + language: inputBooking.attendee.language || "en", + metadata: inputBooking.metadata || {}, + hasHashedBookingLink: false, + guests, + // note(Lauris): responses with name and email are required by the handleNewBooking + responses: inputBooking.bookingFieldsResponses + ? { + ...inputBooking.bookingFieldsResponses, + name: inputBooking.attendee.name, + email: inputBooking.attendee.email ?? "", + attendeePhoneNumber: inputBooking.attendee.phoneNumber, + guests, + } + : { + name: inputBooking.attendee.name, + email: inputBooking.attendee.email ?? "", + attendeePhoneNumber: inputBooking.attendee.phoneNumber, + guests, + }, + }; + } + + validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) { + const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata); + if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) { + throw new BadRequestException( + "Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request." + ); + } + if ( + inputBooking.lengthInMinutes && + !eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes) + ) { + throw new BadRequestException( + `Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join( + ", " + )}` + ); + } + } + + async createRecurringBookingRequest( + request: Request, + body: CreateRecurringBookingInput_2024_08_13 + ): Promise { + // note(Lauris): update to this.transformInputCreate when rescheduling is implemented + const bodyTransformed = await this.transformInputCreateRecurringBooking(body); + const oAuthClientParams = await this.getOAuthClientParams(body.eventTypeId); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + + const location = request.body.location || request.body.meetingUrl; + + if (oAuthClientParams) { + Object.assign(newRequest, { + userId, + ...oAuthClientParams, + platformBookingLocation: location, + noEmail: !oAuthClientParams.arePlatformEmailsEnabled, + }); + } else { + Object.assign(newRequest, { userId, platformBookingLocation: location }); + } + + newRequest.body = bodyTransformed.map((event) => ({ + ...event, + creationSource: CreationSource.API_V2, + })); + + return newRequest as unknown as BookingRequest; + } + + async transformInputCreateRecurringBooking(inputBooking: CreateRecurringBookingInput_2024_08_13) { + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam( + inputBooking.eventTypeId + ); + if (!eventType) { + throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`); + } + if (!eventType.recurringEvent) { + throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`); + } + + const occurrence = recurringEventSchema.parse(eventType.recurringEvent); + const repeatsEvery = occurrence.interval; + + if (inputBooking.recurrenceCount && inputBooking.recurrenceCount > occurrence.count) { + throw new BadRequestException( + "Provided recurrence count is higher than the event type's recurring event count." + ); + } + const repeatsTimes = inputBooking.recurrenceCount || occurrence.count; + // note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly + const timeBetween = occurrence.freq; + + const events = []; + const recurringEventId = uuidv4(); + + let startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone( + inputBooking.attendee.timeZone + ); + + const guests = inputBooking.guests; + + for (let i = 0; i < repeatsTimes; i++) { + const endTime = startTime.plus({ minutes: eventType.length }); + + events.push({ + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: inputBooking.eventTypeId, + recurringEventId, + timeZone: inputBooking.attendee.timeZone, + language: inputBooking.attendee.language || "en", + metadata: inputBooking.metadata || {}, + hasHashedBookingLink: false, + guests, + // note(Lauris): responses with name and email are required by the handleNewBooking + responses: inputBooking.bookingFieldsResponses + ? { + ...inputBooking.bookingFieldsResponses, + name: inputBooking.attendee.name, + email: inputBooking.attendee.email, + guests, + } + : { name: inputBooking.attendee.name, email: inputBooking.attendee.email, guests }, + schedulingType: eventType.schedulingType, + }); + + switch (timeBetween) { + case 0: // Yearly + startTime = startTime.plus({ years: repeatsEvery }); + break; + case 1: // Monthly + startTime = startTime.plus({ months: repeatsEvery }); + break; + case 2: // Weekly + startTime = startTime.plus({ weeks: repeatsEvery }); + break; + default: + throw new Error("Unsupported timeBetween value"); + } + } + + return events; + } + + async createRescheduleBookingRequest( + request: Request, + bookingUid: string, + body: RescheduleBookingInput + ): Promise { + const bodyTransformed = this.isRescheduleSeatedBody(body) + ? await this.transformInputRescheduleSeatedBooking(bookingUid, body) + : await this.transformInputRescheduleBooking(bookingUid, body); + + const oAuthClientParams = await this.getOAuthClientParams(bodyTransformed.eventTypeId); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + + const location = await this.getRescheduleBookingLocation(bookingUid); + if (oAuthClientParams) { + Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location }); + newRequest.body = { + ...bodyTransformed, + noEmail: !oAuthClientParams.arePlatformEmailsEnabled, + creationSource: CreationSource.API_V2, + }; + } else { + Object.assign(newRequest, { userId, platformBookingLocation: location }); + newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 }; + } + + return newRequest as unknown as BookingRequest; + } + + isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 { + return body.hasOwnProperty("seatUid"); + } + + async transformInputRescheduleSeatedBooking( + bookingUid: string, + inputBooking: RescheduleSeatedBookingInput_2024_08_13 + ) { + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); + // todo create booking seat module, repository and fetch the seat to get info + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} not found`); + } + if (!booking.eventTypeId) { + throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`); + } + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId); + if (!eventType) { + throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`); + } + + const seat = await this.bookingSeatRepository.getByReferenceUid(inputBooking.seatUid); + if (!seat) { + throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`); + } + + const { responses: bookingResponses } = seatedBookingDataSchema.parse(seat.data); + const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email); + + if (!attendee) { + throw new NotFoundException( + `Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} and seatUid=${inputBooking.seatUid} not found` + ); + } + + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone); + const endTime = startTime.plus({ minutes: eventType.length }); + + return { + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: eventType.id, + timeZone: attendee.timeZone, + language: attendee.locale, + metadata: seat.metadata || {}, + hasHashedBookingLink: false, + guests: [], + responses: { ...bookingResponses }, + rescheduleUid: inputBooking.seatUid, + }; + } + + async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) { + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} not found`); + } + if (!booking.eventTypeId) { + throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`); + } + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId); + if (!eventType) { + throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`); + } + + const bookingResponses = bookingResponsesSchema.parse(booking.responses); + const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email); + + if (!attendee) { + throw new NotFoundException( + `Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} not found` + ); + } + + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone); + const endTime = startTime.plus({ minutes: eventType.length }); + + return { + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: eventType.id, + timeZone: attendee.timeZone, + language: attendee.locale, + metadata: booking.metadata || {}, + hasHashedBookingLink: false, + guests: bookingResponses.guests, + responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason }, + rescheduleUid: bookingUid, + }; + } + + async getRescheduleBookingLocation(rescheduleBookingUid: string) { + const booking = await this.bookingsRepository.getByUid(rescheduleBookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${rescheduleBookingUid} not found`); + } + return booking.location; + } + + private async createBookingRequestOwnerId(req: Request): Promise { + try { + const bearerToken = req.get("Authorization")?.replace("Bearer ", ""); + if (bearerToken) { + if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) { + const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix")); + const apiKeyHash = hashAPIKey(strippedApiKey); + const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); + return keyData?.userId; + } else { + // Access Token + return this.oAuthFlowService.getOwnerId(bearerToken); + } + } + } catch (err) { + this.logger.error(err); + } + } + + transformGetBookingsFilters(queryParams: GetBookingsInput_2024_08_13) { + return { + attendeeEmail: queryParams.attendeeEmail, + attendeeName: queryParams.attendeeName, + afterStartDate: queryParams.afterStart, + beforeEndDate: queryParams.beforeEnd, + teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined), + eventTypeIds: + queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined), + }; + } + + transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) { + if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) { + return undefined; + } + + return { + sortStart: queryParams.sortStart, + sortEnd: queryParams.sortEnd, + sortCreated: queryParams.sortCreated, + }; + } + + async createCancelBookingRequest( + request: Request, + bookingUid: string, + body: CancelBookingInput + ): Promise { + const bodyTransformed = this.isCancelSeatedBody(body) + ? await this.transformInputCancelSeatedBooking(bookingUid, body) + : await this.transformInputCancelBooking(bookingUid, body); + + const booking = await this.bookingsRepository.getByUid(bodyTransformed.uid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} not found`); + } + + const oAuthClientParams = booking.eventTypeId + ? await this.getOAuthClientParams(booking.eventTypeId) + : undefined; + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + + if (oAuthClientParams) { + Object.assign(newRequest, { userId, ...oAuthClientParams }); + newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled }; + } else { + Object.assign(newRequest, { userId }); + newRequest.body = { ...bodyTransformed, noEmail: false }; + } + + return newRequest as unknown as BookingRequest; + } + + isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 { + return body.hasOwnProperty("seatUid"); + } + + async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) { + const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid); + // note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings. + // That is what recurringEventId refers to. + const isRecurringUid = !!recurringBooking.length; + + if (isRecurringUid && inputBooking.cancelSubsequentBookings) { + throw new BadRequestException( + "Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking." + ); + } + + if (isRecurringUid) { + return { + // note(Lauris): set uid as one of the oldest individual recurring ids + uid: recurringBooking[0].uid, + cancellationReason: inputBooking.cancellationReason, + allRemainingBookings: true, + }; + } + + if (inputBooking.cancelSubsequentBookings) { + return { + uid: bookingUid, + cancellationReason: inputBooking.cancellationReason, + cancelSubsequentBookings: true, + }; + } + + return { + uid: bookingUid, + cancellationReason: inputBooking.cancellationReason, + allRemainingBookings: false, + }; + } + + async transformInputCancelSeatedBooking( + bookingUid: string, + inputBooking: CancelSeatedBookingInput_2024_08_13 + ) { + // note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings + // for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it. + return { + uid: bookingUid, + cancellationReason: "", + allRemainingBookings: false, + seatReferenceUid: inputBooking.seatUid, + }; + } + + transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) { + return { + noShowHost: inputBooking.host, + attendees: inputBooking.attendees?.map((attendee) => ({ + email: attendee.email, + noShow: attendee.absent, + })), + }; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts new file mode 100644 index 00000000000000..a18abfbe815b5a --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts @@ -0,0 +1,425 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { + defaultBookingMetadata, + defaultBookingResponses, + defaultSeatedBookingData, + defaultSeatedBookingMetadata, +} from "@/lib/safe-parse/default-responses-booking"; +import { safeParse } from "@/lib/safe-parse/safe-parse"; +import { Injectable } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import { DateTime } from "luxon"; +import { z } from "zod"; + +import { bookingMetadataSchema } from "@calcom/platform-libraries"; +import { + BookingOutput_2024_08_13, + CreateRecurringSeatedBookingOutput_2024_08_13, + CreateSeatedBookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + ReassignBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + SeatedAttendee, +} from "@calcom/platform-types"; +import { Booking, BookingSeat } from "@calcom/prisma/client"; + +export const bookingResponsesSchema = z + .object({ + email: z.string(), + name: z.union([ + z.string(), + z.object({ + firstName: z.string(), + lastName: z.string(), + }), + ]), + guests: z.array(z.string()).optional(), + rescheduleReason: z.string().optional(), + }) + .passthrough() + .describe("BookingResponses"); + +export const seatedBookingDataSchema = z + .object({ + responses: z + .object({ + email: z.string(), + name: z.union([ + z.string(), + z.object({ + firstName: z.string(), + lastName: z.string(), + }), + ]), + }) + .passthrough(), + }) + .passthrough() + .describe("SeatedBookingData"); + +const seatedBookingMetadataSchema = z.object({}).catchall(z.string()).describe("SeatedBookingMetadata"); + +type DatabaseUser = { id: number; name: string | null; email: string; username: string | null }; + +type DatabaseBooking = Booking & { + eventType: { + id: number; + slug: string; + } | null; + attendees: { + name: string; + email: string; + timeZone: string; + locale: string | null; + phoneNumber?: string | null; + noShow: boolean | null; + bookingSeat?: BookingSeat | null; + }[]; + user: DatabaseUser | null; + createdAt: Date; +}; + +type BookingWithUser = Booking & { user: DatabaseUser | null }; + +type DatabaseMetadata = z.infer; + +@Injectable() +export class OutputBookingsService_2024_08_13 { + constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {} + + getOutputBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + const bookingResponses = safeParse( + bookingResponsesSchema, + databaseBooking.responses, + defaultBookingResponses + ); + const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata); + const location = metadata?.videoCallUrl || databaseBooking.location; + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + title: databaseBooking.title, + description: databaseBooking.description, + hosts: [this.getHost(databaseBooking.user)], + status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, + reschedulingReason: bookingResponses?.rescheduledReason, + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventType: databaseBooking.eventType, + // note(Lauris): eventTypeId is deprecated + eventTypeId: databaseBooking.eventTypeId, + attendees: databaseBooking.attendees.map((attendee) => ({ + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + phoneNumber: attendee.phoneNumber ?? undefined, + })), + guests: bookingResponses.guests, + location, + // note(Lauris): meetingUrl is deprecated + meetingUrl: location, + absentHost: !!databaseBooking.noShowHost, + createdAt: databaseBooking.createdAt, + }; + + const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); + // note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually + bookingTransformed.bookingFieldsResponses = bookingResponses; + bookingTransformed.metadata = this.getUserDefinedMetadata(metadata); + return bookingTransformed; + } + + getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) { + if (databaseMetadata === null) return {}; + + const { videoCallUrl, ...userDefinedMetadata } = databaseMetadata; + + return userDefinedMetadata; + } + + getHost(user: DatabaseUser | null) { + if (!user) { + return { + id: "unknown", + name: "unknown", + email: "unknown", + username: "unknown", + }; + } + + return { + ...user, + username: user.username || "unknown", + }; + } + + async getOutputRecurringBookings(bookingsIds: number[]) { + const transformed = []; + + for (const bookingId of bookingsIds) { + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(bookingId); + if (!databaseBooking) { + throw new Error(`Booking with id=${bookingId} was not found in the database`); + } + + transformed.push(this.getOutputRecurringBooking(databaseBooking)); + } + + return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + } + + getOutputRecurringBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + const bookingResponses = safeParse( + bookingResponsesSchema, + databaseBooking.responses, + defaultBookingResponses + ); + const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata); + const location = metadata?.videoCallUrl || databaseBooking.location; + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + title: databaseBooking.title, + description: databaseBooking.description, + hosts: [this.getHost(databaseBooking.user)], + status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, + reschedulingReason: bookingResponses?.rescheduledReason, + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventType: databaseBooking.eventType, + // note(Lauris): eventTypeId is deprecated + eventTypeId: databaseBooking.eventTypeId, + attendees: databaseBooking.attendees.map((attendee) => ({ + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + })), + guests: bookingResponses.guests, + location, + // note(Lauris): meetingUrl is deprecated + meetingUrl: location, + recurringBookingUid: databaseBooking.recurringEventId, + absentHost: !!databaseBooking.noShowHost, + bookingFieldsResponses: databaseBooking.responses, + createdAt: databaseBooking.createdAt, + }; + + const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, { + strategy: "excludeAll", + }); + // note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually + bookingTransformed.bookingFieldsResponses = bookingResponses; + bookingTransformed.metadata = this.getUserDefinedMetadata(metadata); + return bookingTransformed; + } + + getOutputCreateSeatedBooking( + databaseBooking: DatabaseBooking, + seatUid: string + ): CreateSeatedBookingOutput_2024_08_13 { + const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking); + return { ...getSeatedBookingOutput, seatUid }; + } + + getOutputSeatedBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata); + const location = metadata?.videoCallUrl || databaseBooking.location; + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + title: databaseBooking.title, + description: databaseBooking.description, + hosts: [this.getHost(databaseBooking.user)], + status: databaseBooking.status.toLowerCase(), + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventType: databaseBooking.eventType, + // note(Lauris): eventTypeId is deprecated + eventTypeId: databaseBooking.eventTypeId, + attendees: [], + location, + // note(Lauris): meetingUrl is deprecated + meetingUrl: location, + absentHost: !!databaseBooking.noShowHost, + createdAt: databaseBooking.createdAt, + }; + + const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); + + // note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually + parsed.attendees = databaseBooking.attendees.map((attendee) => { + const { responses } = safeParse( + seatedBookingDataSchema, + attendee.bookingSeat?.data, + defaultSeatedBookingData + ); + + const attendeeData = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + seatUid: attendee.bookingSeat?.referenceUid, + bookingFieldsResponses: {}, + }; + const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); + attendeeParsed.bookingFieldsResponses = responses || {}; + attendeeParsed.metadata = safeParse( + seatedBookingMetadataSchema, + attendee.bookingSeat?.metadata, + defaultSeatedBookingMetadata + ); + // note(Lauris): as of now email is not returned for privacy + delete attendeeParsed.bookingFieldsResponses.email; + + return attendeeParsed; + }); + + return parsed; + } + + async getOutputRecurringSeatedBookings(bookingsIds: number[]) { + const transformed = []; + + for (const bookingId of bookingsIds) { + const databaseBooking = + await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(bookingId); + if (!databaseBooking) { + throw new Error(`Booking with id=${bookingId} was not found in the database`); + } + + transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking)); + } + + return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + } + + async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) { + const transformed = []; + + for (const booking of bookings) { + const databaseBooking = + await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid); + if (!databaseBooking) { + throw new Error(`Booking with uid=${booking.uid} was not found in the database`); + } + transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid)); + } + + return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + } + + getOutputCreateRecurringSeatedBooking( + databaseBooking: DatabaseBooking, + seatUid: string + ): CreateRecurringSeatedBookingOutput_2024_08_13 { + const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking); + return { ...getRecurringSeatedBookingOutput, seatUid }; + } + + getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata); + const location = metadata?.videoCallUrl || databaseBooking.location; + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + title: databaseBooking.title, + description: databaseBooking.description, + hosts: [this.getHost(databaseBooking.user)], + status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventType: databaseBooking.eventType, + // note(Lauris): eventTypeId is deprecated + eventTypeId: databaseBooking.eventTypeId, + attendees: [], + location, + // note(Lauris): meetingUrl is deprecated + meetingUrl: location, + recurringBookingUid: databaseBooking.recurringEventId, + absentHost: !!databaseBooking.noShowHost, + createdAt: databaseBooking.createdAt, + }; + + const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, { + strategy: "excludeAll", + }); + + // note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually + parsed.attendees = databaseBooking.attendees.map((attendee) => { + const { responses } = safeParse( + seatedBookingDataSchema, + attendee.bookingSeat?.data, + defaultSeatedBookingData + ); + + const attendeeData = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + seatUid: attendee.bookingSeat?.referenceUid, + bookingFieldsResponses: {}, + }; + const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); + attendeeParsed.bookingFieldsResponses = responses || {}; + attendeeParsed.metadata = safeParse( + seatedBookingMetadataSchema, + attendee.bookingSeat?.metadata, + defaultSeatedBookingMetadata + ); + // note(Lauris): as of now email is not returned for privacy + delete attendeeParsed.bookingFieldsResponses.email; + return attendeeParsed; + }); + + return parsed; + } + + getOutputReassignedBooking( + databaseBooking: Pick + ): ReassignBookingOutput_2024_08_13 { + return { + bookingUid: databaseBooking.uid, + reassignedTo: { + id: databaseBooking?.user?.id || 0, + name: databaseBooking?.user?.name || "unknown", + email: databaseBooking?.user?.email || "unknown", + }, + }; + } +} diff --git a/apps/api/v2/src/ee/calendars/calendars.interface.ts b/apps/api/v2/src/ee/calendars/calendars.interface.ts new file mode 100644 index 00000000000000..03fca182dd6ccd --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.interface.ts @@ -0,0 +1,28 @@ +import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output"; +import { Request } from "express"; + +import { ApiResponse } from "@calcom/platform-types"; + +export interface CalendarApp { + save(state: string, code: string, origin: string): Promise<{ url: string }>; + check(userId: number): Promise; +} + +export interface CredentialSyncCalendarApp { + save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>; + check(userId: number): Promise; +} + +export interface ICSFeedCalendarApp { + save( + userId: number, + userEmail: string, + urls: string[], + readonly?: boolean + ): Promise; + check(userId: number): Promise; +} + +export interface OAuthCalendarApp extends CalendarApp { + connect(authorization: string, req: Request): Promise>; +} diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts new file mode 100644 index 00000000000000..09c367369fe818 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -0,0 +1,32 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller"; +import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service"; +import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service"; +import { OutlookService } from "@/ee/calendars/services/outlook.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, UsersModule, TokensModule], + providers: [ + CredentialsRepository, + CalendarsService, + OutlookService, + GoogleCalendarService, + AppleCalendarService, + IcsFeedService, + SelectedCalendarsRepository, + AppsRepository, + CalendarsRepository, + ], + controllers: [CalendarsController], + exports: [CalendarsService], +}) +export class CalendarsModule {} diff --git a/apps/api/v2/src/ee/calendars/calendars.repository.ts b/apps/api/v2/src/ee/calendars/calendars.repository.ts new file mode 100644 index 00000000000000..9d1c08acc7f54b --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.repository.ts @@ -0,0 +1,51 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +const credentialForCalendarRepositorySelect = Prisma.validator()({ + id: true, + appId: true, + type: true, + userId: true, + user: { + select: { + email: true, + }, + }, + teamId: true, + key: true, + invalid: true, +}); + +@Injectable() +export class CalendarsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getCalendarCredentials(credentialId: number, userId: number) { + return await this.dbRead.prisma.credential.findFirst({ + where: { + id: credentialId, + userId, + }, + select: { + ...credentialForCalendarRepositorySelect, + app: { + select: { + slug: true, + categories: true, + dirName: true, + }, + }, + }, + }); + } + + async deleteCredentials(credentialId: number) { + return await this.dbWrite.prisma.credential.delete({ + where: { + id: credentialId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts new file mode 100644 index 00000000000000..84db5e556d9ac8 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts @@ -0,0 +1,279 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output"; +import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { CalendarsServiceMock } from "test/mocks/calendars-service-mock"; +import { IcsCalendarServiceMock } from "test/mocks/ics-calendar-service-mock"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + GOOGLE_CALENDAR, + OFFICE_365_CALENDAR, + GOOGLE_CALENDAR_TYPE, + GOOGLE_CALENDAR_ID, +} from "@calcom/platform-constants"; +import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants"; +import { ICS_CALENDAR } from "@calcom/platform-constants/apps"; +import { IcsFeedCalendarService } from "@calcom/platform-libraries"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Calendars Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let user: User; + let office365Credentials: Credential; + let googleCalendarCredentials: Credential; + let accessTokenSecret: string; + let refreshTokenSecret: string; + let icsCalendarCredentials: CreateIcsFeedOutput; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: `calendars-organization-${randomString()}` }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser( + `calendars-user-${randomString()}@api.com`, + oAuthClient.id + ); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + await app.init(); + jest + .spyOn(CalendarsService.prototype, "getCalendars") + .mockImplementation(CalendarsServiceMock.prototype.getCalendars); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`) + .set("Authorization", `Bearer invalid_access_token`) + .expect(401); + }); + + // TODO: Uncomment this once CI is ready to run proper Office365 tests + // it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar OAuth with valid access token `, async () => { + // const response = await request(app.getHttpServer()) + // .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`) + // .set("Authorization", `Bearer ${accessTokenSecret}`) + // .set("Origin", CLIENT_REDIRECT_URI) + // .expect(200); + // const data = response.body.data; + // expect(data.authUrl).toBeDefined(); + // }); + + it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => { + const response = await request(app.getHttpServer()) + .get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + const data = response.body.data; + expect(data.authUrl).toBeDefined(); + }); + + it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/random-calendar/connect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access` + ) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access` + ) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => { + await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => { + office365Credentials = await credentialsRepositoryFixture.create( + OFFICE_365_CALENDAR_TYPE, + {}, + user.id, + OFFICE_365_CALENDAR_ID + ); + await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => { + googleCalendarCredentials = await credentialsRepositoryFixture.create( + GOOGLE_CALENDAR_TYPE, + {}, + user.id, + GOOGLE_CALENDAR_ID + ); + await request(app.getHttpServer()) + .get(`/v2/calendars/${GOOGLE_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should fail to create a new ics feed calendar credentials with invalid urls`, async () => { + const body = { + urls: ["https://cal.com/ics/feed.ics", "https://not-an-ics-feed.com"], + readOnly: false, + }; + await request(app.getHttpServer()) + .post(`/v2/calendars/${ICS_CALENDAR}/save`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .send(body) + .expect(400); + }); + + it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should create a new ics feed calendar credentials`, async () => { + const body = { + urls: ["https://cal.com/ics/feed.ics"], + readOnly: false, + }; + jest + .spyOn(IcsFeedCalendarService.prototype, "listCalendars") + .mockImplementation(IcsCalendarServiceMock.prototype.listCalendars); + await request(app.getHttpServer()) + .post(`/v2/calendars/${ICS_CALENDAR}/save`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: CreateIcsFeedOutputResponseDto = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.userId).toBeDefined(); + expect(responseBody.data.userId).toEqual(user.id); + expect(responseBody.data.id).toBeDefined(); + icsCalendarCredentials = responseBody.data; + }); + }); + + it(`/GET/v2/calendars/${ICS_CALENDAR}/check with access token`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/${ICS_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => { + const body = { + id: 10, + }; + + return request(app.getHttpServer()) + .post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: Promise = response.body; + + expect((await responseBody).status).toEqual(SUCCESS_STATUS); + expect((await responseBody).data).toBeDefined(); + expect((await responseBody).data.id).toEqual(body.id); + expect((await responseBody).data.userId).toEqual(user.id); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await credentialsRepositoryFixture.delete(office365Credentials.id); + await credentialsRepositoryFixture.delete(googleCalendarCredentials.id); + await credentialsRepositoryFixture.delete(icsCalendarCredentials.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts new file mode 100644 index 00000000000000..515aa64d5ebe31 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -0,0 +1,247 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CreateIcsFeedInputDto } from "@/ee/calendars/input/create-ics.input"; +import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output"; +import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input"; +import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output"; +import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output"; +import { + DeletedCalendarCredentialsOutputResponseDto, + DeletedCalendarCredentialsOutputDto, +} from "@/ee/calendars/outputs/delete-calendar-credentials.output"; +import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service"; +import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service"; +import { OutlookService } from "@/ee/calendars/services/outlook.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Get, + UseGuards, + Query, + HttpStatus, + HttpCode, + Req, + Param, + Headers, + Redirect, + BadRequestException, + Post, + Body, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { User } from "@prisma/client"; +import { plainToClass } from "class-transformer"; +import { Request } from "express"; +import { z } from "zod"; + +import { APPS_READ } from "@calcom/platform-constants"; +import { + SUCCESS_STATUS, + CALENDARS, + GOOGLE_CALENDAR, + OFFICE_365_CALENDAR, + APPLE_CALENDAR, + CREDENTIAL_CALENDARS, +} from "@calcom/platform-constants"; +import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/calendars", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Calendars") +export class CalendarsController { + constructor( + private readonly calendarsService: CalendarsService, + private readonly outlookService: OutlookService, + private readonly googleCalendarService: GoogleCalendarService, + private readonly appleCalendarService: AppleCalendarService, + private readonly icsFeedService: IcsFeedService, + private readonly calendarsRepository: CalendarsRepository + ) {} + + @Post("/ics-feed/save") + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Save an ICS feed" }) + async createIcsFeed( + @GetUser("id") userId: number, + @GetUser("email") userEmail: string, + @Body() body: CreateIcsFeedInputDto + ): Promise { + return await this.icsFeedService.save(userId, userEmail, body.urls, body.readOnly); + } + + @Get("/ics-feed/check") + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Check an ICS feed" }) + async checkIcsFeed(@GetUser("id") userId: number): Promise { + return await this.icsFeedService.check(userId); + } + + @UseGuards(ApiAuthGuard) + @Get("/busy-times") + @ApiOperation({ + summary: "Get busy times", + description: + "Get busy times from a calendar. Example request URL is `https://api.cal.com/v2/calendars/busy-times?loggedInUsersTz=Europe%2FMadrid&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=skrauciz%40gmail.com`", + }) + async getBusyTimes( + @Query() queryParams: CalendarBusyTimesInput, + @GetUser() user: UserWithProfile + ): Promise { + const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams; + + const busyTimes = await this.calendarsService.getBusyTimes( + calendarsToLoad, + user.id, + dateFrom, + dateTo, + loggedInUsersTz + ); + + return { + status: SUCCESS_STATUS, + data: busyTimes, + }; + } + + @Get("/") + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Get all calendars" }) + async getCalendars(@GetUser("id") userId: number): Promise { + const calendars = await this.calendarsService.getCalendars(userId); + + return { + status: SUCCESS_STATUS, + data: calendars, + }; + } + + @UseGuards(ApiAuthGuard) + @Get("/:calendar/connect") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get connect URL" }) + async redirect( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @Param("calendar") calendar: string, + @Query("redir") redir?: string | null + ): Promise> { + switch (calendar) { + case OFFICE_365_CALENDAR: + return await this.outlookService.connect(authorization, req, redir ?? ""); + case GOOGLE_CALENDAR: + return await this.googleCalendarService.connect(authorization, req, redir ?? ""); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } + + @Get("/:calendar/save") + @HttpCode(HttpStatus.OK) + @Redirect(undefined, 301) + @ApiOperation({ summary: "Save a calendar" }) + async save( + @Query("state") state: string, + @Query("code") code: string, + @Param("calendar") calendar: string + ): Promise<{ url: string }> { + // state params contains our user access token + const stateParams = new URLSearchParams(state); + const { accessToken, origin, redir } = z + .object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() }) + .parse({ + accessToken: stateParams.get("accessToken"), + origin: stateParams.get("origin"), + redir: stateParams.get("redir"), + }); + switch (calendar) { + case OFFICE_365_CALENDAR: + return await this.outlookService.save(code, accessToken, origin, redir ?? ""); + case GOOGLE_CALENDAR: + return await this.googleCalendarService.save(code, accessToken, origin, redir ?? ""); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } + + @UseGuards(ApiAuthGuard) + @Post("/:calendar/credentials") + @ApiOperation({ summary: "Sync credentials" }) + async syncCredentials( + @GetUser() user: User, + @Param("calendar") calendar: string, + @Body() body: { username: string; password: string } + ): Promise<{ status: string }> { + const { username, password } = body; + + switch (calendar) { + case APPLE_CALENDAR: + return await this.appleCalendarService.save(user.id, user.email, username, password); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CREDENTIAL_CALENDARS.join(", ") + ); + } + } + + @Get("/:calendar/check") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @Permissions([APPS_READ]) + @ApiOperation({ summary: "Check a calendar connection" }) + async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise { + switch (calendar) { + case OFFICE_365_CALENDAR: + return await this.outlookService.check(userId); + case GOOGLE_CALENDAR: + return await this.googleCalendarService.check(userId); + case APPLE_CALENDAR: + return await this.appleCalendarService.check(userId); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } + + @UseGuards(ApiAuthGuard) + @Post("/:calendar/disconnect") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Disconnect a calendar" }) + async deleteCalendarCredentials( + @Param("calendar") calendar: string, + @Body() body: DeleteCalendarCredentialsInputBodyDto, + @GetUser() user: UserWithProfile + ): Promise { + const { id: credentialId } = body; + await this.calendarsService.checkCalendarCredentials(credentialId, user.id); + + const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials( + credentialId + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass( + DeletedCalendarCredentialsOutputDto, + { id, type, userId, teamId, appId, invalid }, + { strategy: "excludeAll" } + ), + }; + } +} diff --git a/apps/api/v2/src/ee/calendars/input/create-ics.input.ts b/apps/api/v2/src/ee/calendars/input/create-ics.input.ts new file mode 100644 index 00000000000000..3a605af9e42d91 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/input/create-ics.input.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + ArrayNotEmpty, + IsBoolean, + IsOptional, + Validate, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; +import { IsNotEmpty, IsArray } from "class-validator"; + +// Custom constraint to validate ICS URLs +@ValidatorConstraint({ async: false }) +export class IsICSUrlConstraint implements ValidatorConstraintInterface { + validate(url: unknown) { + if (typeof url !== "string") return false; + + // Check if it's a valid URL and ends with .ics + try { + const urlObject = new URL(url); + return ( + (urlObject.protocol === "http:" || urlObject.protocol === "https:") && + urlObject.pathname.endsWith(".ics") + ); + } catch (error) { + return false; + } + } + + defaultMessage() { + return "The URL must be a valid ICS URL (ending with .ics)"; + } +} + +export class CreateIcsFeedInputDto { + @ApiProperty({ + example: ["https://cal.com/ics/feed.ics", "http://cal.com/ics/feed.ics"], + description: "An array of ICS URLs", + type: "array", + items: { + type: "string", + example: "https://cal.com/ics/feed.ics", + }, + required: true, + }) + @IsArray() + @ArrayNotEmpty() + @IsNotEmpty({ each: true }) + @Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array + urls!: string[]; + + @IsBoolean() + @ApiPropertyOptional({ + example: false, + description: "Whether to allowing writing to the calendar or not", + type: "boolean", + default: true, + }) + @IsOptional() + readOnly?: boolean = true; +} diff --git a/apps/api/v2/src/ee/calendars/input/create-ics.output.ts b/apps/api/v2/src/ee/calendars/input/create-ics.output.ts new file mode 100644 index 00000000000000..6f9de1c3002020 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/input/create-ics.output.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class CreateIcsFeedOutput { + @IsInt() + @Expose() + @ApiProperty({ example: 1234567890, description: "The id of the calendar credential" }) + readonly id!: number; + + @IsString() + @Expose() + @ApiProperty({ example: "ics-feed_calendar", description: "The type of the calendar" }) + readonly type!: string; + + @IsInt() + @Expose() + @ApiProperty({ + example: 1234567890, + description: "The user id of the user that created the calendar", + type: "integer", + }) + readonly userId!: number | null; + + @IsInt() + @Expose() + @ApiProperty({ + example: 1234567890, + nullable: true, + description: "The team id of the user that created the calendar", + type: "integer", + }) + readonly teamId!: number | null; + + @IsString() + @Expose() + @ApiProperty({ example: "ics-feed", description: "The slug of the calendar" }) + readonly appId!: string | null; + + @IsBoolean() + @Expose() + @ApiProperty({ example: false, description: "Whether the calendar credentials are valid or not" }) + readonly invalid!: boolean | null; +} + +export class CreateIcsFeedOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => CreateIcsFeedOutput) + data!: CreateIcsFeedOutput; +} diff --git a/apps/api/v2/src/ee/calendars/input/delete-calendar-credentials.input.ts b/apps/api/v2/src/ee/calendars/input/delete-calendar-credentials.input.ts new file mode 100644 index 00000000000000..31e4aa2b63581c --- /dev/null +++ b/apps/api/v2/src/ee/calendars/input/delete-calendar-credentials.input.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsInt } from "class-validator"; + +export class DeleteCalendarCredentialsInputBodyDto { + @IsInt() + @Expose() + @ApiProperty({ + example: 10, + description: "Credential ID of the calendar to delete, as returned by the /calendars endpoint", + type: "integer", + required: true, + }) + readonly id!: number; +} diff --git a/apps/api/v2/src/ee/calendars/outputs/busy-times.output.ts b/apps/api/v2/src/ee/calendars/outputs/busy-times.output.ts new file mode 100644 index 00000000000000..ff9913ae8cd4ec --- /dev/null +++ b/apps/api/v2/src/ee/calendars/outputs/busy-times.output.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class BusyTimesOutput { + @IsDate() + @ApiProperty({ type: Date }) + start!: Date; + + @IsDate() + @ApiProperty({ type: Date }) + end!: Date; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, nullable: true }) + source?: string | null; +} + +export class GetBusyTimesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @Type(() => BusyTimesOutput) + @IsArray() + @ApiProperty({ type: [BusyTimesOutput] }) + data!: BusyTimesOutput[]; +} diff --git a/apps/api/v2/src/ee/calendars/outputs/connected-calendars.output.ts b/apps/api/v2/src/ee/calendars/outputs/connected-calendars.output.ts new file mode 100644 index 00000000000000..1adbcebdc6e4bb --- /dev/null +++ b/apps/api/v2/src/ee/calendars/outputs/connected-calendars.output.ts @@ -0,0 +1,270 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsObject, + IsOptional, + IsString, + IsUrl, + ValidateNested, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Integration { + @IsOptional() + @IsObject() + @ApiPropertyOptional({ type: Object, nullable: true }) + appData?: object | null; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + dirName?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + __template?: string; + + @IsString() + @ApiProperty() + name!: string; + + @IsString() + @ApiProperty() + description!: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + installed?: boolean; + + @IsString() + @ApiProperty() + type!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + title?: string; + + @IsString() + @ApiProperty() + variant!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + category?: string; + + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: [String] }) + categories!: string[]; + + @IsString() + @ApiProperty() + logo!: string; + + @IsString() + @ApiProperty() + publisher!: string; + + @IsString() + @ApiProperty() + slug!: string; + + @IsUrl() + @ApiProperty() + url!: string; + + @IsEmail() + @ApiProperty() + email!: string; + + @IsObject() + @ApiProperty({ type: Object, nullable: true }) + locationOption!: object | null; +} + +class Primary { + @IsEmail() + @ApiProperty() + externalId!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + integration?: string; + + @IsOptional() + @IsEmail() + @ApiPropertyOptional() + name?: string; + + @IsBoolean() + @ApiProperty({ type: Boolean, nullable: true }) + primary!: boolean | null; + + @IsBoolean() + @ApiProperty() + readOnly!: boolean; + + @IsEmail() + @IsOptional() + @ApiPropertyOptional() + email?: string; + + @IsBoolean() + @ApiProperty() + isSelected!: boolean; + + @IsInt() + @ApiProperty() + credentialId!: number; +} + +export class Calendar { + @IsEmail() + @ApiProperty() + externalId!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + integration?: string; + + @IsEmail() + @IsOptional() + @ApiPropertyOptional() + name?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, nullable: true }) + primary?: boolean | null; + + @IsBoolean() + @ApiProperty() + readOnly!: boolean; + + @IsEmail() + @IsOptional() + @ApiPropertyOptional() + email?: string; + + @IsBoolean() + @ApiProperty() + isSelected!: boolean; + + @IsInt() + @ApiProperty() + credentialId!: number; +} + +export class ConnectedCalendar { + @ValidateNested() + @IsObject() + @ApiProperty({ type: Integration }) + integration!: Integration; + + @IsInt() + @ApiProperty() + credentialId!: number; + + @ValidateNested() + @IsObject() + @IsOptional() + @ApiPropertyOptional({ type: Primary }) + primary?: Primary; + + @ValidateNested({ each: true }) + @IsArray() + @IsOptional() + @ApiPropertyOptional({ type: [Calendar] }) + calendars?: Calendar[]; +} + +class DestinationCalendar { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty() + integration!: string; + + @IsEmail() + @ApiProperty() + externalId!: string; + + @IsEmail() + @ApiProperty({ type: String, nullable: true }) + primaryEmail!: string | null; + + @IsInt() + @ApiProperty({ nullable: true }) + userId!: number | null; + + @IsOptional() + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + eventTypeId!: number | null; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + credentialId!: number | null; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + name?: string | null; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + primary?: boolean; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + readOnly?: boolean; + + @IsEmail() + @IsOptional() + @ApiPropertyOptional() + email?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + integrationTitle?: string; +} + +export class ConnectedCalendarsData { + @ValidateNested({ each: true }) + @IsArray() + @ApiProperty({ type: [ConnectedCalendar] }) + connectedCalendars!: ConnectedCalendar[]; + + @ValidateNested() + @IsObject() + @ApiProperty({ type: DestinationCalendar }) + destinationCalendar!: DestinationCalendar; +} + +export class ConnectedCalendarsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @Type(() => ConnectedCalendarsData) + @ApiProperty({ type: ConnectedCalendarsData }) + data!: ConnectedCalendarsData; +} diff --git a/apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.output.ts b/apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.output.ts new file mode 100644 index 00000000000000..fe10dfbd16d0d3 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.output.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class DeletedCalendarCredentialsOutputDto { + @IsInt() + @Expose() + readonly id!: number; + + @IsString() + @Expose() + readonly type!: string; + + @IsInt() + @Expose() + readonly userId!: number | null; + + @IsInt() + @Expose() + readonly teamId!: number | null; + + @IsString() + @Expose() + readonly appId!: string | null; + + @IsBoolean() + @Expose() + readonly invalid!: boolean | null; +} + +export class DeletedCalendarCredentialsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => DeletedCalendarCredentialsOutputDto) + data!: DeletedCalendarCredentialsOutputDto; +} diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts new file mode 100644 index 00000000000000..d54f4b0df597dd --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -0,0 +1,137 @@ +import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants"; +import { symmetricEncrypt, CalendarService, symmetricDecrypt } from "@calcom/platform-libraries"; +import { Credential } from "@calcom/prisma/client"; + +@Injectable() +export class AppleCalendarService implements CredentialSyncCalendarApp { + constructor( + private readonly calendarsService: CalendarsService, + private readonly credentialRepository: CredentialsRepository + ) {} + + async save( + userId: number, + userEmail: string, + username: string, + password: string + ): Promise<{ status: string }> { + return await this.saveCalendarCredentials(userId, userEmail, username, password); + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + return await this.checkIfCalendarConnected(userId); + } + + async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId( + APPLE_CALENDAR_TYPE, + userId + ); + + if (!appleCalendarCredentials) { + throw new BadRequestException("Credentials for apple calendar not found."); + } + + if (appleCalendarCredentials.invalid) { + throw new BadRequestException("Invalid apple calendar credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const appleCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE + ); + if (!appleCalendar) { + throw new UnauthorizedException("Apple calendar not connected."); + } + if (appleCalendar.error?.message) { + throw new UnauthorizedException(appleCalendar.error?.message); + } + + return { + status: SUCCESS_STATUS, + }; + } + + async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) { + if (username.length <= 1 || password.length <= 1) + throw new BadRequestException(`Username or password cannot be empty`); + + const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId( + APPLE_CALENDAR_TYPE, + userId + ); + + let hasMatchingUsernameAndPassword = false; + + if (existingAppleCalendarCredentials.length > 0) { + const hasCalendarWithGivenCredentials = existingAppleCalendarCredentials.find( + (calendarCredential: Credential) => { + const decryptedKey = JSON.parse( + symmetricDecrypt(calendarCredential.key as string, process.env.CALENDSO_ENCRYPTION_KEY || "") + ); + + if (decryptedKey.username === username) { + if (decryptedKey.password === password) { + hasMatchingUsernameAndPassword = true; + } + + return true; + } + } + ); + + if (!!hasCalendarWithGivenCredentials && hasMatchingUsernameAndPassword) { + return { + status: SUCCESS_STATUS, + }; + } + + if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) { + await this.credentialRepository.upsertAppCredential( + APPLE_CALENDAR_TYPE, + symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""), + userId, + hasCalendarWithGivenCredentials.id + ); + + return { + status: SUCCESS_STATUS, + }; + } + } + + try { + const data = { + type: APPLE_CALENDAR_TYPE, + key: symmetricEncrypt( + JSON.stringify({ username, password }), + process.env.CALENDSO_ENCRYPTION_KEY || "" + ), + userId: userId, + teamId: null, + appId: APPLE_CALENDAR_ID, + invalid: false, + }; + + const dav = new CalendarService({ + id: 0, + ...data, + user: { email: userEmail }, + }); + await dav?.listCalendars(); + await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId); + } catch (reason) { + throw new BadRequestException(`Could not add this apple calendar account: ${reason}`); + } + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts new file mode 100644 index 00000000000000..6f4a23e839dd5a --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -0,0 +1,184 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { + CredentialsRepository, + CredentialsWithUserEmail, +} from "@/modules/credentials/credentials.repository"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Injectable, + InternalServerErrorException, + UnauthorizedException, + NotFoundException, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { DateTime } from "luxon"; +import { z } from "zod"; + +import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; +import { + getConnectedDestinationCalendarsAndEnsureDefaultsInDb, + getBusyCalendarTimes, +} from "@calcom/platform-libraries"; +import { Calendar } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class CalendarsService { + private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() }); + + constructor( + private readonly usersRepository: UsersRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly appsRepository: AppsRepository, + private readonly calendarsRepository: CalendarsRepository, + private readonly dbWrite: PrismaWriteService, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository + ) {} + + async getCalendars(userId: number) { + const userWithCalendars = await this.usersRepository.findByIdWithCalendars(userId); + if (!userWithCalendars) { + throw new NotFoundException("User not found"); + } + return getConnectedDestinationCalendarsAndEnsureDefaultsInDb({ + user: { + ...userWithCalendars, + allSelectedCalendars: userWithCalendars.selectedCalendars, + userLevelSelectedCalendars: userWithCalendars.selectedCalendars.filter( + (calendar) => !calendar.eventTypeId + ), + }, + onboarding: false, + eventTypeId: null, + prisma: this.dbWrite.prisma as unknown as PrismaClient, + }); + } + + async getBusyTimes( + calendarsToLoad: Calendar[], + userId: User["id"], + dateFrom: string, + dateTo: string, + timezone: string + ) { + const credentials = await this.getUniqCalendarCredentials(calendarsToLoad, userId); + const composedSelectedCalendars = await this.getCalendarsWithCredentials( + credentials, + calendarsToLoad, + userId + ); + try { + const calendarBusyTimes = await getBusyCalendarTimes( + credentials, + dateFrom, + dateTo, + composedSelectedCalendars + ); + const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => { + const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone); + const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone); + const busyTimeStartDate = busyTimeStart.toJSDate(); + const busyTimeEndDate = busyTimeEnd.toJSDate(); + return { + ...busyTime, + start: busyTimeStartDate, + end: busyTimeEndDate, + }; + }); + return calendarBusyTimesConverted; + } catch (error) { + throw new InternalServerErrorException( + "Unable to fetch connected calendars events. Please try again later." + ); + } + } + + async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) { + const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId))); + const credentials = await this.credentialsRepository.getUserCredentialsByIds(userId, uniqueCredentialIds); + + if (credentials.length !== uniqueCredentialIds.length) { + throw new UnauthorizedException("These credentials do not belong to you"); + } + + return credentials; + } + + async getCalendarsWithCredentials( + credentials: CredentialsWithUserEmail, + calendarsToLoad: Calendar[], + userId: User["id"] + ) { + const composedSelectedCalendars = calendarsToLoad.map((calendar) => { + const credential = credentials.find((item) => item.id === calendar.credentialId); + if (!credential) { + throw new UnauthorizedException("These credentials do not belong to you"); + } + return { + ...calendar, + userId, + integration: credential.type, + }; + }); + return composedSelectedCalendars; + } + + async getAppKeys(appName: string) { + const app = await this.appsRepository.getAppBySlug(appName); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys); + + if (!client_id) { + throw new NotFoundException(); + } + + if (!client_secret) { + throw new NotFoundException(); + } + + return { client_id, client_secret }; + } + + async checkCalendarCredentials(credentialId: number, userId: number) { + const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId); + if (!credential) { + throw new NotFoundException("Calendar credentials not found"); + } + } + + async createAndLinkCalendarEntry( + userId: number, + externalId: string, + key: Prisma.InputJsonValue, + calendarType: keyof typeof APPS_TYPE_ID_MAPPING, + credentialId?: number | null + ) { + const credential = await this.credentialsRepository.upsertAppCredential( + calendarType, + key, + userId, + credentialId + ); + + await this.selectedCalendarsRepository.upsertSelectedCalendar( + externalId, + credential.id, + userId, + calendarType + ); + } + + async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) { + const credential = await this.credentialsRepository.getUserCredentialById(userId, credentialId, type); + + return !credential?.invalid; + } +} diff --git a/apps/api/v2/src/ee/calendars/services/gcal.service.ts b/apps/api/v2/src/ee/calendars/services/gcal.service.ts new file mode 100644 index 00000000000000..292c611fbd09cd --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/gcal.service.ts @@ -0,0 +1,186 @@ +import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { calendar_v3 } from "@googleapis/calendar"; +import { Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Prisma } from "@prisma/client"; +import { Request } from "express"; +import { OAuth2Client } from "googleapis-common"; +import { z } from "zod"; + +import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; + +const CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; + +@Injectable() +export class GoogleCalendarService implements OAuthCalendarApp { + private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`; + private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() }); + private logger = new Logger("GcalService"); + + constructor( + private readonly config: ConfigService, + private readonly appsRepository: AppsRepository, + private readonly credentialRepository: CredentialsRepository, + private readonly calendarsService: CalendarsService, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository + ) {} + + async connect( + authorization: string, + req: Request, + redir?: string + ): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> { + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir); + + return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } }; + } + + async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> { + return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir); + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + return await this.checkIfCalendarConnected(userId); + } + + async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) { + const oAuth2Client = await this.getOAuthClient(this.redirectUri); + + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: CALENDAR_SCOPES, + prompt: "consent", + state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`, + }); + + return authUrl; + } + + async getOAuthClient(redirectUri: string) { + this.logger.log("Getting Google Calendar OAuth Client"); + const app = await this.appsRepository.getAppBySlug("google-calendar"); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys); + + const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri); + return oAuth2Client; + } + + async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId); + + if (!gcalCredentials) { + throw new BadRequestException("Credentials for google_calendar not found."); + } + + if (gcalCredentials.invalid) { + throw new BadRequestException("Invalid google OAuth credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const googleCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE + ); + if (!googleCalendar) { + throw new UnauthorizedException("Google Calendar not connected."); + } + if (googleCalendar.error?.message) { + throw new UnauthorizedException(googleCalendar.error?.message); + } + + return { status: SUCCESS_STATUS }; + } + + async saveCalendarCredentialsAndRedirect( + code: string, + accessToken: string, + origin: string, + redir?: string + ) { + // User chose not to authorize your app or didn't authorize your app + // redirect directly without oauth code + if (!code || code === "undefined") { + return { url: redir || origin }; + } + + const parsedCode = z.string().parse(code); + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const oAuth2Client = await this.getOAuthClient(this.redirectUri); + const token = await oAuth2Client.getToken(parsedCode); + // Google oAuth Credentials are stored in token.tokens + const key = token.tokens; + + oAuth2Client.setCredentials(key); + + const calendar = new calendar_v3.Calendar({ + auth: oAuth2Client, + }); + + const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); + + const primaryCal = cals.data.items?.find((cal) => cal.primary); + + if (primaryCal?.id) { + const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar( + ownerId, + GOOGLE_CALENDAR_TYPE, + primaryCal.id + ); + + if (alreadyExistingSelectedCalendar) { + const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity( + ownerId, + alreadyExistingSelectedCalendar.credentialId ?? 0, + GOOGLE_CALENDAR_TYPE + ); + + // user credential probably got expired in this case + if (!isCredentialValid) { + await this.calendarsService.createAndLinkCalendarEntry( + ownerId, + alreadyExistingSelectedCalendar.externalId, + key as Prisma.InputJsonValue, + GOOGLE_CALENDAR_TYPE, + alreadyExistingSelectedCalendar.credentialId + ); + } + + return { + url: redir || origin, + }; + } + + await this.calendarsService.createAndLinkCalendarEntry( + ownerId, + primaryCal.id, + key as Prisma.InputJsonValue, + GOOGLE_CALENDAR_TYPE + ); + } + + return { url: redir || origin }; + } +} diff --git a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts new file mode 100644 index 00000000000000..c18da622bc2e1b --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts @@ -0,0 +1,103 @@ +import { ICSFeedCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { BadRequestException, UnauthorizedException, Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { SUCCESS_STATUS, ICS_CALENDAR_TYPE, ICS_CALENDAR } from "@calcom/platform-constants"; +import { symmetricEncrypt, IcsFeedCalendarService } from "@calcom/platform-libraries"; + +@Injectable() +export class IcsFeedService implements ICSFeedCalendarApp { + constructor( + private readonly calendarsService: CalendarsService, + private readonly credentialRepository: CredentialsRepository + ) {} + + private logger = new Logger("IcsFeedService"); + + async save( + userId: number, + userEmail: string, + urls: string[], + readonly = true + ): Promise { + const data = { + type: ICS_CALENDAR_TYPE, + ICS_CALENDAR, + key: symmetricEncrypt( + JSON.stringify({ urls, skipWriting: readonly }), + process.env.CALENDSO_ENCRYPTION_KEY || "" + ), + userId: userId, + teamId: null, + appId: ICS_CALENDAR, + invalid: false, + }; + + try { + const dav = new IcsFeedCalendarService({ + id: 0, + ...data, + user: { email: userEmail }, + }); + + const listedCals = await dav.listCalendars(); + + if (listedCals.length !== urls.length) { + throw new BadRequestException( + `Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}` + ); + } + + const credential = await this.credentialRepository.upsertAppCredential( + ICS_CALENDAR_TYPE, + data.key, + userId + ); + return { + status: SUCCESS_STATUS, + data: { + id: credential.id, + type: credential.type, + userId: credential.userId, + teamId: credential.teamId, + appId: credential.appId, + invalid: credential.invalid, + }, + }; + } catch (e) { + this.logger.error("Could not add ICS feeds", e); + throw new BadRequestException("Could not add ICS feeds, try using private ics feed."); + } + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId); + + if (!icsFeedCredentials) { + throw new BadRequestException("Credentials for Ics Feed calendar not found."); + } + + if (icsFeedCredentials.invalid) { + throw new BadRequestException("Invalid Ics Feed credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const icsCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === ICS_CALENDAR_TYPE + ); + + if (!icsCalendar) { + throw new UnauthorizedException("Ics Feed not connected."); + } + if (icsCalendar.error?.message) { + throw new UnauthorizedException(icsCalendar.error?.message); + } + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/calendars/services/outlook.service.ts b/apps/api/v2/src/ee/calendars/services/outlook.service.ts new file mode 100644 index 00000000000000..075f0018f7cba5 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/outlook.service.ts @@ -0,0 +1,212 @@ +import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Request } from "express"; +import { stringify } from "querystring"; +import { z } from "zod"; + +import { + SUCCESS_STATUS, + OFFICE_365_CALENDAR, + OFFICE_365_CALENDAR_ID, + OFFICE_365_CALENDAR_TYPE, +} from "@calcom/platform-constants"; + +@Injectable() +export class OutlookService implements OAuthCalendarApp { + private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`; + + constructor( + private readonly config: ConfigService, + private readonly calendarsService: CalendarsService, + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository + ) {} + + async connect( + authorization: string, + req: Request, + redir?: string + ): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> { + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir); + + return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } }; + } + + async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> { + return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir); + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + return await this.checkIfCalendarConnected(userId); + } + + async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) { + const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID); + + const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"]; + const params = { + response_type: "code", + scope: scopes.join(" "), + client_id, + prompt: "select_account", + redirect_uri: this.redirectUri, + state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`, + }; + + const query = stringify(params); + + const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`; + + return url; + } + + async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId( + "office365_calendar", + userId + ); + + if (!office365CalendarCredentials) { + throw new BadRequestException("Credentials for office_365_calendar not found."); + } + + if (office365CalendarCredentials.invalid) { + throw new BadRequestException("Invalid office 365 calendar credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const office365Calendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE + ); + if (!office365Calendar) { + throw new UnauthorizedException("Office 365 calendar not connected."); + } + if (office365Calendar.error?.message) { + throw new UnauthorizedException(office365Calendar.error?.message); + } + + return { + status: SUCCESS_STATUS, + }; + } + + async getOAuthCredentials(code: string) { + const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"]; + const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID); + + const toUrlEncoded = (payload: Record) => + Object.keys(payload) + .map((key) => `${key}=${encodeURIComponent(payload[key])}`) + .join("&"); + + const body = toUrlEncoded({ + client_id, + grant_type: "authorization_code", + code, + scope: scopes.join(" "), + redirect_uri: this.redirectUri, + client_secret, + }); + + const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body, + }); + + const responseBody = await response.json(); + + return responseBody; + } + + async getDefaultCalendar(accessToken: string): Promise { + const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + const responseBody = await response.json(); + + return responseBody as OfficeCalendar; + } + + async saveCalendarCredentialsAndRedirect( + code: string, + accessToken: string, + origin: string, + redir?: string + ) { + // if code is not defined, user denied to authorize office 365 app, just redirect straight away + if (!code || code === "undefined") { + return { url: redir || origin }; + } + + const parsedCode = z.string().parse(code); + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode); + + const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token); + + if (defaultCalendar?.id) { + const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar( + ownerId, + OFFICE_365_CALENDAR_TYPE, + defaultCalendar.id + ); + + if (alreadyExistingSelectedCalendar) { + const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity( + ownerId, + alreadyExistingSelectedCalendar.credentialId ?? 0, + OFFICE_365_CALENDAR_TYPE + ); + + // user credential probably got expired in this case + if (!isCredentialValid) { + await this.calendarsService.createAndLinkCalendarEntry( + ownerId, + alreadyExistingSelectedCalendar.externalId, + office365OAuthCredentials, + OFFICE_365_CALENDAR_TYPE, + alreadyExistingSelectedCalendar.credentialId + ); + } + + return { + url: redir || origin, + }; + } + + await this.calendarsService.createAndLinkCalendarEntry( + ownerId, + defaultCalendar.id, + office365OAuthCredentials, + OFFICE_365_CALENDAR_TYPE + ); + } + + return { + url: redir || origin, + }; + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/constants/constants.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/constants/constants.ts new file mode 100644 index 00000000000000..690c1b4deceb6d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/constants/constants.ts @@ -0,0 +1,16 @@ +export const DEFAULT_EVENT_TYPES = { + thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" }, + thirtyMinutesVideo: { + length: 30, + slug: "thirty-minutes-video", + title: "30 Minutes", + locations: [{ type: "integrations:daily" }], + }, + sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" }, + sixtyMinutesVideo: { + length: 60, + slug: "sixty-minutes-video", + title: "60 Minutes", + locations: [{ type: "integrations:daily" }], + }, +}; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts new file mode 100644 index 00000000000000..cd3deb317ecec0 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts @@ -0,0 +1,440 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { Editable } from "@/ee/event-types/event-types_2024_04_15//inputs/enums/editable"; +import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type"; +import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input"; +import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output"; +import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output"; +import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { EventType, PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { + SUCCESS_STATUS, + VERSION_2024_06_11, + VERSION_2024_04_15, + CAL_API_VERSION_HEADER, +} from "@calcom/platform-constants"; +import { + EventTypesByViewer, + EventTypesPublic, + eventTypeBookingFields, + eventTypeLocations, +} from "@calcom/platform-libraries"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Event types Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/event-types/100").expect(401); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const userEmail = `event-types-2024-04-15-user-${randomString()}@api.com`; + const name = `event-types-2024-04-15-user-${randomString()}`; + const username = name; + let eventType: EventType; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, TokensModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ + name: `event-types-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.create({ + email: userEmail, + name, + username, + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create an event type", async () => { + const body: CreateEventTypeInput_2024_04_15 = { + title: "Test Event Type", + slug: `event-types-event-type-${randomString()}`, + description: "A description of the test event type.", + length: 60, + hidden: false, + disableGuests: true, + slotInterval: 15, + afterEventBuffer: 5, + beforeEventBuffer: 10, + minimumBookingNotice: 120, + locations: [ + { + type: "Online", + link: "https://example.com/meet", + displayLocationPublicly: true, + }, + ], + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data).toHaveProperty("id"); + expect(responseBody.data.title).toEqual(body.title); + expect(responseBody.data.disableGuests).toEqual(body.disableGuests); + expect(responseBody.data.slotInterval).toEqual(body.slotInterval); + expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice); + expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer); + expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer); + + eventType = responseBody.data; + }); + }); + + it("should update event type", async () => { + const newTitle = "Updated title"; + + const body: UpdateEventTypeInput_2024_04_15 = { + title: newTitle, + disableGuests: false, + slotInterval: 30, + afterEventBuffer: 10, + beforeEventBuffer: 15, + minimumBookingNotice: 240, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data.title).toEqual(newTitle); + expect(responseBody.data.disableGuests).toEqual(body.disableGuests); + expect(responseBody.data.slotInterval).toEqual(body.slotInterval); + expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice); + expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer); + expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer); + + eventType.title = newTitle; + eventType.disableGuests = responseBody.data.disableGuests ?? false; + eventType.slotInterval = body.slotInterval ?? null; + eventType.minimumBookingNotice = body.minimumBookingNotice ?? 10; + eventType.beforeEventBuffer = body.beforeEventBuffer ?? 10; + eventType.afterEventBuffer = body.afterEventBuffer ?? 10; + }); + }); + + it("should return 400 if param event type id is null", async () => { + const locations = [{ type: "inPerson", address: "123 Main St" }]; + + const body: UpdateEventTypeInput_2024_04_15 = { + locations, + }; + + return request(app.getHttpServer()).patch(`/api/v2/event-types/null`).send(body).expect(400); + }); + + it("should update event type locations", async () => { + const locations = [{ type: "inPerson", address: "123 Main St" }]; + + const body: UpdateEventTypeInput_2024_04_15 = { + locations, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const responseLocations = eventTypeLocations.parse(responseBody.data.locations); + expect(responseLocations).toBeDefined(); + expect(responseLocations.length).toEqual(locations.length); + expect(responseLocations).toEqual(locations); + eventType.locations = responseLocations; + }); + }); + + it("should update event type bookingFields", async () => { + const bookingFieldName = "location-name"; + const bookingFields = [ + { + name: bookingFieldName, + type: BaseField.radio, + label: "Location", + options: [ + { + label: "Via Bari 10, Roma, 90119, Italy", + value: "Via Bari 10, Roma, 90119, Italy", + }, + { + label: "Via Reale 28, Roma, 9001, Italy", + value: "Via Reale 28, Roma, 9001, Italy", + }, + ], + sources: [ + { + id: "user", + type: "user", + label: "User", + fieldRequired: true, + }, + ], + editable: Editable.user, + required: true, + placeholder: "", + }, + ]; + + const body: UpdateEventTypeInput_2024_04_15 = { + bookingFields, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const responseBookingFields = eventTypeBookingFields.parse(responseBody.data.bookingFields); + expect(responseBookingFields).toBeDefined(); + // note(Lauris): response bookingFields are already existing default bookingFields + the new one + const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName); + expect(responseBookingField).toEqual(bookingFields[0]); + eventType.bookingFields = responseBookingFields; + }); + }); + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypeOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.eventType.id).toEqual(eventType.id); + expect(responseBody.data.eventType.title).toEqual(eventType.title); + expect(responseBody.data.eventType.slug).toEqual(eventType.slug); + expect(responseBody.data.eventType.userId).toEqual(user.id); + }); + + it(`/GET/:id with version VERSION_2024_06_11`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypeOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.eventType.id).toEqual(eventType.id); + expect(responseBody.data.eventType.title).toEqual(eventType.title); + expect(responseBody.data.eventType.slug).toEqual(eventType.slug); + expect(responseBody.data.eventType.userId).toEqual(user.id); + }); + + it(`/GET/:username/public`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${username}/public`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypesPublicOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toEqual(1); + expect(responseBody.data?.[0]?.id).toEqual(eventType.id); + expect(responseBody.data?.[0]?.title).toEqual(eventType.title); + expect(responseBody.data?.[0]?.slug).toEqual(eventType.slug); + expect(responseBody.data?.[0]?.length).toEqual(eventType.length); + }); + + it(`/GET/:username/:eventSlug/public`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${username}/${eventType.slug}/public`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypePublicOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.id).toEqual(eventType.id); + expect(responseBody.data?.title).toEqual(eventType.title); + expect(responseBody.data?.slug).toEqual(eventType.slug); + expect(responseBody.data?.length).toEqual(eventType.length); + }); + + it(`/GET/`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.eventTypeGroups).toBeDefined(); + expect(responseBody.data.eventTypeGroups).toBeDefined(); + expect(responseBody.data.eventTypeGroups[0]).toBeDefined(); + expect(responseBody.data.eventTypeGroups[0].profile).toBeDefined(); + expect(responseBody.data.eventTypeGroups?.[0]?.profile?.name).toEqual(name); + expect(responseBody.data.eventTypeGroups?.[0]?.eventTypes?.[0]?.id).toEqual(eventType.id); + expect(responseBody.data.profiles?.[0]?.name).toEqual(name); + }); + + it(`/GET/public/:username/`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${username}/public`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(1); + expect(responseBody.data[0].id).toEqual(eventType.id); + }); + + it(`/GET/:id not existing`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/event-types/1000`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(404); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()) + .delete(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await eventTypesRepositoryFixture.delete(eventType.id); + } catch (e) { + // Event type might have been deleted by the test + } + try { + await userRepositoryFixture.delete(user.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts new file mode 100644 index 00000000000000..e08562a65f9c96 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts @@ -0,0 +1,185 @@ +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { EventTypeIdParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input"; +import { GetPublicEventTypeQueryParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input"; +import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input"; +import { CreateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output"; +import { DeleteEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output"; +import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output"; +import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output"; +import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output"; +import { + GetEventTypesData, + GetEventTypesOutput, +} from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output"; +import { UpdateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output"; +import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service"; +import { VERSION_2024_04_15, VERSION_2024_06_11 } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Param, + Post, + Body, + NotFoundException, + Patch, + HttpCode, + HttpStatus, + Delete, + Query, + InternalServerErrorException, + ParseIntPipe, +} from "@nestjs/common"; +import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; + +import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2"; +import { PrismaClient } from "@calcom/prisma"; + +@Controller({ + path: "/v2/event-types", + version: [VERSION_2024_04_15, VERSION_2024_06_11], +}) +@UseGuards(PermissionsGuard) +@DocsExcludeController(true) +export class EventTypesController_2024_04_15 { + constructor( + private readonly eventTypesService: EventTypesService_2024_04_15, + private readonly prismaReadService: PrismaReadService + ) {} + + @Post("/") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(ApiAuthGuard) + async createEventType( + @Body() body: CreateEventTypeInput_2024_04_15, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.createUserEventType(user, body); + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/:eventTypeId") + @Permissions([EVENT_TYPE_READ]) + @UseGuards(ApiAuthGuard) + async getEventType( + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId)); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/") + @Permissions([EVENT_TYPE_READ]) + @UseGuards(ApiAuthGuard) + async getEventTypes(@GetUser() user: UserWithProfile): Promise { + const eventTypes = await getEventTypesByViewer({ + id: user.id, + profile: { + upId: `usr-${user.id}`, + }, + }); + + return { + status: SUCCESS_STATUS, + data: eventTypes as GetEventTypesData, + }; + } + + @Get("/:username/:eventSlug/public") + async getPublicEventType( + @Param("username") username: string, + @Param("eventSlug") eventSlug: string, + @Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15 + ): Promise { + try { + const event = await getPublicEvent( + username.toLowerCase(), + eventSlug, + queryParams.isTeamEvent, + queryParams.org || null, + this.prismaReadService.prisma as unknown as PrismaClient, + // We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license + // If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink` + true + ); + return { + data: event, + status: SUCCESS_STATUS, + }; + } catch (err) { + if (err instanceof Error) { + throw new NotFoundException(err.message); + } + } + throw new InternalServerErrorException("Could not find public event."); + } + + @Get("/:username/public") + async getPublicEventTypes(@Param("username") username: string): Promise { + const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username); + + return { + status: SUCCESS_STATUS, + data: eventTypes, + }; + } + + @Patch("/:eventTypeId") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + async updateEventType( + @Param() params: EventTypeIdParams_2024_04_15, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @Body() body: UpdateEventTypeInput_2024_04_15, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user); + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Delete("/:eventTypeId") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(ApiAuthGuard) + async deleteEventType( + @Param() params: EventTypeIdParams_2024_04_15, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @GetUser("id") userId: number + ): Promise { + const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId); + + return { + status: SUCCESS_STATUS, + data: { + id: eventType.id, + length: eventType.length, + slug: eventType.slug, + title: eventType.title, + }, + }; + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.module.ts new file mode 100644 index 00000000000000..7b54e9c7f7e508 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.module.ts @@ -0,0 +1,17 @@ +import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/controllers/event-types.controller"; +import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository"; +import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule], + providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15], + controllers: [EventTypesController_2024_04_15], + exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15], +}) +export class EventTypesModule_2024_04_15 {} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts new file mode 100644 index 00000000000000..4fbde6d2c10771 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts @@ -0,0 +1,88 @@ +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +import { getEventTypeById } from "@calcom/platform-libraries"; +import type { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class EventTypesRepository_2024_04_15 { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private usersService: UsersService + ) {} + + async createUserEventType( + userId: number, + body: Pick + ) { + return this.dbWrite.prisma.eventType.create({ + data: { + ...body, + userId, + users: { connect: { id: userId } }, + }, + }); + } + + async getEventTypeWithSeats(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, + }); + } + + async getUserEventType(userId: number, eventTypeId: number) { + return this.dbRead.prisma.eventType.findFirst({ + where: { + id: eventTypeId, + userId, + }, + }); + } + + async getUserEventTypeForAtom( + user: UserWithProfile, + isUserOrganizationAdmin: boolean, + eventTypeId: number + ) { + return await getEventTypeById({ + currentOrganizationId: this.usersService.getUserMainOrgId(user), + eventTypeId, + userId: user.id, + prisma: this.dbRead.prisma as unknown as PrismaClient, + isUserOrganizationAdmin, + isTrpcCall: true, + }); + } + + async getEventTypeById(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } }); + } + + async getUserEventTypeBySlug(userId: number, slug: string) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + userId_slug: { + userId: userId, + slug: slug, + }, + }, + }); + } + + async deleteEventType(eventTypeId: number) { + return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } }); + } + + async getEventTypeWithDuration(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { length: true }, + }); + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input.ts new file mode 100644 index 00000000000000..ecb9af1d074482 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input.ts @@ -0,0 +1,97 @@ +import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input"; +import { ApiProperty as DocsProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsString, + IsNumber, + IsBoolean, + IsOptional, + ValidateNested, + Min, + IsArray, + IsInt, +} from "class-validator"; + +export const CREATE_EVENT_LENGTH_EXAMPLE = 60; +export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class"; +export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!"; +export const CREATE_EVENT_DESCRIPTION_EXAMPLE = + "Discover the culinary wonders of the Argentina by making the best flan ever!"; + +// note(Lauris): We will gradually expose more properties if any customer needs them. +// Just uncomment any below when requested. +export class CreateEventTypeInput_2024_04_15 { + @IsNumber() + @Min(1) + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: CREATE_EVENT_DESCRIPTION_EXAMPLE, + }) + description?: string; + + @IsOptional() + @IsBoolean() + @ApiHideProperty() + hidden?: boolean; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => EventTypeLocation_2024_04_15) + @IsArray() + @ApiPropertyOptional({ + type: [EventTypeLocation_2024_04_15], + }) + locations?: EventTypeLocation_2024_04_15[]; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + disableGuests?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + slotInterval?: number; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + minimumBookingNotice?: number; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + beforeEventBuffer?: number; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional() + afterEventBuffer?: number; + + // @ApiHideProperty() + // @IsOptional() + // @IsNumber() + // teamId?: number; + + // @ApiHideProperty() + // @IsOptional() + // @IsEnum(SchedulingType) + // schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type"; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/editable.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/editable.ts new file mode 100644 index 00000000000000..819f010f126f52 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/editable.ts @@ -0,0 +1,7 @@ +export enum Editable { + system = "system", + systemButOptional = "system-but-optional", + systemButHidden = "system-but-hidden", + user = "user", + userReadonly = "user-readonly", +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/field-type.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/field-type.ts new file mode 100644 index 00000000000000..e24f7ad635ef99 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/field-type.ts @@ -0,0 +1,16 @@ +export enum BaseField { + number = "number", + boolean = "boolean", + address = "address", + name = "name", + text = "text", + textarea = "textarea", + email = "email", + phone = "phone", + multiemail = "multiemail", + select = "select", + multiselect = "multiselect", + checkbox = "checkbox", + radio = "radio", + radioInput = "radioInput", +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/frequency.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/frequency.ts new file mode 100644 index 00000000000000..830bb7abfc9d09 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/frequency.ts @@ -0,0 +1,9 @@ +export enum Frequency { + YEARLY = 0, + MONTHLY = 1, + WEEKLY = 2, + DAILY = 3, + HOURLY = 4, + MINUTELY = 5, + SECONDLY = 6, +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/period-type.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/period-type.ts new file mode 100644 index 00000000000000..95c0e138b7be99 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/period-type.ts @@ -0,0 +1,5 @@ +export enum PeriodType { + UNLIMITED = "UNLIMITED", + ROLLING = "ROLLING", + RANGE = "RANGE", +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type.ts new file mode 100644 index 00000000000000..cc581daa4fa07b --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type.ts @@ -0,0 +1,5 @@ +export enum SchedulingType { + ROUND_ROBIN = "ROUND_ROBIN", + COLLECTIVE = "COLLECTIVE", + MANAGED = "MANAGED", +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input.ts new file mode 100644 index 00000000000000..37d108c7462122 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input.ts @@ -0,0 +1,6 @@ +import { IsNumberString } from "class-validator"; + +export class EventTypeIdParams_2024_04_15 { + @IsNumberString() + eventTypeId!: number; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input.ts new file mode 100644 index 00000000000000..bb27e144c6dbba --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiHideProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator"; + +// note(Lauris): We will gradually expose more properties if any customer needs them. +// Just uncomment any below when requested. + +export class EventTypeLocation_2024_04_15 { + @IsString() + @ApiProperty({ example: "link" }) + type!: string; + + @IsOptional() + @IsString() + @ApiHideProperty() + address?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ example: "https://masterchief.com/argentina/flan/video/9129412" }) + link?: string; + + @IsOptional() + @IsBoolean() + @ApiHideProperty() + displayLocationPublicly?: boolean; + + @IsOptional() + @IsString() + @ApiHideProperty() + hostPhoneNumber?: string; + + @IsOptional() + @IsNumber() + @ApiHideProperty() + credentialId?: number; + + @IsOptional() + @IsString() + @ApiHideProperty() + teamName?: string; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input.ts new file mode 100644 index 00000000000000..30ee95f842e08d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; + +export class GetPublicEventTypeQueryParams_2024_04_15 { + @Transform(({ value }: { value: string }) => value === "true") + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + isTeamEvent?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, nullable: true }) + org?: string | null; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input.ts new file mode 100644 index 00000000000000..3867d1bedacec0 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input.ts @@ -0,0 +1,485 @@ +import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/editable"; +import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type"; +import { Frequency } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/frequency"; +import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsString, + IsBoolean, + IsOptional, + ValidateNested, + Min, + IsInt, + IsEnum, + IsArray, + IsDate, + IsNumber, +} from "class-validator"; + +// note(Lauris): We will gradually expose more properties if any customer needs them. +// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput. + +class Option { + @IsString() + @ApiProperty() + value!: string; + + @IsString() + @ApiProperty() + label!: string; +} + +class Source { + @IsString() + @ApiProperty() + id!: string; + + @IsString() + @ApiProperty() + type!: string; + + @IsString() + @ApiProperty() + label!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + editUrl?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + fieldRequired?: boolean; +} + +class View { + @IsString() + @ApiProperty() + id!: string; + + @IsString() + @ApiProperty() + label!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + description?: string; +} + +class OptionsInput { + @IsString() + @ApiProperty({ + description: 'Type of the field, can be one of "address", "text", or "phone".', + enum: ["address", "text", "phone"], + example: "text", + }) + type!: "address" | "text" | "phone"; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + required?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + placeholder?: string; +} + +class VariantField { + @IsString() + @ApiProperty() + type!: BaseField; + + @IsString() + @ApiProperty() + name!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + label?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + labelAsSafeHtml?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + placeholder?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + required?: boolean; +} + +class Variant { + @ValidateNested({ each: true }) + @Type(() => VariantField) + @ApiProperty({ type: [VariantField] }) + fields!: VariantField[]; +} + +class VariantsConfig { + @ApiProperty({ type: Object }) + variants!: Record; +} + +export class BookingField_2024_04_15 { + @IsEnum(BaseField) + @ApiProperty({ enum: BaseField }) + type!: BaseField; + + @IsString() + @ApiProperty() + name!: string; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => Option) + @ApiPropertyOptional({ type: [Option] }) + options?: Option[]; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + label?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + labelAsSafeHtml?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + defaultLabel?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + placeholder?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + required?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + getOptionsAt?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OptionsInput) + @ApiPropertyOptional() + optionsInputs?: Record; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + variant?: string; + + @IsOptional() + @ValidateNested() + @Type(() => VariantsConfig) + @ApiPropertyOptional({ type: VariantsConfig }) + variantsConfig?: VariantsConfig; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => View) + @ApiPropertyOptional({ type: [View] }) + views?: View[]; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + hideWhenJustOneOption?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + hidden?: boolean; + + @IsOptional() + @IsEnum(Editable) + @ApiPropertyOptional() + editable?: Editable; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => Source) + @ApiPropertyOptional({ type: [Source] }) + sources?: Source[]; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + disableOnPrefill?: boolean; +} + +export class RecurringEvent_2024_04_15 { + @IsDate() + @IsOptional() + @ApiPropertyOptional({ type: Date }) + dtstart?: Date; + + @IsInt() + @ApiProperty() + interval!: number; + + @IsInt() + @ApiProperty() + count!: number; + + @IsEnum(Frequency) + @ApiProperty({ enum: Frequency }) + freq!: Frequency; + + @IsDate() + @IsOptional() + @ApiPropertyOptional({ type: Date }) + until?: Date; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + tzid?: string; +} + +export class IntervalLimits_2024_04_15 { + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + PER_DAY?: number; + + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + PER_WEEK?: number; + + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + PER_MONTH?: number; + + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + PER_YEAR?: number; +} + +export class UpdateEventTypeInput_2024_04_15 { + @IsInt() + @Min(1) + @IsOptional() + @ApiPropertyOptional() + length?: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + slug?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + title?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + description?: string; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + hidden?: boolean; + + @ValidateNested({ each: true }) + @Type(() => EventTypeLocation_2024_04_15) + @IsOptional() + @ApiPropertyOptional({ type: [EventTypeLocation_2024_04_15] }) + locations?: EventTypeLocation_2024_04_15[]; + + // @IsInt() + // @IsOptional() + // position?: number; + + // @IsInt() + // @IsOptional() + // offsetStart?: number; + + // @IsInt() + // @IsOptional() + // userId?: number; + + // @IsInt() + // @IsOptional() + // profileId?: number; + + // @IsInt() + // @IsOptional() + // teamId?: number; + + // @IsString() + // @IsOptional() + // eventName?: string; + + // @IsInt() + // @IsOptional() + // parentId?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookingField_2024_04_15) + @ApiPropertyOptional({ type: [BookingField_2024_04_15] }) + bookingFields?: BookingField_2024_04_15[]; + + // @IsString() + // @IsOptional() + // timeZone?: string; + + // @IsEnum(PeriodType) + // @IsOptional() + // periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type"; + + // @IsDate() + // @IsOptional() + // periodStartDate?: Date; + + // @IsDate() + // @IsOptional() + // periodEndDate?: Date; + + // @IsInt() + // @IsOptional() + // periodDays?: number; + + // @IsBoolean() + // @IsOptional() + // periodCountCalendarDays?: boolean; + + // @IsBoolean() + // @IsOptional() + // lockTimeZoneToggleOnBookingPage?: boolean; + + // @IsBoolean() + // @IsOptional() + // requiresConfirmation?: boolean; + + // @IsBoolean() + // @IsOptional() + // requiresBookerEmailVerification?: boolean; + + // @ValidateNested() + // @Type(() => RecurringEvent) + // @IsOptional() + // recurringEvent?: RecurringEvent; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + disableGuests?: boolean; + + // @IsBoolean() + // @IsOptional() + // hideCalendarNotes?: boolean; + + @IsInt() + @Min(0) + @IsOptional() + @ApiPropertyOptional() + minimumBookingNotice?: number; + + @IsInt() + @Min(0) + @IsOptional() + @ApiPropertyOptional() + beforeEventBuffer?: number; + + @IsInt() + @Min(0) + @IsOptional() + @ApiPropertyOptional() + afterEventBuffer?: number; + + // @IsInt() + // @IsOptional() + // seatsPerTimeSlot?: number; + + // @IsBoolean() + // @IsOptional() + // onlyShowFirstAvailableSlot?: boolean; + + // @IsBoolean() + // @IsOptional() + // seatsShowAttendees?: boolean; + + // @IsBoolean() + // @IsOptional() + // seatsShowAvailabilityCount?: boolean; + + // @IsEnum(SchedulingType) + // @IsOptional() + // schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type"; + + // @IsInt() + // @IsOptional() + // scheduleId?: number; + + // @IsInt() + // @IsOptional() + // price?: number; + + // @IsString() + // @IsOptional() + // currency?: string; + + @IsInt() + @Min(0) + @IsOptional() + @ApiPropertyOptional() + slotInterval?: number; + + // @IsString() + // @IsOptional() + // @IsUrl() + // successRedirectUrl?: string; + + // @ValidateNested() + // @Type(() => IntervalLimits) + // @IsOptional() + // bookingLimits?: IntervalLimits; + + // @ValidateNested() + // @Type(() => IntervalLimits) + // @IsOptional() + // durationLimits?: IntervalLimits; + + // @IsBoolean() + // @IsOptional() + // isInstantEvent?: boolean; + + // @IsBoolean() + // @IsOptional() + // assignAllTeamMembers?: boolean; + + // @IsBoolean() + // @IsOptional() + // useEventTypeDestinationCalendarEmail?: boolean; + + // @IsInt() + // @IsOptional() + // secondaryEmailId?: number; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output.ts new file mode 100644 index 00000000000000..c7675708fe99d9 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output.ts @@ -0,0 +1,20 @@ +import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EventTypeOutput) + data!: EventTypeOutput; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output.ts new file mode 100644 index 00000000000000..76ddc81563d5f1 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output.ts @@ -0,0 +1,38 @@ +import { + CREATE_EVENT_LENGTH_EXAMPLE, + CREATE_EVENT_SLUG_EXAMPLE, + CREATE_EVENT_TITLE_EXAMPLE, +} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class DeleteData { + @IsInt() + @DocsProperty({ example: 1 }) + id!: number; + + @IsInt() + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; +} + +export class DeleteEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => DeleteData) + data!: DeleteData; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/event-type.output.ts new file mode 100644 index 00000000000000..700f8a4786715f --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/event-type.output.ts @@ -0,0 +1,232 @@ +import { + CREATE_EVENT_DESCRIPTION_EXAMPLE, + CREATE_EVENT_LENGTH_EXAMPLE, + CREATE_EVENT_SLUG_EXAMPLE, + CREATE_EVENT_TITLE_EXAMPLE, +} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { PeriodType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/period-type"; +import { SchedulingType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type"; +import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input"; +import { + BookingField_2024_04_15, + IntervalLimits_2024_04_15, + RecurringEvent_2024_04_15, +} from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input"; +import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDate, + IsEnum, + IsInt, + IsJSON, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +export class EventTypeOutput { + @IsInt() + @ApiProperty({ example: 1 }) + id!: number; + + @IsInt() + @ApiProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @ApiProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @ApiProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; + + @IsString() + @ApiProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE, nullable: true }) + description!: string | null; + + @IsBoolean() + @ApiHideProperty() + hidden!: boolean; + + @ValidateNested({ each: true }) + @Type(() => EventTypeLocation_2024_04_15) + @IsArray() + @ApiProperty({ type: [EventTypeLocation_2024_04_15], nullable: true }) + locations!: EventTypeLocation_2024_04_15[] | null; + + @IsInt() + @ApiHideProperty() + @IsOptional() + position?: number; + + @IsInt() + @ApiHideProperty() + offsetStart!: number; + + @IsInt() + @ApiHideProperty() + userId!: number | null; + + @IsInt() + @ApiHideProperty() + @IsOptional() + profileId?: number | null; + + @IsInt() + @ApiHideProperty() + teamId!: number | null; + + @IsString() + @ApiHideProperty() + eventName!: string | null; + + @IsInt() + @ApiHideProperty() + @IsOptional() + parentId?: number | null; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookingField_2024_04_15) + @ApiHideProperty() + bookingFields!: BookingField_2024_04_15[] | null; + + @IsString() + @ApiHideProperty() + timeZone!: string | null; + + @IsEnum(PeriodType) + @ApiHideProperty() + periodType!: PeriodType | null; + + @IsDate() + @ApiHideProperty() + periodStartDate!: Date | null; + + @IsDate() + @ApiHideProperty() + periodEndDate!: Date | null; + + @IsInt() + @ApiHideProperty() + periodDays!: number | null; + + @IsBoolean() + @ApiHideProperty() + periodCountCalendarDays!: boolean | null; + + @IsBoolean() + @ApiHideProperty() + lockTimeZoneToggleOnBookingPage!: boolean; + + @IsBoolean() + @ApiHideProperty() + requiresConfirmation!: boolean; + + @IsBoolean() + @ApiHideProperty() + requiresBookerEmailVerification!: boolean; + + @ValidateNested() + @Type(() => RecurringEvent_2024_04_15) + @IsOptional() + @ApiHideProperty() + recurringEvent!: RecurringEvent_2024_04_15 | null; + + @IsBoolean() + @ApiHideProperty() + disableGuests!: boolean; + + @IsBoolean() + @ApiHideProperty() + hideCalendarNotes!: boolean; + + @IsInt() + @ApiHideProperty() + minimumBookingNotice!: number; + + @IsInt() + @ApiHideProperty() + beforeEventBuffer!: number; + + @IsInt() + @ApiHideProperty() + afterEventBuffer!: number; + + @IsInt() + @ApiHideProperty() + seatsPerTimeSlot!: number | null; + + @IsBoolean() + @ApiHideProperty() + onlyShowFirstAvailableSlot!: boolean; + + @IsBoolean() + @ApiHideProperty() + seatsShowAttendees!: boolean; + + @IsBoolean() + @ApiHideProperty() + seatsShowAvailabilityCount!: boolean; + + @IsEnum(SchedulingType) + @ApiHideProperty() + schedulingType!: SchedulingType | null; + + @IsInt() + @ApiHideProperty() + @IsOptional() + scheduleId?: number | null; + + @IsNumber() + @ApiHideProperty() + price!: number; + + @IsString() + @ApiHideProperty() + currency!: string; + + @IsInt() + @ApiHideProperty() + slotInterval!: number | null; + + @IsJSON() + @ApiHideProperty() + metadata!: Record | null; + + @IsString() + @ApiHideProperty() + successRedirectUrl!: string | null; + + @ValidateNested() + @Type(() => IntervalLimits_2024_04_15) + @IsOptional() + @ApiHideProperty() + bookingLimits!: IntervalLimits_2024_04_15; + + @ValidateNested() + @Type(() => IntervalLimits_2024_04_15) + @ApiHideProperty() + durationLimits!: IntervalLimits_2024_04_15; + + @IsBoolean() + @ApiHideProperty() + isInstantEvent!: boolean; + + @IsBoolean() + @ApiHideProperty() + assignAllTeamMembers!: boolean; + + @IsBoolean() + @ApiHideProperty() + useEventTypeDestinationCalendarEmail!: boolean; + + @IsInt() + @ApiHideProperty() + secondaryEmailId!: number | null; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output.ts new file mode 100644 index 00000000000000..acd4a2918f5107 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output.ts @@ -0,0 +1,450 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + IsUrl, + ValidateNested, + IsArray, + IsObject, + IsNumber, + IsEnum, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Location { + @IsString() + @ApiProperty() + type!: string; +} + +class Source { + @IsString() + @ApiProperty() + id!: string; + + @IsString() + @ApiProperty() + type!: string; + + @IsString() + @ApiProperty() + label!: string; +} + +class OptionInput { + @IsString() + @ApiProperty() + type!: string; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + required?: boolean; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + placeholder?: string; +} + +class BookingField { + @IsString() + @ApiProperty() + name!: string; + + @IsString() + @ApiProperty() + type!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + defaultLabel?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + label?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + placeholder?: string; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + required?: boolean; + + @IsOptional() + @ApiPropertyOptional() + getOptionsAt?: string; + + @IsObject() + @IsOptional() + @ApiPropertyOptional() + optionsInputs?: { [key: string]: OptionInput }; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + hideWhenJustOneOption?: boolean; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + editable?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Source) + @IsOptional() + @ApiPropertyOptional({ type: [Source] }) + sources?: Source[]; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + disableOnPrefill?: boolean; +} + +class Organization { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + slug?: string | null; + + @IsString() + @ApiProperty() + name!: string; + + @IsOptional() + @ApiPropertyOptional({ type: Object }) + metadata?: Record; +} + +class Profile { + @IsString() + @ApiProperty({ type: String, nullable: true }) + username!: string | null; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + id!: number | null; + + @IsInt() + @IsOptional() + @ApiPropertyOptional() + userId?: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + uid?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + name?: string; + + @IsInt() + @ApiProperty({ type: Number, nullable: true }) + organizationId!: number | null; + + @ValidateNested() + @Type(() => Organization) + @ApiPropertyOptional({ type: Organization, nullable: true }) + organization?: Organization | null; + + @IsString() + @ApiProperty() + upId!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + image?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + brandColor?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + darkBrandColor?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + theme?: string; + + @IsOptional() + @ApiPropertyOptional() + bookerLayouts?: any; +} + +class Owner { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + avatarUrl?: string | null; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + username!: string | null; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @IsString() + @ApiProperty() + weekStart!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + brandColor?: string | null; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + darkBrandColor?: string | null; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + theme?: string | null; + + @IsOptional() + @ApiPropertyOptional() + metadata?: any; + + @IsInt() + @IsOptional() + @ApiPropertyOptional({ type: Number, nullable: true }) + defaultScheduleId?: number | null; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + nonProfileUsername!: string | null; + + @ValidateNested() + @Type(() => Profile) + @ApiProperty({ type: Profile }) + profile!: Profile; +} + +class User { + @IsString() + @ApiProperty({ type: String, nullable: true }) + username!: string | null; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @IsString() + @ApiProperty() + weekStart!: string; + + @IsInt() + @IsOptional() + @ApiPropertyOptional() + organizationId?: number; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + avatarUrl?: string | null; + + @ValidateNested() + @ApiProperty({ type: Profile }) + profile!: Profile; + + @IsString() + @ApiProperty() + bookerUrl!: string; +} + +class Schedule { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty({ type: String, nullable: true }) + timeZone!: string | null; +} + +class PublicEventTypeOutput { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty() + title!: string; + + @IsString() + @ApiProperty() + description!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, nullable: true }) + eventName?: string | null; + + @IsString() + @ApiProperty() + slug!: string; + + @IsBoolean() + @ApiProperty() + isInstantEvent!: boolean; + + @IsOptional() + @ApiPropertyOptional() + aiPhoneCallConfig?: any; + + @IsOptional() + @ApiPropertyOptional() + schedulingType?: any; + + @IsInt() + @ApiProperty() + length!: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Location) + @ApiProperty({ type: [Location] }) + locations!: Location[]; + + @IsArray() + @ApiProperty({ type: [Object] }) + customInputs!: any[]; + + @IsBoolean() + @ApiProperty() + disableGuests!: boolean; + + @IsObject() + @ApiProperty({ type: Object, nullable: true }) + metadata!: object | null; + + @IsBoolean() + @ApiProperty() + lockTimeZoneToggleOnBookingPage!: boolean; + + @IsBoolean() + @ApiProperty() + requiresConfirmation!: boolean; + + @IsBoolean() + @ApiProperty() + requiresBookerEmailVerification!: boolean; + + @IsOptional() + @ApiPropertyOptional() + recurringEvent?: any; + + @IsNumber() + @ApiProperty() + price!: number; + + @IsString() + @ApiProperty() + currency!: string; + + @IsOptional() + @ApiPropertyOptional({ type: Number, nullable: true }) + seatsPerTimeSlot?: number | null; + + @IsBoolean() + @ApiProperty({ type: Boolean, nullable: true }) + seatsShowAvailabilityCount!: boolean | null; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookingField) + @ApiProperty({ type: [BookingField] }) + bookingFields!: BookingField[]; + + @IsOptional() + @ApiPropertyOptional() + team?: any; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ type: String, nullable: true }) + successRedirectUrl?: string | null; + + @IsArray() + @ApiProperty() + workflows!: any[]; + + @IsArray() + @ApiPropertyOptional() + hosts?: any[]; + + @ValidateNested() + @Type(() => Owner) + @ApiProperty({ type: Owner, nullable: true }) + owner!: Owner | null; + + @ValidateNested() + @Type(() => Schedule) + @ApiProperty({ type: Schedule, nullable: true }) + schedule!: Schedule | null; + + @IsBoolean() + @ApiProperty() + hidden!: boolean; + + @IsBoolean() + @ApiProperty() + assignAllTeamMembers!: boolean; + + @IsOptional() + @ApiPropertyOptional() + bookerLayouts?: any; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => User) + @ApiPropertyOptional({ type: [User] }) + users?: User[]; + + @IsObject() + @ApiProperty({ type: Object }) + entity!: object; + + @IsBoolean() + isDynamic!: boolean; +} + +export class GetEventTypePublicOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => PublicEventTypeOutput) + @IsArray() + data!: PublicEventTypeOutput | null; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output.ts new file mode 100644 index 00000000000000..4d0cc43eb41477 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output.ts @@ -0,0 +1,28 @@ +import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Data { + @ApiProperty({ + type: EventTypeOutput, + }) + @ValidateNested() + @Type(() => EventTypeOutput) + eventType!: EventTypeOutput; +} + +export class GetEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: Data, + }) + @ValidateNested() + @Type(() => Data) + data!: Data; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output.ts new file mode 100644 index 00000000000000..1fecef2ffa7e63 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output.ts @@ -0,0 +1,43 @@ +import { + CREATE_EVENT_LENGTH_EXAMPLE, + CREATE_EVENT_SLUG_EXAMPLE, + CREATE_EVENT_TITLE_EXAMPLE, +} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, IsInt, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class PublicEventType { + @IsInt() + @DocsProperty({ example: 1 }) + id!: number; + + @IsInt() + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; + + @IsString() + description?: string | null; +} + +export class GetEventTypesPublicOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => PublicEventType) + @IsArray() + data!: PublicEventType[]; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output.ts new file mode 100644 index 00000000000000..de791d0a7c54ce --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output.ts @@ -0,0 +1,31 @@ +import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class EventTypeGroup { + @ValidateNested({ each: true }) + @Type(() => EventTypeOutput) + @IsArray() + eventTypes!: EventTypeOutput[]; +} + +export class GetEventTypesData { + @ValidateNested({ each: true }) + @Type(() => EventTypeGroup) + @IsArray() + eventTypeGroups!: EventTypeGroup[]; +} + +export class GetEventTypesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => GetEventTypesData) + @IsArray() + data!: GetEventTypesData; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output.ts new file mode 100644 index 00000000000000..52b68f4399da5e --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output.ts @@ -0,0 +1,20 @@ +import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EventTypeOutput) + data!: EventTypeOutput; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts new file mode 100644 index 00000000000000..2c6ac159fc4267 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts @@ -0,0 +1,189 @@ +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants"; +import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository"; +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input"; +import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; + +import { + createEventType, + updateEventType, + EventTypesPublic, + getEventTypesPublic, + systemBeforeFieldEmail, +} from "@calcom/platform-libraries"; +import { EventType } from "@calcom/prisma/client"; + +@Injectable() +export class EventTypesService_2024_04_15 { + constructor( + private readonly eventTypesRepository: EventTypesRepository_2024_04_15, + private readonly membershipsRepository: MembershipsRepository, + private readonly usersRepository: UsersRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly dbWrite: PrismaWriteService, + private usersService: UsersService + ) {} + + async createUserEventType( + user: UserWithProfile, + body: CreateEventTypeInput_2024_04_15 + ): Promise { + await this.checkCanCreateEventType(user.id, body); + const eventTypeUser = await this.getUserToCreateEvent(user); + const { eventType } = await createEventType({ + input: body, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + return eventType as EventTypeOutput; + } + + async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_04_15) { + const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug); + if (existsWithSlug) { + throw new BadRequestException("User already has an event type with this slug."); + } + } + + async getUserToCreateEvent(user: UserWithProfile) { + const organizationId = this.usersService.getUserMainOrgId(user); + const isOrgAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + const profileId = this.usersService.getUserMainProfile(user)?.id || null; + return { + id: user.id, + role: user.role, + organizationId: user.organizationId, + organization: { isOrgAdmin }, + profile: { id: profileId }, + metadata: user.metadata, + }; + } + + async getUserEventType(userId: number, eventTypeId: number) { + const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId); + + if (!eventType) { + return null; + } + + this.checkUserOwnsEventType(userId, eventType); + return eventType; + } + + async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) { + const organizationId = this.usersService.getUserMainOrgId(user); + + const isUserOrganizationAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + + const eventType = await this.eventTypesRepository.getUserEventTypeForAtom( + user, + isUserOrganizationAdmin, + eventTypeId + ); + + if (!eventType) { + return null; + } + + this.checkUserOwnsEventType(user.id, eventType.eventType); + return eventType as { eventType: EventTypeOutput }; + } + + async getEventTypesPublicByUsername(username: string): Promise { + const user = await this.usersRepository.findByUsername(username); + if (!user) { + throw new NotFoundException(`User with username "${username}" not found`); + } + + return await getEventTypesPublic(user.id); + } + + async createUserDefaultEventTypes(userId: number) { + const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES; + + const defaultEventTypes = await Promise.all([ + this.eventTypesRepository.createUserEventType(userId, thirtyMinutes), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutes), + this.eventTypesRepository.createUserEventType(userId, thirtyMinutesVideo), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutesVideo), + ]); + + return defaultEventTypes; + } + + async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) { + this.checkCanUpdateEventType(user.id, eventTypeId); + const eventTypeUser = await this.getUserToUpdateEvent(user); + const bookingFields = [...(body.bookingFields || [])]; + + if ( + !bookingFields.find((field) => field.type === "email") && + !bookingFields.find((field) => field.type === "phone") + ) { + bookingFields.push(systemBeforeFieldEmail); + } + + await updateEventType({ + input: { id: eventTypeId, ...body, bookingFields }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + const eventType = await this.getUserEventTypeForAtom(user, eventTypeId); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return eventType.eventType; + } + + async checkCanUpdateEventType(userId: number, eventTypeId: number) { + const existingEventType = await this.getUserEventType(userId, eventTypeId); + if (!existingEventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + this.checkUserOwnsEventType(userId, existingEventType); + } + + async getUserToUpdateEvent(user: UserWithProfile) { + const profileId = this.usersService.getUserMainProfile(user)?.id || null; + const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id); + return { ...user, profile: { id: profileId }, selectedCalendars }; + } + + async deleteEventType(eventTypeId: number, userId: number) { + const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId); + if (!existingEventType) { + throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`); + } + + this.checkUserOwnsEventType(userId, existingEventType); + + return this.eventTypesRepository.deleteEventType(eventTypeId); + } + + checkUserOwnsEventType(userId: number, eventType: Pick) { + if (userId !== eventType.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`); + } + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/constants/constants.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/constants/constants.ts new file mode 100644 index 00000000000000..ad1343e84d7dd9 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/constants/constants.ts @@ -0,0 +1,44 @@ +import { OrganizerIntegrationLocation } from "@calcom/lib"; + +type BaseEventType = { + length: number; + slug: string; + title: string; +}; + +type EventTypeWithLocation = BaseEventType & { + locations: OrganizerIntegrationLocation[]; +}; + +const thirtyMinutes: BaseEventType = { + length: 30, + slug: "thirty-minutes", + title: "30 Minutes", +}; + +const thirtyMinutesVideo: EventTypeWithLocation = { + length: 30, + slug: "thirty-minutes-video", + title: "30 Minutes", + locations: [{ type: "integrations:daily" }], +}; + +const sixtyMinutes: BaseEventType = { + length: 60, + slug: "sixty-minutes", + title: "60 Minutes", +}; + +const sixtyMinutesVideo: EventTypeWithLocation = { + length: 60, + slug: "sixty-minutes-video", + title: "60 Minutes", + locations: [{ type: "integrations:daily" }], +}; + +export const DEFAULT_EVENT_TYPES = { + thirtyMinutes, + thirtyMinutesVideo, + sixtyMinutes, + sixtyMinutesVideo, +}; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts new file mode 100644 index 00000000000000..b5273e45869f40 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -0,0 +1,1841 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Schedule, EventType } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants"; +import { + BookingWindowPeriodInputTypeEnum_2024_06_14, + BookerLayoutsInputEnum_2024_06_14, + ConfirmationPolicyEnum, + NoticeThresholdUnitEnum, + FrequencyInput, +} from "@calcom/platform-enums"; +import { + ApiSuccessResponse, + CreateEventTypeInput_2024_06_14, + EventTypeOutput_2024_06_14, + NameDefaultFieldInput_2024_06_14, + NotesDefaultFieldInput_2024_06_14, + UpdateEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; +import { SchedulingType } from "@calcom/prisma/enums"; + +const orderBySlug = (a: { slug: string }, b: { slug: string }) => { + if (a.slug < b.slug) return -1; + if (a.slug > b.slug) return 1; + return 0; +}; + +describe("Event types Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/event-types/100").expect(401); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let schedulesRepostoryFixture: SchedulesRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`; + const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`; + const name = `event-types-2024-06-14-user-${randomString()}`; + const username = name; + let eventType: EventTypeOutput_2024_06_14; + let user: User; + let orgUser: User; + let falseTestUser: User; + let firstSchedule: Schedule; + let secondSchedule: Schedule; + let falseTestSchedule: Schedule; + let orgUserEventType1: EventType; + let orgUserEventType2: EventType; + let orgUserEventType3: EventType; + + const defaultResponseBookingFieldName = { + isDefault: true, + type: "name", + slug: "name", + required: true, + disableOnPrefill: false, + }; + + const defaultResponseBookingFieldEmail = { + isDefault: true, + type: "email", + slug: "email", + required: true, + disableOnPrefill: false, + }; + + const defaultResponseBookingFieldLocation = { + isDefault: true, + type: "radioInput", + slug: "location", + required: false, + hidden: false, + }; + + const defaultResponseBookingFieldTitle = { + isDefault: true, + type: "text", + slug: "title", + required: true, + hidden: true, + disableOnPrefill: false, + }; + + const defaultResponseBookingFieldNotes = { + isDefault: true, + type: "textarea", + slug: "notes", + required: false, + hidden: false, + disableOnPrefill: false, + }; + + const defaultResponseBookingFieldGuests = { + isDefault: true, + type: "multiemail", + slug: "guests", + required: false, + hidden: false, + disableOnPrefill: false, + }; + + const defaultResponseBookingFieldRescheduleReason = { + isDefault: true, + type: "textarea", + slug: "rescheduleReason", + required: false, + hidden: false, + disableOnPrefill: false, + }; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ + name: `event-types-2024-06-14-organization-${randomString()}`, + slug: `event-type-2024-06-14-org-slug-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.create({ + email: userEmail, + name, + username, + }); + + orgUser = await userRepositoryFixture.create({ + email: `event-types-2024-06-14-org-user-${randomString()}@example.com`, + name: `event-types-2024-06-14-org-user-${randomString()}`, + username: `event-types-2024-06-14-org-user-${randomString()}`, + }); + + profileRepositoryFixture.create({ + uid: `usr-${orgUser.id}`, + username: orgUser.username as string, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: orgUser.id, + }, + }, + }); + + orgUserEventType1 = await eventTypesRepositoryFixture.create( + { + title: `event-types-2024-06-14-event-type-${randomString()}`, + slug: `event-types-2024-06-14-event-type-${randomString()}`, + length: 60, + locations: [], + }, + orgUser.id + ); + + orgUserEventType2 = await eventTypesRepositoryFixture.create( + { + title: `event-types-2024-06-14-event-type-${randomString()}`, + slug: `event-types-2024-06-14-event-type-${randomString()}`, + length: 60, + locations: [], + }, + orgUser.id + ); + + orgUserEventType3 = await eventTypesRepositoryFixture.create( + { + title: `event-types-2024-06-14-event-type-${randomString()}`, + slug: `event-types-2024-06-14-event-type-${randomString()}`, + length: 60, + locations: [], + }, + orgUser.id + ); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: orgUser.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + falseTestUser = await userRepositoryFixture.create({ + email: falseTestEmail, + name: `event-types-2024-06-14-false-test-user-${randomString()}`, + username: falseTestEmail, + }); + + firstSchedule = await schedulesRepostoryFixture.create({ + userId: user.id, + name: `event-types-2024-06-14-schedule-work-${randomString()}`, + timeZone: "Europe/Rome", + }); + + secondSchedule = await schedulesRepostoryFixture.create({ + userId: user.id, + name: `event-types-2024-06-14-schedule-chill-${randomString()}`, + timeZone: "Europe/Rome", + }); + + falseTestSchedule = await schedulesRepostoryFixture.create({ + userId: falseTestUser.id, + name: `event-types-2024-06-14-schedule-work-${randomString()}`, + timeZone: "Europe/Rome", + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should not allow creating an event type with schedule user does not own", async () => { + const scheduleId = falseTestSchedule.id; + + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class", + slug: "coding-class", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + bookingFields: [ + { + type: "select", + label: "select which language you want to learn", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + }, + ], + scheduleId, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(404); + }); + + it("should create an event type", async () => { + const nameBookingField: NameDefaultFieldInput_2024_06_14 = { + type: "name", + label: "Your name sir / madam", + placeholder: "john doe", + disableOnPrefill: false, + }; + + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class", + slug: "coding-class", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + lengthInMinutesOptions: [30, 60, 90], + locations: [ + { + type: "integration", + integration: "cal-video", + }, + { + type: "attendeePhone", + }, + { + type: "attendeeAddress", + }, + { + type: "attendeeDefined", + }, + ], + bookingFields: [ + nameBookingField, + { + type: "select", + label: "select which language you want to learn", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + disableOnPrefill: true, + hidden: false, + }, + ], + scheduleId: firstSchedule.id, + bookingLimitsCount: { + day: 2, + week: 5, + }, + onlyShowFirstAvailableSlot: true, + bookingLimitsDuration: { + day: 60, + week: 100, + }, + offsetStart: 30, + bookingWindow: { + type: BookingWindowPeriodInputTypeEnum_2024_06_14.calendarDays, + value: 30, + rolling: true, + }, + bookerLayouts: { + enabledLayouts: [ + BookerLayoutsInputEnum_2024_06_14.column, + BookerLayoutsInputEnum_2024_06_14.month, + BookerLayoutsInputEnum_2024_06_14.week, + ], + defaultLayout: BookerLayoutsInputEnum_2024_06_14.month, + }, + confirmationPolicy: { + type: ConfirmationPolicyEnum.TIME, + noticeThreshold: { + count: 60, + unit: NoticeThresholdUnitEnum.MINUTES, + }, + blockUnconfirmedBookingsInBooker: true, + }, + recurrence: { + frequency: FrequencyInput.weekly, + interval: 2, + occurrences: 10, + disabled: false, + }, + requiresBookerEmailVerification: false, + hideCalendarNotes: false, + hideCalendarEventDetails: false, + lockTimeZoneToggleOnBookingPage: true, + color: { + darkThemeHex: "#292929", + lightThemeHex: "#fafafa", + }, + customName: `{Event type title} between {Organiser} and {Scheduler}`, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.description).toEqual(body.description); + expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes); + expect(createdEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions); + expect(createdEventType.locations).toEqual(body.locations); + expect(createdEventType.ownerId).toEqual(user.id); + expect(createdEventType.scheduleId).toEqual(firstSchedule.id); + expect(createdEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount); + expect(createdEventType.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); + expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); + expect(createdEventType.offsetStart).toEqual(body.offsetStart); + expect(createdEventType.bookingWindow).toEqual(body.bookingWindow); + expect(createdEventType.bookerLayouts).toEqual(body.bookerLayouts); + expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy); + expect(createdEventType.recurrence).toEqual(body.recurrence); + expect(createdEventType.customName).toEqual(body.customName); + expect(createdEventType.requiresBookerEmailVerification).toEqual( + body.requiresBookerEmailVerification + ); + + expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes); + expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails); + expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual( + body.lockTimeZoneToggleOnBookingPage + ); + expect(createdEventType.color).toEqual(body.color); + + const expectedBookingFields = [ + { ...defaultResponseBookingFieldName, ...nameBookingField }, + { ...defaultResponseBookingFieldEmail }, + { ...defaultResponseBookingFieldLocation }, + { + type: "select", + label: "select which language you want to learn", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + disableOnPrefill: true, + hidden: false, + isDefault: false, + }, + { ...defaultResponseBookingFieldTitle }, + { ...defaultResponseBookingFieldNotes }, + { ...defaultResponseBookingFieldGuests }, + { ...defaultResponseBookingFieldRescheduleReason }, + ]; + + expect(createdEventType.bookingFields).toEqual(expectedBookingFields); + eventType = responseBody.data; + }); + }); + + it(`/GET/event-types by username`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types?username=${username}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toEqual(1); + + const fetchedEventType = responseBody.data?.[0]; + + expect(fetchedEventType?.id).toEqual(eventType.id); + expect(fetchedEventType?.title).toEqual(eventType.title); + expect(fetchedEventType?.description).toEqual(eventType.description); + expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes); + expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions); + expect(fetchedEventType?.locations).toEqual(eventType.locations); + expect(fetchedEventType?.bookingFields.sort(orderBySlug)).toEqual( + eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true)) + ); + expect(fetchedEventType?.ownerId).toEqual(user.id); + expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount); + expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot); + expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration); + expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart); + expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow); + expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts); + expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy); + expect(fetchedEventType.recurrence).toEqual(eventType.recurrence); + expect(fetchedEventType.customName).toEqual(eventType.customName); + expect(fetchedEventType.requiresBookerEmailVerification).toEqual( + eventType.requiresBookerEmailVerification + ); + expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes); + expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails); + expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual( + eventType.lockTimeZoneToggleOnBookingPage + ); + expect(fetchedEventType.color).toEqual(eventType.color); + }); + + it(`/GET/event-types by username and orgSlug`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toEqual(3); + expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined(); + expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined(); + expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined(); + }); + + it(`/GET/event-types by username and orgSlug and eventSlug`, async () => { + const response = await request(app.getHttpServer()) + .get( + `/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}&eventSlug=${orgUserEventType1.slug}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toEqual(1); + expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined(); + }); + + it(`/GET/event-types by username and orgId`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types?username=${orgUser.username}&orgId=${organization.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toEqual(3); + expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined(); + expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined(); + expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined(); + }); + + it("should return an error when creating an event type with seats enabled and multiple locations", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 2", + slug: "coding-class-2", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + { + type: "phone", + phone: "+37120993151", + public: true, + }, + ], + scheduleId: firstSchedule.id, + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + }; + + await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(400); + }); + + it("should return an error when trying to enable seats for an event type with multiple locations", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 3", + slug: "coding-class-3", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + { + type: "phone", + phone: "+37120993151", + public: true, + }, + ], + scheduleId: firstSchedule.id, + }; + + const createResponse = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(201); + + const createdEventType = createResponse.body.data; + + expect(createdEventType).toMatchObject({ + id: expect.any(Number), + title: body.title, + description: body.description, + lengthInMinutes: body.lengthInMinutes, + locations: body.locations, + scheduleId: firstSchedule.id, + }); + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${createdEventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send({ + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + }) + .expect(400); + }); + + it("should return an error when creating an event type with seats enabled and confirmationPolicy enabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 4", + slug: "coding-class-4", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + scheduleId: firstSchedule.id, + confirmationPolicy: { + type: ConfirmationPolicyEnum.ALWAYS, + blockUnconfirmedBookingsInBooker: false, + }, + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + }; + + await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(400); + }); + + it("should return an error when trying to enable seats for an event type with confirmationPolicy enabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 5", + slug: "coding-class-5", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + confirmationPolicy: { + type: ConfirmationPolicyEnum.ALWAYS, + blockUnconfirmedBookingsInBooker: false, + }, + scheduleId: firstSchedule.id, + }; + + const createResponse = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(201); + + const createdEventType = createResponse.body.data; + + expect(createdEventType).toMatchObject({ + id: expect.any(Number), + title: body.title, + description: body.description, + lengthInMinutes: body.lengthInMinutes, + confirmationPolicy: body.confirmationPolicy, + scheduleId: firstSchedule.id, + }); + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${createdEventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send({ + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + }) + .expect(400); + }); + + it("should return an error when trying to set multiple locations for an event type with seats enabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 6", + slug: "coding-class-6", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + scheduleId: firstSchedule.id, + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + }; + + const createResponse = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(201); + + const createdEventType = createResponse.body.data; + + expect(createdEventType).toMatchObject({ + id: expect.any(Number), + title: body.title, + description: body.description, + lengthInMinutes: body.lengthInMinutes, + seats: body.seats, + scheduleId: firstSchedule.id, + }); + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${createdEventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send({ + locations: [ + { + type: "integration", + integration: "cal-video", + }, + { + type: "phone", + phone: "+37120993151", + public: true, + }, + ], + }) + .expect(400); + }); + + it("should return an error when creating an event type with confirmationPolicy enabled and seats enabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 7", + slug: "coding-class-7", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + confirmationPolicy: { + type: ConfirmationPolicyEnum.ALWAYS, + blockUnconfirmedBookingsInBooker: false, + }, + scheduleId: firstSchedule.id, + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + }; + + await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(400); + }); + + it("should return an error when trying to enable confirmationPolicy for an event type with seats enabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class 8", + slug: "coding-class-8", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + seats: { + seatsPerTimeSlot: 4, + showAttendeeInfo: true, + showAvailabilityCount: true, + }, + scheduleId: firstSchedule.id, + }; + + const createResponse = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(201); + + const createdEventType = createResponse.body.data; + + expect(createdEventType).toMatchObject({ + id: expect.any(Number), + title: body.title, + description: body.description, + lengthInMinutes: body.lengthInMinutes, + seats: body.seats, + scheduleId: firstSchedule.id, + }); + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${createdEventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send({ + confirmationPolicy: { + type: ConfirmationPolicyEnum.ALWAYS, + blockUnconfirmedBookingsInBooker: false, + }, + }) + .expect(400); + }); + + it("should update event type", async () => { + const nameBookingField: NameDefaultFieldInput_2024_06_14 = { + type: "name", + label: "Your name sir / madam", + placeholder: "john doe", + disableOnPrefill: true, + }; + + const notesBookingField: NotesDefaultFieldInput_2024_06_14 = { + slug: "notes", + label: "lemme take some notes", + placeholder: "write your notes here", + required: true, + }; + + const newTitle = "Coding class in Italian!"; + + const body: UpdateEventTypeInput_2024_06_14 = { + title: newTitle, + scheduleId: secondSchedule.id, + lengthInMinutesOptions: [15, 30], + bookingFields: [ + nameBookingField, + { + type: "select", + label: "select which language you want to learn", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + disableOnPrefill: false, + hidden: false, + }, + notesBookingField, + ], + bookingLimitsCount: { + day: 4, + week: 10, + }, + onlyShowFirstAvailableSlot: true, + bookingLimitsDuration: { + day: 100, + week: 200, + }, + offsetStart: 50, + bookingWindow: { + type: BookingWindowPeriodInputTypeEnum_2024_06_14.businessDays, + value: 40, + rolling: false, + }, + bookerLayouts: { + enabledLayouts: [ + BookerLayoutsInputEnum_2024_06_14.column, + BookerLayoutsInputEnum_2024_06_14.month, + BookerLayoutsInputEnum_2024_06_14.week, + ], + defaultLayout: BookerLayoutsInputEnum_2024_06_14.month, + }, + confirmationPolicy: { + type: ConfirmationPolicyEnum.ALWAYS, + blockUnconfirmedBookingsInBooker: false, + }, + recurrence: { + frequency: FrequencyInput.monthly, + interval: 4, + occurrences: 10, + disabled: false, + }, + requiresBookerEmailVerification: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + lockTimeZoneToggleOnBookingPage: true, + color: { + darkThemeHex: "#292929", + lightThemeHex: "#fafafa", + }, + customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const updatedEventType = responseBody.data; + expect(updatedEventType.title).toEqual(body.title); + + expect(updatedEventType.id).toEqual(eventType.id); + expect(updatedEventType.title).toEqual(newTitle); + expect(updatedEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions); + expect(updatedEventType.description).toEqual(eventType.description); + expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes); + expect(updatedEventType.locations).toEqual(eventType.locations); + + const expectedBookingFields = [ + { ...defaultResponseBookingFieldName, ...nameBookingField }, + { ...defaultResponseBookingFieldEmail }, + { ...defaultResponseBookingFieldLocation }, + { + type: "select", + label: "select which language you want to learn", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + disableOnPrefill: false, + hidden: false, + isDefault: false, + }, + { ...defaultResponseBookingFieldTitle }, + { ...defaultResponseBookingFieldNotes, ...notesBookingField }, + { ...defaultResponseBookingFieldGuests }, + { ...defaultResponseBookingFieldRescheduleReason }, + ]; + + expect(updatedEventType.bookingFields).toEqual(expectedBookingFields); + + expect(updatedEventType.ownerId).toEqual(user.id); + expect(updatedEventType.scheduleId).toEqual(secondSchedule.id); + expect(updatedEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount); + expect(updatedEventType.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); + expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); + expect(updatedEventType.offsetStart).toEqual(body.offsetStart); + expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow); + expect(updatedEventType.bookerLayouts).toEqual(body.bookerLayouts); + expect(updatedEventType.confirmationPolicy).toEqual(body.confirmationPolicy); + expect(updatedEventType.recurrence).toEqual(body.recurrence); + expect(updatedEventType.customName).toEqual(body.customName); + expect(updatedEventType.requiresBookerEmailVerification).toEqual( + body.requiresBookerEmailVerification + ); + expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes); + expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails); + expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual( + body.lockTimeZoneToggleOnBookingPage + ); + expect(updatedEventType.color).toEqual(body.color); + + eventType.title = newTitle; + eventType.scheduleId = secondSchedule.id; + eventType.lengthInMinutesOptions = updatedEventType.lengthInMinutesOptions; + eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount; + eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot; + eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration; + eventType.offsetStart = updatedEventType.offsetStart; + eventType.bookingWindow = updatedEventType.bookingWindow; + eventType.bookerLayouts = updatedEventType.bookerLayouts; + eventType.confirmationPolicy = updatedEventType.confirmationPolicy; + eventType.recurrence = updatedEventType.recurrence; + eventType.customName = updatedEventType.customName; + eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification; + eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes; + eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails; + eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage; + eventType.color = updatedEventType.color; + eventType.bookingFields = updatedEventType.bookingFields; + }); + }); + + it("should not allow to update event type with scheduleId user does not own", async () => { + const body: UpdateEventTypeInput_2024_06_14 = { + scheduleId: falseTestSchedule.id, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(404); + }); + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(fetchedEventType.id).toEqual(eventType.id); + expect(fetchedEventType.title).toEqual(eventType.title); + expect(fetchedEventType.description).toEqual(eventType.description); + expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes); + expect(fetchedEventType.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions); + expect(fetchedEventType.locations).toEqual(eventType.locations); + expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields); + expect(fetchedEventType.ownerId).toEqual(user.id); + expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount); + expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot); + expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration); + expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart); + expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow); + expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts); + expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy); + expect(fetchedEventType.recurrence).toEqual(eventType.recurrence); + expect(fetchedEventType.customName).toEqual(eventType.customName); + expect(fetchedEventType.requiresBookerEmailVerification).toEqual( + eventType.requiresBookerEmailVerification + ); + expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes); + expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails); + expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual( + eventType.lockTimeZoneToggleOnBookingPage + ); + expect(fetchedEventType.color).toEqual(eventType.color); + }); + + it(`/GET/event-types by username and eventSlug`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data[0]; + + expect(fetchedEventType?.id).toEqual(eventType.id); + expect(fetchedEventType?.title).toEqual(eventType.title); + expect(fetchedEventType?.description).toEqual(eventType.description); + expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes); + expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions); + expect(fetchedEventType?.locations).toEqual(eventType.locations); + expect(fetchedEventType?.bookingFields.sort(orderBySlug)).toEqual( + eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true)) + ); + expect(fetchedEventType?.ownerId).toEqual(user.id); + expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount); + expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot); + expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration); + expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart); + expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow); + expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts); + expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy); + expect(fetchedEventType.recurrence).toEqual(eventType.recurrence); + expect(fetchedEventType.customName).toEqual(eventType.customName); + expect(fetchedEventType.requiresBookerEmailVerification).toEqual( + eventType.requiresBookerEmailVerification + ); + expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes); + expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails); + expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual( + eventType.lockTimeZoneToggleOnBookingPage + ); + expect(fetchedEventType.color).toEqual(eventType.color); + }); + + it(`/GET/:id not existing`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/event-types/1000`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(404); + }); + + it("should delete event type", async () => { + return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await eventTypesRepositoryFixture.delete(eventType.id); + } catch (e) { + // Event type might have been deleted by the test + } + try { + await userRepositoryFixture.delete(user.id); + } catch (e) { + // User might have been deleted by the test + } + try { + await userRepositoryFixture.delete(falseTestUser.id); + } catch (e) { + // User might have been deleted by the test + } + + try { + await userRepositoryFixture.delete(orgUser.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); + + describe("Handle event-types booking fields", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const userEmail = "legacy-event-types-test-e2e@api.com"; + const name = "bob-the-builder"; + const username = name; + let user: User; + let legacyEventTypeId1: number; + let legacyEventTypeId2: number; + + const expectedReturnSystemFields = [ + { isDefault: true, required: true, slug: "name", type: "name", disableOnPrefill: false }, + { isDefault: true, required: true, slug: "email", type: "email", disableOnPrefill: false }, + { + isDefault: true, + type: "radioInput", + slug: "location", + required: false, + hidden: false, + }, + { isDefault: true, required: true, slug: "title", type: "text", disableOnPrefill: false, hidden: true }, + { + isDefault: true, + required: false, + slug: "guests", + type: "multiemail", + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + required: false, + slug: "rescheduleReason", + type: "textarea", + disableOnPrefill: false, + hidden: false, + }, + { + disableOnPrefill: false, + isDefault: true, + type: "phone", + slug: "attendeePhoneNumber", + required: false, + hidden: true, + }, + { + isDefault: true, + required: false, + slug: "notes", + type: "textarea", + disableOnPrefill: false, + hidden: false, + }, + ]; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ + name: `event-types-2024-06-14-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.create({ + email: userEmail, + name, + username, + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should not allow creating an event type with input of event-types version 2024_04_15", async () => { + const body: CreateEventTypeInput_2024_04_15 = { + title: "Coding class", + slug: "coding-class", + description: "Let's learn how to code like a pro.", + length: 60, + locations: [{ type: "integrations:daily" }], + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .send(body) + .expect(400); + }); + + it("should return system bookingFields stored in database", async () => { + const legacyEventTypeInput = { + title: "legacy event type", + description: "legacy event type description", + length: 40, + hidden: false, + slug: "legacy-event-type", + locations: [], + schedulingType: SchedulingType.ROUND_ROBIN, + bookingFields: [ + { + name: "name", + type: "name", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: true, + defaultLabel: "your_name", + }, + { + name: "email", + type: "email", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: true, + defaultLabel: "email_address", + }, + { + name: "location", + type: "radioInput", + label: "", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: false, + placeholder: "", + defaultLabel: "location", + getOptionsAt: "locations", + optionsInputs: { + phone: { type: "phone", required: true, placeholder: "" }, + attendeeInPerson: { type: "address", required: true, placeholder: "" }, + }, + hideWhenJustOneOption: true, + }, + { + name: "title", + type: "text", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "what_is_this_meeting_about", + defaultPlaceholder: "", + }, + { + name: "guests", + type: "multiemail", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_guests", + defaultPlaceholder: "email", + }, + { + name: "rescheduleReason", + type: "textarea", + views: [{ id: "reschedule", label: "Reschedule View" }], + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "reason_for_reschedule", + defaultPlaceholder: "reschedule_placeholder", + }, + { + name: "attendeePhoneNumber", + type: "phone", + hidden: true, + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "phone_number", + }, + { + name: "notes", + type: "textarea", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_notes", + defaultPlaceholder: "share_additional_notes", + }, + ], + }; + const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id); + legacyEventTypeId1 = legacyEventType.id; + + return request(app.getHttpServer()) + .get(`/api/v2/event-types/${legacyEventTypeId1}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + expect(fetchedEventType.bookingFields).toEqual(expectedReturnSystemFields); + }); + }); + + it("should return user created bookingFields with system fields", async () => { + const userDefinedBookingField = { + name: "team", + type: "textarea", + label: "your team", + sources: [ + { + id: "user", + type: "user", + label: "User", + fieldRequired: true, + }, + ], + editable: "user", + required: true, + placeholder: "FC Barcelona", + }; + + const legacyEventTypeInput = { + title: "legacy event type two", + description: "legacy event type description two", + length: 40, + hidden: false, + slug: "legacy-event-type-two", + locations: [], + schedulingType: SchedulingType.ROUND_ROBIN, + bookingFields: [ + userDefinedBookingField, + { + name: "name", + type: "name", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: true, + defaultLabel: "your_name", + }, + { + name: "email", + type: "email", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: true, + defaultLabel: "email_address", + }, + { + name: "location", + type: "radioInput", + label: "", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system", + required: false, + placeholder: "", + defaultLabel: "location", + getOptionsAt: "locations", + optionsInputs: { + phone: { type: "phone", required: true, placeholder: "" }, + attendeeInPerson: { type: "address", required: true, placeholder: "" }, + }, + hideWhenJustOneOption: true, + }, + { + name: "title", + type: "text", + hidden: true, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: true, + defaultLabel: "what_is_this_meeting_about", + defaultPlaceholder: "", + }, + { + name: "guests", + type: "multiemail", + hidden: false, + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_guests", + defaultPlaceholder: "email", + }, + { + name: "rescheduleReason", + type: "textarea", + views: [{ id: "reschedule", label: "Reschedule View" }], + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "reason_for_reschedule", + defaultPlaceholder: "reschedule_placeholder", + }, + { + name: "attendeePhoneNumber", + type: "phone", + hidden: true, + sources: [ + { + id: "default", + type: "default", + label: "Default", + }, + ], + editable: "system-but-optional", + required: false, + defaultLabel: "phone_number", + }, + { + name: "notes", + type: "textarea", + sources: [{ id: "default", type: "default", label: "Default" }], + editable: "system-but-optional", + required: false, + defaultLabel: "additional_notes", + defaultPlaceholder: "share_additional_notes", + }, + ], + }; + const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id); + legacyEventTypeId2 = legacyEventType.id; + + return request(app.getHttpServer()) + .get(`/api/v2/event-types/${legacyEventTypeId2}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + + expect(fetchedEventType.bookingFields.sort(orderBySlug)).toEqual( + [ + { + isDefault: false, + type: userDefinedBookingField.type, + slug: userDefinedBookingField.name, + label: userDefinedBookingField.label, + required: userDefinedBookingField.required, + placeholder: userDefinedBookingField.placeholder, + disableOnPrefill: false, + hidden: false, + }, + ...expectedReturnSystemFields, + ].sort(orderBySlug) + ); + }); + }); + + it("should return event type with unknown bookingField", async () => { + const unknownSystemField = { + name: "unknown-whatever", + type: "unknown-whatever", + label: "your team", + sources: [ + { + id: "user", + type: "user", + label: "User", + fieldRequired: true, + }, + ], + editable: "user", + required: true, + placeholder: "FC Barcelona", + }; + + const eventTypeInput = { + title: "unknown field event type two", + description: "unknown field event type description two", + length: 40, + hidden: false, + slug: "unknown-field-type-two", + locations: [], + schedulingType: SchedulingType.ROUND_ROBIN, + bookingFields: [unknownSystemField], + }; + const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id); + + return request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + + expect(fetchedEventType.bookingFields).toEqual([ + { + type: "unknown", + slug: "unknown", + bookingField: JSON.stringify(unknownSystemField), + }, + ]); + }); + }); + + it("should return event type with default bookingFields if they are not defined", async () => { + const eventTypeInput = { + title: "undefined booking fields", + description: "undefined booking fields", + length: 40, + hidden: false, + slug: "undefined-booking-fields", + locations: [], + schedulingType: SchedulingType.ROUND_ROBIN, + }; + const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id); + + return request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + + expect(fetchedEventType.bookingFields).toEqual([ + { + isDefault: true, + type: "name", + slug: "name", + required: true, + disableOnPrefill: false, + }, + { + isDefault: true, + type: "email", + slug: "email", + required: true, + disableOnPrefill: false, + }, + { + isDefault: true, + type: "radioInput", + slug: "location", + required: false, + hidden: false, + }, + { + isDefault: true, + type: "text", + slug: "title", + required: true, + disableOnPrefill: false, + hidden: true, + }, + { + isDefault: true, + type: "textarea", + slug: "notes", + required: false, + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + type: "multiemail", + slug: "guests", + required: false, + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + type: "textarea", + slug: "rescheduleReason", + required: false, + disableOnPrefill: false, + hidden: false, + }, + ]); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await eventTypesRepositoryFixture.delete(legacyEventTypeId1); + await eventTypesRepositoryFixture.delete(legacyEventTypeId2); + } catch (e) { + // Event type might have been deleted by the test + } + try { + await userRepositoryFixture.delete(user.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); + + describe("Handle event-types locations", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const userEmail = "locations-event-types-test-e2e@api.com"; + const name = "bob-the-locations-builder"; + const username = name; + let user: User; + let legacyEventTypeId1: number; + let legacyEventTypeId2: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ + name: `event-types-2024-06-14-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.create({ + email: userEmail, + name, + username, + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should return integration location with link and credentialId", async () => { + const eventTypeInput = { + title: "event type discord", + description: "event type description", + length: 40, + hidden: false, + slug: "discord-event-type", + locations: [ + { + type: "integrations:discord_video", + link: "https://discord.com/users/100", + credentialId: 100, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, + bookingFields: [], + }; + const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id); + legacyEventTypeId1 = legacyEventType.id; + + return request(app.getHttpServer()) + .get(`/api/v2/event-types/${legacyEventTypeId1}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + expect(fetchedEventType.locations).toEqual([ + { + type: "integration", + integration: "discord-video", + link: eventTypeInput.locations[0].link, + credentialId: eventTypeInput.locations[0].credentialId, + }, + ]); + }); + }); + + it("should return unsupported location", async () => { + const eventTypeInput = { + title: "event type not existing", + description: "event type description", + length: 40, + hidden: false, + slug: "not-existing-event-type", + locations: [ + { + type: "this-type-does-not-exist", + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, + bookingFields: [], + }; + const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id); + legacyEventTypeId1 = legacyEventType.id; + + return request(app.getHttpServer()) + .get(`/api/v2/event-types/${legacyEventTypeId1}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + expect(fetchedEventType.locations).toEqual([ + { type: "unknown", location: JSON.stringify(eventTypeInput.locations[0]) }, + ]); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await eventTypesRepositoryFixture.delete(legacyEventTypeId1); + await eventTypesRepositoryFixture.delete(legacyEventTypeId2); + } catch (e) { + // Event type might have been deleted by the test + } + try { + await userRepositoryFixture.delete(user.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts new file mode 100644 index 00000000000000..3104a736fb7cdb --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts @@ -0,0 +1,196 @@ +import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output"; +import { DeleteEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output"; +import { GetEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output"; +import { GetEventTypesOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output"; +import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output"; +import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer"; +import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service"; +import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service"; +import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Param, + Post, + Body, + NotFoundException, + Patch, + HttpCode, + HttpStatus, + Delete, + Query, + ParseIntPipe, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + UpdateEventTypeInput_2024_06_14, + GetEventTypesQuery_2024_06_14, + CreateEventTypeInput_2024_06_14, + EventTypeOutput_2024_06_14, +} from "@calcom/platform-types"; + +@Controller({ + path: "/v2/event-types", + version: VERSION_2024_06_14_VALUE, +}) +@UseGuards(PermissionsGuard) +@DocsTags("Event Types") +@ApiHeader({ + name: "cal-api-version", + description: `Must be set to \`2024-06-14\``, + required: true, +}) +export class EventTypesController_2024_06_14 { + constructor( + private readonly eventTypesService: EventTypesService_2024_06_14, + private readonly inputEventTypesService: InputEventTypesService_2024_06_14, + private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe + ) {} + + @Post("/") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(ApiAuthGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Create an event type" }) + async createEventType( + @Body() body: CreateEventTypeInput_2024_06_14, + @GetUser() user: UserWithProfile + ): Promise { + const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput( + user.id, + body + ); + + const eventType = await this.eventTypesService.createUserEventType(user, transformedBody); + + return { + status: SUCCESS_STATUS, + data: this.eventTypeResponseTransformPipe.transform(eventType), + }; + } + + @Get("/:eventTypeId") + @Permissions([EVENT_TYPE_READ]) + @UseGuards(ApiAuthGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Get an event type" }) + async getEventTypeById( + @Param("eventTypeId") eventTypeId: string, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId)); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + status: SUCCESS_STATUS, + data: this.eventTypeResponseTransformPipe.transform(eventType), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get all event types" }) + async getEventTypes( + @Query() queryParams: GetEventTypesQuery_2024_06_14 + ): Promise { + const eventTypes = await this.eventTypesService.getEventTypes(queryParams); + const eventTypesFormatted = this.eventTypeResponseTransformPipe.transform(eventTypes); + const eventTypesWithoutHiddenFields = eventTypesFormatted.map((eventType) => { + return { + ...eventType, + bookingFields: Array.isArray(eventType?.bookingFields) + ? eventType?.bookingFields + .map((field) => { + if ("hidden" in field) { + return field.hidden !== true ? field : null; + } + return field; + }) + .filter((f) => f) + : [], + }; + }) as EventTypeOutput_2024_06_14[]; + + return { + status: SUCCESS_STATUS, + data: eventTypesWithoutHiddenFields, + }; + } + + @Patch("/:eventTypeId") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(ApiAuthGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update an event type" }) + async updateEventType( + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @Body() body: UpdateEventTypeInput_2024_06_14, + @GetUser() user: UserWithProfile + ): Promise { + const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput( + body, + user.id, + eventTypeId + ); + + const eventType = await this.eventTypesService.updateEventType(eventTypeId, transformedBody, user); + + return { + status: SUCCESS_STATUS, + data: this.eventTypeResponseTransformPipe.transform(eventType), + }; + } + + @Delete("/:eventTypeId") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(ApiAuthGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @ApiOperation({ summary: "Delete an event type" }) + async deleteEventType( + @Param("eventTypeId") eventTypeId: number, + @GetUser("id") userId: number + ): Promise { + const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId); + + return { + status: SUCCESS_STATUS, + data: { + id: eventType.id, + lengthInMinutes: eventType.length, + slug: eventType.slug, + title: eventType.title, + }, + }; + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts new file mode 100644 index 00000000000000..23b8cda8e350e4 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts @@ -0,0 +1,44 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer"; +import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service"; +import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service"; +import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service"; +import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule], + providers: [ + EventTypesRepository_2024_06_14, + EventTypesService_2024_06_14, + InputEventTypesService_2024_06_14, + OutputEventTypesService_2024_06_14, + UsersRepository, + UsersService, + SchedulesRepository_2024_06_11, + EventTypeResponseTransformPipe, + CalendarsService, + CredentialsRepository, + AppsRepository, + CalendarsRepository, + ], + controllers: [EventTypesController_2024_06_14], + exports: [ + EventTypesService_2024_06_14, + EventTypesRepository_2024_06_14, + InputEventTypesService_2024_06_14, + OutputEventTypesService_2024_06_14, + ], +}) +export class EventTypesModule_2024_06_14 {} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts new file mode 100644 index 00000000000000..a4df49c30a3449 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts @@ -0,0 +1,98 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { Injectable } from "@nestjs/common"; + +import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types"; + +@Injectable() +export class EventTypesRepository_2024_06_14 { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private usersService: UsersService + ) {} + + async createUserEventType( + userId: number, + body: Omit + ) { + return this.dbWrite.prisma.eventType.create({ + data: { + ...body, + userId, + locations: body.locations, + bookingFields: body.bookingFields, + users: { connect: { id: userId } }, + }, + }); + } + + async getEventTypeWithSeats(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { + users: { select: { id: true } }, + seatsPerTimeSlot: true, + locations: true, + requiresConfirmation: true, + }, + }); + } + + async getEventTypeWithMetaData(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { metadata: true }, + }); + } + + async getUserEventType(userId: number, eventTypeId: number) { + return this.dbRead.prisma.eventType.findFirst({ + where: { + id: eventTypeId, + userId, + }, + include: { users: true, schedule: true, destinationCalendar: true }, + }); + } + + async getUserEventTypes(userId: number) { + return this.dbRead.prisma.eventType.findMany({ + where: { + userId, + }, + include: { users: true, schedule: true, destinationCalendar: true }, + }); + } + + async getEventTypeById(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + include: { users: true, schedule: true, destinationCalendar: true }, + }); + } + + async getEventTypeByIdWithOwnerAndTeam(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + include: { owner: true, team: true }, + }); + } + + async getUserEventTypeBySlug(userId: number, slug: string) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + userId_slug: { + userId: userId, + slug: slug, + }, + }, + include: { users: true, schedule: true, destinationCalendar: true }, + }); + } + + async deleteEventType(eventTypeId: number) { + return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } }); + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts new file mode 100644 index 00000000000000..f2e664ea28064d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input.ts @@ -0,0 +1,71 @@ +import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator"; + +export enum TemplateType { + CHECK_IN_APPOINTMENT = "CHECK_IN_APPOINTMENT", + CUSTOM_TEMPLATE = "CUSTOM_TEMPLATE", +} + +export class CreatePhoneCallInput { + @IsString() + @Matches(/^\+[1-9]\d{1,14}$/, { + message: + "Invalid phone number format. Expected format: + with no spaces or separators.", + }) + @DocsProperty({ description: "Your phone number" }) + yourPhoneNumber!: string; + + @IsString() + @Matches(/^\+[1-9]\d{1,14}$/, { + message: + "Invalid phone number format. Expected format: + with no spaces or separators.", + }) + @DocsProperty({ description: "Number to call" }) + numberToCall!: string; + + @IsString() + @DocsProperty({ description: "CAL API Key" }) + calApiKey!: string; + + @IsBoolean() + @DocsProperty({ description: "Enabled status", default: true }) + enabled = true; + + @IsEnum(TemplateType) + @DocsProperty({ description: "Template type", enum: TemplateType }) + templateType: TemplateType = TemplateType.CUSTOM_TEMPLATE; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "Scheduler name" }) + schedulerName?: string; + + @IsOptional() + @IsString() + @Transform(({ value }) => (value ? value : undefined)) + @ApiPropertyOptional({ description: "Guest name" }) + guestName?: string; + + @IsOptional() + @IsString() + @Transform(({ value }) => (value ? value : undefined)) + @ApiPropertyOptional({ description: "Guest email" }) + guestEmail?: string; + + @IsOptional() + @IsString() + @Transform(({ value }) => (value ? value : undefined)) + @ApiPropertyOptional({ description: "Guest company" }) + guestCompany?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "Begin message" }) + beginMessage?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "General prompt" }) + generalPrompt?: string; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output.ts new file mode 100644 index 00000000000000..89ff167e82ee0e --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class CreateEventTypeOutput_2024_06_14 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput_2024_06_14, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EventTypeOutput_2024_06_14) + data!: EventTypeOutput_2024_06_14; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts new file mode 100644 index 00000000000000..ac4c2dd965176f --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Data { + @IsString() + @ApiProperty() + callId!: string; + + @IsString() + @ApiProperty() + agentId!: string; +} + +export class CreatePhoneCallOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: Data, + }) + @ValidateNested() + @Type(() => Data) + data!: Data; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output.ts new file mode 100644 index 00000000000000..df64142ce4658e --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, IsInt, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { CREATE_EVENT_LENGTH_EXAMPLE, CREATE_EVENT_TITLE_EXAMPLE } from "@calcom/platform-types"; + +class DeleteData_2024_06_14 { + @IsInt() + @DocsProperty({ example: 1 }) + id!: number; + + @IsInt() + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + lengthInMinutes!: number; + + @IsString() + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; +} + +export class DeleteEventTypeOutput_2024_06_14 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => DeleteData_2024_06_14) + data!: DeleteData_2024_06_14; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output.ts new file mode 100644 index 00000000000000..b42034fd9c2590 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class GetEventTypeOutput_2024_06_14 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput_2024_06_14, + }) + @ValidateNested() + @Type(() => EventTypeOutput_2024_06_14) + data!: EventTypeOutput_2024_06_14 | null; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output.ts new file mode 100644 index 00000000000000..918b845d0edb9e --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsIn, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class GetEventTypesOutput_2024_06_14 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => EventTypeOutput_2024_06_14) + @IsArray() + data!: EventTypeOutput_2024_06_14[]; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output.ts new file mode 100644 index 00000000000000..7170de9f812d7a --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class UpdateEventTypeOutput_2024_06_14 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput_2024_06_14, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EventTypeOutput_2024_06_14) + data!: EventTypeOutput_2024_06_14; +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer.ts new file mode 100644 index 00000000000000..c22902960132ce --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer.ts @@ -0,0 +1,39 @@ +import { Injectable, PipeTransform } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; + +import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +import { + DatabaseEventType, + OutputEventTypesService_2024_06_14, +} from "../services/output-event-types.service"; + +type EventTypeResponse = DatabaseEventType & { ownerId: number }; + +@Injectable() +export class EventTypeResponseTransformPipe implements PipeTransform { + constructor(private readonly outputEventTypesService: OutputEventTypesService_2024_06_14) {} + + private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 { + return plainToClass( + EventTypeOutput_2024_06_14, + this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType, false), + { strategy: "exposeAll" } + ); + } + + // Implementing function overloading to ensure correct return types based on input type: + transform(value: EventTypeResponse[]): EventTypeOutput_2024_06_14[]; + + transform(value: EventTypeResponse): EventTypeOutput_2024_06_14; + + transform( + value: EventTypeResponse | EventTypeResponse[] + ): EventTypeOutput_2024_06_14 | EventTypeOutput_2024_06_14[] { + if (Array.isArray(value)) { + return value.map((item) => this.transformEventType(item)); + } else { + return this.transformEventType(value); + } + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts new file mode 100644 index 00000000000000..61e7bcf92608f2 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts @@ -0,0 +1,276 @@ +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; + +import { createEventType, updateEventType } from "@calcom/platform-libraries"; +import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries"; +import { dynamicEvent } from "@calcom/platform-libraries"; +import { GetEventTypesQuery_2024_06_14, InputEventTransformed_2024_06_14 } from "@calcom/platform-types"; +import { EventType } from "@calcom/prisma/client"; + +@Injectable() +export class EventTypesService_2024_06_14 { + constructor( + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly membershipsRepository: MembershipsRepository, + private readonly usersRepository: UsersRepository, + private readonly usersService: UsersService, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly dbWrite: PrismaWriteService, + private readonly schedulesRepository: SchedulesRepository_2024_06_11 + ) {} + + async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) { + await this.checkCanCreateEventType(user.id, body); + const eventTypeUser = await this.getUserToCreateEvent(user); + const { destinationCalendar: _destinationCalendar, ...rest } = body; + + const { eventType: eventTypeCreated } = await createEventType({ + input: rest, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + await updateEventType({ + input: { + id: eventTypeCreated.id, + ...body, + }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeCreated.id); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeCreated.id} not found`); + } + + return { + ownerId: user.id, + ...eventType, + }; + } + + async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) { + const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug); + if (existsWithSlug) { + throw new BadRequestException("User already has an event type with this slug."); + } + await this.checkUserOwnsSchedule(userId, body.scheduleId); + } + + async getEventTypeByUsernameAndSlug( + username: string, + eventTypeSlug: string, + orgSlug?: string, + orgId?: number + ) { + const user = await this.usersRepository.findByUsername(username, orgSlug, orgId); + if (!user) { + return null; + } + + const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, eventTypeSlug); + + if (!eventType) { + return null; + } + + return { + ownerId: user.id, + ...eventType, + }; + } + + async getEventTypesByUsername(username: string, orgSlug?: string, orgId?: number) { + const user = await this.usersRepository.findByUsername(username, orgSlug, orgId); + if (!user) { + return []; + } + return await this.getUserEventTypes(user.id); + } + + async getUserToCreateEvent(user: UserWithProfile) { + const organizationId = this.usersService.getUserMainOrgId(user); + const isOrgAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + const profileId = this.usersService.getUserMainProfile(user)?.id || null; + const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id); + return { + id: user.id, + role: user.role, + username: user.username, + organizationId: user.organizationId, + organization: { isOrgAdmin }, + profile: { id: profileId }, + metadata: user.metadata, + selectedCalendars, + }; + } + + async getUserEventType(userId: number, eventTypeId: number) { + const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId); + + if (!eventType) { + return null; + } + + this.checkUserOwnsEventType(userId, eventType); + + return { + ownerId: userId, + ...eventType, + }; + } + + async getUserEventTypes(userId: number) { + const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId); + + return eventTypes.map((eventType) => { + return { ownerId: userId, ...eventType }; + }); + } + + async getEventTypesPublicByUsername(username: string): Promise { + const user = await this.usersRepository.findByUsername(username); + if (!user) { + throw new NotFoundException(`User with username "${username}" not found`); + } + + return await getEventTypesPublic(user.id); + } + + async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) { + const { username, eventSlug, usernames, orgSlug, orgId } = queryParams; + if (username && eventSlug) { + const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug, orgSlug, orgId); + return eventType ? [eventType] : []; + } + + if (username) { + return await this.getEventTypesByUsername(username, orgSlug, orgId); + } + + if (usernames) { + const dynamicEventType = await this.getDynamicEventType(usernames, orgSlug, orgId); + return [dynamicEventType]; + } + + return []; + } + + async getDynamicEventType(usernames: string[], orgSlug?: string, orgId?: number) { + const users = await this.usersService.getByUsernames(usernames, orgSlug, orgId); + const usersFiltered: UserWithProfile[] = []; + for (const user of users) { + if (user) { + usersFiltered.push(user); + } + } + return { + ownerId: 0, + ...dynamicEvent, + users: usersFiltered, + isInstantEvent: false, + }; + } + + async createUserDefaultEventTypes(userId: number) { + const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES; + + const defaultEventTypes = await Promise.all([ + this.eventTypesRepository.createUserEventType(userId, thirtyMinutes), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutes), + this.eventTypesRepository.createUserEventType(userId, thirtyMinutesVideo), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutesVideo), + ]); + + return defaultEventTypes; + } + + async updateEventType(eventTypeId: number, body: InputEventTransformed_2024_06_14, user: UserWithProfile) { + await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId); + const eventTypeUser = await this.getUserToUpdateEvent(user); + + await updateEventType({ + input: { id: eventTypeId, ...body }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + ownerId: user.id, + ...eventType, + }; + } + + async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) { + const existingEventType = await this.getUserEventType(userId, eventTypeId); + if (!existingEventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId }); + await this.checkUserOwnsSchedule(userId, scheduleId); + } + + async getUserToUpdateEvent(user: UserWithProfile) { + const profileId = this.usersService.getUserMainProfile(user)?.id || null; + const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id); + return { ...user, profile: { id: profileId }, selectedCalendars }; + } + + async deleteEventType(eventTypeId: number, userId: number) { + const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId); + if (!existingEventType) { + throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`); + } + + this.checkUserOwnsEventType(userId, existingEventType); + + return this.eventTypesRepository.deleteEventType(eventTypeId); + } + + checkUserOwnsEventType(userId: number, eventType: Pick) { + if (userId !== eventType.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`); + } + } + + async checkUserOwnsSchedule(userId: number, scheduleId: number | null | undefined) { + if (!scheduleId) { + return; + } + + const schedule = await this.schedulesRepository.getScheduleByIdAndUserId(scheduleId, userId); + + if (!schedule) { + throw new NotFoundException(`User with ID=${userId} does not own schedule with ID=${scheduleId}`); + } + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts new file mode 100644 index 00000000000000..cd60e4f15dc00a --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts @@ -0,0 +1,416 @@ +import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, BadRequestException } from "@nestjs/common"; + +import { + transformBookingFieldsApiToInternal, + transformLocationsApiToInternal, + transformIntervalLimitsApiToInternal, + transformFutureBookingLimitsApiToInternal, + transformRecurrenceApiToInternal, + systemBeforeFieldName, + systemBeforeFieldEmail, + systemBeforeFieldLocation, + systemAfterFieldTitle, + systemAfterFieldNotes, + systemAfterFieldGuests, + systemAfterFieldRescheduleReason, + EventTypeMetaDataSchema, + transformBookerLayoutsApiToInternal, + transformConfirmationPolicyApiToInternal, + transformEventColorsApiToInternal, + validateCustomEventName, + transformSeatsApiToInternal, + SystemField, + CustomField, +} from "@calcom/platform-libraries"; +import { + CreateEventTypeInput_2024_06_14, + DestinationCalendar_2024_06_14, + InputEventTransformed_2024_06_14, + UpdateEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; + +import { OutputEventTypesService_2024_06_14 } from "./output-event-types.service"; + +interface ValidationContext { + eventTypeId?: number; + seatsPerTimeSlot?: number | null; + locations?: InputEventTransformed_2024_06_14["locations"]; + requiresConfirmation?: boolean; + eventName?: string; +} + +@Injectable() +export class InputEventTypesService_2024_06_14 { + constructor( + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly outputEventTypesService: OutputEventTypesService_2024_06_14, + private readonly calendarsService: CalendarsService + ) {} + + async transformAndValidateCreateEventTypeInput( + userId: UserWithProfile["id"], + inputEventType: CreateEventTypeInput_2024_06_14 + ) { + const transformedBody = this.transformInputCreateEventType(inputEventType); + + await this.validateEventTypeInputs({ + seatsPerTimeSlot: transformedBody.seatsPerTimeSlot, + locations: transformedBody.locations, + requiresConfirmation: transformedBody.requiresConfirmation, + eventName: transformedBody.eventName, + }); + + transformedBody.destinationCalendar && + (await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar)); + + transformedBody.useEventTypeDestinationCalendarEmail && + (await this.validateInputUseDestinationCalendarEmail(userId)); + + return transformedBody; + } + + async transformAndValidateUpdateEventTypeInput( + inputEventType: UpdateEventTypeInput_2024_06_14, + userId: UserWithProfile["id"], + eventTypeId: number + ) { + const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId); + + await this.validateEventTypeInputs({ + eventTypeId: eventTypeId, + seatsPerTimeSlot: transformedBody.seatsPerTimeSlot, + locations: transformedBody.locations, + requiresConfirmation: transformedBody.requiresConfirmation, + eventName: transformedBody.eventName, + }); + + transformedBody.destinationCalendar && + (await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar)); + + transformedBody.useEventTypeDestinationCalendarEmail && + (await this.validateInputUseDestinationCalendarEmail(userId)); + + return transformedBody; + } + + transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) { + const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [ + { + type: "integration", + integration: "cal-video", + }, + ]; + + const { + lengthInMinutes, + lengthInMinutesOptions, + locations, + bookingFields, + bookingLimitsCount, + bookingLimitsDuration, + bookingWindow, + bookerLayouts, + confirmationPolicy, + color, + recurrence, + seats, + customName, + useDestinationCalendarEmail, + ...rest + } = inputEventType; + const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy); + + const hasMultipleLocations = (locations || defaultLocations).length > 1; + const eventType = { + ...rest, + length: lengthInMinutes, + locations: this.transformInputLocations(locations || defaultLocations), + bookingFields: this.transformInputBookingFields(bookingFields, hasMultipleLocations), + bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined, + durationLimits: bookingLimitsDuration + ? this.transformInputIntervalLimits(bookingLimitsDuration) + : undefined, + ...this.transformInputBookingWindow(bookingWindow), + metadata: { + bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), + requiresConfirmationThreshold: + confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, + multipleDuration: lengthInMinutesOptions, + }, + requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined, + requiresConfirmationWillBlockSlot: + confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined, + eventTypeColor: this.transformInputEventTypeColor(color), + recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined, + ...this.transformInputSeatOptions(seats), + eventName: customName, + useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail, + }; + + return eventType; + } + + async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) { + const { + lengthInMinutes, + lengthInMinutesOptions, + locations, + bookingFields, + bookingLimitsCount, + bookingLimitsDuration, + bookingWindow, + bookerLayouts, + confirmationPolicy, + color, + recurrence, + seats, + customName, + useDestinationCalendarEmail, + ...rest + } = inputEventType; + const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId); + const metadataTransformed = !!eventTypeDb?.metadata + ? EventTypeMetaDataSchema.parse(eventTypeDb.metadata) + : {}; + + const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy); + const hasMultipleLocations = !!(locations && locations?.length > 1); + + const eventType = { + ...rest, + length: lengthInMinutes, + locations: locations ? this.transformInputLocations(locations) : undefined, + bookingFields: bookingFields + ? this.transformInputBookingFields(bookingFields, hasMultipleLocations) + : undefined, + bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined, + durationLimits: bookingLimitsDuration + ? this.transformInputIntervalLimits(bookingLimitsDuration) + : undefined, + ...this.transformInputBookingWindow(bookingWindow), + metadata: { + ...metadataTransformed, + bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), + requiresConfirmationThreshold: + confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, + multipleDuration: lengthInMinutesOptions, + }, + recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined, + requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined, + requiresConfirmationWillBlockSlot: + confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined, + eventTypeColor: this.transformInputEventTypeColor(color), + ...this.transformInputSeatOptions(seats), + eventName: customName, + useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail, + }; + + return eventType; + } + + transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) { + return transformLocationsApiToInternal(inputLocations); + } + + transformInputBookingFields( + inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"], + hasMultipleLocations: boolean + ) { + const internalFields: (SystemField | CustomField)[] = inputBookingFields + ? transformBookingFieldsApiToInternal(inputBookingFields) + : []; + const systemCustomFields = internalFields.filter((field) => !this.isUserCustomField(field)); + const userCustomFields = internalFields.filter((field) => this.isUserCustomField(field)); + + const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name"); + const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email"); + const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title"); + const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes"); + const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests"); + const systemCustomRescheduleReasonField = systemCustomFields?.find( + (field) => field.name === "rescheduleReason" + ); + + const defaultFieldsBefore: (SystemField | CustomField)[] = [ + systemCustomNameField || systemBeforeFieldName, + systemCustomEmailField || systemBeforeFieldEmail, + systemBeforeFieldLocation, + ]; + + const defaultFieldsAfter = [ + systemCustomTitleField || systemAfterFieldTitle, + systemCustomNotesField || systemAfterFieldNotes, + systemCustomGuestsField || systemAfterFieldGuests, + systemCustomRescheduleReasonField || systemAfterFieldRescheduleReason, + ]; + + return [...defaultFieldsBefore, ...userCustomFields, ...defaultFieldsAfter]; + } + + isUserCustomField(field: SystemField | CustomField): field is CustomField { + return ( + field.type !== "name" && + field.type !== "email" && + field.name !== "title" && + field.name !== "notes" && + field.name !== "guests" && + field.name !== "rescheduleReason" + ); + } + + transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) { + return transformIntervalLimitsApiToInternal(inputBookingFields); + } + + transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) { + const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow); + return !!res ? res : {}; + } + + transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) { + return transformBookerLayoutsApiToInternal(inputBookerLayouts); + } + + transformInputConfirmationPolicy( + requiresConfirmation: CreateEventTypeInput_2024_06_14["confirmationPolicy"] + ) { + return transformConfirmationPolicyApiToInternal(requiresConfirmation); + } + transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) { + if (!recurrence || recurrence.disabled) { + return undefined; + } + + return transformRecurrenceApiToInternal(recurrence); + } + + transformInputEventTypeColor(color: CreateEventTypeInput_2024_06_14["color"]) { + return transformEventColorsApiToInternal(color); + } + + transformInputSeatOptions(seats: CreateEventTypeInput_2024_06_14["seats"]) { + return transformSeatsApiToInternal(seats); + } + async validateEventTypeInputs({ + eventTypeId, + seatsPerTimeSlot, + locations, + requiresConfirmation, + eventName, + }: ValidationContext) { + let seatsPerTimeSlotDb: number | null = null; + let locationsDb: ReturnType = []; + let requiresConfirmationDb = false; + + if (eventTypeId != null) { + const eventTypeDb = await this.eventTypesRepository.getEventTypeWithSeats(eventTypeId); + seatsPerTimeSlotDb = eventTypeDb?.seatsPerTimeSlot ?? null; + locationsDb = this.outputEventTypesService.transformLocations(eventTypeDb?.locations) ?? []; + requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false; + } + + const seatsPerTimeSlotFinal = seatsPerTimeSlot !== undefined ? seatsPerTimeSlot : seatsPerTimeSlotDb; + const seatsEnabledFinal = seatsPerTimeSlotFinal != null && seatsPerTimeSlotFinal > 0; + + const locationsFinal = locations !== undefined ? locations : locationsDb; + const requiresConfirmationFinal = + requiresConfirmation !== undefined ? requiresConfirmation : requiresConfirmationDb; + + this.validateSeatsSingleLocationRule(seatsEnabledFinal, locationsFinal); + this.validateSeatsRequiresConfirmationFalseRule(seatsEnabledFinal, requiresConfirmationFinal); + this.validateMultipleLocationsSeatsDisabledRule(locationsFinal, seatsEnabledFinal); + this.validateRequiresConfirmationSeatsDisabledRule(requiresConfirmationFinal, seatsEnabledFinal); + + if (eventName) { + await this.validateCustomEventNameInput(eventName); + } + } + validateSeatsSingleLocationRule( + seatsEnabled: boolean, + locations: ReturnType + ) { + if (seatsEnabled && locations.length > 1) { + throw new BadRequestException( + "Seats Validation failed: Seats are enabled but more than one location provided." + ); + } + } + + validateSeatsRequiresConfirmationFalseRule(seatsEnabled: boolean, requiresConfirmation: boolean) { + if (seatsEnabled && requiresConfirmation) { + throw new BadRequestException( + "Seats Validation failed: Seats are enabled but requiresConfirmation is true." + ); + } + } + + validateMultipleLocationsSeatsDisabledRule( + locations: ReturnType, + seatsEnabled: boolean + ) { + if (locations.length > 1 && seatsEnabled) { + throw new BadRequestException("Locations Validation failed: Multiple locations but seats are enabled."); + } + } + + validateRequiresConfirmationSeatsDisabledRule(requiresConfirmation: boolean, seatsEnabled: boolean) { + if (requiresConfirmation && seatsEnabled) { + throw new BadRequestException( + "RequiresConfirmation Validation failed: Seats are enabled but requiresConfirmation is true." + ); + } + } + + async validateCustomEventNameInput(value: string) { + const validationResult = validateCustomEventName(value); + if (validationResult !== true) { + throw new BadRequestException(`Invalid event name variables: ${validationResult}`); + } + return; + } + + async validateInputDestinationCalendar( + userId: number, + destinationCalendar: DestinationCalendar_2024_06_14 + ) { + const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId); + + const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat(); + + const matchedCalendar = allCals.find( + (cal) => + cal.externalId === destinationCalendar.externalId && + cal.integration === destinationCalendar.integration + ); + + if (!matchedCalendar) { + throw new BadRequestException("Invalid destinationCalendarId: Calendar does not exist"); + } + + if (matchedCalendar.readOnly) { + throw new BadRequestException("Invalid destinationCalendarId: Calendar does not have write permission"); + } + + return; + } + + async validateInputUseDestinationCalendarEmail(userId: number) { + const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId); + + const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat(); + + const primaryCalendar = allCals.find((cal) => cal.primary); + + if (!primaryCalendar) { + throw new BadRequestException( + "Validation failed: A primary connected calendar is required to set useDestinationCalendarEmail" + ); + } + + return; + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts new file mode 100644 index 00000000000000..2c85608a79f664 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -0,0 +1,332 @@ +import { Injectable } from "@nestjs/common"; +import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client"; + +import { + EventTypeMetaDataSchema, + userMetadata, + transformLocationsInternalToApi, + transformBookingFieldsInternalToApi, + parseRecurringEvent, + InternalLocationSchema, + SystemField, + CustomField, + parseBookingLimit, + transformIntervalLimitsInternalToApi, + transformFutureBookingLimitsInternalToApi, + transformRecurrenceInternalToApi, + transformBookerLayoutsInternalToApi, + transformRequiresConfirmationInternalToApi, + transformEventTypeColorsInternalToApi, + parseEventTypeColor, + transformSeatsInternalToApi, + InternalLocation, + BookingFieldSchema, + getBookingFieldsWithSystemFields, +} from "@calcom/platform-libraries"; +import { + TransformFutureBookingsLimitSchema_2024_06_14, + BookerLayoutsTransformedSchema, + NoticeThresholdTransformedSchema, + EventTypeOutput_2024_06_14, + OutputUnknownLocation_2024_06_14, + OutputUnknownBookingField_2024_06_14, +} from "@calcom/platform-types"; + +type EventTypeRelations = { + users: User[]; + schedule: Schedule | null; + destinationCalendar?: DestinationCalendar | null; +}; +export type DatabaseEventType = EventType & EventTypeRelations; + +type Input = Pick< + DatabaseEventType, + | "id" + | "length" + | "title" + | "description" + | "disableGuests" + | "slotInterval" + | "minimumBookingNotice" + | "beforeEventBuffer" + | "afterEventBuffer" + | "slug" + | "schedulingType" + | "requiresConfirmation" + | "price" + | "currency" + | "lockTimeZoneToggleOnBookingPage" + | "seatsPerTimeSlot" + | "forwardParamsSuccessRedirect" + | "successRedirectUrl" + | "seatsShowAvailabilityCount" + | "isInstantEvent" + | "locations" + | "bookingFields" + | "recurringEvent" + | "metadata" + | "users" + | "scheduleId" + | "bookingLimits" + | "durationLimits" + | "onlyShowFirstAvailableSlot" + | "offsetStart" + | "periodType" + | "periodDays" + | "periodCountCalendarDays" + | "periodStartDate" + | "periodEndDate" + | "requiresBookerEmailVerification" + | "hideCalendarNotes" + | "eventTypeColor" + | "seatsShowAttendees" + | "requiresConfirmationWillBlockSlot" + | "eventName" + | "destinationCalendar" + | "useEventTypeDestinationCalendarEmail" + | "hideCalendarEventDetails" +>; + +@Injectable() +export class OutputEventTypesService_2024_06_14 { + getResponseEventType( + ownerId: number, + databaseEventType: Input, + isOrgTeamEvent: boolean + ): EventTypeOutput_2024_06_14 { + const { + id, + length, + title, + description, + disableGuests, + slotInterval, + minimumBookingNotice, + beforeEventBuffer, + afterEventBuffer, + slug, + price, + currency, + lockTimeZoneToggleOnBookingPage, + seatsPerTimeSlot, + forwardParamsSuccessRedirect, + successRedirectUrl, + seatsShowAvailabilityCount, + isInstantEvent, + scheduleId, + onlyShowFirstAvailableSlot, + offsetStart, + requiresBookerEmailVerification, + hideCalendarNotes, + seatsShowAttendees, + useEventTypeDestinationCalendarEmail, + hideCalendarEventDetails, + } = databaseEventType; + + const locations = this.transformLocations(databaseEventType.locations); + const customName = databaseEventType?.eventName ?? undefined; + const bookingFields = databaseEventType.bookingFields + ? this.transformBookingFields(databaseEventType.bookingFields) + : this.getDefaultBookingFields(isOrgTeamEvent); + + const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent); + const metadata = this.transformMetadata(databaseEventType.metadata) || {}; + const users = this.transformUsers(databaseEventType.users || []); + const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits); + const bookingLimitsDuration = this.transformIntervalLimits(databaseEventType.durationLimits); + const color = this.transformEventTypeColor(databaseEventType.eventTypeColor); + const bookerLayouts = this.transformBookerLayouts( + metadata.bookerLayouts as unknown as BookerLayoutsTransformedSchema + ); + const confirmationPolicy = this.transformRequiresConfirmation( + !!databaseEventType.requiresConfirmation, + !!databaseEventType.requiresConfirmationWillBlockSlot, + metadata.requiresConfirmationThreshold as NoticeThresholdTransformedSchema + ); + delete metadata["bookerLayouts"]; + delete metadata["requiresConfirmationThreshold"]; + const seats = this.transformSeats(seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount); + const bookingWindow = this.transformBookingWindow({ + periodType: databaseEventType.periodType, + periodDays: databaseEventType.periodDays, + periodCountCalendarDays: databaseEventType.periodCountCalendarDays, + periodStartDate: databaseEventType.periodStartDate, + periodEndDate: databaseEventType.periodEndDate, + } as TransformFutureBookingsLimitSchema_2024_06_14); + const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar); + + return { + id, + ownerId, + lengthInMinutes: length, + lengthInMinutesOptions: metadata.multipleDuration, + title, + slug, + description: description || "", + locations, + bookingFields, + recurrence, + disableGuests, + slotInterval, + minimumBookingNotice, + beforeEventBuffer, + afterEventBuffer, + metadata, + price, + currency, + lockTimeZoneToggleOnBookingPage, + forwardParamsSuccessRedirect, + successRedirectUrl, + isInstantEvent, + users, + scheduleId, + bookingLimitsCount, + bookingLimitsDuration, + onlyShowFirstAvailableSlot, + offsetStart, + bookingWindow, + bookerLayouts, + confirmationPolicy, + requiresBookerEmailVerification, + hideCalendarNotes, + color, + seats, + customName, + destinationCalendar, + useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail, + hideCalendarEventDetails, + }; + } + + transformLocations(locations: any) { + if (!locations) return []; + + const knownLocations: InternalLocation[] = []; + const unknownLocations: OutputUnknownLocation_2024_06_14[] = []; + + for (const location of locations) { + const result = InternalLocationSchema.safeParse(location); + if (result.success) { + knownLocations.push(result.data); + } else { + unknownLocations.push({ type: "unknown", location: JSON.stringify(location) }); + } + } + + return [...transformLocationsInternalToApi(knownLocations), ...unknownLocations]; + } + + transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) { + if (!destinationCalendar) return undefined; + return { + integration: destinationCalendar.integration, + externalId: destinationCalendar.externalId, + }; + } + + transformBookingFields(bookingFields: any) { + if (!bookingFields) return []; + + const knownBookingFields: (SystemField | CustomField)[] = []; + const unknownBookingFields: OutputUnknownBookingField_2024_06_14[] = []; + + for (const bookingField of bookingFields) { + const result = BookingFieldSchema.safeParse(bookingField); + if (result.success) { + knownBookingFields.push(result.data); + } else { + unknownBookingFields.push({ + type: "unknown", + slug: "unknown", + bookingField: JSON.stringify(bookingField), + }); + } + } + + return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields]; + } + + getDefaultBookingFields(isOrgTeamEvent: boolean) { + const defaultBookingFields = getBookingFieldsWithSystemFields({ + disableGuests: false, + bookingFields: null, + customInputs: [], + metadata: null, + workflows: [], + isOrgTeamEvent, + }); + return this.transformBookingFields(defaultBookingFields); + } + + transformRecurringEvent(recurringEvent: any) { + if (!recurringEvent) return null; + const recurringEventParsed = parseRecurringEvent(recurringEvent); + if (!recurringEventParsed) return null; + return transformRecurrenceInternalToApi(recurringEventParsed); + } + + transformMetadata(metadata: any) { + if (!metadata) return {}; + return EventTypeMetaDataSchema.parse(metadata); + } + + transformUsers(users: User[]) { + return users.map((user) => { + const metadata = user.metadata ? userMetadata.parse(user.metadata) : {}; + return { + id: user.id, + name: user.name, + username: user.username, + avatarUrl: user.avatarUrl, + brandColor: user.brandColor, + darkBrandColor: user.darkBrandColor, + weekStart: user.weekStart, + metadata: metadata || {}, + }; + }); + } + + transformIntervalLimits(bookingLimits: any) { + const bookingLimitsParsed = parseBookingLimit(bookingLimits); + return transformIntervalLimitsInternalToApi(bookingLimitsParsed); + } + + transformBookingWindow(bookingLimits: TransformFutureBookingsLimitSchema_2024_06_14) { + return transformFutureBookingLimitsInternalToApi(bookingLimits); + } + + transformBookerLayouts(bookerLayouts: BookerLayoutsTransformedSchema) { + if (!bookerLayouts) return undefined; + return transformBookerLayoutsInternalToApi(bookerLayouts); + } + + transformRequiresConfirmation( + requiresConfirmation: boolean, + requiresConfirmationWillBlockSlot: boolean, + requiresConfirmationThreshold?: NoticeThresholdTransformedSchema + ) { + return transformRequiresConfirmationInternalToApi( + requiresConfirmation, + requiresConfirmationWillBlockSlot, + requiresConfirmationThreshold + ); + } + + transformEventTypeColor(eventTypeColor: any) { + if (!eventTypeColor) return undefined; + const parsedeventTypeColor = parseEventTypeColor(eventTypeColor); + return transformEventTypeColorsInternalToApi(parsedeventTypeColor); + } + + transformSeats( + seatsPerTimeSlot: number | null, + seatsShowAttendees: boolean | null, + seatsShowAvailabilityCount: boolean | null + ) { + return transformSeatsInternalToApi({ + seatsPerTimeSlot, + seatsShowAttendees: !!seatsShowAttendees, + seatsShowAvailabilityCount: !!seatsShowAvailabilityCount, + }); + } +} diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts new file mode 100644 index 00000000000000..62d60286da7089 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts @@ -0,0 +1,172 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { CalendarsServiceMock } from "test/mocks/calendars-service-mock"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Gcal Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let user: User; + let gcalCredentials: Credential; + let accessTokenSecret: string; + let refreshTokenSecret: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser("gcal-connect@gmail.com", oAuthClient.id); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + await app.init(); + jest + .spyOn(CalendarsService.prototype, "getCalendars") + .mockImplementation(CalendarsServiceMock.prototype.getCalendars); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => { + await request(app.getHttpServer()) + .get(`/v2/gcal/oauth/auth-url`) + .set("Authorization", `Bearer invalid_access_token`) + .expect(401); + }); + + it(`/GET/gcal/oauth/auth-url: it should auth-url to google OAuth with valid access token `, async () => { + const response = await request(app.getHttpServer()) + .get(`/v2/gcal/oauth/auth-url`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + const data = response.body.data; + expect(data.authUrl).toBeDefined(); + }); + + it(`/GET/gcal/oauth/save: without OAuth code`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(301); + }); + + it(`/GET/gcal/oauth/save: without access token`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(301); + }); + + it(`/GET/gcal/oauth/save: without origin`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(301); + }); + + it(`/GET/gcal/check with access token but without origin`, async () => { + await request(app.getHttpServer()) + .get(`/v2/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .expect(400); + }); + + it(`/GET/gcal/check without access token`, async () => { + await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401); + }); + + it(`/GET/gcal/check with access token and origin but no credentials`, async () => { + await request(app.getHttpServer()) + .get(`/v2/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => { + gcalCredentials = await credentialsRepositoryFixture.create( + "google_calendar", + {}, + user.id, + "google-calendar" + ); + await request(app.getHttpServer()) + .get(`/v2/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await credentialsRepositoryFixture.delete(gcalCredentials.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts new file mode 100644 index 00000000000000..3e43006c8a53ea --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -0,0 +1,119 @@ +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output"; +import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output"; +import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GCalService } from "@/modules/apps/services/gcal.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Redirect, + Req, + UnauthorizedException, + UseGuards, + Headers, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { Request } from "express"; + +import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants"; + +const CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; + +// Controller for the GCalConnect Atom +@Controller({ + path: "/v2/gcal", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Platform / Google Calendar") +export class GcalController { + private readonly logger = new Logger("Platform Gcal Provider"); + + constructor( + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly config: ConfigService, + private readonly gcalService: GCalService, + private readonly calendarsService: CalendarsService + ) {} + + private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`; + + @Get("/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Get auth URL" }) + async redirect( + @Headers("Authorization") authorization: string, + @Req() req: Request + ): Promise { + const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri); + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: CALENDAR_SCOPES, + prompt: "consent", + state: `accessToken=${accessToken}&origin=${origin}`, + }); + return { status: SUCCESS_STATUS, data: { authUrl } }; + } + + @Get("/oauth/save") + @Redirect(undefined, 301) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Connect a calendar" }) + async save(@Query("state") state: string, @Query("code") code: string): Promise { + const url = new URL(this.config.get("api.url") + "/calendars/google/save"); + url.searchParams.append("code", code); + url.searchParams.append("state", state); + return { url: url.href }; + } + + @Get("/check") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard, PermissionsGuard) + @Permissions([APPS_READ]) + @ApiOperation({ summary: "Check a calendar connection status" }) + async check(@GetUser("id") userId: number): Promise { + const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId); + + if (!gcalCredentials) { + throw new BadRequestException("Credentials for google_calendar not found."); + } + + if (gcalCredentials.invalid) { + throw new BadRequestException("Invalid google OAuth credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const googleCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE + ); + if (!googleCalendar) { + throw new UnauthorizedException("Google Calendar not connected."); + } + if (googleCalendar.error?.message) { + throw new UnauthorizedException(googleCalendar.error?.message); + } + + return { status: SUCCESS_STATUS }; + } +} diff --git a/apps/api/v2/src/ee/gcal/gcal.module.ts b/apps/api/v2/src/ee/gcal/gcal.module.ts new file mode 100644 index 00000000000000..ea7eae9e5325ce --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.module.ts @@ -0,0 +1,29 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GcalController } from "@/ee/gcal/gcal.controller"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { GCalService } from "@/modules/apps/services/gcal.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, TokensModule, OAuthClientModule], + providers: [ + AppsRepository, + ConfigService, + CredentialsRepository, + SelectedCalendarsRepository, + GCalService, + CalendarsService, + UsersRepository, + CalendarsRepository, + ], + controllers: [GcalController], +}) +export class GcalModule {} diff --git a/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts b/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts new file mode 100644 index 00000000000000..a550faf4e4e171 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class AuthUrlData { + @IsString() + authUrl!: string; +} + +export class GcalAuthUrlOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => AuthUrlData) + @ValidateNested() + data!: AuthUrlData; +} diff --git a/apps/api/v2/src/ee/gcal/outputs/check.output.ts b/apps/api/v2/src/ee/gcal/outputs/check.output.ts new file mode 100644 index 00000000000000..fd533efece2a3b --- /dev/null +++ b/apps/api/v2/src/ee/gcal/outputs/check.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GcalCheckOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/gcal/outputs/save-redirect.output.ts b/apps/api/v2/src/ee/gcal/outputs/save-redirect.output.ts new file mode 100644 index 00000000000000..42f727169c9f41 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/outputs/save-redirect.output.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class GcalSaveRedirectOutput { + @IsString() + url!: string; +} diff --git a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts new file mode 100644 index 00000000000000..fb2df5f26f2279 --- /dev/null +++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts @@ -0,0 +1,169 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User, Team } from "@prisma/client"; +import * as request from "supertest"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UserResponse } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Me Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let schedulesRepositoryFixture: SchedulesRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + const userEmail = `me-controller-user-${randomString()}@api.com`; + let user: User; + let org: Team; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `me-controller-organization-${randomString()}`, + isOrganization: true, + isPlatform: true, + }); + + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: user.id } }, + organization: { connect: { id: org.id } }, + movedFromUser: { connect: { id: user.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should get user associated with access token", async () => { + return request(app.getHttpServer()) + .get("/v2/me") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.id).toEqual(user.id); + expect(responseBody.data.email).toEqual(user.email); + expect(responseBody.data.timeFormat).toEqual(user.timeFormat); + expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); + expect(responseBody.data.weekStart).toEqual(user.weekStart); + expect(responseBody.data.timeZone).toEqual(user.timeZone); + expect(responseBody.data.organization?.isPlatform).toEqual(true); + expect(responseBody.data.organization?.id).toEqual(org.id); + }); + }); + + it("should update user associated with access token", async () => { + const body: UpdateManagedUserInput = { timeZone: "Europe/Rome" }; + + return request(app.getHttpServer()) + .patch("/v2/me") + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.id).toEqual(user.id); + expect(responseBody.data.email).toEqual(user.email); + expect(responseBody.data.timeFormat).toEqual(user.timeFormat); + expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); + expect(responseBody.data.weekStart).toEqual(user.weekStart); + expect(responseBody.data.timeZone).toEqual(body.timeZone); + + if (user.defaultScheduleId) { + const defaultSchedule = await schedulesRepositoryFixture.getById(user.defaultScheduleId); + expect(defaultSchedule?.timeZone).toEqual(body.timeZone); + } + }); + }); + + it("should update user associated with access token given badly formatted timezone", async () => { + const bodyWithBadlyFormattedTimeZone: UpdateManagedUserInput = { timeZone: "America/New_york" }; + + return request(app.getHttpServer()) + .patch("/v2/me") + .send(bodyWithBadlyFormattedTimeZone) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.timeZone).toEqual("America/New_York"); + }); + }); + + it("should not update user associated with access token given invalid timezone", async () => { + const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" }; + + return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeZone).expect(400); + }); + + it("should not update user associated with access token given invalid time format", async () => { + const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any }; + + return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400); + }); + + it("should not update user associated with access token given invalid week start", async () => { + const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any }; + + return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts new file mode 100644 index 00000000000000..682743750d6696 --- /dev/null +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -0,0 +1,75 @@ +import { GetMeOutput } from "@/ee/me/outputs/get-me.output"; +import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { userSchemaResponse } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/me", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, PermissionsGuard) +@DocsTags("Me") +export class MeController { + constructor( + private readonly usersRepository: UsersRepository, + private readonly schedulesService: SchedulesService_2024_04_15, + private readonly usersService: UsersService + ) {} + + @Get("/") + @Permissions([PROFILE_READ]) + @ApiOperation({ summary: "Get my profile" }) + async getMe(@GetUser() user: UserWithProfile): Promise { + const organization = this.usersService.getUserMainProfile(user)?.organization; + const me = userSchemaResponse.parse( + organization + ? { + ...user, + organizationId: organization.id, + organization: { + id: organization.id, + isPlatform: organization.isPlatform, + }, + } + : user + ); + return { + status: SUCCESS_STATUS, + data: me, + }; + } + + @Patch("/") + @Permissions([PROFILE_WRITE]) + @ApiOperation({ summary: "Update my profile" }) + async updateMe( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateManagedUserInput + ): Promise { + const updatedUser = await this.usersRepository.update(user.id, bodySchedule); + if (bodySchedule.timeZone && user.defaultScheduleId) { + await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, { + timeZone: bodySchedule.timeZone, + }); + } + + const me = userSchemaResponse.parse(updatedUser); + + return { + status: SUCCESS_STATUS, + data: me, + }; + } +} diff --git a/apps/api/v2/src/ee/me/me.module.ts b/apps/api/v2/src/ee/me/me.module.ts new file mode 100644 index 00000000000000..1ee07742cf412e --- /dev/null +++ b/apps/api/v2/src/ee/me/me.module.ts @@ -0,0 +1,11 @@ +import { MeController } from "@/ee/me/me.controller"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule], + controllers: [MeController], +}) +export class MeModule {} diff --git a/apps/api/v2/src/ee/me/outputs/get-me.output.ts b/apps/api/v2/src/ee/me/outputs/get-me.output.ts new file mode 100644 index 00000000000000..9f0bdfd4e65cdf --- /dev/null +++ b/apps/api/v2/src/ee/me/outputs/get-me.output.ts @@ -0,0 +1,20 @@ +import { MeOutput } from "@/ee/me/outputs/me.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetMeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: MeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => MeOutput) + data!: MeOutput; +} diff --git a/apps/api/v2/src/ee/me/outputs/me.output.ts b/apps/api/v2/src/ee/me/outputs/me.output.ts new file mode 100644 index 00000000000000..2b28cdec905ad1 --- /dev/null +++ b/apps/api/v2/src/ee/me/outputs/me.output.ts @@ -0,0 +1,51 @@ +import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsString, IsOptional, IsInt, IsEmail, ValidateNested } from "class-validator"; + +export class MeOrgOutput { + @DocsProperty() + isPlatform!: boolean; + + @DocsProperty() + id!: number; +} + +export class MeOutput { + @IsInt() + @DocsProperty() + id!: number; + + @IsString() + @DocsProperty() + username!: string; + + @IsEmail() + @DocsProperty() + email!: string; + + @IsInt() + @DocsProperty() + timeFormat!: number; + + @IsInt() + @DocsProperty({ type: Number, nullable: true }) + defaultScheduleId!: number | null; + + @IsString() + @DocsProperty() + weekStart!: string; + + @IsString() + @DocsProperty() + timeZone!: string; + + @IsInt() + @DocsProperty({ type: Number, nullable: true }) + organizationId!: number | null; + + @IsOptional() + @ValidateNested() + @Type(() => MeOrgOutput) + @ApiPropertyOptional({ type: MeOrgOutput }) + organization?: MeOrgOutput; +} diff --git a/apps/api/v2/src/ee/me/outputs/update-me.output.ts b/apps/api/v2/src/ee/me/outputs/update-me.output.ts new file mode 100644 index 00000000000000..53fc44cb26b6e6 --- /dev/null +++ b/apps/api/v2/src/ee/me/outputs/update-me.output.ts @@ -0,0 +1,20 @@ +import { MeOutput } from "@/ee/me/outputs/me.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateMeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: MeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => MeOutput) + data!: MeOutput; +} diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts new file mode 100644 index 00000000000000..534d80d6047fa8 --- /dev/null +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -0,0 +1,41 @@ +import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module"; +import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module"; +import { CalendarsModule } from "@/ee/calendars/calendars.module"; +import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { GcalModule } from "@/ee/gcal/gcal.module"; +import { MeModule } from "@/ee/me/me.module"; +import { ProviderModule } from "@/ee/provider/provider.module"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; +import { SlotsModule } from "@/modules/slots/slots.module"; +import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; +import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; +import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + GcalModule, + ProviderModule, + SchedulesModule_2024_04_15, + SchedulesModule_2024_06_11, + TeamsEventTypesModule, + MeModule, + EventTypesModule_2024_04_15, + EventTypesModule_2024_06_14, + CalendarsModule, + BookingsModule_2024_04_15, + BookingsModule_2024_08_13, + TeamsMembershipsModule, + SlotsModule, + TeamsModule, + ], +}) +export class PlatformEndpointsModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { + // TODO: apply ratelimits + } +} diff --git a/apps/api/v2/src/ee/provider/outputs/verify-access-token.output.ts b/apps/api/v2/src/ee/provider/outputs/verify-access-token.output.ts new file mode 100644 index 00000000000000..ff5aa337e0092d --- /dev/null +++ b/apps/api/v2/src/ee/provider/outputs/verify-access-token.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class ProviderVerifyAccessTokenOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/provider/outputs/verify-client.output.ts b/apps/api/v2/src/ee/provider/outputs/verify-client.output.ts new file mode 100644 index 00000000000000..e11ee13d18eb59 --- /dev/null +++ b/apps/api/v2/src/ee/provider/outputs/verify-client.output.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsNumber, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class ProviderVerifyClientData { + @IsString() + clientId!: string; + @IsNumber() + organizationId!: number; + @IsString() + name!: string; +} +export class ProviderVerifyClientOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + data!: ProviderVerifyClientData; +} diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts new file mode 100644 index 00000000000000..25ee2018953e08 --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.controller.ts @@ -0,0 +1,72 @@ +import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output"; +import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/provider", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Platform / Cal Provider") +export class CalProviderController { + constructor(private readonly oauthClientRepository: OAuthClientRepository) {} + + @Get("/:clientId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get a provider" }) + async verifyClientId(@Param("clientId") clientId: string): Promise { + if (!clientId) { + throw new NotFoundException(); + } + const oAuthClient = await this.oauthClientRepository.getOAuthClient(clientId); + + if (!oAuthClient) throw new UnauthorizedException(); + + return { + status: SUCCESS_STATUS, + data: { + clientId: oAuthClient.id, + organizationId: oAuthClient.organizationId, + name: oAuthClient.name, + }, + }; + } + + @Get("/:clientId/access-token") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Verify an access token" }) + async verifyAccessToken( + @Param("clientId") clientId: string, + @GetUser() user: UserWithProfile + ): Promise { + if (!clientId) { + throw new BadRequestException(); + } + + if (!user) { + throw new UnauthorizedException(); + } + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/provider/provider.module.ts b/apps/api/v2/src/ee/provider/provider.module.ts new file mode 100644 index 00000000000000..d96be50d3a6fbb --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.module.ts @@ -0,0 +1,13 @@ +import { CalProviderController } from "@/ee/provider/provider.controller"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, TokensModule, OAuthClientModule], + providers: [CredentialsRepository], + controllers: [CalProviderController], +}) +export class ProviderModule {} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts new file mode 100644 index 00000000000000..d858a497d296e5 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts @@ -0,0 +1,222 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output"; +import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output"; +import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants"; +import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types"; + +describe("Schedules Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = `schedules-2024-04-15-user-${randomString()}@api.com`; + let user: User; + + let createdSchedule: CreateScheduleOutput_2024_04_15["data"]; + const scheduleName = `schedules-2024-04-15-schedule-${randomString()}`; + const defaultAvailabilityDays = [1, 2, 3, 4, 5]; + const defaultAvailabilityStartTime = "1970-01-01T09:00:00.000Z"; + const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z"; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should not create an invalid schedule", async () => { + const scheduleTimeZone = "Europe/Rome"; + const isDefault = true; + + const body = { + name: scheduleName, + timeZone: scheduleTimeZone, + isDefault, + availabilities: [ + { + days: ["Monday"], + endTime: "11:15", + startTime: "10:00", + }, + ], + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .send(body) + .expect(400) + .then(async (response) => { + expect(response.body.status).toEqual("error"); + expect(response.body.error.message).toEqual( + "Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: 11:15" + ); + }); + }); + + it("should create a default schedule", async () => { + const isDefault = true; + + const body: CreateScheduleInput_2024_04_15 = { + name: scheduleName, + timeZone: "Europe/Rome", + isDefault, + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .send(body) + .expect(201) + .then(async (response) => { + const responseData: CreateScheduleOutput_2024_04_15 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data.isDefault).toEqual(isDefault); + expect(responseData.data.timeZone).toEqual("Europe/Rome"); + expect(responseData.data.name).toEqual(scheduleName); + + const schedule = responseData.data.schedule; + expect(schedule).toBeDefined(); + expect(schedule.length).toEqual(1); + expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + + const scheduleUser = schedule?.[0].userId + ? await userRepositoryFixture.get(schedule?.[0].userId) + : null; + expect(scheduleUser?.defaultScheduleId).toEqual(responseData.data.id); + createdSchedule = responseData.data; + }); + }); + + it("should get default schedule", async () => { + return request(app.getHttpServer()) + .get("/api/v2/schedules/default") + .expect(200) + .then(async (response) => { + const responseData: CreateScheduleOutput_2024_04_15 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data.id).toEqual(createdSchedule.id); + expect(responseData.data.schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId); + + const schedule = responseData.data.schedule; + expect(schedule).toBeDefined(); + expect(schedule.length).toEqual(1); + expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + }); + }); + + it("should get schedules", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules`) + .expect(200) + .then((response) => { + const responseData: GetSchedulesOutput_2024_04_15 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data?.[0].id).toEqual(createdSchedule.id); + expect(responseData.data?.[0].schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId); + + const schedule = responseData.data?.[0].schedule; + expect(schedule).toBeDefined(); + expect(schedule.length).toEqual(1); + expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + }); + }); + + it("should update schedule name", async () => { + const newScheduleName = `schedules-2024-04-15-schedule-${randomString()}`; + + const body: UpdateScheduleInput_2024_04_15 = { + name: newScheduleName, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/schedules/${createdSchedule.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .send(body) + .expect(200) + .then((response: any) => { + const responseData: UpdateScheduleOutput_2024_04_15 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data.schedule.name).toEqual(newScheduleName); + expect(responseData.data.schedule.id).toEqual(createdSchedule.id); + expect(responseData.data.schedule.userId).toEqual(createdSchedule.schedule[0].userId); + + const availability = responseData.data.schedule.availability; + expect(availability).toBeDefined(); + expect(availability?.length).toEqual(1); + expect(availability?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(availability?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + + createdSchedule.name = newScheduleName; + }); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + try { + await scheduleRepositoryFixture.deleteById(createdSchedule.id); + } catch (e) {} + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts new file mode 100644 index 00000000000000..ad6c3dffc993ea --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts @@ -0,0 +1,133 @@ +import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output"; +import { DeleteScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output"; +import { GetDefaultScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output"; +import { GetScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output"; +import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output"; +import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { VERSION_2024_04_15_VALUE } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Patch, + UseGuards, +} from "@nestjs/common"; +import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; + +import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types"; + +import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input"; + +@Controller({ + path: "/v2/schedules", + version: VERSION_2024_04_15_VALUE, +}) +@UseGuards(ApiAuthGuard, PermissionsGuard) +@DocsExcludeController(true) +export class SchedulesController_2024_04_15 { + constructor(private readonly schedulesService: SchedulesService_2024_04_15) {} + + @Post("/") + @Permissions([SCHEDULE_WRITE]) + async createSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: CreateScheduleInput_2024_04_15 + ): Promise { + const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule); + const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule); + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/default") + @Permissions([SCHEDULE_READ]) + async getDefaultSchedule( + @GetUser() user: UserWithProfile + ): Promise { + const schedule = await this.schedulesService.getUserScheduleDefault(user.id); + const scheduleFormatted = schedule + ? await this.schedulesService.formatScheduleForAtom(user, schedule) + : null; + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/:scheduleId") + @Permissions([SCHEDULE_READ]) + async getSchedule( + @GetUser() user: UserWithProfile, + @Param("scheduleId") scheduleId: number + ): Promise { + const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId); + const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule); + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/") + @Permissions([SCHEDULE_READ]) + async getSchedules(@GetUser() user: UserWithProfile): Promise { + const schedules = await this.schedulesService.getUserSchedules(user.id); + const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules); + + return { + status: SUCCESS_STATUS, + data: schedulesFormatted, + }; + } + + // note(Lauris): currently this endpoint is atoms only + @Patch("/:scheduleId") + @Permissions([SCHEDULE_WRITE]) + async updateSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateScheduleInput_2024_04_15, + @Param("scheduleId") scheduleId: string + ): Promise { + const updatedSchedule = await this.schedulesService.updateUserSchedule( + user, + Number(scheduleId), + bodySchedule + ); + + return { + status: SUCCESS_STATUS, + data: updatedSchedule, + }; + } + + @Delete("/:scheduleId") + @HttpCode(HttpStatus.OK) + @Permissions([SCHEDULE_WRITE]) + async deleteSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number + ): Promise { + await this.schedulesService.deleteUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-availability.input.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-availability.input.ts new file mode 100644 index 00000000000000..f8346b83a81238 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-availability.input.ts @@ -0,0 +1,58 @@ +import { BadRequestException } from "@nestjs/common"; +import { ApiProperty } from "@nestjs/swagger"; +import { Transform, TransformFnParams } from "class-transformer"; +import { IsArray, IsDate, IsNumber } from "class-validator"; + +export class CreateAvailabilityInput_2024_04_15 { + @IsArray() + @IsNumber({}, { each: true }) + @ApiProperty({ example: [1, 2] }) + days!: number[]; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + startTime!: Date; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + endTime!: Date; +} + +function transformStringToDate(value: string, key: string): Date { + if (!value) { + throw new BadRequestException( + `Missing ${key}. Expected value is in ISO8061 format e.g. 2025-0412T13:17:56.324Z` + ); + } + + const dateTimeParts = value.split("T"); + if (dateTimeParts.length !== 2) { + throw new BadRequestException( + `Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: ${value}` + ); + } + + const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds + const parts = timePart.split(":"); + + if (parts.length !== 3) { + throw new BadRequestException( + `Invalid time format. Expected format(ISO8061): 2025-0412T13:17:56.324Z. Received: ${value}` + ); + } + const [hours, minutes, seconds] = parts.map(Number); + + if (hours < 0 || hours > 23) { + throw new BadRequestException(`Invalid ${key} hours. Expected value between 0 and 23`); + } + + if (minutes < 0 || minutes > 59) { + throw new BadRequestException(`Invalid ${key} minutes. Expected value between 0 and 59`); + } + + if (seconds < 0 || seconds > 59) { + throw new BadRequestException(`Invalid ${key} seconds. Expected value between 0 and 59`); + } + + return new Date(new Date().setUTCHours(hours, minutes, seconds, 0)); +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input.ts new file mode 100644 index 00000000000000..40e0f53abd38fb --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input.ts @@ -0,0 +1,25 @@ +import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator"; + +export class CreateScheduleInput_2024_04_15 { + @IsString() + @ApiProperty() + name!: string; + + @IsTimeZone() + @ApiProperty() + timeZone!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateAvailabilityInput_2024_04_15) + @IsOptional() + @ApiPropertyOptional({ type: [CreateAvailabilityInput_2024_04_15] }) + availabilities?: CreateAvailabilityInput_2024_04_15[]; + + @IsBoolean() + @ApiProperty() + isDefault!: boolean; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output.ts new file mode 100644 index 00000000000000..6a1184683e769c --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output.ts @@ -0,0 +1,20 @@ +import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateScheduleOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ScheduleOutput) + data!: ScheduleOutput; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output.ts new file mode 100644 index 00000000000000..d6427fb7045788 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteScheduleOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output.ts new file mode 100644 index 00000000000000..0d14fb73268fac --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output.ts @@ -0,0 +1,20 @@ +import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetDefaultScheduleOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ScheduleOutput) + data!: ScheduleOutput | null; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output.ts new file mode 100644 index 00000000000000..ae7709e0ba3602 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output.ts @@ -0,0 +1,20 @@ +import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetScheduleOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ScheduleOutput) + data!: ScheduleOutput; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output.ts new file mode 100644 index 00000000000000..81a9b911bd5b03 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output.ts @@ -0,0 +1,21 @@ +import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetSchedulesOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested({ each: true }) + @Type(() => ScheduleOutput) + @IsArray() + data!: ScheduleOutput[]; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output.ts new file mode 100644 index 00000000000000..594792402bfcf4 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output.ts @@ -0,0 +1,117 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator"; + +class EventTypeModel_2024_04_15 { + @IsInt() + @ApiProperty() + id!: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, nullable: true }) + eventName?: string | null; +} + +class AvailabilityModel_2024_04_15 { + @IsInt() + @ApiProperty() + id!: number; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + userId?: number | null; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + scheduleId?: number | null; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + eventTypeId?: number | null; + + @IsArray() + @IsInt({ each: true }) + @ApiProperty({ type: [Number] }) + days!: number[]; + + @IsOptional() + @Type(() => Date) + @IsString() + @ApiPropertyOptional() + startTime?: Date; + + @IsOptional() + @Type(() => Date) + @IsString() + @ApiPropertyOptional() + endTime?: Date; + + @IsOptional() + @Type(() => Date) + @IsString() + @ApiPropertyOptional({ type: String, nullable: true }) + date?: Date | null; +} + +class ScheduleModel_2024_04_15 { + @IsInt() + @ApiProperty() + id!: number; + + @IsInt() + @ApiProperty() + userId!: number; + + @IsString() + @ApiProperty() + name!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, nullable: true }) + timeZone?: string | null; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => EventTypeModel_2024_04_15) + @IsArray() + @ApiPropertyOptional({ type: [EventTypeModel_2024_04_15] }) + eventType?: EventTypeModel_2024_04_15[]; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AvailabilityModel_2024_04_15) + @IsArray() + @ApiPropertyOptional({ type: [AvailabilityModel_2024_04_15] }) + availability?: AvailabilityModel_2024_04_15[]; +} + +export class UpdatedScheduleOutput_2024_04_15 { + @ValidateNested() + @Type(() => ScheduleModel_2024_04_15) + @ApiProperty({ type: ScheduleModel_2024_04_15 }) + schedule!: ScheduleModel_2024_04_15; + + @IsBoolean() + @ApiProperty() + isDefault!: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + timeZone?: string; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + prevDefaultId?: number | null; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + currentDefaultId?: number | null; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule.output.ts new file mode 100644 index 00000000000000..9c462b7062b214 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule.output.ts @@ -0,0 +1,132 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsDate, IsOptional, IsArray, IsBoolean, IsInt, IsString, ValidateNested } from "class-validator"; + +class AvailabilityModel { + @IsInt() + @ApiProperty() + id!: number; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + userId?: number | null; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + eventTypeId?: number | null; + + @IsArray() + @IsInt({ each: true }) + @ApiProperty({ type: [Number] }) + days!: number[]; + + @IsDate() + @Type(() => Date) + @ApiProperty({ type: Date }) + startTime!: Date; + + @IsDate() + @Type(() => Date) + @ApiProperty({ type: Date }) + endTime!: Date; + + @IsOptional() + @IsDate() + @Type(() => Date) + @ApiPropertyOptional({ type: Date, nullable: true }) + date?: Date | null; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + scheduleId?: number | null; +} + +class WorkingHours { + @IsArray() + @IsInt({ each: true }) + @ApiProperty({ type: [Number] }) + days!: number[]; + + @IsInt() + @ApiProperty() + startTime!: number; + + @IsInt() + @ApiProperty() + endTime!: number; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + userId?: number | null; +} + +class TimeRange { + @IsOptional() + @IsInt() + @ApiPropertyOptional({ type: Number, nullable: true }) + userId?: number | null; + + @IsDate() + @ApiProperty({ type: Date }) + start!: Date; + + @IsDate() + @ApiProperty({ type: Date }) + end!: Date; +} + +export class ScheduleOutput { + @IsInt() + @ApiProperty() + id!: number; + + @IsString() + @ApiProperty() + name!: string; + + @IsBoolean() + @ApiProperty() + isManaged!: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkingHours) + @ApiProperty({ type: [WorkingHours] }) + workingHours!: WorkingHours[]; + + @ValidateNested({ each: true }) + @Type(() => AvailabilityModel) + @IsArray() + @ApiProperty({ type: [AvailabilityModel] }) + schedule!: AvailabilityModel[]; + + @ApiProperty({ type: [[TimeRange]] }) + availability!: TimeRange[][]; + + @IsString() + @ApiProperty() + timeZone!: string; + + @ValidateNested({ each: true }) + @IsArray() + @ApiPropertyOptional({ type: [Object] }) + // note(Lauris) it should be + // dateOverrides!: { ranges: TimeRange[] }[]; + // but docs aren't generating correctly it results in array of strings + dateOverrides!: unknown[]; + + @IsBoolean() + @ApiProperty() + isDefault!: boolean; + + @IsBoolean() + @ApiProperty() + isLastSchedule!: boolean; + + @IsBoolean() + @ApiProperty() + readOnly!: boolean; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output.ts new file mode 100644 index 00000000000000..8633a77d1205a0 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output.ts @@ -0,0 +1,20 @@ +import { UpdatedScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateScheduleOutput_2024_04_15 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: UpdatedScheduleOutput_2024_04_15, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => UpdatedScheduleOutput_2024_04_15) + data!: UpdatedScheduleOutput_2024_04_15; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.module.ts new file mode 100644 index 00000000000000..c6564123a58006 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.module.ts @@ -0,0 +1,15 @@ +import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller"; +import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, UsersModule, TokensModule], + providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15], + controllers: [SchedulesController_2024_04_15], + exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15], +}) +export class SchedulesModule_2024_04_15 {} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.repository.ts new file mode 100644 index 00000000000000..a17f1ae6c4dc8a --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.repository.ts @@ -0,0 +1,95 @@ +import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulesRepository_2024_04_15 { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createScheduleWithAvailabilities( + userId: number, + schedule: CreateScheduleInput_2024_04_15, + availabilities: CreateAvailabilityInput_2024_04_15[] + ) { + const createScheduleData: Prisma.ScheduleCreateInput = { + user: { + connect: { + id: userId, + }, + }, + name: schedule.name, + timeZone: schedule.timeZone, + }; + + if (availabilities.length > 0) { + createScheduleData.availability = { + createMany: { + data: availabilities.map((availability) => { + return { + days: availability.days, + startTime: availability.startTime, + endTime: availability.endTime, + userId, + }; + }), + }, + }; + } + + const createdSchedule = await this.dbWrite.prisma.schedule.create({ + data: { + ...createScheduleData, + }, + include: { + availability: true, + }, + }); + + return createdSchedule; + } + + async getScheduleById(scheduleId: number) { + const schedule = await this.dbRead.prisma.schedule.findUnique({ + where: { + id: scheduleId, + }, + include: { + availability: true, + }, + }); + + return schedule; + } + + async getSchedulesByUserId(userId: number) { + const schedules = await this.dbRead.prisma.schedule.findMany({ + where: { + userId, + }, + include: { + availability: true, + }, + }); + + return schedules; + } + + async deleteScheduleById(scheduleId: number) { + return this.dbWrite.prisma.schedule.delete({ + where: { + id: scheduleId, + }, + }); + } + + async getUserSchedulesCount(userId: number) { + return this.dbRead.prisma.schedule.count({ + where: { + userId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts new file mode 100644 index 00000000000000..3d5d1d4a9015dd --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts @@ -0,0 +1,175 @@ +import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output"; +import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Schedule } from "@prisma/client"; +import { User } from "@prisma/client"; + +import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2"; +import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2"; +import { + transformWorkingHoursForClient, + transformAvailabilityForClient, + transformDateOverridesForClient, +} from "@calcom/platform-libraries-0.0.2"; +import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types"; + +@Injectable() +export class SchedulesService_2024_04_15 { + constructor( + private readonly schedulesRepository: SchedulesRepository_2024_04_15, + private readonly usersRepository: UsersRepository + ) {} + + async createUserDefaultSchedule(userId: number, timeZone: string) { + const schedule = { + isDefault: true, + name: "Default schedule", + timeZone, + }; + + return this.createUserSchedule(userId, schedule); + } + + async createUserSchedule(userId: number, schedule: CreateScheduleInput_2024_04_15) { + const availabilities = schedule.availabilities?.length + ? schedule.availabilities + : [this.getDefaultAvailabilityInput()]; + + const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities( + userId, + schedule, + availabilities + ); + + if (schedule.isDefault) { + await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id); + } + + return createdSchedule; + } + + async getUserScheduleDefault(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.defaultScheduleId) return null; + + return this.schedulesRepository.getScheduleById(user.defaultScheduleId); + } + + async getUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return existingSchedule; + } + + async getUserSchedules(userId: number) { + return this.schedulesRepository.getSchedulesByUserId(userId); + } + + async updateUserSchedule( + user: UserWithProfile, + scheduleId: number, + bodySchedule: UpdateScheduleInput_2024_04_15 + ) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(user.id, existingSchedule); + + const schedule = await this.getUserSchedule(user.id, Number(scheduleId)); + const scheduleFormatted = await this.formatScheduleForAtom(user, schedule); + + if (!bodySchedule.schedule) { + // note(Lauris): When updating an availability in cal web app, lets say only its name, also + // the schedule is sent and then passed to the update handler. Notably, availability is passed too + // and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect + // schedule that would be passed by the web app. If we don't, then updating schedule name will erase + // schedule. + bodySchedule.schedule = scheduleFormatted.availability; + } + + return updateScheduleHandler({ + input: { scheduleId: Number(scheduleId), ...bodySchedule }, + ctx: { user }, + }); + } + + async deleteUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return this.schedulesRepository.deleteScheduleById(scheduleId); + } + + async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise { + const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id); + return this.transformScheduleForAtom(schedule, usersSchedulesCount, user); + } + + async formatSchedulesForAtom( + user: User, + schedules: ScheduleWithAvailabilities[] + ): Promise { + const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id); + return Promise.all( + schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user)) + ); + } + + async transformScheduleForAtom( + schedule: ScheduleWithAvailabilities, + userSchedulesCount: number, + user: Pick + ): Promise { + const timeZone = schedule.timeZone || user.timeZone; + const defaultSchedule = await this.getUserScheduleDefault(user.id); + + return { + id: schedule.id, + name: schedule.name, + isManaged: schedule.userId !== user.id, + workingHours: transformWorkingHoursForClient(schedule), + schedule: schedule.availability, + availability: transformAvailabilityForClient(schedule), + timeZone, + dateOverrides: transformDateOverridesForClient(schedule, timeZone), + isDefault: defaultSchedule?.id === schedule.id, + isLastSchedule: userSchedulesCount <= 1, + readOnly: schedule.userId !== user.id, + }; + } + + checkUserOwnsSchedule(userId: number, schedule: Pick) { + if (userId !== schedule.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`); + } + } + + getDefaultAvailabilityInput(): CreateAvailabilityInput_2024_04_15 { + const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0)); + const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0)); + + return { + days: [1, 2, 3, 4, 5], + startTime, + endTime, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts new file mode 100644 index 00000000000000..b0bec9d735c1fd --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts @@ -0,0 +1,267 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_11 } from "@calcom/platform-constants"; +import { + CreateScheduleInput_2024_06_11, + CreateScheduleOutput_2024_06_11, + GetScheduleOutput_2024_06_11, + GetSchedulesOutput_2024_06_11, + ScheduleOutput_2024_06_11, + UpdateScheduleOutput_2024_06_11, +} from "@calcom/platform-types"; +import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types"; + +describe("Schedules Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = `schedules-2024-06-11-user@api.com`; + let user: User; + + const createScheduleInput: CreateScheduleInput_2024_06_11 = { + name: `schedules-2024-06-11-work`, + timeZone: "Europe/Rome", + isDefault: true, + }; + + const defaultAvailability: CreateScheduleInput_2024_06_11["availability"] = [ + { + days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + startTime: "09:00", + endTime: "17:00", + }, + ]; + + let createdSchedule: CreateScheduleOutput_2024_06_11["data"]; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_06_11], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a default schedule", async () => { + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + .send(createScheduleInput) + .expect(201) + .then(async (response) => { + const responseBody: CreateScheduleOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + createdSchedule = response.body.data; + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1); + + const scheduleOwner = createdSchedule.ownerId + ? await userRepositoryFixture.get(createdSchedule.ownerId) + : null; + expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id); + }); + }); + + function outputScheduleMatchesExpected( + outputSchedule: ScheduleOutput_2024_06_11 | null, + expected: CreateScheduleInput_2024_06_11 & { + availability: CreateScheduleInput_2024_06_11["availability"]; + } & { + overrides: CreateScheduleInput_2024_06_11["overrides"]; + }, + expectedAvailabilityLength: number + ) { + expect(outputSchedule).toBeTruthy(); + expect(outputSchedule?.name).toEqual(expected.name); + expect(outputSchedule?.timeZone).toEqual(expected.timeZone); + expect(outputSchedule?.isDefault).toEqual(expected.isDefault); + expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength); + + if (expectedAvailabilityLength) { + const outputScheduleAvailability = outputSchedule?.availability[0]; + expect(outputScheduleAvailability).toBeDefined(); + expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days); + expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime); + expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime); + } + + expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides)); + } + + it("should get default schedule", async () => { + return request(app.getHttpServer()) + .get("/api/v2/schedules/default") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + .expect(200) + .then(async (response) => { + const responseBody: GetScheduleOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const outputSchedule = responseBody.data; + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1); + }); + }); + + it("should get schedules", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + .expect(200) + .then((response) => { + const responseBody: GetSchedulesOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const outputSchedule = responseBody.data[0]; + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1); + }); + }); + + it("should update schedule name", async () => { + const newScheduleName = "updated-schedule-name"; + + const body: UpdateScheduleInput_2024_06_11 = { + name: newScheduleName, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/schedules/${createdSchedule.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + .send(body) + .expect(200) + .then((response: any) => { + const responseData: UpdateScheduleOutput_2024_06_11 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + const responseSchedule = responseData.data; + + const expectedSchedule = { ...createdSchedule, name: newScheduleName }; + outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1); + + createdSchedule = responseSchedule; + }); + }); + + it("should add overrides", async () => { + const overrides = [ + { + date: "2026-05-05", + startTime: "10:00", + endTime: "12:00", + }, + ]; + + const body: UpdateScheduleInput_2024_06_11 = { + overrides, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/schedules/${createdSchedule.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + .send(body) + .expect(200) + .then((response: any) => { + const responseData: UpdateScheduleOutput_2024_06_11 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + const responseSchedule = responseData.data; + + const expectedSchedule = { ...createdSchedule, overrides }; + outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1); + + createdSchedule = responseSchedule; + }); + }); + + it("should empty availabilities and overrides", async () => { + const body: UpdateScheduleInput_2024_06_11 = { + availability: [], + overrides: [], + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/schedules/${createdSchedule.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11) + .send(body) + .expect(200) + .then((response: any) => { + const responseData: UpdateScheduleOutput_2024_06_11 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + const responseSchedule = responseData.data; + + const expectedSchedule = { + ...createdSchedule, + overrides: body.overrides, + availability: body.availability, + }; + outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 0); + + createdSchedule = responseSchedule; + }); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + try { + await scheduleRepositoryFixture.deleteById(createdSchedule.id); + } catch (e) {} + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts new file mode 100644 index 00000000000000..a194754919a504 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts @@ -0,0 +1,169 @@ +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { VERSION_2024_06_14, VERSION_2024_06_11 } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Patch, + UseGuards, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + CreateScheduleOutput_2024_06_11, + CreateScheduleInput_2024_06_11, + UpdateScheduleInput_2024_06_11, + GetScheduleOutput_2024_06_11, + UpdateScheduleOutput_2024_06_11, + GetDefaultScheduleOutput_2024_06_11, + DeleteScheduleOutput_2024_06_11, + GetSchedulesOutput_2024_06_11, +} from "@calcom/platform-types"; + +@Controller({ + path: "/v2/schedules", + version: [VERSION_2024_06_14, VERSION_2024_06_11], +}) +@UseGuards(ApiAuthGuard, PermissionsGuard) +@DocsTags("Schedules") +@ApiHeader({ + name: "cal-api-version", + description: `Must be set to \`2024-06-11\``, + required: true, +}) +@ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, +}) +export class SchedulesController_2024_06_11 { + constructor(private readonly schedulesService: SchedulesService_2024_06_11) {} + + @Post("/") + @Permissions([SCHEDULE_WRITE]) + @ApiOperation({ + summary: "Create a schedule", + description: ` + Create a schedule for the authenticated user. + + The point of creating schedules is for event types to be available at specific times. + + The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule. + 1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the \`AvailabilitySettings\` atom. + 2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying + \`"isDefault": true\` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom. + + The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule. + After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint. + + When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc. + `, + }) + async createSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: CreateScheduleInput_2024_06_11 + ): Promise { + const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule); + + return { + status: SUCCESS_STATUS, + data: schedule, + }; + } + + @Get("/default") + @Permissions([SCHEDULE_READ]) + @ApiResponse({ + status: 200, + type: GetDefaultScheduleOutput_2024_06_11, + }) + @ApiOperation({ + summary: "Get default schedule", + description: "Get the default schedule of the authenticated user.", + }) + async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise { + const schedule = await this.schedulesService.getUserScheduleDefault(user.id); + + return { + status: SUCCESS_STATUS, + data: schedule, + }; + } + + @Get("/:scheduleId") + @Permissions([SCHEDULE_READ]) + @ApiOperation({ summary: "Get a schedule" }) + async getSchedule( + @GetUser() user: UserWithProfile, + @Param("scheduleId") scheduleId: number + ): Promise { + const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId); + + return { + status: SUCCESS_STATUS, + data: schedule, + }; + } + + @Get("/") + @Permissions([SCHEDULE_READ]) + @ApiOperation({ + summary: "Get all schedules", + description: "Get all schedules of the authenticated user.", + }) + async getSchedules(@GetUser() user: UserWithProfile): Promise { + const schedules = await this.schedulesService.getUserSchedules(user.id); + + return { + status: SUCCESS_STATUS, + data: schedules, + }; + } + + @Patch("/:scheduleId") + @Permissions([SCHEDULE_WRITE]) + @ApiOperation({ summary: "Update a schedule" }) + async updateSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateScheduleInput_2024_06_11, + @Param("scheduleId") scheduleId: string + ): Promise { + const updatedSchedule = await this.schedulesService.updateUserSchedule( + user.id, + Number(scheduleId), + bodySchedule + ); + + return { + status: SUCCESS_STATUS, + data: updatedSchedule, + }; + } + + @Delete("/:scheduleId") + @HttpCode(HttpStatus.OK) + @Permissions([SCHEDULE_WRITE]) + @ApiOperation({ summary: "Delete a schedule" }) + async deleteSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number + ): Promise { + await this.schedulesService.deleteUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.module.ts new file mode 100644 index 00000000000000..abd2f7cf985a9f --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.module.ts @@ -0,0 +1,22 @@ +import { SchedulesController_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/controllers/schedules.controller"; +import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository"; +import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service"; +import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service"; +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, UsersModule, TokensModule], + providers: [ + SchedulesRepository_2024_06_11, + SchedulesService_2024_06_11, + InputSchedulesService_2024_06_11, + OutputSchedulesService_2024_06_11, + ], + controllers: [SchedulesController_2024_06_11], + exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11, OutputSchedulesService_2024_06_11], +}) +export class SchedulesModule_2024_06_11 {} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.repository.ts new file mode 100644 index 00000000000000..92d05bc871985d --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.repository.ts @@ -0,0 +1,232 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +import type { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types"; + +type InputScheduleAvailabilityTransformed = { + days: number[]; + startTime: Date; + endTime: Date; +}; + +type InputScheduleOverrideTransformed = { + date: Date; + startTime: Date; + endTime: Date; +}; + +type InputScheduleTransformed = Omit & { + availability: InputScheduleAvailabilityTransformed[]; + overrides: InputScheduleOverrideTransformed[]; +}; + +@Injectable() +export class SchedulesRepository_2024_06_11 { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createSchedule(userId: number, schedule: Omit) { + const { availability, overrides } = schedule; + + const createScheduleData: Prisma.ScheduleCreateInput = { + user: { + connect: { + id: userId, + }, + }, + name: schedule.name, + timeZone: schedule.timeZone, + }; + + const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = []; + + if (availability && availability.length > 0) { + availability.forEach((availability) => { + availabilitiesAndOverrides.push({ + days: availability.days, + startTime: availability.startTime, + endTime: availability.endTime, + userId, + }); + }); + } + + if (overrides && overrides.length > 0) { + overrides.forEach((override) => { + availabilitiesAndOverrides.push({ + date: override.date, + startTime: override.startTime, + endTime: override.endTime, + userId, + }); + }); + } + + if (availabilitiesAndOverrides.length > 0) { + createScheduleData.availability = { + createMany: { + data: availabilitiesAndOverrides, + }, + }; + } + + const createdSchedule = await this.dbWrite.prisma.schedule.create({ + data: { + ...createScheduleData, + }, + include: { + availability: true, + }, + }); + + return createdSchedule; + } + + async getScheduleById(scheduleId: number) { + const schedule = await this.dbRead.prisma.schedule.findUnique({ + where: { + id: scheduleId, + }, + include: { + availability: true, + }, + }); + + return schedule; + } + + async getScheduleByIdAndUserId(scheduleId: number, userId: number) { + const schedule = await this.dbRead.prisma.schedule.findUnique({ + where: { + id: scheduleId, + userId, + }, + }); + + return schedule; + } + + async updateSchedule( + userId: number, + scheduleId: number, + schedule: Partial> + ) { + const { availability, overrides } = schedule; + + const updateScheduleData: Prisma.ScheduleUpdateInput = { + name: schedule.name, + timeZone: schedule.timeZone, + }; + + const createAvailabilityStatements: Prisma.AvailabilityCreateManyInput[] = []; + const createOverridesStatements: Prisma.AvailabilityCreateManyInput[] = []; + + const deleteAvailabilityStatements: Prisma.AvailabilityWhereInput[] = []; + const deleteOverridesStatements: Prisma.AvailabilityWhereInput[] = []; + + if (availability) { + // note(Lauris): availabilities and overrides are stored in the same "Availability" table, + // but availabilities have "date" field as null, while overrides have it as not null, so delete + // condition below results in deleting only rows from Availability table that are availabilities. + deleteAvailabilityStatements.push({ + scheduleId: { equals: scheduleId }, + date: null, + }); + } + + if (overrides) { + // note(Lauris): availabilities and overrides are stored in the same "Availability" table, + // but overrides have "date" field as not-null, while availabilities have it as null, so delete + // condition below results in deleting only rows from Availability table that are overrides. + deleteOverridesStatements.push({ + scheduleId: { equals: scheduleId }, + NOT: { date: null }, + }); + } + + if (availability && availability.length > 0) { + availability.forEach((availability) => { + createAvailabilityStatements.push({ + days: availability.days, + startTime: availability.startTime, + endTime: availability.endTime, + userId, + }); + }); + } + + if (overrides && overrides.length > 0) { + overrides.forEach((override) => { + createOverridesStatements.push({ + date: override.date, + startTime: override.startTime, + endTime: override.endTime, + userId, + }); + }); + } + + const deleteStatements = [...deleteAvailabilityStatements, ...deleteOverridesStatements]; + const createStatements = [...createAvailabilityStatements, ...createOverridesStatements]; + + if (deleteStatements.length > 0) { + updateScheduleData.availability = { + deleteMany: deleteStatements, + }; + } + + if (createStatements.length > 0) { + updateScheduleData.availability = { + // note(Lauris): keep deleteMany statements + ...(updateScheduleData.availability || {}), + createMany: { + data: createStatements, + }, + }; + } + + const updatedSchedule = await this.dbWrite.prisma.schedule.update({ + where: { + id: scheduleId, + }, + data: { + ...updateScheduleData, + }, + include: { + availability: true, + }, + }); + + return updatedSchedule; + } + + async getSchedulesByUserId(userId: number) { + const schedules = await this.dbRead.prisma.schedule.findMany({ + where: { + userId, + }, + include: { + availability: true, + }, + }); + + return schedules; + } + + async deleteScheduleById(scheduleId: number) { + return this.dbWrite.prisma.schedule.delete({ + where: { + id: scheduleId, + }, + }); + } + + async getUserSchedulesCount(userId: number) { + return this.dbRead.prisma.schedule.count({ + where: { + userId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts new file mode 100644 index 00000000000000..137987670fe1bd --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from "@nestjs/common"; + +import { transformApiScheduleOverrides, transformApiScheduleAvailability } from "@calcom/platform-libraries"; +import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types"; +import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types"; + +@Injectable() +export class InputSchedulesService_2024_06_11 { + transformInputCreateSchedule(inputSchedule: CreateScheduleInput_2024_06_11) { + const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [ + { + days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + startTime: "09:00", + endTime: "17:00", + }, + ]; + const defaultOverrides: ScheduleOverrideInput_2024_06_11[] = []; + + const availability = this.transformInputScheduleAvailability( + inputSchedule.availability || defaultAvailability + ); + const overrides = this.transformInputOverrides(inputSchedule.overrides || defaultOverrides); + + const internalCreateSchedule = { + ...inputSchedule, + availability, + overrides, + }; + + return internalCreateSchedule; + } + + transformInputScheduleAvailability(inputAvailability: ScheduleAvailabilityInput_2024_06_11[]) { + return transformApiScheduleAvailability(inputAvailability); + } + + transformInputOverrides(inputOverrides: ScheduleOverrideInput_2024_06_11[]) { + return transformApiScheduleOverrides(inputOverrides); + } +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/output-schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/output-schedules.service.ts new file mode 100644 index 00000000000000..18f882f93b06bb --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/output-schedules.service.ts @@ -0,0 +1,78 @@ +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import type { Availability, Schedule } from "@prisma/client"; + +import { WeekDay } from "@calcom/platform-types"; + +@Injectable() +export class OutputSchedulesService_2024_06_11 { + constructor(private readonly usersRepository: UsersRepository) {} + + async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) { + if (!databaseSchedule.timeZone) { + databaseSchedule.timeZone = "Europe/London"; + } + + const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId( + databaseSchedule.userId + ); + + const createdScheduleAvailabilities = databaseSchedule.availability.filter( + (availability) => !!availability.days.length + ); + const createdScheduleOverrides = databaseSchedule.availability.filter((override) => !!override.date); + + return { + id: databaseSchedule.id, + ownerId: databaseSchedule.userId, + name: databaseSchedule.name, + timeZone: databaseSchedule.timeZone, + availability: createdScheduleAvailabilities.map((availability) => ({ + days: availability.days.map(transformNumberToDay), + startTime: this.padHoursMinutesWithZeros( + availability.startTime.getUTCHours() + ":" + availability.startTime.getUTCMinutes() + ), + endTime: this.padHoursMinutesWithZeros( + availability.endTime.getUTCHours() + ":" + availability.endTime.getUTCMinutes() + ), + })), + isDefault: databaseSchedule.id === ownerDefaultScheduleId, + overrides: createdScheduleOverrides.map((override) => ({ + date: + override.date?.getUTCFullYear() + + "-" + + (override.date ? override.date.getUTCMonth() + 1 : "").toString().padStart(2, "0") + + "-" + + override.date?.getUTCDate().toString().padStart(2, "0"), + startTime: this.padHoursMinutesWithZeros( + override.startTime.getUTCHours() + ":" + override.startTime.getUTCMinutes() + ), + endTime: this.padHoursMinutesWithZeros( + override.endTime.getUTCHours() + ":" + override.endTime.getUTCMinutes() + ), + })), + }; + } + + padHoursMinutesWithZeros(hhMM: string) { + const [hours, minutes] = hhMM.split(":"); + + const formattedHours = hours.padStart(2, "0"); + const formattedMinutes = minutes.padStart(2, "0"); + + return `${formattedHours}:${formattedMinutes}`; + } +} + +function transformNumberToDay(day: number): WeekDay { + const weekMap: { [key: number]: WeekDay } = { + 0: "Sunday", + 1: "Monday", + 2: "Tuesday", + 3: "Wednesday", + 4: "Thursday", + 5: "Friday", + 6: "Saturday", + }; + return weekMap[day]; +} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/schedules.service.ts new file mode 100644 index 00000000000000..d68400a5721d41 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/schedules.service.ts @@ -0,0 +1,123 @@ +import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository"; +import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service"; +import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Schedule } from "@prisma/client"; + +import { CreateScheduleInput_2024_06_11, ScheduleOutput_2024_06_11 } from "@calcom/platform-types"; +import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types"; + +@Injectable() +export class SchedulesService_2024_06_11 { + constructor( + private readonly schedulesRepository: SchedulesRepository_2024_06_11, + private readonly inputSchedulesService: InputSchedulesService_2024_06_11, + private readonly outputSchedulesService: OutputSchedulesService_2024_06_11, + private readonly usersRepository: UsersRepository + ) {} + + async createUserDefaultSchedule(userId: number, timeZone: string) { + const defaultSchedule = { + isDefault: true, + name: "Default schedule", + timeZone, + }; + + return this.createUserSchedule(userId, defaultSchedule); + } + + async createUserSchedule( + userId: number, + scheduleInput: CreateScheduleInput_2024_06_11 + ): Promise { + const schedule = this.inputSchedulesService.transformInputCreateSchedule(scheduleInput); + + const createdSchedule = await this.schedulesRepository.createSchedule(userId, schedule); + + if (schedule.isDefault) { + await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id); + } + + return this.outputSchedulesService.getResponseSchedule(createdSchedule); + } + + async getUserScheduleDefault(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.defaultScheduleId) return null; + + const defaultSchedule = await this.schedulesRepository.getScheduleById(user.defaultScheduleId); + + if (!defaultSchedule) return null; + return this.outputSchedulesService.getResponseSchedule(defaultSchedule); + } + + async getUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return this.outputSchedulesService.getResponseSchedule(existingSchedule); + } + + async getUserSchedules(userId: number) { + const schedules = await this.schedulesRepository.getSchedulesByUserId(userId); + return Promise.all( + schedules.map(async (schedule) => { + return this.outputSchedulesService.getResponseSchedule(schedule); + }) + ); + } + + async updateUserSchedule(userId: number, scheduleId: number, bodySchedule: UpdateScheduleInput_2024_06_11) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + const availability = bodySchedule.availability + ? this.inputSchedulesService.transformInputScheduleAvailability(bodySchedule.availability) + : undefined; + const overrides = bodySchedule.overrides + ? this.inputSchedulesService.transformInputOverrides(bodySchedule.overrides) + : undefined; + + if (bodySchedule.isDefault) { + await this.usersRepository.setDefaultSchedule(userId, scheduleId); + } + + const updatedSchedule = await this.schedulesRepository.updateSchedule(userId, scheduleId, { + ...bodySchedule, + availability, + overrides, + }); + + return this.outputSchedulesService.getResponseSchedule(updatedSchedule); + } + + async deleteUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return this.schedulesRepository.deleteScheduleById(scheduleId); + } + + checkUserOwnsSchedule(userId: number, schedule: Pick) { + if (userId !== schedule.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`); + } + } +} diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts new file mode 100644 index 00000000000000..97d1ea64a3829f --- /dev/null +++ b/apps/api/v2/src/env.ts @@ -0,0 +1,48 @@ +import { logLevels } from "@/lib/logger"; + +export type Environment = { + NODE_ENV: "development" | "production"; + API_PORT: string; + API_URL: string; + DATABASE_READ_URL: string; + DATABASE_WRITE_URL: string; + NEXTAUTH_SECRET: string; + DATABASE_URL: string; + JWT_SECRET: string; + SENTRY_DSN: string; + SENTRY_TRACES_SAMPLE_RATE?: number; + SENTRY_PROFILES_SAMPLE_RATE?: number; + LOG_LEVEL: keyof typeof logLevels; + REDIS_URL: string; + STRIPE_API_KEY: string; + STRIPE_WEBHOOK_SECRET: string; + WEB_APP_URL: string; + IS_E2E: boolean; + CALCOM_LICENSE_KEY: string; + GET_LICENSE_KEY_URL: string; + API_KEY_PREFIX: string; + DOCS_URL: string; + RATE_LIMIT_DEFAULT_TTL_MS: number; + RATE_LIMIT_DEFAULT_LIMIT_API_KEY: number; + RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT: number; + RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number; + RATE_LIMIT_DEFAULT_LIMIT: number; + RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number; + AXIOM_DATASET: string; + AXIOM_TOKEN: string; + STRIPE_TEAM_MONTHLY_PRICE_ID: string; + IS_TEAM_BILLING_ENABLED: boolean; +}; + +export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { + const value = process.env[key] as Environment[K] | undefined; + + if (value === undefined) { + if (fallback) { + return fallback; + } + throw new Error(`Missing environment variable: ${key}.`); + } + + return value; +}; diff --git a/apps/api/v2/src/filters/http-exception.filter.ts b/apps/api/v2/src/filters/http-exception.filter.ts new file mode 100644 index 00000000000000..2aa35ba002c67a --- /dev/null +++ b/apps/api/v2/src/filters/http-exception.filter.ts @@ -0,0 +1,34 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common"; +import { Request } from "express"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("HttpExceptionFilter"); + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + 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, details: exception.getResponse() }, + }); + } +} diff --git a/apps/api/v2/src/filters/prisma-exception.filter.ts b/apps/api/v2/src/filters/prisma-exception.filter.ts new file mode 100644 index 00000000000000..7977f84961ad07 --- /dev/null +++ b/apps/api/v2/src/filters/prisma-exception.filter.ts @@ -0,0 +1,85 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, HttpStatus, Logger } from "@nestjs/common"; +import { + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientRustPanicError, + PrismaClientUnknownRequestError, + PrismaClientValidationError, +} from "@prisma/client/runtime/library"; +import { Request } from "express"; + +import { + BAD_REQUEST, + CONFLICT, + ERROR_STATUS, + INTERNAL_SERVER_ERROR, + NOT_FOUND, +} from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +type PrismaError = + | PrismaClientInitializationError + | PrismaClientKnownRequestError + | PrismaClientRustPanicError + | PrismaClientUnknownRequestError + | PrismaClientValidationError; + +@Catch( + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientRustPanicError, + PrismaClientUnknownRequestError, + PrismaClientValidationError +) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("PrismaExceptionFilter"); + + catch(error: PrismaError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + 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, + }); + + let message = "There was an error, please try again later."; + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let errorCode = INTERNAL_SERVER_ERROR; + if (error instanceof PrismaClientKnownRequestError) { + switch (error.code) { + case "P2002": // Unique constraint failed + errorCode = CONFLICT; + statusCode = HttpStatus.CONFLICT; + message = "Invalid Input: Trying to create a record that already exists."; + break; + case "P2025": // Record not found + errorCode = NOT_FOUND; + statusCode = HttpStatus.NOT_FOUND; + message = "Invalid Query: The requested record was not found."; + break; + case "P2003": // Foreign key constraint failed + errorCode = BAD_REQUEST; + statusCode = HttpStatus.BAD_REQUEST; + message = "Invalid input: The referenced data does not exist."; + break; + default: + break; + } + } + + response.status(statusCode).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: errorCode, message: message }, + }); + } +} diff --git a/apps/api/v2/src/filters/trpc-exception.filter.ts b/apps/api/v2/src/filters/trpc-exception.filter.ts new file mode 100644 index 00000000000000..47dbcd00b4c85a --- /dev/null +++ b/apps/api/v2/src/filters/trpc-exception.filter.ts @@ -0,0 +1,62 @@ +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common"; +import { Request } from "express"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { TRPCError } from "@calcom/platform-libraries"; +import { Response } from "@calcom/platform-types"; + +@Catch(TRPCError) +export class TRPCExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("TRPCExceptionFilter"); + + catch(exception: TRPCError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let statusCode = 500; + switch (exception.code) { + case "UNAUTHORIZED": + statusCode = 401; + break; + case "FORBIDDEN": + statusCode = 403; + break; + case "NOT_FOUND": + statusCode = 404; + break; + case "INTERNAL_SERVER_ERROR": + statusCode = 500; + break; + case "BAD_REQUEST": + statusCode = 400; + break; + case "CONFLICT": + statusCode = 409; + break; + case "TOO_MANY_REQUESTS": + statusCode = 429; + default: + statusCode = 500; + 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({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: exception.name, message: exception.message }, + }); + } +} diff --git a/apps/api/v2/src/filters/zod-exception.filter.ts b/apps/api/v2/src/filters/zod-exception.filter.ts new file mode 100644 index 00000000000000..7a61b5c71b39ac --- /dev/null +++ b/apps/api/v2/src/filters/zod-exception.filter.ts @@ -0,0 +1,41 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, HttpStatus, Logger } from "@nestjs/common"; +import { Request } from "express"; +import { ZodError } from "zod"; + +import { BAD_REQUEST, ERROR_STATUS } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch(ZodError) +export class ZodExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("ZodExceptionFilter"); + + catch(error: ZodError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const requestId = request.headers["X-Request-Id"]; + + this.logger.error(`ZodError: ${error.message}`, { + error, + body: request.body, + headers: request.headers, + url: request.url, + method: request.method, + requestId, + }); + + response.status(HttpStatus.BAD_REQUEST).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { + code: BAD_REQUEST, + message: error.issues.reduce( + (acc, issue) => `${issue.path.join(".")} - ${issue.message}, ${acc}`, + "" + ), + }, + }); + } +} diff --git a/apps/api/v2/src/instrument.ts b/apps/api/v2/src/instrument.ts new file mode 100644 index 00000000000000..a579907a2b7373 --- /dev/null +++ b/apps/api/v2/src/instrument.ts @@ -0,0 +1,15 @@ +import { getEnv } from "@/env"; +import * as Sentry from "@sentry/nestjs"; +import { nodeProfilingIntegration } from "@sentry/profiling-node"; + +if (process.env.SENTRY_DSN) { + // Ensure to call this before requiring any other modules! + Sentry.init({ + dsn: getEnv("SENTRY_DSN"), + integrations: [nodeProfilingIntegration()], + // Performance Monitoring + tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: getEnv("SENTRY_PROFILES_SAMPLE_RATE") ?? 1.0, + }); +} diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts new file mode 100644 index 00000000000000..ad7ec0d2a9f3e7 --- /dev/null +++ b/apps/api/v2/src/lib/api-key/index.ts @@ -0,0 +1,8 @@ +import { createHash } from "crypto"; + +export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); + +export const isApiKey = (authString: string, prefix: string): boolean => + authString?.startsWith(prefix ?? "cal_"); + +export const stripApiKey = (apiKey: string, prefix?: string): string => apiKey.replace(prefix ?? "cal_", ""); diff --git a/apps/api/v2/src/lib/api-versions.ts b/apps/api/v2/src/lib/api-versions.ts new file mode 100644 index 00000000000000..ab9fcdae091c31 --- /dev/null +++ b/apps/api/v2/src/lib/api-versions.ts @@ -0,0 +1,20 @@ +import { VersionValue } from "@nestjs/common/interfaces"; + +import { + API_VERSIONS, + VERSION_2024_04_15, + VERSION_2024_06_11, + VERSION_2024_06_14, + VERSION_2024_08_13, +} from "@calcom/platform-constants"; + +export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue; +export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unknown as VersionValue; +export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue; +export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue; +export const VERSION_2024_08_13_VALUE: VersionValue = VERSION_2024_08_13 as unknown as VersionValue; + +export { VERSION_2024_04_15 }; +export { VERSION_2024_06_11 }; +export { VERSION_2024_06_14 }; +export { VERSION_2024_08_13 }; diff --git a/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts b/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts new file mode 100644 index 00000000000000..bbcadf9d2a5e71 --- /dev/null +++ b/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const ForAtom = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.query.for === "atom"; +}); diff --git a/apps/api/v2/src/lib/enums/auth-methods.ts b/apps/api/v2/src/lib/enums/auth-methods.ts new file mode 100644 index 00000000000000..282aa7462dabe2 --- /dev/null +++ b/apps/api/v2/src/lib/enums/auth-methods.ts @@ -0,0 +1,6 @@ +export enum AuthMethods { + "API_KEY" = "api-key", + "ACCESS_TOKEN" = "access-token", + "OAUTH_CLIENT" = "oauth-client", + "NEXT_AUTH" = "next-auth", +} diff --git a/apps/api/v2/src/lib/enums/locales.ts b/apps/api/v2/src/lib/enums/locales.ts new file mode 100644 index 00000000000000..afa8b800731acc --- /dev/null +++ b/apps/api/v2/src/lib/enums/locales.ts @@ -0,0 +1,44 @@ +export enum Locales { + AR = "ar", + CA = "ca", + DE = "de", + ES = "es", + EU = "eu", + HE = "he", + ID = "id", + JA = "ja", + LV = "lv", + PL = "pl", + RO = "ro", + SR = "sr", + TH = "th", + VI = "vi", + AZ = "az", + CS = "cs", + EL = "el", + ES_419 = "es-419", + FI = "fi", + HR = "hr", + IT = "it", + KM = "km", + NL = "nl", + PT = "pt", + RU = "ru", + SV = "sv", + TR = "tr", + ZH_CN = "zh-CN", + BG = "bg", + DA = "da", + EN = "en", + ET = "et", + FR = "fr", + HU = "hu", + IW = "iw", + KO = "ko", + NO = "no", + PT_BR = "pt-BR", + SK = "sk", + TA = "ta", + UK = "uk", + ZH_TW = "zh-TW", +} diff --git a/apps/api/v2/src/lib/inputs/capitalize-timezone.spec.ts b/apps/api/v2/src/lib/inputs/capitalize-timezone.spec.ts new file mode 100644 index 00000000000000..4c4ce6b90f56b0 --- /dev/null +++ b/apps/api/v2/src/lib/inputs/capitalize-timezone.spec.ts @@ -0,0 +1,61 @@ +import { plainToClass } from "class-transformer"; +import { IsOptional, IsString } from "class-validator"; + +import { CapitalizeTimeZone } from "./capitalize-timezone"; + +class TestDto { + @IsOptional() + @IsString() + @CapitalizeTimeZone() + timeZone?: string; +} + +describe("CapitalizeTimeZone", () => { + it("should capitalize single part time zone correctly", () => { + const input = { timeZone: "egypt" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("Egypt"); + }); + + it("should capitalize one-part time zone correctly", () => { + const input = { timeZone: "europe/rome" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("Europe/Rome"); + }); + + it("should capitalize multi-part time zone correctly", () => { + const input = { timeZone: "america/new_york" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("America/New_York"); + }); + + it("should capitalize complex time zone correctly", () => { + const input = { timeZone: "europe/isle_of_man" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("Europe/Isle_Of_Man"); + }); + + it("should handle already capitalized time zones correctly", () => { + const input = { timeZone: "Asia/Tokyo" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("Asia/Tokyo"); + }); + + it("should handle missing time zone correctly", () => { + const input = {}; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBeUndefined(); + }); + + it("should capitalize EST at the end of the string", () => { + const input = { email: "test@example.com", timeZone: "utc/est" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("UTC/EST"); + }); + + it("should capitalize UTC when surrounded by non-alphabetical characters", () => { + const input = { email: "test@example.com", timeZone: "utc/gmt+3_est" }; + const output = plainToClass(TestDto, input); + expect(output.timeZone).toBe("UTC/GMT+3_EST"); + }); +}); diff --git a/apps/api/v2/src/lib/inputs/capitalize-timezone.ts b/apps/api/v2/src/lib/inputs/capitalize-timezone.ts new file mode 100644 index 00000000000000..c94a9dc4c4c9d3 --- /dev/null +++ b/apps/api/v2/src/lib/inputs/capitalize-timezone.ts @@ -0,0 +1,28 @@ +import { Transform } from "class-transformer"; + +export function CapitalizeTimeZone(): PropertyDecorator { + return Transform(({ value }) => { + if (typeof value === "string") { + const parts = value.split("/"); + const normalizedParts = parts.map((part) => + part + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join("_") + ); + let normalizedTimeZone = normalizedParts.join("/"); + + // note(Lauris): regex matching GMT, EST, UTC at the start, end, or surrounded by non-letters and capitalizing them + const specialCases = ["GMT", "EST", "UTC"]; + specialCases.forEach((specialCase) => { + const regex = new RegExp(`(^|[^a-zA-Z])(${specialCase})([^a-zA-Z]|$)`, "gi"); + normalizedTimeZone = normalizedTimeZone.replace(regex, (match, p1, p2, p3) => { + return `${p1}${specialCase}${p3}`; + }); + }); + + return normalizedTimeZone; + } + return value; + }); +} diff --git a/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.spec.ts b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.spec.ts new file mode 100644 index 00000000000000..e2525c40b6a6e1 --- /dev/null +++ b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.spec.ts @@ -0,0 +1,77 @@ +import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; + +describe("isOriginAllowed", () => { + describe("is allowed", () => { + it("should return true for exact match without wildcard", () => { + const allowedOrigins = ["https://app.cal.com/callback"]; + const origin = "https://app.cal.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should return true for wildcard domain match", () => { + const allowedOrigins = ["*.multiscreen.d1test.biz"]; + const origin = "https://sub.multiscreen.d1test.biz"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should return true for wildcard pattern matching any domain", () => { + const allowedOrigins = ["*/callback"]; + const origin = "https://another.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle a wildcard only pattern", () => { + const allowedOrigins = ["*"]; + const origin = "https://any.domain.com/anypath"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle multiple allowed URIs where only one matches", () => { + const allowedOrigins = [ + "https://app.cal.com/other", + "*.notthisone.com", + "https://app.cal.com/callback", + ]; + const origin = "https://app.cal.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle patterns with multiple wildcards correctly", () => { + const allowedOrigins = ["https://*.mydomain.com/*"]; + const origin = "https://sub.mydomain.com/path/to/resource"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle patterns with wildcard for routes correctly", () => { + const allowedOrigins = ["https://domain.com/*"]; + const origin = "https://domain.com/dashboard"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + + it("should handle patterns with wildcard for root route correctly", () => { + const allowedOrigins = ["https://domain.com*"]; + const origin = "https://domain.com"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(true); + }); + }); + + describe("is not allowed", () => { + it("should return false if no allowed patterns match", () => { + const allowedOrigins = ["https://app.cal.com/callback", "*.multiscreen.d1test.biz", "*/callback"]; + const origin = "https://unknown.com"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(false); + }); + + it("should return false if wildcard pattern doesn't match the given origin", () => { + const allowedOrigins = ["*.example.com"]; + const origin = "https://notexample.org"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(false); + }); + + it("should handle empty allowedUris array correctly", () => { + const allowedOrigins: string[] = []; + const origin = "https://app.cal.com/callback"; + expect(isOriginAllowed(origin, allowedOrigins)).toBe(false); + }); + }); +}); diff --git a/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.ts b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.ts new file mode 100644 index 00000000000000..007a0a56f42212 --- /dev/null +++ b/apps/api/v2/src/lib/is-origin-allowed/is-origin-allowed.ts @@ -0,0 +1,18 @@ +export function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean { + return allowedOrigins.some((allowedOrigin) => { + if (allowedOrigin.includes("*")) { + return wildcardToRegex(allowedOrigin).test(origin); + } + return origin === allowedOrigin; + }); +} + +function wildcardToRegex(pattern: string): RegExp { + const escaped = escapeRegex(pattern); + const regexPattern = "^" + escaped.replace(/\\\*/g, ".*") + "$"; + return new RegExp(regexPattern); +} + +function escapeRegex(str: string): string { + return str.replace(/[.+*?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/api/v2/src/lib/logger.ts b/apps/api/v2/src/lib/logger.ts new file mode 100644 index 00000000000000..b5950e51b72b10 --- /dev/null +++ b/apps/api/v2/src/lib/logger.ts @@ -0,0 +1,50 @@ +import { WinstonTransport as AxiomTransport } from "@axiomhq/winston"; +import type { LoggerOptions } from "winston"; +import { format, transports as Transports, config } from "winston"; +import type Transport from "winston-transport"; + +const formattedTimestamp = format.timestamp({ + format: "YYYY-MM-DD HH:mm:ss.SSS", +}); + +const colorizer = format.colorize({ + colors: config.npm.colors, +}); + +const WINSTON_DEV_FORMAT = format.combine( + format.errors({ stack: true }), + colorizer, + formattedTimestamp, + format.simple() +); +const WINSTON_PROD_FORMAT = format.combine(format.errors({ stack: true }), formattedTimestamp, format.json()); + +export const logLevels = config.npm.levels; + +export const loggerConfig = (): LoggerOptions => { + const isProduction = process.env.NODE_ENV === "production"; + + const transports: Transport[] = []; + transports.push(new Transports.Console()); + + if (!!process.env.AXIOM_TOKEN && !!process.env.AXIOM_DATASET) { + transports.push( + new AxiomTransport({ + token: process.env.AXIOM_TOKEN, + dataset: process.env.AXIOM_DATASET, + }) + ); + } + + return { + levels: logLevels, + level: process.env.LOG_LEVEL ?? "info", + format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT, + transports, + exceptionHandlers: transports, + rejectionHandlers: transports, + defaultMeta: { + service: "cal-platform-api", + }, + }; +}; diff --git a/apps/api/v2/src/lib/passport/strategies/types.ts b/apps/api/v2/src/lib/passport/strategies/types.ts new file mode 100644 index 00000000000000..34bf58941d69c4 --- /dev/null +++ b/apps/api/v2/src/lib/passport/strategies/types.ts @@ -0,0 +1,11 @@ +import { UserWithProfile } from "@/modules/users/users.repository"; + +export class BaseStrategy { + success!: (user: unknown) => void; + error!: (error: Error) => void; +} + +export class NextAuthPassportStrategy { + success!: (user: UserWithProfile) => void; + error!: (error: Error) => void; +} diff --git a/apps/api/v2/src/lib/response/response.dto.ts b/apps/api/v2/src/lib/response/response.dto.ts new file mode 100644 index 00000000000000..e4ec569e6318f3 --- /dev/null +++ b/apps/api/v2/src/lib/response/response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class BaseApiResponseDto { + @ApiProperty({ example: "success" }) + @IsString() + @IsNotEmpty() + status: string; + + @ApiProperty({ + description: "The payload of the response, which can be any type of data.", + }) + data: T; + + constructor(status: string, data: T) { + this.status = status; + this.data = data; + } +} + +export class OAuthClientDto { + @ApiProperty({ example: "abc123" }) + @IsString() + clientId!: string; + + @ApiProperty({ example: "secretKey123" }) + @IsString() + clientSecret!: string; +} diff --git a/apps/api/v2/src/lib/roles/constants.ts b/apps/api/v2/src/lib/roles/constants.ts new file mode 100644 index 00000000000000..03a9db1840fea3 --- /dev/null +++ b/apps/api/v2/src/lib/roles/constants.ts @@ -0,0 +1,13 @@ +import { MembershipRole } from "@prisma/client"; + +export const SYSTEM_ADMIN_ROLE = "SYSADMIN"; +export const ORG_ROLES = [ + `ORG_${MembershipRole.OWNER}`, + `ORG_${MembershipRole.ADMIN}`, + `ORG_${MembershipRole.MEMBER}`, +] as const; +export const TEAM_ROLES = [ + `TEAM_${MembershipRole.OWNER}`, + `TEAM_${MembershipRole.ADMIN}`, + `TEAM_${MembershipRole.MEMBER}`, +] as const; diff --git a/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts b/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts new file mode 100644 index 00000000000000..fa06643b6bcf3f --- /dev/null +++ b/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts @@ -0,0 +1,12 @@ +export const defaultBookingResponses = { + name: "unknown", + email: "unknown", + guests: [], + rescheduleReason: "unknown", +}; + +export const defaultBookingMetadata = { videoCallUrl: "unknown" }; + +export const defaultSeatedBookingData = { responses: { name: "unknown", email: "unknown" } }; + +export const defaultSeatedBookingMetadata = {}; diff --git a/apps/api/v2/src/lib/safe-parse/safe-parse.ts b/apps/api/v2/src/lib/safe-parse/safe-parse.ts new file mode 100644 index 00000000000000..198cb22e087c2e --- /dev/null +++ b/apps/api/v2/src/lib/safe-parse/safe-parse.ts @@ -0,0 +1,23 @@ +import { Logger } from "@nestjs/common"; +import { ZodSchema } from "zod"; + +const logger = new Logger("safeParse"); + +export function safeParse(schema: ZodSchema, value: unknown, defaultValue: T): T { + const result = schema.safeParse(value); + if (result.success) { + return result.data; + } else { + const errorStack = new Error().stack; + + logger.error( + `Zod parsing failed.\n` + + `1. Schema: ${schema.description || "UnnamedSchema"}\n` + + `2. Input: ${JSON.stringify(value, null, 2)}\n` + + `3. Zod Error: ${result.error}\n` + + `4. Call Stack: ${errorStack}` + ); + + return defaultValue; + } +} diff --git a/apps/api/v2/src/lib/throttler-guard.ts b/apps/api/v2/src/lib/throttler-guard.ts new file mode 100644 index 00000000000000..2f773f0dce569f --- /dev/null +++ b/apps/api/v2/src/lib/throttler-guard.ts @@ -0,0 +1,233 @@ +import { getEnv } from "@/env"; +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; +import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { + ThrottlerGuard, + ThrottlerException, + ThrottlerRequest, + ThrottlerModuleOptions, + seconds, +} from "@nestjs/throttler"; +import { Request, Response } from "express"; +import { z } from "zod"; + +import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; + +const rateLimitSchema = z.object({ + name: z.string(), + limit: z.number(), + ttl: z.number(), + blockDuration: z.number(), +}); +type RateLimitType = z.infer; +const rateLimitsSchema = z.array(rateLimitSchema); + +const sixtySecondsMs = 60 * 1000; + +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + private logger = new Logger("CustomThrottlerGuard"); + + private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", sixtySecondsMs)); + + private defaultLimitApiKey = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_API_KEY", 120)); + private defaultLimitOAuthClient = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT", 500)); + private defaultLimitAccessToken = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN", 500)); + private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120)); + + private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", sixtySecondsMs)); + + constructor( + options: ThrottlerModuleOptions, + @Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService, + reflector: Reflector, + private readonly dbRead: PrismaReadService + ) { + super(options, storageService, reflector); + this.storageService = storageService; + } + + protected async handleRequest(requestProps: ThrottlerRequest): Promise { + const { context } = requestProps; + + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const tracker = await this.getTracker(request); + this.logger.verbose( + `Tracker "${tracker}" generated based on: Bearer token "${request.get( + "Authorization" + )}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"` + ); + + if (tracker.startsWith("api_key_")) { + return this.handleApiKeyRequest(tracker, response); + } else { + return this.handleNonApiKeyRequest(tracker, response); + } + } + + private async handleApiKeyRequest(tracker: string, response: Response): Promise { + const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker); + + let allLimitsBlocked = true; + for (const rateLimit of rateLimits) { + const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response); + if (!isBlocked) { + allLimitsBlocked = false; + } + } + + if (allLimitsBlocked) { + throw new ThrottlerException("Too many requests. Please try again later."); + } + + return true; + } + + private async handleNonApiKeyRequest(tracker: string, response: Response): Promise { + const rateLimit = this.getDefaultRateLimit(tracker); + this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key: + ${JSON.stringify(rateLimit, null, 2)} + `); + + const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response); + if (isBlocked) { + throw new ThrottlerException("Too many requests. Please try again later."); + } + + return true; + } + + private getDefaultRateLimit(tracker: string) { + return { + name: "default", + limit: this.getDefaultLimit(tracker), + ttl: this.getDefaultTtl(), + blockDuration: this.getDefaultBlockDuration(), + }; + } + + getDefaultLimit(tracker: string) { + if (tracker.startsWith("api_key_")) { + return this.defaultLimitApiKey; + } else if (tracker.startsWith("oauth_client_")) { + return this.defaultLimitOAuthClient; + } else if (tracker.startsWith("access_token_")) { + return this.defaultLimitAccessToken; + } else { + return this.defaultLimit; + } + } + + getDefaultTtl() { + return this.defaultTttl; + } + + getDefaultBlockDuration() { + return this.defaultBlockDuration; + } + + private async getRateLimitsForApiKeyTracker(tracker: string) { + const cacheKey = `rate_limit:${tracker}`; + + const cachedRateLimits = await this.storageService.redis.get(cacheKey); + if (cachedRateLimits) { + this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache: + ${cachedRateLimits} + `); + return rateLimitsSchema.parse(JSON.parse(cachedRateLimits)); + } + + const apiKey = tracker.replace("api_key_", ""); + let rateLimits: RateLimitType[]; + const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({ + where: { hashedKey: apiKey }, + select: { id: true }, + }); + + if (!apiKeyRecord) { + throw new UnauthorizedException("Invalid API Key"); + } + + rateLimits = await this.dbRead.prisma.rateLimit.findMany({ + where: { apiKeyId: apiKeyRecord.id }, + select: { name: true, limit: true, ttl: true, blockDuration: true }, + }); + + if (rateLimits) { + this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database: + ${JSON.stringify(rateLimits, null, 2)}`); + } + + if (!rateLimits || rateLimits.length === 0) { + rateLimits = [this.getDefaultRateLimit(tracker)]; + this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits: + ${JSON.stringify(rateLimits, null, 2)}`); + } + + await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600); + + return rateLimits; + } + + private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) { + const { name, limit, ttl, blockDuration } = rateLimit; + + const key = `${tracker}:${limit}:${ttl}`; + + const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment( + key, + ttl, + limit, + blockDuration, + name + ); + + const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1); + response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit); + response.setHeader( + `X-RateLimit-Remaining-${nameFirstUpper}`, + timeToBlockExpire ? 0 : Math.max(0, limit - totalHits) + ); + response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire); + + this.logger.verbose( + `Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}` + ); + this.logger.verbose( + `Tracker "${tracker}" rate limit "${name}" response headers: + X-RateLimit-Limit-${nameFirstUpper}: ${limit}, + X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)}, + X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}` + ); + + return { isBlocked }; + } + + protected async getTracker(request: Request): Promise { + const authorizationHeader = request.get("Authorization")?.replace("Bearer ", ""); + + if (authorizationHeader) { + const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_"); + return isApiKey(authorizationHeader, apiKeyPrefix) + ? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}` + : `access_token_${authorizationHeader}`; + } + + const oauthClientId = request.get(X_CAL_CLIENT_ID); + + if (oauthClientId) { + return `oauth_client_${oauthClientId}`; + } + + if (request.ip) { + return `ip_${request.ip}`; + } + + this.logger.verbose(`no tracker found: ${request.url}`); + return "unknown"; + } +} diff --git a/apps/api/v2/src/main.ts b/apps/api/v2/src/main.ts new file mode 100644 index 00000000000000..b16f360d92af0d --- /dev/null +++ b/apps/api/v2/src/main.ts @@ -0,0 +1,138 @@ +import type { AppConfig } from "@/config/type"; +import { getEnv } from "@/env"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { NestFactory } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; +import { + PathItemObject, + PathsObject, + OperationObject, + TagObject, +} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface"; +import "dotenv/config"; +import * as fs from "fs"; +import { Server } from "http"; +import { WinstonModule } from "nest-winston"; + +import { bootstrap } from "./app"; +import { AppModule } from "./app.module"; +import { loggerConfig } from "./lib/logger"; + +const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"]; + +const run = async () => { + const app = await NestFactory.create(AppModule, { + logger: WinstonModule.createLogger(loggerConfig()), + bodyParser: false, + }); + + const logger = new Logger("App"); + + try { + bootstrap(app); + const port = app.get(ConfigService).get("api.port", { infer: true }); + void generateSwagger(app); + await app.listen(port); + logger.log(`Application started on port: ${port}`); + } catch (error) { + console.error(error); + logger.error("Application crashed", { + error, + }); + } +}; + +function customTagSort(a: string, b: string): number { + const platformPrefix = "Platform"; + const orgsPrefix = "Orgs"; + + if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) { + return -1; + } + if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) { + return 1; + } + + if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) { + return -1; + } + if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) { + return 1; + } + + return a.localeCompare(b); +} + +function isOperationObject(obj: any): obj is OperationObject { + return obj && typeof obj === "object" && "tags" in obj; +} + +function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject { + const groupedPaths: { [key: string]: PathsObject } = {}; + + Object.keys(paths).forEach((pathKey) => { + const pathItem = paths[pathKey]; + + HttpMethods.forEach((method) => { + const operation = pathItem[method]; + + if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) { + const firstTag = operation.tags[0]; + + if (!groupedPaths[firstTag]) { + groupedPaths[firstTag] = {}; + } + + groupedPaths[firstTag][pathKey] = pathItem; + } + }); + }); + + const sortedTags = Object.keys(groupedPaths).sort(customTagSort); + const sortedPaths: PathsObject = {}; + + sortedTags.forEach((tag) => { + Object.assign(sortedPaths, groupedPaths[tag]); + }); + + return sortedPaths; +} + +async function generateSwagger(app: NestExpressApplication) { + const logger = new Logger("App"); + logger.log(`Generating Swagger documentation...\n`); + + const config = new DocumentBuilder().setTitle("Cal.com API v2").build(); + const document = SwaggerModule.createDocument(app, config); + document.paths = groupAndSortPathsByFirstTag(document.paths); + + const swaggerOutputFile = "./swagger/documentation.json"; + const docsOutputFile = "../../../docs/api-reference/v2/openapi.json"; + const stringifiedContents = JSON.stringify(document, null, 2); + + if (fs.existsSync(swaggerOutputFile)) { + fs.unlinkSync(swaggerOutputFile); + } + + fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" }); + + if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") { + fs.unlinkSync(docsOutputFile); + fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" }); + } + + if (!process.env.DOCS_URL) { + SwaggerModule.setup("docs", app, document, { + customCss: ".swagger-ui .topbar { display: none }", + }); + + logger.log(`Swagger documentation available in the "/docs" endpoint\n`); + } +} + +run().catch((error: Error) => { + console.error("Failed to start Cal Platform API", { error: error.stack }); + process.exit(1); +}); diff --git a/apps/api/v2/src/middleware/app.logger.middleware.ts b/apps/api/v2/src/middleware/app.logger.middleware.ts new file mode 100644 index 00000000000000..e69a0282f99d86 --- /dev/null +++ b/apps/api/v2/src/middleware/app.logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, NextFunction } from "express"; + +import { Response } from "@calcom/platform-types"; + +@Injectable() +export class AppLoggerMiddleware implements NestMiddleware { + private logger = new Logger("HTTP"); + + use(request: Request, response: Response, next: NextFunction): void { + const { ip, method, originalUrl } = request; + const userAgent = request.get("user-agent") || ""; + + response.on("close", () => { + const { statusCode } = response; + const contentLength = response.get("content-length"); + this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`); + }); + next(); + } +} diff --git a/apps/api/v2/src/middleware/app.redirects.middleware.ts b/apps/api/v2/src/middleware/app.redirects.middleware.ts new file mode 100644 index 00000000000000..a4b12cdc5204b2 --- /dev/null +++ b/apps/api/v2/src/middleware/app.redirects.middleware.ts @@ -0,0 +1,12 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import { Request, Response } from "express"; + +@Injectable() +export class RedirectsMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => void) { + if (process.env.DOCS_URL && (req.url.startsWith("/v2/docs") || req.url.startsWith("/docs"))) { + return res.redirect(process.env.DOCS_URL); + } + next(); + } +} diff --git a/apps/api/v2/src/middleware/app.rewrites.middleware.ts b/apps/api/v2/src/middleware/app.rewrites.middleware.ts new file mode 100644 index 00000000000000..a69505797f5c96 --- /dev/null +++ b/apps/api/v2/src/middleware/app.rewrites.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import { Request, Response } from "express"; + +@Injectable() +export class RewriterMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => void) { + if (req.url.startsWith("/api/v2")) { + req.url = req.url.replace("/api/v2", "/v2"); + } + if (req.url.startsWith("/v2/ee")) { + req.url = req.url.replace("/v2/ee", "/v2"); + } + if (req.url.includes("reccuring")) { + req.url = req.url.replace("reccuring", "recurring"); + } + next(); + } +} diff --git a/apps/api/v2/src/middleware/body/json.body.middleware.ts b/apps/api/v2/src/middleware/body/json.body.middleware.ts new file mode 100644 index 00000000000000..8f454090cf46c5 --- /dev/null +++ b/apps/api/v2/src/middleware/body/json.body.middleware.ts @@ -0,0 +1,10 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import * as bodyParser from "body-parser"; +import type { Request, Response } from "express"; + +@Injectable() +export class JsonBodyMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => any) { + bodyParser.json()(req, res, next); + } +} diff --git a/apps/api/v2/src/middleware/body/raw.body.middleware.ts b/apps/api/v2/src/middleware/body/raw.body.middleware.ts new file mode 100644 index 00000000000000..6cdae2f61766a8 --- /dev/null +++ b/apps/api/v2/src/middleware/body/raw.body.middleware.ts @@ -0,0 +1,10 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import * as bodyParser from "body-parser"; +import type { Request, Response } from "express"; + +@Injectable() +export class RawBodyMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => any) { + bodyParser.raw({ type: "*/*" })(req, res, next); + } +} diff --git a/apps/api/v2/src/middleware/request-ids/request-id.interceptor.ts b/apps/api/v2/src/middleware/request-ids/request-id.interceptor.ts new file mode 100644 index 00000000000000..d517afbfc00178 --- /dev/null +++ b/apps/api/v2/src/middleware/request-ids/request-id.interceptor.ts @@ -0,0 +1,36 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common"; +import { Request, Response } from "express"; +import { tap } from "rxjs/operators"; + +@Injectable() +export class ResponseInterceptor implements NestInterceptor { + private readonly logger = new Logger("ResponseInterceptor - NestInterceptor"); + + intercept(context: ExecutionContext, next: CallHandler) { + const ctx = context.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id"; + response.setHeader("X-Request-Id", requestId.toString()); + const { method, url } = request; + const startTime = Date.now(); + + return next.handle().pipe( + tap((data) => { + const { statusCode } = response; + const responseTime = Date.now() - startTime; + + this.logger.log("Outgoing Response", { + requestId, + method, + url, + statusCode, + responseTime, + responseBody: data, + timestamp: new Date().toISOString(), + }); + }) + ); + } +} diff --git a/apps/api/v2/src/middleware/request-ids/request-id.middleware.ts b/apps/api/v2/src/middleware/request-ids/request-id.middleware.ts new file mode 100644 index 00000000000000..08c6a691bae95d --- /dev/null +++ b/apps/api/v2/src/middleware/request-ids/request-id.middleware.ts @@ -0,0 +1,25 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; +import { v4 as uuid } from "uuid"; + +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + private readonly logger = new Logger("RequestIdMiddleware - NestMiddleware"); + + use(req: Request, res: Response, next: NextFunction) { + const requestId = uuid(); + req.headers["X-Request-Id"] = requestId; + const { method, headers, body: requestBody, baseUrl } = req; + + this.logger.log("Incoming Request", { + requestId, + method, + url: baseUrl, + headers, + requestBody, + timestamp: new Date().toISOString(), + }); + + next(); + } +} diff --git a/apps/api/v2/src/modules/api-key/api-key-repository.ts b/apps/api/v2/src/modules/api-key/api-key-repository.ts new file mode 100644 index 00000000000000..66af5f68c45905 --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key-repository.ts @@ -0,0 +1,16 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class ApiKeyRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getApiKeyFromHash(hashedKey: string) { + return this.dbRead.prisma.apiKey.findUnique({ + where: { + hashedKey, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/api-key/api-key.module.ts b/apps/api/v2/src/modules/api-key/api-key.module.ts new file mode 100644 index 00000000000000..993210e07af22d --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key.module.ts @@ -0,0 +1,10 @@ +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ApiKeyRepository], + exports: [ApiKeyRepository], +}) +export class ApiKeyModule {} diff --git a/apps/api/v2/src/modules/apps/apps.module.ts b/apps/api/v2/src/modules/apps/apps.module.ts new file mode 100644 index 00000000000000..587b18ce8d91cf --- /dev/null +++ b/apps/api/v2/src/modules/apps/apps.module.ts @@ -0,0 +1,14 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, TokensModule], + providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository], + exports: [], +}) +export class AppsModule {} diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts new file mode 100644 index 00000000000000..5495abb49f7537 --- /dev/null +++ b/apps/api/v2/src/modules/apps/apps.repository.ts @@ -0,0 +1,68 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { App, Prisma } from "@prisma/client"; + +@Injectable() +export class AppsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getAppBySlug(slug: string): Promise { + return await this.dbRead.prisma.app.findUnique({ where: { slug } }); + } + + async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) { + return this.dbWrite.prisma.credential.create({ + data: { + type: type, + key: key, + userId: userId, + appId: appId, + }, + }); + } + + async deleteAppCredentials(credentialIdsToDelete: number[], userId: number) { + return this.dbWrite.prisma.credential.deleteMany({ + where: { + id: { in: credentialIdsToDelete }, + userId, + }, + }); + } + + async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) { + return this.dbWrite.prisma.credential.create({ + data: { + type: type, + key: key, + teamId: teamId, + appId: appId, + }, + }); + } + + async findAppCredential({ + type, + appId, + userId, + teamId, + }: { + type: string; + appId: string; + userId?: number; + teamId?: number; + }) { + return this.dbWrite.prisma.credential.findMany({ + select: { + id: true, + }, + where: { + type, + userId, + teamId, + appId, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/apps/services/gcal.service.ts b/apps/api/v2/src/modules/apps/services/gcal.service.ts new file mode 100644 index 00000000000000..73a7657d3d0596 --- /dev/null +++ b/apps/api/v2/src/modules/apps/services/gcal.service.ts @@ -0,0 +1,27 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { OAuth2Client } from "googleapis-common"; +import { z } from "zod"; + +@Injectable() +export class GCalService { + private logger = new Logger("GcalService"); + + private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() }); + + constructor(private readonly appsRepository: AppsRepository) {} + + async getOAuthClient(redirectUri: string) { + this.logger.log("Getting Google Calendar OAuth Client"); + const app = await this.appsRepository.getAppBySlug("google-calendar"); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys); + + const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri); + return oAuth2Client; + } +} diff --git a/apps/api/v2/src/modules/atoms/atoms.module.ts b/apps/api/v2/src/modules/atoms/atoms.module.ts new file mode 100644 index 00000000000000..7b55b27910d3fe --- /dev/null +++ b/apps/api/v2/src/modules/atoms/atoms.module.ts @@ -0,0 +1,31 @@ +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository"; +import { AtomsRepository } from "@/modules/atoms/atoms.repository"; +import { AtomsController } from "@/modules/atoms/controllers/atoms.controller"; +import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service"; +import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule], + providers: [ + EventTypesAtomService, + ConferencingAtomsService, + MembershipsRepository, + CredentialsRepository, + UsersRepository, + AtomsRepository, + UsersService, + SchedulesRepository_2024_06_11, + ], + exports: [EventTypesAtomService], + controllers: [AtomsController], +}) +export class AtomsModule {} diff --git a/apps/api/v2/src/modules/atoms/atoms.repository.ts b/apps/api/v2/src/modules/atoms/atoms.repository.ts new file mode 100644 index 00000000000000..5a113d4e0f89cb --- /dev/null +++ b/apps/api/v2/src/modules/atoms/atoms.repository.ts @@ -0,0 +1,67 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries"; +import { paymentDataSelect } from "@calcom/platform-libraries"; + +@Injectable() +export class AtomsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getRawPayment(uid: string) { + return await this.dbRead.prisma.payment.findFirst({ + where: { uid }, + select: paymentDataSelect, + }); + } + + async getUserTeams(userId: number) { + const userTeams = await this.dbRead.prisma.team.findMany({ + where: { + members: { + some: { + userId, + accepted: true, + }, + }, + }, + select: { + id: true, + credentials: { + select: credentialForCalendarServiceSelect, + }, + name: true, + logoUrl: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + parent: { + select: { + id: true, + credentials: { + select: credentialForCalendarServiceSelect, + }, + name: true, + logoUrl: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }, + }, + }); + + return userTeams; + } +} diff --git a/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts b/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts new file mode 100644 index 00000000000000..5452415077114d --- /dev/null +++ b/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts @@ -0,0 +1,158 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { + BulkUpdateEventTypeToDefaultLocationDto, + EventTypesAppInput, +} from "@/modules/atoms/inputs/event-types-app.input"; +import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service"; +import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Get, + Param, + ParseIntPipe, + UseGuards, + Version, + VERSION_NEUTRAL, + Patch, + Body, + Query, +} from "@nestjs/common"; +import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { UpdateEventTypeReturn } from "@calcom/platform-libraries"; +import { ConnectedApps } from "@calcom/platform-libraries"; +import { ApiResponse } from "@calcom/platform-types"; + +/* + +Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms +these endpoints should not be recommended for use by third party and are excluded from docs + +*/ + +@Controller({ + path: "/v2/atoms", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Atoms - endpoints for atoms") +@DocsExcludeController(true) +export class AtomsController { + constructor( + private readonly eventTypesService: EventTypesAtomService, + private readonly conferencingService: ConferencingAtomsService + ) {} + + @Get("event-types/:eventTypeId") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async getAtomEventType( + @GetUser() user: UserWithProfile, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise> { + const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId); + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/event-types") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async getAtomEventTypes(@GetUser("id") userId: number): Promise> { + const eventType = await this.eventTypesService.getUserEventTypes(userId); + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("event-types-app/:appSlug") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async getAtomEventTypeApp( + @GetUser() user: UserWithProfile, + @Param("appSlug") appSlug: string, + @Query() queryParams: EventTypesAppInput + ): Promise> { + const { teamId } = queryParams; + + const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user.id, user.name, teamId); + + return { + status: SUCCESS_STATUS, + data: { + app, + }, + }; + } + + @Get("payment/:uid") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async getUserPaymentInfoById(@Param("uid") uid: string): Promise> { + const data = await this.eventTypesService.getUserPaymentInfo(uid); + + return { + status: SUCCESS_STATUS, + data, + }; + } + + @Patch("/event-types/bulk-update-to-default-location") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async bulkUpdateAtomEventTypes( + @GetUser() user: UserWithProfile, + @Body() body: BulkUpdateEventTypeToDefaultLocationDto + ): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> { + await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds); + return { + status: SUCCESS_STATUS, + }; + } + + @Patch("event-types/:eventTypeId") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async updateAtomEventType( + @GetUser() user: UserWithProfile, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @Body() body: UpdateEventTypeReturn + ): Promise> { + const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user); + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async updateAtomTeamEventType( + @GetUser() user: UserWithProfile, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: UpdateEventTypeReturn + ): Promise> { + const eventType = await this.eventTypesService.updateTeamEventType(eventTypeId, body, user, teamId); + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/conferencing") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise> { + const conferencingApps = await this.conferencingService.getConferencingApps(user); + + return { status: SUCCESS_STATUS, data: conferencingApps }; + } +} diff --git a/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts b/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts new file mode 100644 index 00000000000000..422e1c6aa7c81e --- /dev/null +++ b/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator"; + +export class EventTypesAppInput { + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + teamId?: number; +} + +export class BulkUpdateEventTypeToDefaultLocationDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @ApiProperty({ type: [Number] }) + eventTypeIds!: number[]; +} diff --git a/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts new file mode 100644 index 00000000000000..de591fcccb1efa --- /dev/null +++ b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts @@ -0,0 +1,25 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { getConnectedApps, ConnectedApps } from "@calcom/platform-libraries"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class ConferencingAtomsService { + private logger = new Logger("ConferencingAtomService"); + + constructor(private readonly dbWrite: PrismaWriteService) {} + + async getConferencingApps(user: UserWithProfile): Promise { + return getConnectedApps({ + user, + input: { + variant: "conferencing", + onlyInstalled: true, + }, + prisma: this.dbWrite.prisma as unknown as PrismaClient, + }); + } +} diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts new file mode 100644 index 00000000000000..6a22323c7ebbad --- /dev/null +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -0,0 +1,332 @@ +import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service"; +import { AtomsRepository } from "@/modules/atoms/atoms.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common"; + +import { + updateEventType, + TUpdateEventTypeInputSchema, + systemBeforeFieldEmail, + getEventTypeById, + getEnabledAppsFromCredentials, + getAppFromSlug, + MembershipRole, + EventTypeMetaDataSchema, + getClientSecretFromPayment, + getBulkEventTypes, + bulkUpdateEventsToDefaultLocation, +} from "@calcom/platform-libraries"; +import type { + App, + CredentialDataWithTeamName, + LocationOption, + TeamQuery, + CredentialOwner, + TDependencyData, + CredentialPayload, +} from "@calcom/platform-libraries"; +import { PrismaClient } from "@calcom/prisma"; + +type EnabledAppType = App & { + credential: CredentialDataWithTeamName; + credentials: CredentialDataWithTeamName[]; + locationOption: LocationOption | null; +}; + +@Injectable() +export class EventTypesAtomService { + constructor( + private readonly membershipsRepository: MembershipsRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly atomsRepository: AtomsRepository, + private readonly usersService: UsersService, + private readonly dbWrite: PrismaWriteService, + private readonly dbRead: PrismaReadService, + private readonly eventTypeService: EventTypesService_2024_06_14, + private readonly teamEventTypeService: TeamsEventTypesService + ) {} + + async getUserEventType(user: UserWithProfile, eventTypeId: number) { + const organizationId = this.usersService.getUserMainOrgId(user); + + const isUserOrganizationAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + + const eventType = await getEventTypeById({ + currentOrganizationId: this.usersService.getUserMainOrgId(user), + eventTypeId, + userId: user.id, + prisma: this.dbRead.prisma as unknown as PrismaClient, + isUserOrganizationAdmin, + isTrpcCall: true, + }); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + if (eventType?.team?.id) { + await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id); + } else { + this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType); + } + + return eventType; + } + + async getUserEventTypes(userId: number) { + return getBulkEventTypes(userId); + } + + async updateTeamEventType( + eventTypeId: number, + body: TUpdateEventTypeInputSchema, + user: UserWithProfile, + teamId: number + ) { + await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId); + const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user); + const bookingFields = [...(body.bookingFields || [])]; + + if ( + !bookingFields.find((field) => field.type === "email") && + !bookingFields.find((field) => field.type === "phone") + ) { + bookingFields.push(systemBeforeFieldEmail); + } + + const eventType = await updateEventType({ + input: { id: eventTypeId, ...body, bookingFields }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return eventType.eventType; + } + + async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) { + await this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId); + const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user); + const bookingFields = [...(body.bookingFields || [])]; + + if ( + !bookingFields.find((field) => field.type === "email") && + !bookingFields.find((field) => field.type === "phone") + ) { + bookingFields.push(systemBeforeFieldEmail); + } + + const eventType = await updateEventType({ + input: { id: eventTypeId, ...body, bookingFields }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return eventType.eventType; + } + + async checkCanUpdateTeamEventType(userId: number, eventTypeId: number, teamId: number, scheduleId: number) { + await this.checkTeamOwnsEventType(userId, eventTypeId, teamId); + await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId); + await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId); + } + + async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) { + const membership = await this.dbRead.prisma.membership.findFirst({ + where: { + userId, + teamId, + accepted: true, + OR: [{ role: "ADMIN" }, { role: "OWNER" }], + }, + select: { + team: { + select: { + eventTypes: true, + }, + }, + }, + }); + if (!membership?.team?.eventTypes?.some((item) => item.id === eventTypeId)) { + throw new ForbiddenException( + `Access denied. Either the team with ID=${teamId} does not own the event type with ID=${eventTypeId}, or your MEMBER role does not have permission to access this resource.` + ); + } + } + + async getEventTypesAppIntegration(slug: string, userId: number, userName: string | null, teamId?: number) { + let credentials = await this.credentialsRepository.getAllUserCredentialsById(userId); + let userTeams: TeamQuery[] = []; + if (teamId) { + const teamsQuery = await this.atomsRepository.getUserTeams(userId); + // If a team is a part of an org then include those apps + // Don't want to iterate over these parent teams + const filteredTeams: TeamQuery[] = []; + const parentTeams: TeamQuery[] = []; + // Only loop and grab parent teams if a teamId was given. If not then all teams will be queried + if (teamId) { + teamsQuery.forEach((team) => { + if (team?.parent) { + const { parent, ...filteredTeam } = team; + filteredTeams.push(filteredTeam); + // Only add parent team if it's not already in teamsQuery + if (!teamsQuery.some((t) => t.id === parent.id)) { + parentTeams.push(parent); + } + } + }); + } + userTeams = [...teamsQuery, ...parentTeams]; + const teamAppCredentials: CredentialPayload[] = userTeams.flatMap((teamApp) => { + return teamApp.credentials ? teamApp.credentials.flat() : []; + }); + if (teamId) { + credentials = teamAppCredentials; + } else { + credentials = credentials.concat(teamAppCredentials); + } + } + const enabledApps = await getEnabledAppsFromCredentials(credentials, { + where: { slug }, + }); + const apps = await Promise.all( + enabledApps + .filter(({ ...app }) => app.slug === slug) + .map( + async ({ + credentials: _, + credential, + key: _2 /* don't leak to frontend */, + ...app + }: EnabledAppType) => { + const userCredentialIds = credentials + .filter((c) => c.appId === app.slug && !c.teamId) + .map((c) => c.id); + const invalidCredentialIds = credentials + .filter((c) => c.appId === app.slug && c.invalid) + .map((c) => c.id); + const teams = await Promise.all( + credentials + .filter((c) => c.appId === app.slug && c.teamId) + .map(async (c) => { + const team = userTeams.find((team) => team.id === c.teamId); + if (!team) { + return null; + } + return { + teamId: team.id, + name: team.name, + logoUrl: team.logoUrl, + credentialId: c.id, + isAdmin: + team.members[0].role === MembershipRole.ADMIN || + team.members[0].role === MembershipRole.OWNER, + }; + }) + ); + const isSetupAlready = credential && app.categories.includes("payment") ? true : undefined; + let dependencyData: TDependencyData = []; + if (app.dependencies?.length) { + dependencyData = app.dependencies.map((dependency) => { + const dependencyInstalled = enabledApps.some( + (dbAppIterator: EnabledAppType) => + dbAppIterator.credentials.length && dbAppIterator.slug === dependency + ); + // If the app marked as dependency is simply deleted from the codebase, + // we can have the situation where App is marked installed in DB but we couldn't get the app. + const dependencyName = getAppFromSlug(dependency)?.name; + return { name: dependencyName, installed: dependencyInstalled }; + }); + } + const credentialOwner: CredentialOwner = { + name: userName, + teamId, + }; + return { + ...app, + ...(teams.length && { + credentialOwner, + }), + userCredentialIds, + invalidCredentialIds, + teams, + isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal, + isSetupAlready, + ...(app.dependencies && { dependencyData }), + }; + } + ) + ); + return apps[0]; + } + + async getUserPaymentInfo(uid: string) { + const rawPayment = await this.atomsRepository.getRawPayment(uid); + if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`); + const { data, booking: _booking, ...restPayment } = rawPayment; + const payment = { + ...restPayment, + data: data as Record, + }; + if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`); + const { startTime, endTime, eventType, ...restBooking } = _booking; + const booking = { + ...restBooking, + startTime: startTime.toString(), + endTime: endTime.toString(), + }; + if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`); + if (eventType.users.length === 0 && !!!eventType.team) + throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`); + const [user] = eventType?.users.length + ? eventType.users + : [{ name: null, theme: null, hideBranding: null, username: null }]; + const profile = { + name: eventType.team?.name || user?.name || null, + theme: (!eventType.team?.name && user?.theme) || null, + hideBranding: eventType.team?.hideBranding || user?.hideBranding || null, + }; + return { + user, + eventType: { + ...eventType, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata), + }, + booking, + payment, + clientSecret: getClientSecretFromPayment(payment), + profile, + }; + } + + async bulkUpdateEventTypesDefaultLocation(user: UserWithProfile, eventTypeIds: number[]) { + return bulkUpdateEventsToDefaultLocation({ + eventTypeIds, + user, + prisma: this.dbWrite.prisma as unknown as PrismaClient, + }); + } +} diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts new file mode 100644 index 00000000000000..8663d429b761f4 --- /dev/null +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -0,0 +1,30 @@ +import { ApiKeyModule } from "@/modules/api-key/api-key.module"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { DeploymentsModule } from "@/modules/deployments/deployments.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { ProfilesModule } from "@/modules/profiles/profiles.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; +import { PassportModule } from "@nestjs/passport"; + +@Module({ + imports: [ + PassportModule, + RedisModule, + ApiKeyModule, + UsersModule, + MembershipsModule, + TokensModule, + DeploymentsModule, + ProfilesModule, + ], + providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService], + exports: [NextAuthGuard, ApiAuthGuard], +}) +export class AuthModule {} diff --git a/apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts b/apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts new file mode 100644 index 00000000000000..e43da047812385 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts @@ -0,0 +1,4 @@ +import type { PlatformPlanType } from "@/modules/billing/types"; +import { Reflector } from "@nestjs/core"; + +export const PlatformPlan = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/decorators/get-membership/get-membership.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-membership/get-membership.decorator.ts new file mode 100644 index 00000000000000..6dc57ed261fa2a --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/get-membership/get-membership.decorator.ts @@ -0,0 +1,33 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; + +import { Membership } from "@calcom/prisma/client"; + +export type GetMembershipReturnType = Membership; + +export const GetMembership = createParamDecorator< + keyof GetMembershipReturnType | (keyof GetMembershipReturnType)[], + ExecutionContext +>((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const membership = request.membership as GetMembershipReturnType; + + if (!membership) { + throw new Error("GetMembership decorator : Membership not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: membership[curr], + }; + }, {}); + } + + if (data) { + return membership[data]; + } + + return membership; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/get-org/get-org.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-org/get-org.decorator.ts new file mode 100644 index 00000000000000..50d3bb543d4c31 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/get-org/get-org.decorator.ts @@ -0,0 +1,33 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; + +import { Team } from "@calcom/prisma/client"; + +export type GetOrgReturnType = Team; + +export const GetOrg = createParamDecorator< + keyof GetOrgReturnType | (keyof GetOrgReturnType)[], + ExecutionContext +>((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const organization = request.organization as GetOrgReturnType; + + if (!organization) { + throw new Error("GetOrg decorator : Org not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: organization[curr], + }; + }, {}); + } + + if (data) { + return organization[data]; + } + + return organization; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/get-team/get-team.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-team/get-team.decorator.ts new file mode 100644 index 00000000000000..80560f8aa7a95b --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/get-team/get-team.decorator.ts @@ -0,0 +1,33 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; + +import { Team } from "@calcom/prisma/client"; + +export type GetTeamReturnType = Team; + +export const GetTeam = createParamDecorator< + keyof GetTeamReturnType | (keyof GetTeamReturnType)[], + ExecutionContext +>((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const team = request.team as GetTeamReturnType; + + if (!team) { + throw new Error("GetTeam decorator : Team not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: team[curr], + }; + }, {}); + } + + if (data) { + return team[data]; + } + + return team; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts new file mode 100644 index 00000000000000..b8bc554dabdf25 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts @@ -0,0 +1,30 @@ +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; + +export const GetUser = createParamDecorator< + keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[], + ExecutionContext +>((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as ApiAuthGuardUser; + + if (!user) { + throw new Error("GetUser decorator : User not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: user[curr], + }; + }, {}); + } + + if (data) { + return user[data]; + } + + return user; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/methods/get-auth-methods.decorator.ts b/apps/api/v2/src/modules/auth/decorators/methods/get-auth-methods.decorator.ts new file mode 100644 index 00000000000000..0b12370f994e1c --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/methods/get-auth-methods.decorator.ts @@ -0,0 +1,13 @@ +import { AuthMethods } from "@/lib/enums/auth-methods"; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const GetAuthMethod = createParamDecorator((_data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const authMethod = request.authMethod as AuthMethods; + + if (!authMethod) { + throw new Error("GetAuthMethod decorator : auth method not set"); + } + + return authMethod; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts b/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts new file mode 100644 index 00000000000000..425a3006daa6ea --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts @@ -0,0 +1,5 @@ +import { Reflector } from "@nestjs/core"; + +import { PERMISSIONS } from "@calcom/platform-constants"; + +export const Permissions = Reflector.createDecorator<(typeof PERMISSIONS)[number][]>(); diff --git a/apps/api/v2/src/modules/auth/decorators/roles/membership-roles.decorator.ts b/apps/api/v2/src/modules/auth/decorators/roles/membership-roles.decorator.ts new file mode 100644 index 00000000000000..1165605374b347 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/roles/membership-roles.decorator.ts @@ -0,0 +1,4 @@ +import { Reflector } from "@nestjs/core"; +import { MembershipRole } from "@prisma/client"; + +export const MembershipRoles = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts new file mode 100644 index 00000000000000..1a0256b0803dce --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants"; +import { Reflector } from "@nestjs/core"; + +export const Roles = Reflector.createDecorator< + (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE +>(); diff --git a/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts b/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts new file mode 100644 index 00000000000000..bfc6e240df1e34 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts @@ -0,0 +1,7 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class ApiAuthGuard extends AuthGuard("api-auth") { + constructor() { + super(); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/api-auth/token-expired.exception.ts b/apps/api/v2/src/modules/auth/guards/api-auth/token-expired.exception.ts new file mode 100644 index 00000000000000..1e3f4eea837ec1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/api-auth/token-expired.exception.ts @@ -0,0 +1,9 @@ +import { HttpException } from "@nestjs/common"; + +import { ACCESS_TOKEN_EXPIRED, HTTP_CODE_TOKEN_EXPIRED } from "@calcom/platform-constants"; + +export class TokenExpiredException extends HttpException { + constructor() { + super(ACCESS_TOKEN_EXPIRED, HTTP_CODE_TOKEN_EXPIRED); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts new file mode 100644 index 00000000000000..e65dbb581552fe --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts @@ -0,0 +1,123 @@ +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +describe("PlatformPlanGuard", () => { + let guard: PlatformPlanGuard; + let reflector: Reflector; + let organizationsRepository: OrganizationsRepository; + let redisService: RedisService; + + const mockContext = createMockExecutionContext({ + params: { teamId: "1", orgId: "1" }, + user: { id: "1" }, + }); + + beforeEach(async () => { + reflector = new Reflector(); + organizationsRepository = createMock(); + redisService = createMock({ + redis: { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(null), + }, + }); + guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + }); + + it("should return true", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: { + subscriptionId: "sub_123", + plan: "SCALE", + }, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if the organization does not exist", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return true if the organization is not platform", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: false, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: undefined, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if the organization has no subscription", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: { + subscriptionId: null, + plan: "STARTER", + }, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false if the user has a lower plan than required", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: { + subscriptionId: "sub_123", + plan: "STARTER", + }, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return true if the result is cached in Redis", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true)); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if the result is cached in Redis", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false)); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + function createMockExecutionContext(context: Record): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + params: context.params, + user: context.user, + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts new file mode 100644 index 00000000000000..7974a65e5511d9 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts @@ -0,0 +1,79 @@ +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { PlatformPlanType } from "@/modules/billing/types"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; + +@Injectable() +export class PlatformPlanGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly organizationsRepository: OrganizationsRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const teamId = request.params.teamId as string; + const orgId = request.params.orgId as string; + const user = request.user as ApiAuthGuardUser; + const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType; + + const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${ + teamId ?? "none" + }:guard:platformbilling:${minimumPlan}`; + + const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false"); + + if (cachedAccess) { + return cachedAccess; + } + + let canAccess = false; + + if (user && orgId) { + const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId)); + const isPlatform = team?.isPlatform; + const hasSubscription = team?.platformBilling?.subscriptionId; + + if (!team) { + canAccess = false; + } else if (!isPlatform) { + canAccess = true; + } else if (!hasSubscription) { + canAccess = false; + } else { + canAccess = hasMinimumPlan({ + currentPlan: team.platformBilling?.plan as PlatformPlanType, + minimumPlan: minimumPlan, + plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"], + }); + } + } + + await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300); + return canAccess; + } +} + +type HasMinimumPlanProp = { + currentPlan: PlatformPlanType; + minimumPlan: PlatformPlanType; + plans: PlatformPlanType[]; +}; + +export function hasMinimumPlan(props: HasMinimumPlanProp): boolean { + const currentPlanIndex = props.plans.indexOf(props.currentPlan); + const minimumPlanIndex = props.plans.indexOf(props.minimumPlan); + + if (currentPlanIndex === -1 || minimumPlanIndex === -1) { + throw new Error( + `Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}` + ); + } + + return currentPlanIndex >= minimumPlanIndex; +} diff --git a/apps/api/v2/src/modules/auth/guards/memberships/is-membership-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/memberships/is-membership-in-org.guard.ts new file mode 100644 index 00000000000000..56a3f3c48cde46 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/memberships/is-membership-in-org.guard.ts @@ -0,0 +1,42 @@ +import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository"; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; +import { Request } from "express"; + +import { Membership } from "@calcom/prisma/client"; + +@Injectable() +export class IsMembershipInOrg implements CanActivate { + constructor(private organizationsMembershipRepository: OrganizationsMembershipRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const membershipId: string = request.params.membershipId; + const orgId: string = request.params.orgId; + + if (!orgId) { + throw new ForbiddenException("No org id found in request params."); + } + + if (!membershipId) { + throw new ForbiddenException("No membership id found in request params."); + } + + const membership = await this.organizationsMembershipRepository.findOrgMembership( + Number(orgId), + Number(membershipId) + ); + + if (!membership) { + throw new NotFoundException(`Membership (${membershipId}) not found.`); + } + + request.membership = membership; + return true; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts b/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts new file mode 100644 index 00000000000000..a2597709da7e8d --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts @@ -0,0 +1,7 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class NextAuthGuard extends AuthGuard("next-auth") { + constructor() { + super(); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts new file mode 100644 index 00000000000000..1105a9a3a1e91e --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts @@ -0,0 +1,63 @@ +import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { MembershipRole } from "@calcom/prisma/enums"; + +@Injectable() +export class OrganizationRolesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private organizationsService: OrganizationsService, + private membershipRepository: MembershipsRepository, + private usersService: UsersService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user: UserWithProfile = request.user; + const organizationId = user ? this.usersService.getUserMainOrgId(user) : null; + + if (!user || !organizationId) { + throw new ForbiddenException("No organization associated with the user."); + } + + await this.isPlatform(organizationId); + + const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id); + const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler()); + + this.isMembershipAccepted(membership.accepted); + this.isRoleAllowed(membership.role, allowedRoles); + + return true; + } + + async isPlatform(organizationId: number) { + const isPlatform = await this.organizationsService.isPlatform(organizationId); + if (!isPlatform) { + throw new ForbiddenException("Organization is not a platform (SHP)."); + } + } + + isMembershipAccepted(accepted: boolean) { + if (!accepted) { + throw new ForbiddenException(`User has not accepted membership in the organization.`); + } + } + + isRoleAllowed(membershipRole: MembershipRole, allowedRoles: MembershipRole[]) { + if (!allowedRoles?.length || !Object.keys(allowedRoles)?.length) { + return true; + } + + const hasRequiredRole = allowedRoles.includes(membershipRole); + if (!hasRequiredRole) { + throw new ForbiddenException(`User must have one of the roles: ${allowedRoles.join(", ")}.`); + } + } +} diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts new file mode 100644 index 00000000000000..a922160c6d41da --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts @@ -0,0 +1,62 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +type CachedData = { + org?: Team; + canAccess?: boolean; +}; + +@Injectable() +export class IsAdminAPIEnabledGuard implements CanActivate { + constructor( + private organizationsRepository: OrganizationsRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + let canAccess = false; + const request = context.switchToHttp().getRequest(); + const organizationId: string = request.params.orgId; + + if (!organizationId) { + throw new ForbiddenException("No organization id found in request params."); + } + + const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`; + const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY); + + if (cachedData) { + const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData; + if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) { + request.organization = cachedOrg; + return cachedCanAccess; + } + } + + const org = await this.organizationsRepository.findById(Number(organizationId)); + + if (org?.isOrganization && !org?.isPlatform) { + const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus( + Number(organizationId) + ); + if (!adminAPIAccessIsEnabledInOrg) { + throw new ForbiddenException( + `Organization does not have Admin API access, please contact https://cal.com/sales to upgrade` + ); + } + } + canAccess = true; + org && + (await this.redisService.redis.set( + REDIS_CACHE_KEY, + JSON.stringify({ org: org, canAccess } satisfies CachedData), + "EX", + 300 + )); + return canAccess; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts new file mode 100644 index 00000000000000..ce9143d36f1fa2 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts @@ -0,0 +1,58 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +type CachedData = { + org?: Team; + canAccess?: boolean; +}; + +@Injectable() +export class IsOrgGuard implements CanActivate { + constructor( + private organizationsRepository: OrganizationsRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + let canAccess = false; + const request = context.switchToHttp().getRequest(); + const organizationId: string = request.params.orgId; + + if (!organizationId) { + throw new ForbiddenException("No organization id found in request params."); + } + + const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`; + const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY); + + if (cachedData) { + const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData; + if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) { + request.organization = cachedOrg; + return cachedCanAccess; + } + } + + const org = await this.organizationsRepository.findById(Number(organizationId)); + + if (org?.isOrganization) { + request.organization = org; + canAccess = true; + } + + if (org) { + await this.redisService.redis.set( + REDIS_CACHE_KEY, + JSON.stringify({ org: org, canAccess } satisfies CachedData), + "EX", + 300 + ); + } + + return canAccess; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts new file mode 100644 index 00000000000000..0dbdc9fe651023 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts @@ -0,0 +1,67 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +type CachedData = { + org?: Team; + canAccess?: boolean; +}; + +@Injectable() +export class IsWebhookInOrg implements CanActivate { + constructor( + private organizationsRepository: OrganizationsRepository, + private organizationsWebhooksRepository: OrganizationsWebhooksRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + let canAccess = false; + const request = context.switchToHttp().getRequest(); + const webhookId: string = request.params.webhookId; + const organizationId: string = request.params.orgId; + + if (!organizationId) { + throw new ForbiddenException("No organization id found in request params."); + } + if (!webhookId) { + throw new ForbiddenException("No webhook id found in request params."); + } + + const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`; + const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY); + + if (cachedData) { + const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData; + if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) { + request.organization = cachedOrg; + return cachedCanAccess; + } + } + + const org = await this.organizationsRepository.findById(Number(organizationId)); + + if (org?.isOrganization) { + const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook( + Number(organizationId), + webhookId + ); + if (isWebhookInOrg) canAccess = true; + } + + if (org) { + await this.redisService.redis.set( + REDIS_CACHE_KEY, + JSON.stringify({ org: org, canAccess } satisfies CachedData), + "EX", + 300 + ); + } + + return canAccess; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts new file mode 100644 index 00000000000000..116721308fcf67 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts @@ -0,0 +1,171 @@ +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; + +import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants"; + +import { PermissionsGuard } from "./permissions.guard"; + +describe("PermissionsGuard", () => { + let guard: PermissionsGuard; + let reflector: Reflector; + + beforeEach(async () => { + reflector = new Reflector(); + guard = new PermissionsGuard( + reflector, + createMock(), + createMock({ + get: jest.fn().mockImplementation((key: string) => { + switch (key) { + case "api.apiKeyPrefix": + return "cal_"; + default: + return null; + } + }), + }), + createMock() + ); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + }); + + describe("when access token is missing", () => { + it("should return false", async () => { + const mockContext = createMockExecutionContext({}); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + describe("when access token is provided", () => { + it("should return true for valid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for multiple valid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + oAuthClientPermissions |= SCHEDULE_READ; + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for empty Permissions decorator", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false for invalid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= APPS_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false for a missing permission", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + describe("when OAuth id is provided", () => { + it("should return true for valid permissions", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for multiple valid permissions", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + oAuthClientPermissions |= SCHEDULE_READ; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for empty Permissions decorator", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false for invalid permissions", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= APPS_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false for a missing permission", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + function createMockExecutionContext(headers: Record): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + get: (headerName: string) => headers[headerName], + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts new file mode 100644 index 00000000000000..176bab52a8becc --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -0,0 +1,68 @@ +import { isApiKey } from "@/lib/api-key"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { getToken } from "next-auth/jwt"; + +import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +import { hasPermissions } from "@calcom/platform-utils"; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor( + private reflector: Reflector, + private tokensRepository: TokensRepository, + private readonly config: ConfigService, + private readonly oAuthClientRepository: OAuthClientRepository + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPermissions = this.reflector.get(Permissions, context.getHandler()); + + if (!requiredPermissions?.length || !Object.keys(requiredPermissions)?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const authString = request.get("Authorization")?.replace("Bearer ", ""); + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); + const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID); + + if (nextAuthToken) { + return true; + } + + if (!authString && !oAuthClientId) { + return false; + } + + // only check permissions for accessTokens attached to an oAuth Client + if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) { + return true; + } + + const oAuthClientPermissions = authString + ? await this.getOAuthClientPermissionsByAccessToken(authString) + : await this.getOAuthClientPermissionsById(oAuthClientId); + + if (!oAuthClientPermissions) { + return false; + } + + return hasPermissions(oAuthClientPermissions, [...requiredPermissions]); + } + + async getOAuthClientPermissionsByAccessToken(accessToken: string) { + const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken); + return oAuthClient?.permissions; + } + + async getOAuthClientPermissionsById(id: string) { + const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id); + return oAuthClient?.permissions; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts new file mode 100644 index 00000000000000..8756281ff9282d --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts @@ -0,0 +1,163 @@ +import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +@Injectable() +export class RolesGuard implements CanActivate { + private readonly logger = new Logger("RolesGuard Logger"); + constructor( + private reflector: Reflector, + private membershipRepository: MembershipsRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const teamId = request.params.teamId as string; + const orgId = request.params.orgId as string; + const user = request.user as ApiAuthGuardUser; + const allowedRole = this.reflector.get(Roles, context.getHandler()); + const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${ + teamId ?? "none" + }:guard:roles:${allowedRole}`; + const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false"); + + if (cachedAccess) { + return cachedAccess; + } + + let canAccess = false; + + // User is not authenticated + if (!user) { + this.logger.log("User is not authenticated, denying access."); + canAccess = false; + } + + // System admin can access everything + else if (user.isSystemAdmin) { + this.logger.log(`User (${user.id}) is system admin, allowing access.`); + canAccess = true; + } + + // if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false + else if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) { + this.logger.log(`User (${user.id}) is not system admin, denying access.`); + canAccess = false; + } + + // Checking the role of the user within the organization + else if (Boolean(orgId) && !Boolean(teamId)) { + const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id); + if (!membership) { + this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`); + throw new ForbiddenException(`User is not a member of the organization.`); + } + + if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) { + canAccess = hasMinimumRole({ + checkRole: `ORG_${membership.role}`, + minimumRole: allowedRole, + roles: ORG_ROLES, + }); + } + } + + // Checking the role of the user within the team + else if (Boolean(teamId) && !Boolean(orgId)) { + const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id); + if (!membership) { + this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`); + throw new ForbiddenException(`User is not a member of the team.`); + } + if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) { + canAccess = hasMinimumRole({ + checkRole: `TEAM_${membership.role}`, + minimumRole: allowedRole, + roles: TEAM_ROLES, + }); + } + } + + // Checking the role for team and org, org is above team in term of permissions + else if (Boolean(teamId) && Boolean(orgId)) { + const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id); + const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id); + + if (!orgMembership) { + this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`); + throw new ForbiddenException(`User is not part of the organization.`); + } + + // if the role checked is a TEAM role + if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) { + // if the user is admin or owner of org, allow request because org > team + if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") { + canAccess = true; + } else { + if (!teamMembership) { + this.logger.log( + `User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).` + ); + throw new ForbiddenException( + "User is not part of the team and/or, is not an admin nor an owner of the organization." + ); + } + + // if user is not admin nor an owner of org, and is part of the team, then check user team membership role + canAccess = hasMinimumRole({ + checkRole: `TEAM_${teamMembership.role}`, + minimumRole: allowedRole, + roles: TEAM_ROLES, + }); + } + } + + // if allowed role is a ORG ROLE, check org membersip role + else if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) { + canAccess = hasMinimumRole({ + checkRole: `ORG_${orgMembership.role}`, + minimumRole: allowedRole, + roles: ORG_ROLES, + }); + } + } + await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300); + return canAccess; + } +} + +type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number]; + +type HasMinimumTeamRoleProp = { + checkRole: (typeof TEAM_ROLES)[number]; + minimumRole: string; + roles: typeof TEAM_ROLES; +}; + +type HasMinimumOrgRoleProp = { + checkRole: (typeof ORG_ROLES)[number]; + minimumRole: string; + roles: typeof ORG_ROLES; +}; + +type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp; + +export function hasMinimumRole(props: HasMinimumRoleProp): boolean { + const checkedRoleIndex = props.roles.indexOf(props.checkRole as never); + const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never); + + // minimum role given does not exist + if (checkedRoleIndex === -1 || requiredRoleIndex === -1) { + throw new Error("Invalid role"); + } + + return checkedRoleIndex <= requiredRoleIndex; +} diff --git a/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts new file mode 100644 index 00000000000000..eb90203845450f --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts @@ -0,0 +1,43 @@ +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +@Injectable() +export class IsTeamInOrg implements CanActivate { + constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const teamId: string = request.params.teamId; + const orgId: string = request.params.orgId; + + if (!orgId) { + throw new ForbiddenException("No org id found in request params."); + } + + if (!teamId) { + throw new ForbiddenException("No team id found in request params."); + } + + const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId)); + + if (team && !team.isOrganization && team.parentId === Number(orgId)) { + request.team = team; + return true; + } + + if (!team) { + throw new NotFoundException(`Team (${teamId}) not found.`); + } + + return false; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts new file mode 100644 index 00000000000000..12bcaee5923057 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts @@ -0,0 +1,42 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +@Injectable() +export class IsUserInOrgTeam implements CanActivate { + constructor(private organizationsRepository: OrganizationsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const teamId: string = request.params.teamId; + const orgId: string = request.params.orgId; + const userId: string = request.params.userId; + + if (!userId) { + throw new ForbiddenException("No user id found in request params."); + } + + if (!orgId) { + throw new ForbiddenException("No org id found in request params."); + } + + if (!teamId) { + throw new ForbiddenException("No team id found in request params."); + } + + const user = await this.organizationsRepository.findOrgTeamUser( + Number(orgId), + Number(teamId), + Number(userId) + ); + + if (user) { + request.user = user; + return true; + } + + return false; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/users/is-user-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org.guard.ts new file mode 100644 index 00000000000000..e35bac45d5bb87 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org.guard.ts @@ -0,0 +1,33 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +@Injectable() +export class IsUserInOrg implements CanActivate { + constructor(private organizationsRepository: OrganizationsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const orgId: string = request.params.orgId; + const userId: string = request.params.userId; + + if (!userId) { + throw new ForbiddenException("No user id found in request params."); + } + + if (!orgId) { + throw new ForbiddenException("No org id found in request params."); + } + + const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId)); + + if (user) { + request.user = user; + return true; + } + + return false; + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts new file mode 100644 index 00000000000000..b4bdb132ab0dda --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts @@ -0,0 +1,242 @@ +import appConfig from "@/config/app"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { DeploymentsRepository } from "@/modules/deployments/deployments.repository"; +import { DeploymentsService } from "@/modules/deployments/deployments.service"; +import { JwtService } from "@/modules/jwt/jwt.service"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { ProfilesModule } from "@/modules/profiles/profiles.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { ExecutionContext, HttpException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ConfigModule } from "@nestjs/config"; +import { JwtService as NestJwtService } from "@nestjs/jwt"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { MockedRedisService } from "test/mocks/mock-redis-service"; +import { randomString } from "test/utils/randomString"; + +import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +import { ApiAuthStrategy } from "./api-auth.strategy"; + +describe("ApiAuthStrategy", () => { + let strategy: ApiAuthStrategy; + let userRepositoryFixture: UserRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + + const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`; + const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`; + const validOAuthEmail = `api-auth-oauth-user-${randomString()}@api.com`; + + let validApiKeyUser: User; + let validAccessTokenUser: User; + let validOAuthUser: User; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [appConfig], + }), + ProfilesModule, + ], + providers: [ + MockedRedisService, + ApiAuthStrategy, + ConfigService, + OAuthFlowService, + UsersRepository, + ApiKeyRepository, + DeploymentsService, + OAuthClientRepository, + PrismaReadService, + PrismaWriteService, + TokensRepository, + JwtService, + DeploymentsRepository, + NestJwtService, + ], + }).compile(); + + strategy = module.get(ApiAuthStrategy); + userRepositoryFixture = new UserRepositoryFixture(module); + tokensRepositoryFixture = new TokensRepositoryFixture(module); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module); + teamRepositoryFixture = new TeamRepositoryFixture(module); + oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); + profilesRepositoryFixture = new ProfileRepositoryFixture(module); + organization = await teamRepositoryFixture.create({ name: `api-auth-organization-${randomString()}` }); + validApiKeyUser = await userRepositoryFixture.create({ + email: validApiKeyEmail, + }); + validAccessTokenUser = await userRepositoryFixture.create({ + email: validAccessTokenEmail, + }); + + validOAuthUser = await userRepositoryFixture.create({ + email: validOAuthEmail, + }); + + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: validOAuthEmail, + user: { connect: { id: validOAuthUser.id } }, + organization: { connect: { id: organization.id } }, + }); + + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:3000"], + permissions: 32, + }; + oAuthClient = await oAuthClientRepositoryFixture.create(organization.id, data, "secret"); + }); + + describe("authenticate with strategy", () => { + it("should return user associated with valid access token", async () => { + const { accessToken } = await tokensRepositoryFixture.createTokens( + validAccessTokenUser.id, + oAuthClient.id + ); + + const user = await strategy.accessTokenStrategy(accessToken); + expect(user).toBeDefined(); + expect(user?.id).toEqual(validAccessTokenUser.id); + }); + + it("should return user associated with valid api key", async () => { + const now = new Date(); + now.setDate(now.getDate() + 1); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now); + + const user = await strategy.apiKeyStrategy(keyString); + expect(user).toBeDefined(); + expect(user?.id).toEqual(validApiKeyUser.id); + }); + + it("should return user associated with valid OAuth client", async () => { + const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret); + expect(user).toBeDefined(); + expect(user.id).toEqual(validOAuthUser.id); + }); + + it("should throw 401 if api key is invalid", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: `Bearer cal_test_}`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + + it("should throw 401 if OAuth ID is invalid", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + [X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`, + [X_CAL_SECRET_KEY]: `secret`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + + it("should throw 401 if OAuth secret is invalid", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + [X_CAL_CLIENT_ID]: `${oAuthClient.id}`, + [X_CAL_SECRET_KEY]: `gibberish`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + + it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + get: (key: string) => ({ Authorization: ``, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + }); + + afterAll(async () => { + await oAuthClientRepositoryFixture.delete(oAuthClient.id); + await userRepositoryFixture.delete(validApiKeyUser.id); + await userRepositoryFixture.delete(validAccessTokenUser.id); + await userRepositoryFixture.delete(validOAuthUser.id); + await teamRepositoryFixture.delete(organization.id); + module.close(); + }); +}); diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts new file mode 100644 index 00000000000000..326ba79e0ed8cd --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -0,0 +1,208 @@ +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { AuthMethods } from "@/lib/enums/auth-methods"; +import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { DeploymentsService } from "@/modules/deployments/deployments.service"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { ProfilesRepository } from "@/modules/profiles/profiles.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; +import { getToken } from "next-auth/jwt"; + +import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean }; + +@Injectable() +export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") { + constructor( + private readonly deploymentsService: DeploymentsService, + private readonly config: ConfigService, + private readonly oauthFlowService: OAuthFlowService, + private readonly tokensRepository: TokensRepository, + private readonly userRepository: UsersRepository, + private readonly apiKeyRepository: ApiKeyRepository, + private readonly oauthRepository: OAuthClientRepository, + private readonly profilesRepository: ProfilesRepository + ) { + super(); + } + + async authenticate(request: Request & { authMethod: AuthMethods }) { + try { + const { params } = request; + const oAuthClientSecret = request.get(X_CAL_SECRET_KEY); + const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID); + const bearerToken = request.get("Authorization")?.replace("Bearer ", ""); + + if (oAuthClientId && oAuthClientSecret) { + request.authMethod = AuthMethods["OAUTH_CLIENT"]; + return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret); + } + + if (bearerToken) { + const requestOrigin = request.get("Origin"); + request.authMethod = isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_") + ? AuthMethods["API_KEY"] + : AuthMethods["ACCESS_TOKEN"]; + return await this.authenticateBearerToken(bearerToken, requestOrigin); + } + + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); + + if (nextAuthToken) { + request.authMethod = AuthMethods["NEXT_AUTH"]; + return await this.authenticateNextAuth(nextAuthToken); + } + + throw new UnauthorizedException( + "No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers" + ); + } catch (err) { + if (err instanceof Error) { + return this.error(err); + } + return this.error( + new InternalServerErrorException("An error occurred while authenticating the request") + ); + } + } + + async authenticateNextAuth(token: { email?: string | null }) { + const user = await this.nextAuthStrategy(token); + return this.success(this.getSuccessUser(user)); + } + + getSuccessUser(user: UserWithProfile): ApiAuthGuardUser { + return { + ...user, + isSystemAdmin: user.role === "ADMIN", + }; + } + + async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) { + const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret); + return this.success(this.getSuccessUser(user)); + } + + async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) { + const client = await this.oauthRepository.getOAuthClient(oAuthClientId); + + if (!client) { + throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`); + } + + if (client.secret !== oAuthClientSecret) { + throw new UnauthorizedException("Invalid client secret"); + } + + const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId); + + if (!platformCreatorId) { + throw new UnauthorizedException("No owner ID found for this OAuth client"); + } + + const user = await this.userRepository.findByIdWithProfile(platformCreatorId); + + if (!user) { + throw new UnauthorizedException("No user associated with the provided OAuth client"); + } + + return user; + } + + async authenticateBearerToken(authString: string, requestOrigin: string | undefined) { + try { + const user = isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_") + ? await this.apiKeyStrategy(authString) + : await this.accessTokenStrategy(authString, requestOrigin); + + if (!user) { + return this.error(new UnauthorizedException("No user associated with the provided token")); + } + + return this.success(this.getSuccessUser(user)); + } catch (err) { + if (err instanceof Error) { + return this.error(err); + } + return this.error( + new InternalServerErrorException("An error occurred while authenticating the request") + ); + } + } + + async apiKeyStrategy(apiKey: string) { + const isLicenseValid = await this.deploymentsService.checkLicense(); + if (!isLicenseValid) { + throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable"); + } + const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix")); + const apiKeyHash = hashAPIKey(strippedApiKey); + const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); + if (!keyData) { + throw new UnauthorizedException("Your api key is not valid"); + } + + const isKeyExpired = + keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0); + if (isKeyExpired) { + throw new UnauthorizedException("Your api key is expired"); + } + + const apiKeyOwnerId = keyData.userId; + if (!apiKeyOwnerId) { + throw new UnauthorizedException("No user tied to this apiKey"); + } + + const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId); + return user; + } + + async accessTokenStrategy(accessToken: string, origin?: string) { + const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken); + if (!accessTokenValid) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + const client = await this.tokensRepository.getAccessTokenClient(accessToken); + if (!client) { + throw new UnauthorizedException("OAuth client not found given the access token"); + } + + if (origin && !isOriginAllowed(origin, client.redirectUris)) { + throw new UnauthorizedException( + `Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'` + ); + } + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); + return user; + } + + async nextAuthStrategy(token: { email?: string | null }) { + if (!token.email) { + throw new UnauthorizedException("Email not found in the authentication token."); + } + + const user = await this.userRepository.findByEmailWithProfile(token.email); + if (!user) { + throw new UnauthorizedException("User associated with the authentication token email not found."); + } + + return user; + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts new file mode 100644 index 00000000000000..a34b0608822af0 --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts @@ -0,0 +1,41 @@ +import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; +import { getToken } from "next-auth/jwt"; + +@Injectable() +export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy, "next-auth") { + constructor(private readonly userRepository: UsersRepository, private readonly config: ConfigService) { + super(); + } + + async authenticate(req: Request) { + try { + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const payload = await getToken({ req, secret: nextAuthSecret }); + + if (!payload) { + throw new UnauthorizedException("Authentication token is missing or invalid."); + } + + if (!payload.email) { + throw new UnauthorizedException("Email not found in the authentication token."); + } + + const user = await this.userRepository.findByEmailWithProfile(payload.email); + if (!user) { + throw new UnauthorizedException("User associated with the authentication token email not found."); + } + + return this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + return this.error( + new InternalServerErrorException("An error occurred while authenticating the request") + ); + } + } +} diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts new file mode 100644 index 00000000000000..b09f662fa00f1c --- /dev/null +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -0,0 +1,33 @@ +import { BillingProcessor } from "@/modules/billing/billing.processor"; +import { BillingRepository } from "@/modules/billing/billing.repository"; +import { BillingController } from "@/modules/billing/controllers/billing.controller"; +import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { BullModule } from "@nestjs/bull"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + PrismaModule, + StripeModule, + MembershipsModule, + OrganizationsModule, + BullModule.registerQueue({ + name: "billing", + limiter: { + max: 1, + duration: 1000, + }, + }), + UsersModule, + ], + providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor], + exports: [BillingService, BillingRepository], + controllers: [BillingController], +}) +export class BillingModule {} diff --git a/apps/api/v2/src/modules/billing/billing.processor.ts b/apps/api/v2/src/modules/billing/billing.processor.ts new file mode 100644 index 00000000000000..f432998051b769 --- /dev/null +++ b/apps/api/v2/src/modules/billing/billing.processor.ts @@ -0,0 +1,91 @@ +import { BillingRepository } from "@/modules/billing/billing.repository"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { Process, Processor } from "@nestjs/bull"; +import { Logger } from "@nestjs/common"; +import { Job } from "bull"; + +export const INCREMENT_JOB = "increment"; +export const BILLING_QUEUE = "billing"; +export type IncrementJobDataType = { + userId: number; +}; + +export type DecrementJobDataType = IncrementJobDataType; + +@Processor(BILLING_QUEUE) +export class BillingProcessor { + private readonly logger = new Logger(BillingProcessor.name); + + constructor( + public readonly stripeService: StripeService, + private readonly billingRepository: BillingRepository, + private readonly teamsRepository: OrganizationsRepository + ) {} + + @Process(INCREMENT_JOB) + async handleIncrement(job: Job) { + const { userId } = job.data; + try { + // get the platform organization of the managed user + const team = await this.teamsRepository.findPlatformOrgFromUserId(userId); + const teamId = team.id; + if (!team.id) { + this.logger.error(`User (${userId}) is not part of the platform organization (${teamId}) `, { + teamId, + userId, + }); + return; + } + + const billingSubscription = await this.billingRepository.getBillingForTeam(teamId); + if (!billingSubscription || !billingSubscription?.subscriptionId) { + this.logger.error(`Team ${teamId} did not have stripe subscription associated to it`, { + teamId, + }); + return; + } + + const stripeSubscription = await this.stripeService + .getStripe() + .subscriptions.retrieve(billingSubscription.subscriptionId); + if (!stripeSubscription?.id) { + this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, { + teamId, + subscriptionId: billingSubscription.subscriptionId, + }); + return; + } + + const meteredItem = stripeSubscription.items.data.find( + (item) => item.price?.recurring?.usage_type === "metered" + ); + // no metered item found to increase usage, return early + if (!meteredItem) { + this.logger.error(`Stripe subscription (${stripeSubscription.id} is not usage based`, { + teamId, + subscriptionId: stripeSubscription.id, + }); + return; + } + + await this.stripeService.getStripe().subscriptionItems.createUsageRecord(meteredItem.id, { + action: "increment", + quantity: 1, + timestamp: "now", + }); + this.logger.log("Increased organization usage for subscription", { + subscriptionId: billingSubscription.subscriptionId, + teamId, + userId, + itemId: meteredItem.id, + }); + } catch (err) { + this.logger.error("Failed to increase usage for Organization", { + userId, + err, + }); + } + return; + } +} diff --git a/apps/api/v2/src/modules/billing/billing.repository.ts b/apps/api/v2/src/modules/billing/billing.repository.ts new file mode 100644 index 00000000000000..87fc4eb63f1d11 --- /dev/null +++ b/apps/api/v2/src/modules/billing/billing.repository.ts @@ -0,0 +1,66 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable, Logger } from "@nestjs/common"; + +@Injectable() +export class BillingRepository { + private readonly logger = new Logger("BillingRepository"); + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + getBillingForTeam = (teamId: number) => + this.dbRead.prisma.platformBilling.findUnique({ + where: { + id: teamId, + }, + }); + + async updateTeamBilling( + teamId: number, + billingStart: number, + billingEnd: number, + plan: PlatformPlan, + subscriptionId?: string + ) { + return this.dbWrite.prisma.platformBilling.update({ + where: { + id: teamId, + }, + data: { + billingCycleStart: billingStart, + billingCycleEnd: billingEnd, + subscriptionId, + plan: plan.toString(), + overdue: false, + }, + }); + } + + async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) { + try { + return this.dbWrite.prisma.platformBilling.update({ + where: { + subscriptionId: subId, + customerId: cusId, + }, + data: { + overdue, + }, + }); + } catch (err) { + this.logger.error("Could not update billing overdue", { + subId, + cusId, + err, + }); + } + } + + async deleteBilling(id: number) { + return this.dbWrite.prisma.platformBilling.delete({ + where: { + id, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts new file mode 100644 index 00000000000000..5a9124c4de17c4 --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts @@ -0,0 +1,214 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import Stripe from "stripe"; +import * as request from "supertest"; +import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { Team, PlatformOAuthClient, PlatformBilling } from "@calcom/prisma/client"; + +describe("Platform Billing Controller (e2e)", () => { + let app: INestApplication; + const userEmail = `billing-user-${randomString()}@api.com`; + let user: UserWithProfile; + let billing: PlatformBilling; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture; + let organization: Team; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef); + organization = await organizationsRepositoryFixture.create({ + name: `billing-organization-${randomString()}`, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + await membershipsRepositoryFixture.create({ + role: "OWNER", + team: { connect: { id: organization.id } }, + user: { connect: { id: user.id } }, + accepted: true, + }); + + billing = await platformBillingRepositoryFixture.create(organization.id); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + + it("/billing/webhook (POST) should set billing free plan for org", () => { + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( + () => + ({ + webhooks: { + constructEventAsync: async () => { + return { + type: "checkout.session.completed", + data: { + object: { + metadata: { + teamId: organization.id, + plan: "FREE", + }, + mode: "subscription", + }, + }, + }; + }, + }, + } as unknown as Stripe) + ); + + return request(app.getHttpServer()) + .post("/v2/billing/webhook") + .expect(200) + .then(async (res) => { + const billing = await platformBillingRepositoryFixture.get(organization.id); + expect(billing?.plan).toEqual("FREE"); + }); + }); + it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => { + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( + () => + ({ + webhooks: { + constructEventAsync: async () => { + return { + type: "invoice.payment_failed", + data: { + object: { + customer: billing?.customerId, + subscription: billing?.subscriptionId, + }, + }, + }; + }, + }, + } as unknown as Stripe) + ); + + return request(app.getHttpServer()) + .post("/v2/billing/webhook") + .expect(200) + .then(async (res) => { + const billing = await platformBillingRepositoryFixture.get(organization.id); + expect(billing?.overdue).toEqual(true); + }); + }); + + it("/billing/webhook (POST) success payment should set billing free plan to not overdue", () => { + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( + () => + ({ + webhooks: { + constructEventAsync: async () => { + return { + type: "invoice.payment_succeeded", + data: { + object: { + customer: billing?.customerId, + subscription: billing?.subscriptionId, + }, + }, + }; + }, + }, + } as unknown as Stripe) + ); + + return request(app.getHttpServer()) + .post("/v2/billing/webhook") + .expect(200) + .then(async (res) => { + const billing = await platformBillingRepositoryFixture.get(organization.id); + expect(billing?.overdue).toEqual(false); + }); + }); + + it("/billing/webhook (POST) should delete subscription", () => { + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( + () => + ({ + webhooks: { + constructEventAsync: async () => { + return { + type: "customer.subscription.deleted", + data: { + object: { + metadata: { + teamId: organization.id, + plan: "FREE", + }, + id: billing?.subscriptionId, + }, + }, + }; + }, + }, + } as unknown as Stripe) + ); + + return request(app.getHttpServer()) + .post("/v2/billing/webhook") + .expect(200) + .then(async (res) => { + const billing = await platformBillingRepositoryFixture.get(organization.id); + expect(billing).toBeNull(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts new file mode 100644 index 00000000000000..f348c18ec5cb11 --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -0,0 +1,130 @@ +import { AppConfig } from "@/config/type"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-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 { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input"; +import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto"; +import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, + Headers, + HttpCode, + HttpStatus, + Logger, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { Request } from "express"; + +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/billing", + version: API_VERSIONS_VALUES, +}) +@ApiExcludeController(true) +export class BillingController { + private readonly stripeWhSecret: string; + private logger = new Logger("Billing Controller"); + + constructor( + private readonly billingService: BillingService, + public readonly stripeService: StripeService, + private readonly configService: ConfigService + ) { + this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? ""; + } + + @Get("/:teamId/check") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @MembershipRoles(["OWNER", "ADMIN", "MEMBER"]) + async checkTeamBilling( + @Param("teamId") teamId: number + ): Promise> { + const { status, plan } = await this.billingService.getBillingData(teamId); + + return { + status: "success", + data: { + valid: status === "valid", + plan, + }, + }; + } + + @Post("/:teamId/subscribe") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @MembershipRoles(["OWNER", "ADMIN"]) + async subscribeTeamToStripe( + @Param("teamId") teamId: number, + @Body() input: SubscribeToPlanInput + ): Promise> { + const customerId = await this.billingService.createTeamBilling(teamId); + const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId); + + return { + status: "success", + data: { + url, + }, + }; + } + + @Post("/:teamId/upgrade") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @MembershipRoles(["OWNER", "ADMIN"]) + async upgradeTeamBillingInStripe( + @Param("teamId") teamId: number, + @Body() input: SubscribeToPlanInput + ): Promise> { + const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan); + + return { + status: "success", + data: { + url, + }, + }; + } + + @Post("/webhook") + @HttpCode(HttpStatus.OK) + async stripeWebhook( + @Req() request: Request, + @Headers("stripe-signature") stripeSignature: string + ): Promise { + const event = await this.billingService.stripeService + .getStripe() + .webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret); + + switch (event.type) { + case "checkout.session.completed": + await this.billingService.handleStripeCheckoutEvents(event); + break; + case "customer.subscription.deleted": + await this.billingService.handleStripeSubscriptionDeleted(event); + break; + case "invoice.payment_failed": + await this.billingService.handleStripePaymentFailed(event); + break; + case "invoice.payment_succeeded": + await this.billingService.handleStripePaymentSuccess(event); + break; + default: + break; + } + + return { + status: "success", + }; + } +} diff --git a/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts b/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts new file mode 100644 index 00000000000000..0a4ee10ee2feef --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts @@ -0,0 +1,7 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { IsEnum } from "class-validator"; + +export class SubscribeToPlanInput { + @IsEnum(PlatformPlan) + plan!: PlatformPlan; +} diff --git a/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts b/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts new file mode 100644 index 00000000000000..c510fe8c6bee17 --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts @@ -0,0 +1,5 @@ +export class CheckPlatformBillingResponseDto { + valid!: boolean; + + plan?: string; +} diff --git a/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts b/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts new file mode 100644 index 00000000000000..9bae0150dc8f79 --- /dev/null +++ b/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts @@ -0,0 +1,4 @@ +export class SubscribeTeamToBillingResponseDto { + url?: string; + action?: "redirect"; +} diff --git a/apps/api/v2/src/modules/billing/services/billing.config.service.ts b/apps/api/v2/src/modules/billing/services/billing.config.service.ts new file mode 100644 index 00000000000000..e5b19a09c8da7e --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing.config.service.ts @@ -0,0 +1,40 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class BillingConfigService { + private readonly config: Map< + PlatformPlan, + { + base: string; + overage: string; + } + >; + + constructor() { + this.config = new Map< + PlatformPlan, + { + base: string; + overage: string; + } + >(); + + const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key))); + for (const key of planKeys) { + this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], { + base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "", + overage: process.env[`STRIPE_PRICE_ID_${key}_OVERAGE`] ?? "", + }); + } + } + + get(plan: PlatformPlan): + | { + base: string; + overage: string; + } + | undefined { + return this.config.get(plan); + } +} diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts new file mode 100644 index 00000000000000..95088ccad53387 --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -0,0 +1,323 @@ +import { AppConfig } from "@/config/type"; +import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor"; +import { BillingRepository } from "@/modules/billing/billing.repository"; +import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; +import { PlatformPlan } from "@/modules/billing/types"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { InjectQueue } from "@nestjs/bull"; +import { + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + OnModuleDestroy, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Queue } from "bull"; +import { DateTime } from "luxon"; +import Stripe from "stripe"; + +@Injectable() +export class BillingService implements OnModuleDestroy { + private logger = new Logger("BillingService"); + private readonly webAppUrl: string; + + constructor( + private readonly teamsRepository: OrganizationsRepository, + public readonly stripeService: StripeService, + private readonly billingRepository: BillingRepository, + private readonly configService: ConfigService, + private readonly billingConfigService: BillingConfigService, + @InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue + ) { + this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; + } + + async getBillingData(teamId: number) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + if (teamWithBilling?.platformBilling) { + if (!teamWithBilling?.platformBilling.subscriptionId) { + return { team: teamWithBilling, status: "no_subscription", plan: "none" }; + } + + return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan }; + } else { + return { team: teamWithBilling, status: "no_billing", plan: "none" }; + } + } + + async createTeamBilling(teamId: number) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + let customerId = teamWithBilling?.platformBilling?.customerId; + + if (!teamWithBilling?.platformBilling) { + customerId = await this.teamsRepository.createNewBillingRelation(teamId); + + this.logger.log("Team had no Stripe Customer ID, created one for them.", { + id: teamId, + stripeId: customerId, + }); + } + + return customerId; + } + + async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) { + const { url } = await this.stripeService.getStripe().checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: this.billingConfigService.get(plan)?.base, + quantity: 1, + }, + { + price: this.billingConfigService.get(plan)?.overage, + }, + ], + success_url: `${this.webAppUrl}/settings/platform/`, + cancel_url: `${this.webAppUrl}/settings/platform/`, + mode: "subscription", + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + currency: "usd", + subscription_data: { + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + }, + allow_promotion_codes: true, + }); + + if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); + + return url; + } + + async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + const customerId = teamWithBilling?.platformBilling?.customerId; + + const { url } = await this.stripeService.getStripe().checkout.sessions.create({ + customer: customerId, + success_url: `${this.webAppUrl}/settings/platform/`, + cancel_url: `${this.webAppUrl}/settings/platform/plans`, + mode: "setup", + metadata: { + teamId: teamId.toString(), + plan: plan.toString(), + }, + currency: "usd", + }); + + if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); + + return url; + } + + async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) { + const billingCycleStart = DateTime.now().get("day"); + const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day"); + + return this.billingRepository.updateTeamBilling( + teamId, + billingCycleStart, + billingCycleEnd, + plan, + subscriptionId + ); + } + + async handleStripeSubscriptionDeleted(event: Stripe.Event) { + const subscription = event.data.object as Stripe.Subscription; + const teamId = subscription?.metadata?.teamId; + const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan]; + if (teamId && plan) { + const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId)); + if (currentBilling?.subscriptionId === subscription.id) { + await this.billingRepository.deleteBilling(currentBilling.id); + this.logger.log(`Stripe Subscription deleted`, { + customerId: currentBilling.customerId, + subscriptionId: currentBilling.subscriptionId, + teamId, + }); + return; + } + this.logger.log("No platform billing found."); + return; + } + this.logger.log("Webhook received but not pertaining to Platform, discarding."); + return; + } + + getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null { + if (typeof invoice.subscription === "string") { + return invoice.subscription; + } else if (invoice.subscription && typeof invoice.subscription === "object") { + return invoice.subscription.id; + } else { + return null; + } + } + getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null { + if (typeof invoice.customer === "string") { + return invoice.customer; + } else if (invoice.customer && typeof invoice.customer === "object") { + return invoice.customer.id; + } else { + return null; + } + } + + async handleStripePaymentSuccess(event: Stripe.Event) { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = this.getSubscriptionIdFromInvoice(invoice); + const customerId = this.getCustomerIdFromInvoice(invoice); + if (subscriptionId && customerId) { + await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false); + } + } + + async handleStripePaymentFailed(event: Stripe.Event) { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = this.getSubscriptionIdFromInvoice(invoice); + const customerId = this.getCustomerIdFromInvoice(invoice); + if (subscriptionId && customerId) { + await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true); + } + } + + async handleStripeCheckoutEvents(event: Stripe.Event) { + const checkoutSession = event.data.object as Stripe.Checkout.Session; + + if (!checkoutSession.metadata?.teamId) { + return; + } + + const teamId = Number.parseInt(checkoutSession.metadata.teamId); + const plan = checkoutSession.metadata.plan; + if (!plan || !teamId) { + this.logger.log("Webhook received but not pertaining to Platform, discarding."); + return; + } + + if (checkoutSession.mode === "subscription") { + await this.setSubscriptionForTeam( + teamId, + checkoutSession.subscription as string, + PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] + ); + } + + if (checkoutSession.mode === "setup") { + await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan); + } + + return; + } + + async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + + if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) { + throw new NotFoundException("Team plan not found"); + } + + const existingUserSubscription = await this.stripeService + .getStripe() + .subscriptions.retrieve(teamWithBilling?.platformBilling?.subscriptionId); + const currentLicensedItem = existingUserSubscription.items.data.find( + (item) => item.price?.recurring?.usage_type === "licensed" + ); + const currentOverageItem = existingUserSubscription.items.data.find( + (item) => item.price?.recurring?.usage_type === "metered" + ); + + if (!currentLicensedItem) { + throw new NotFoundException("There is no licensed item present in the subscription"); + } + + if (!currentOverageItem) { + throw new NotFoundException("There is no overage item present in the subscription"); + } + + await this.stripeService + .getStripe() + .subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, { + items: [ + { + id: currentLicensedItem.id, + price: this.billingConfigService.get(plan)?.base, + }, + { + id: currentOverageItem.id, + price: this.billingConfigService.get(plan)?.overage, + clear_usage: false, + }, + ], + billing_cycle_anchor: "now", + proration_behavior: "create_prorations", + }); + + await this.setSubscriptionForTeam( + teamId, + teamWithBilling?.platformBilling?.subscriptionId, + PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] + ); + } + /** + * + * Adds a job to the queue to increment usage of a stripe subscription. + * we delay the job until the booking starts. + * the delay ensure we can adapt to cancel / reschedule. + */ + async increaseUsageByUserId( + userId: number, + booking: { + uid: string; + startTime: Date; + fromReschedule?: string | null; + } + ) { + const { uid, startTime, fromReschedule } = booking; + + const delay = startTime.getTime() - Date.now(); + if (fromReschedule) { + // cancel the usage increment job for the booking that is being rescheduled + await this.cancelUsageByBookingUid(fromReschedule); + this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`); + } + await this.billingQueue.add( + INCREMENT_JOB, + { + userId, + } satisfies IncrementJobDataType, + { delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true } + ); + this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`); + } + + /** + * + * Cancels the usage increment job for a booking when it is cancelled. + * Removing an attendee from a booking does not cancel the usage increment job. + */ + async cancelUsageByBookingUid(bookingUid: string) { + const job = await this.billingQueue.getJob(`increment-${bookingUid}`); + if (job) { + await job.remove(); + this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`); + } + } + + async onModuleDestroy() { + try { + await this.billingQueue.close(); + } catch (err) { + this.logger.error(err); + } + } +} diff --git a/apps/api/v2/src/modules/billing/types.ts b/apps/api/v2/src/modules/billing/types.ts new file mode 100644 index 00000000000000..d46928613be33f --- /dev/null +++ b/apps/api/v2/src/modules/billing/types.ts @@ -0,0 +1,9 @@ +export enum PlatformPlan { + FREE = "FREE", + STARTER = "STARTER", + ESSENTIALS = "ESSENTIALS", + SCALE = "SCALE", + ENTERPRISE = "ENTERPRISE", +} + +export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE"; diff --git a/apps/api/v2/src/modules/booking-seat/booking-seat.module.ts b/apps/api/v2/src/modules/booking-seat/booking-seat.module.ts new file mode 100644 index 00000000000000..15c4c6f0b226ee --- /dev/null +++ b/apps/api/v2/src/modules/booking-seat/booking-seat.module.ts @@ -0,0 +1,10 @@ +import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [BookingSeatRepository], + exports: [BookingSeatRepository], +}) +export class BookingSeatModule {} diff --git a/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts b/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts new file mode 100644 index 00000000000000..5064609de463f6 --- /dev/null +++ b/apps/api/v2/src/modules/booking-seat/booking-seat.repository.ts @@ -0,0 +1,20 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class BookingSeatRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getByReferenceUid(referenceUid: string) { + return this.dbRead.prisma.bookingSeat.findUnique({ + where: { + referenceUid, + }, + include: { + booking: { select: { uid: true } }, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/conferencing/conferencing.module.ts b/apps/api/v2/src/modules/conferencing/conferencing.module.ts new file mode 100644 index 00000000000000..10707279117151 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/conferencing.module.ts @@ -0,0 +1,31 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller"; +import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository"; +import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; +import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; +import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, ConfigModule], + providers: [ + ConferencingService, + ConferencingRepository, + GoogleMeetService, + CredentialsRepository, + UsersRepository, + TokensRepository, + ZoomVideoService, + Office365VideoService, + AppsRepository, + ], + exports: [], + controllers: [ConferencingController], +}) +export class ConferencingModule {} diff --git a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.e2e-spec.ts b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.e2e-spec.ts new file mode 100644 index 00000000000000..7ab73653650f45 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.e2e-spec.ts @@ -0,0 +1,138 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { ConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-conferencing-apps.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { + ERROR_STATUS, + GOOGLE_CALENDAR_ID, + GOOGLE_CALENDAR_TYPE, + GOOGLE_MEET, + GOOGLE_MEET_TYPE, + SUCCESS_STATUS, +} from "@calcom/platform-constants"; +import { ApiErrorResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Conferencing Endpoints", () => { + describe("conferencing controller e2e tests", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + + const userEmail = `conferencing-user-${randomString()}@api.com`; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should get all the conferencing apps of the auth user", async () => { + return request(app.getHttpServer()) + .get(`/v2/conferencing`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toEqual([]); + }); + }); + + it("should fail to connect google meet if google calendar is not connected ", async () => { + return request(app.getHttpServer()) + .post(`/v2/conferencing/google-meet/connect`) + .expect(400) + .then(async () => { + await credentialsRepositoryFixture.create(GOOGLE_CALENDAR_TYPE, {}, user.id, GOOGLE_CALENDAR_ID); + }); + }); + + it("should connect google meet if google calendar is connected ", async () => { + return request(app.getHttpServer()) + .post(`/v2/conferencing/google-meet/connect`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + }); + }); + + it("should set google meet as default conferencing app", async () => { + return request(app.getHttpServer()) + .post(`/v2/conferencing/google-meet/default`) + .expect(200) + .then(async () => { + const updatedUser = await userRepositoryFixture.get(user.id); + + expect(updatedUser).toBeDefined(); + + if (updatedUser) { + const metadata = updatedUser.metadata as { defaultConferencingApp?: { appSlug?: string } }; + expect(metadata?.defaultConferencingApp?.appSlug).toEqual(GOOGLE_MEET); + } + }); + }); + + it("should get all the conferencing apps of the auth user, and contain google meet", async () => { + return request(app.getHttpServer()) + .get(`/v2/conferencing`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const googleMeet = responseBody.data.find((app) => app.type === GOOGLE_MEET_TYPE); + expect(googleMeet?.userId).toEqual(user.id); + }); + }); + + it("should disconnect google meet", async () => { + return request(app.getHttpServer()).delete(`/v2/conferencing/google-meet/disconnect`).expect(200); + }); + + it("should get all the conferencing apps of the auth user, and not contain google meet", async () => { + return request(app.getHttpServer()) + .get(`/v2/conferencing`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const googleMeet = responseBody.data.find((app) => app.type === GOOGLE_MEET_TYPE); + expect(googleMeet).toBeUndefined(); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts new file mode 100644 index 00000000000000..4ad3517bab17dc --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts @@ -0,0 +1,226 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { + ConferencingAppsOauthUrlOutputDto, + GetConferencingAppsOauthUrlResponseDto, +} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url"; +import { + ConferencingAppsOutputResponseDto, + ConferencingAppOutputResponseDto, + ConferencingAppsOutputDto, + DisconnectConferencingAppOutputResponseDto, +} from "@/modules/conferencing/outputs/get-conferencing-apps.output"; +import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output"; +import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output"; +import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; +import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; +import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Get, + Query, + HttpCode, + HttpStatus, + Logger, + UseGuards, + Post, + Param, + BadRequestException, + Delete, + Headers, + Redirect, + UnauthorizedException, + Req, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; +import { Request } from "express"; + +import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO } from "@calcom/platform-constants"; + +export type OAuthCallbackState = { + accessToken: string; + teamId?: number; + fromApp?: boolean; + returnTo?: string; + onErrorReturnTo?: string; +}; + +@Controller({ + path: "/v2/conferencing", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Conferencing") +export class ConferencingController { + private readonly logger = new Logger("Platform Gcal Provider"); + + constructor( + private readonly tokensRepository: TokensRepository, + private readonly conferencingService: ConferencingService, + private readonly googleMeetService: GoogleMeetService, + private readonly zoomVideoService: ZoomVideoService, + private readonly office365VideoService: Office365VideoService + ) {} + + @Post("/:app/connect") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Connect your conferencing application" }) + async connect( + @GetUser("id") userId: number, + @Param("app") app: string + ): Promise { + switch (app) { + case GOOGLE_MEET: + const credential = await this.googleMeetService.connectGoogleMeetApp(userId); + + return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) }; + + default: + throw new BadRequestException( + "Invalid conferencing app, available apps are: ", + [GOOGLE_MEET].join(", ") + ); + } + } + + @Get("/:app/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Get OAuth conferencing app auth url" }) + async redirect( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @Param("app") app: string, + @Query("returnTo") returnTo?: string, + @Query("onErrorReturnTo") onErrorReturnTo?: string + ): Promise { + let credential; + const origin = req.headers.origin; + const accessToken = authorization.replace("Bearer ", ""); + + const state: OAuthCallbackState = { + returnTo: returnTo ?? origin, + onErrorReturnTo: onErrorReturnTo ?? origin, + fromApp: false, + accessToken, + }; + + switch (app) { + case ZOOM: + credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state)); + return { + status: SUCCESS_STATUS, + data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential), + }; + + case OFFICE_365_VIDEO: + credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state)); + return { + status: SUCCESS_STATUS, + data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential), + }; + + default: + throw new BadRequestException( + "Invalid conferencing app, available apps are: ", + [ZOOM, OFFICE_365_VIDEO].join(", ") + ); + } + } + + @Get("/:app/oauth/callback") + @UseGuards() + @Redirect(undefined, 301) + @ApiOperation({ summary: "conferencing apps oauths callback" }) + async save( + @Query("state") state: string, + @Param("app") app: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined + ): Promise<{ url: string }> { + const decodedCallbackState: OAuthCallbackState = JSON.parse(state); + try { + const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken); + if (error) { + throw new BadRequestException(error_description); + } + + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } + + switch (app) { + case ZOOM: + return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId); + + case OFFICE_365_VIDEO: + return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId); + + default: + throw new BadRequestException( + "Invalid conferencing app, available apps are: ", + [ZOOM, OFFICE_365_VIDEO].join(", ") + ); + } + } catch (error) { + return { + url: decodedCallbackState.onErrorReturnTo ?? "", + }; + } + } + + @Get("/") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "List your conferencing applications" }) + async listInstalledConferencingApps( + @GetUser("id") userId: number + ): Promise { + const conferencingApps = await this.conferencingService.getConferencingApps(userId); + + const data = conferencingApps.map((conferencingApps) => + plainToInstance(ConferencingAppsOutputDto, conferencingApps) + ); + + return { status: SUCCESS_STATUS, data }; + } + + @Post("/:app/default") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Set your default conferencing application" }) + async default( + @GetUser("id") userId: number, + @Param("app") app: string + ): Promise { + await this.conferencingService.setDefaultConferencingApp(userId, app); + return { status: SUCCESS_STATUS }; + } + + @Get("/default") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Get your default conferencing application" }) + async getDefault(@GetUser("id") userId: number): Promise { + const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId); + return { status: SUCCESS_STATUS, data: defaultconferencingApp }; + } + + @Delete("/:app/disconnect") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Disconnect your conferencing application" }) + async disconnect( + @GetUser() user: UserWithProfile, + @Param("app") app: string + ): Promise { + await this.conferencingService.disconnectConferencingApp(user, app); + return { status: SUCCESS_STATUS }; + } +} diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts new file mode 100644 index 00000000000000..cf9827b7e47f93 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class ConferencingAppsOauthUrlOutputDto { + @IsString() + @Expose() + readonly url!: string; +} + +export class GetConferencingAppsOauthUrlResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => ConferencingAppsOauthUrlOutputDto) + data!: ConferencingAppsOauthUrlOutputDto; +} diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts new file mode 100644 index 00000000000000..9fa38161ab07f4 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum, IsNumber, IsOptional, IsBoolean } from "class-validator"; + +import { ERROR_STATUS, GOOGLE_MEET_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class ConferencingAppsOutputDto { + @Expose() + @IsNumber() + @ApiProperty({ description: "Id of the conferencing app credentials" }) + id!: number; + + @ApiProperty({ example: GOOGLE_MEET_TYPE, description: "Type of conferencing app" }) + @Expose() + @IsString() + type!: string; + + @ApiProperty({ description: "Id of the user associated to the conferencing app" }) + @Expose() + @IsNumber() + userId!: number; + + @ApiPropertyOptional({ + example: true, + description: "Whether if the connection is working or not.", + nullable: true, + }) + @Expose() + @IsBoolean() + @IsOptional() + invalid?: boolean | null; +} + +export class ConferencingAppsOutputResponseDto { + @ApiProperty({ type: String, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested({ each: true }) + @Type(() => ConferencingAppsOutputDto) + @ApiProperty({ type: [ConferencingAppsOutputDto] }) + data!: ConferencingAppsOutputDto[]; +} + +export class ConferencingAppOutputResponseDto { + @ApiProperty({ type: String, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => ConferencingAppsOutputDto) + @ApiProperty({ type: ConferencingAppsOutputDto }) + data!: ConferencingAppsOutputDto; +} + +export class DisconnectConferencingAppOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts b/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts new file mode 100644 index 00000000000000..4ed25b2099610f --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class DefaultConferencingAppsOutputDto { + @IsString() + @IsOptional() + @Expose() + readonly appSlug?: string; + + @IsString() + @IsOptional() + @Expose() + readonly appLink?: string; +} + +export class GetDefaultConferencingAppOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @IsOptional() + @Type(() => DefaultConferencingAppsOutputDto) + data?: DefaultConferencingAppsOutputDto; +} diff --git a/apps/api/v2/src/modules/conferencing/outputs/set-default-conferencing-app.output.ts b/apps/api/v2/src/modules/conferencing/outputs/set-default-conferencing-app.output.ts new file mode 100644 index 00000000000000..8e1660fedb412c --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/outputs/set-default-conferencing-app.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class SetDefaultConferencingAppOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts b/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts new file mode 100644 index 00000000000000..546a5df73e2aec --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts @@ -0,0 +1,31 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants"; + +@Injectable() +export class ConferencingRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findConferencingApps(userId: number) { + return this.dbRead.prisma.credential.findMany({ + where: { + userId, + type: { endsWith: "_video" }, + }, + }); + } + + async findGoogleMeet(userId: number) { + return this.dbRead.prisma.credential.findFirst({ + where: { userId, type: GOOGLE_MEET_TYPE }, + }); + } + + async findConferencingApp(userId: number, app: string) { + return this.dbRead.prisma.credential.findFirst({ + where: { userId, appId: app }, + }); + } +} diff --git a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts new file mode 100644 index 00000000000000..c80b30f87667cc --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts @@ -0,0 +1,61 @@ +import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants"; +import { userMetadata, handleDeleteCredential } from "@calcom/platform-libraries"; + +@Injectable() +export class ConferencingService { + private logger = new Logger("ConferencingService"); + + constructor( + private readonly conferencingRepository: ConferencingRepository, + private readonly usersRepository: UsersRepository + ) {} + + async getConferencingApps(userId: number) { + return this.conferencingRepository.findConferencingApps(userId); + } + + async getUserDefaultConferencingApp(userId: number) { + const user = await this.usersRepository.findById(userId); + return userMetadata.parse(user?.metadata)?.defaultConferencingApp; + } + + async checkAppIsValidAndConnected(userId: number, app: string) { + if (!CONFERENCING_APPS.includes(app)) { + throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", ")); + } + const credential = await this.conferencingRepository.findConferencingApp(userId, app); + + if (!credential) { + throw new BadRequestException(`${app} not connected.`); + } + return credential; + } + + async disconnectConferencingApp(user: UserWithProfile, app: string) { + const credential = await this.checkAppIsValidAndConnected(user.id, app); + return handleDeleteCredential({ + userId: user.id, + userMetadata: user?.metadata, + credentialId: credential.id, + }); + } + + async setDefaultConferencingApp(userId: number, app: string) { + // cal-video is global, so we can skip this check + if (app !== CAL_VIDEO) { + await this.checkAppIsValidAndConnected(userId, app); + } + const user = await this.usersRepository.setDefaultConferencingApp(userId, app); + const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } }; + if (metadata?.defaultConferencingApp?.appSlug !== app) { + throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`); + } + return true; + } +} diff --git a/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts b/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts new file mode 100644 index 00000000000000..85272c360e6200 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts @@ -0,0 +1,46 @@ +import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants"; + +@Injectable() +export class GoogleMeetService { + private logger = new Logger("GoogleMeetService"); + + constructor( + private readonly conferencingRepository: ConferencingRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly usersRepository: UsersRepository + ) {} + + async connectGoogleMeetApp(userId: number) { + const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId); + + if (!googleCalendar) { + throw new BadRequestException("Google Meet app requires a Google Calendar connection"); + } + + if (googleCalendar.invalid) { + throw new BadRequestException( + "Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar." + ); + } + + const googleMeet = await this.conferencingRepository.findGoogleMeet(userId); + + if (googleMeet) { + throw new BadRequestException("Google Meet is already connected."); + } + + const googleMeetCredential = await this.credentialsRepository.upsertAppCredential( + GOOGLE_MEET_TYPE, + {}, + userId + ); + + return googleMeetCredential; + } +} diff --git a/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts b/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts new file mode 100644 index 00000000000000..a6edd50d28d90b --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts @@ -0,0 +1,121 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller"; +import { BadRequestException, Logger, NotFoundException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import { OFFICE_365_VIDEO, OFFICE_365_VIDEO_TYPE } from "@calcom/platform-constants"; + +import stringify = require("qs-stringify"); + +const zoomAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +@Injectable() +export class Office365VideoService { + private logger = new Logger("Office365VideoService"); + private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`; + private scopes = ["OnlineMeetings.ReadWrite", "offline_access"]; + + constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {} + + async getOffice365AppKeys() { + const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO); + + const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys); + + if (!client_id) { + throw new NotFoundException("Office365 app not found"); + } + + if (!client_secret) { + throw new NotFoundException("Office365 app not found"); + } + + return { client_id, client_secret }; + } + + async generateOffice365AuthUrl(state: string) { + const { client_id } = await this.getOffice365AppKeys(); + + const params = { + response_type: "code", + client_id, + scope: this.scopes.join(" "), + redirect_uri: this.redirectUri, + state: state, + }; + + const query = stringify(params); + + const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`; + return { url }; + } + + async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) { + const { client_id, client_secret } = await this.getOffice365AppKeys(); + + const toUrlEncoded = (payload: Record) => + Object.keys(payload) + .map((key) => `${key}=${encodeURIComponent(payload[key])}`) + .join("&"); + + const body = toUrlEncoded({ + client_id, + grant_type: "authorization_code", + code, + scope: this.scopes.join(" "), + redirect_uri: this.redirectUri, + client_secret, + }); + + const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body, + }); + + const responseBody = await response.json(); + + if (!response.ok) { + throw new BadRequestException(responseBody.error); + } + + const whoami = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${responseBody.access_token}` }, + }); + + const graphUser = await whoami.json(); + + // In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address. + responseBody.email = graphUser.mail ?? graphUser.userPrincipalName; + responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds + delete responseBody.expires_in; + + const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({ + type: OFFICE_365_VIDEO_TYPE, + userId, + appId: OFFICE_365_VIDEO, + }); + + const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id); + if (credentialIdsToDelete.length > 0) { + await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId); + } + + await this.appsRepository.createAppCredential( + OFFICE_365_VIDEO_TYPE, + responseBody as unknown as Prisma.InputJsonObject, + userId, + OFFICE_365_VIDEO + ); + + return { url: state.returnTo ?? "" }; + } +} diff --git a/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts new file mode 100644 index 00000000000000..1058e587360081 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts @@ -0,0 +1,113 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller"; +import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import { ZOOM, ZOOM_TYPE } from "@calcom/platform-constants"; + +import stringify = require("qs-stringify"); + +const zoomAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +@Injectable() +export class ZoomVideoService { + private logger = new Logger("ZoomVideoService"); + private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`; + + constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {} + + async getZoomAppKeys() { + const app = await this.appsRepository.getAppBySlug(ZOOM); + + const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys); + + if (!client_id) { + throw new NotFoundException("Zoom app not found"); + } + + if (!client_secret) { + throw new NotFoundException("Zoom app not found"); + } + + return { client_id, client_secret }; + } + + async generateZoomAuthUrl(state: string) { + const { client_id } = await this.getZoomAppKeys(); + + const params = { + response_type: "code", + client_id, + redirect_uri: this.redirectUri, + state: state, + }; + + const query = stringify(params); + const url = `https://zoom.us/oauth/authorize?${query}`; + return { url }; + } + + async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) { + const { client_id, client_secret } = await this.getZoomAppKeys(); + const redirectUri = encodeURI(this.redirectUri); + const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; + const result = await fetch( + `https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`, + { + method: "POST", + headers: { + Authorization: authHeader, + }, + } + ); + + if (result.status !== 200) { + let errorMessage = "Something is wrong with Zoom API"; + try { + const responseBody = await result.json(); + errorMessage = responseBody.error; + } catch (e) { + errorMessage = await result.clone().text(); + } + throw new BadRequestException(errorMessage); + } + + const responseBody = await result.json(); + + if (responseBody.error) { + throw new BadRequestException(responseBody.error); + } + + responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); + delete responseBody.expires_in; + + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } + const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({ + type: ZOOM_TYPE, + userId, + appId: ZOOM, + }); + + const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id); + if (credentialIdsToDelete.length > 0) { + await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId); + } + + await this.appsRepository.createAppCredential( + ZOOM_TYPE, + responseBody as unknown as Prisma.InputJsonObject, + userId, + ZOOM + ); + + return { url: state.returnTo ?? "" }; + } +} diff --git a/apps/api/v2/src/modules/credentials/credential.module.ts b/apps/api/v2/src/modules/credentials/credential.module.ts new file mode 100644 index 00000000000000..c837e49328880d --- /dev/null +++ b/apps/api/v2/src/modules/credentials/credential.module.ts @@ -0,0 +1,9 @@ +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [], + providers: [CredentialsRepository], + exports: [CredentialsRepository], +}) +export class CredentialsModule {} diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts new file mode 100644 index 00000000000000..52429443387906 --- /dev/null +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -0,0 +1,118 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; +import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries"; + +@Injectable() +export class CredentialsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async upsertAppCredential( + type: keyof typeof APPS_TYPE_ID_MAPPING, + key: Prisma.InputJsonValue, + userId: number, + credentialId?: number | null + ) { + return this.dbWrite.prisma.credential.upsert({ + create: { + type, + key, + userId, + appId: APPS_TYPE_ID_MAPPING[type], + }, + update: { + key, + invalid: false, + }, + where: { + id: credentialId ?? 0, + }, + }); + } + + getByTypeAndUserId(type: string, userId: number) { + return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } }); + } + + getByTypeAndTeamId(type: string, teamId: number) { + return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } }); + } + + getAllUserCredentialsByTypeAndId(type: string, userId: number) { + return this.dbRead.prisma.credential.findMany({ where: { type, userId } }); + } + + getUserCredentialsByIds(userId: number, credentialIds: number[]) { + return this.dbRead.prisma.credential.findMany({ + where: { + id: { + in: credentialIds, + }, + userId: userId, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + } + + async getAllUserCredentialsById(userId: number) { + return await this.dbRead.prisma.credential.findMany({ + where: { + userId, + }, + select: credentialForCalendarServiceSelect, + orderBy: { + id: "asc", + }, + }); + } + + async getUserCredentialById(userId: number, credentialId: number, type: string) { + return await this.dbRead.prisma.credential.findUnique({ + where: { + userId, + type, + id: credentialId, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + } + + async deleteUserCredentialById(userId: number, credentialId: number) { + return await this.dbWrite.prisma.credential.delete({ + where: { id: credentialId, userId }, + }); + } +} + +export type CredentialsWithUserEmail = Awaited< + ReturnType +>; diff --git a/apps/api/v2/src/modules/deployments/deployments.module.ts b/apps/api/v2/src/modules/deployments/deployments.module.ts new file mode 100644 index 00000000000000..1017f72c7eb81e --- /dev/null +++ b/apps/api/v2/src/modules/deployments/deployments.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Module } from "@nestjs/common"; + +import { DeploymentsRepository } from "./deployments.repository"; +import { DeploymentsService } from "./deployments.service"; + +@Module({ + imports: [PrismaModule], + providers: [DeploymentsRepository, DeploymentsService, RedisService], + exports: [DeploymentsRepository, DeploymentsService], +}) +export class DeploymentsModule {} diff --git a/apps/api/v2/src/modules/deployments/deployments.repository.ts b/apps/api/v2/src/modules/deployments/deployments.repository.ts new file mode 100644 index 00000000000000..ec5168e3e8136a --- /dev/null +++ b/apps/api/v2/src/modules/deployments/deployments.repository.ts @@ -0,0 +1,12 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class DeploymentsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getDeployment() { + return this.dbRead.prisma.deployment.findFirst({ where: { id: 1 } }); + } +} diff --git a/apps/api/v2/src/modules/deployments/deployments.service.ts b/apps/api/v2/src/modules/deployments/deployments.service.ts new file mode 100644 index 00000000000000..86425f15f2565c --- /dev/null +++ b/apps/api/v2/src/modules/deployments/deployments.service.ts @@ -0,0 +1,47 @@ +import { DeploymentsRepository } from "@/modules/deployments/deployments.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +const CACHING_TIME = 86400000; // 24 hours in milliseconds + +const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`; + +type LicenseCheckResponse = { + valid: boolean; +}; +@Injectable() +export class DeploymentsService { + constructor( + private readonly deploymentsRepository: DeploymentsRepository, + private readonly configService: ConfigService, + private readonly redisService: RedisService + ) {} + + async checkLicense() { + if (this.configService.get("e2e")) { + return true; + } + let licenseKey = this.configService.get("api.licenseKey"); + + if (!licenseKey) { + /** We try to check on DB only if env is undefined */ + const deployment = await this.deploymentsRepository.getDeployment(); + licenseKey = deployment?.licenseKey ?? undefined; + } + + if (!licenseKey) { + return false; + } + const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`; + const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey)); + if (cachedData) { + return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid; + } + const response = await fetch(licenseKeyUrl, { mode: "cors" }); + const data = (await response.json()) as LicenseCheckResponse; + const cacheKey = getLicenseCacheKey(licenseKey); + this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME); + return data.valid; + } +} diff --git a/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts new file mode 100644 index 00000000000000..662afd30cef399 --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts @@ -0,0 +1,178 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { DestinationCalendarsOutputResponseDto } from "@/modules/destination-calendars/outputs/destination-calendars.output"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Destination Calendar Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let appleCalendarCredentials: Credential; + let user: User; + let accessTokenSecret: string; + let refreshTokenSecret: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + + .compile(); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ + name: `destination-calendars-organization-${randomString()}`, + }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser( + `destination-calendars-user-${randomString()}@api.com`, + oAuthClient.id + ); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + appleCalendarCredentials = await credentialsRepositoryFixture.create( + APPLE_CALENDAR_TYPE, + {}, + user.id, + APPLE_CALENDAR_ID + ); + jest.spyOn(CalendarsService.prototype, "getCalendars").mockReturnValue( + Promise.resolve({ + connectedCalendars: [ + { + integration: { + installed: false, + type: "apple_calendar", + title: "", + name: "", + description: "", + variant: "calendar", + slug: "", + locationOption: null, + categories: ["calendar"], + logo: "", + publisher: "", + url: "", + email: "", + }, + calendars: { + externalId: + "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/", + readOnly: false, + integration: "apple_calendar", + credentialId: appleCalendarCredentials.id, + primary: true, + email: user.email, + }, + error: { message: "" }, + }, + ], + destinationCalendar: null, + }) + ); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => { + const body = { + integration: appleCalendarCredentials.type, + externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/", + }; + + return request(app.getHttpServer()) + .put("/v2/destination-calendars") + .set("Authorization", `Bearer ${accessTokenSecret}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: DestinationCalendarsOutputResponseDto = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.credentialId).toEqual(appleCalendarCredentials.id); + expect(responseBody.data.integration).toEqual(body.integration); + expect(responseBody.data.externalId).toEqual(body.externalId); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + it(`POST /v2/destination-calendars: should fail 400 if calendar type is invalid`, async () => { + const body = { + integration: "not-supported-calendar", + externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/", + }; + + return request(app.getHttpServer()) + .put("/v2/destination-calendars") + .set("Authorization", `Bearer ${accessTokenSecret}`) + .send(body) + .expect(400); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.ts b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.ts new file mode 100644 index 00000000000000..bac55c9d4cf7b3 --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.ts @@ -0,0 +1,46 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { DestinationCalendarsInputBodyDto } from "@/modules/destination-calendars/inputs/destination-calendars.input"; +import { + DestinationCalendarsOutputDto, + DestinationCalendarsOutputResponseDto, +} from "@/modules/destination-calendars/outputs/destination-calendars.output"; +import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Body, Controller, Put, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/destination-calendars", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Destination Calendars") +export class DestinationCalendarsController { + constructor(private readonly destinationCalendarsService: DestinationCalendarsService) {} + + @Put("/") + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Update destination calendars" }) + async updateDestinationCalendars( + @Body() input: DestinationCalendarsInputBodyDto, + @GetUser() user: UserWithProfile + ): Promise { + const { integration, externalId } = input; + const updatedDestinationCalendar = await this.destinationCalendarsService.updateDestinationCalendars( + integration, + externalId, + user.id + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(DestinationCalendarsOutputDto, updatedDestinationCalendar, { + strategy: "excludeAll", + }), + }; + } +} diff --git a/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts b/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts new file mode 100644 index 00000000000000..46107490752e38 --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/destination-calendars.module.ts @@ -0,0 +1,28 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { DestinationCalendarsController } from "@/modules/destination-calendars/controllers/destination-calendars.controller"; +import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository"; +import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ + CalendarsRepository, + CalendarsService, + DestinationCalendarsService, + DestinationCalendarsRepository, + UsersRepository, + CredentialsRepository, + AppsRepository, + SelectedCalendarsRepository, + ], + controllers: [DestinationCalendarsController], + exports: [DestinationCalendarsRepository], +}) +export class DestinationCalendarsModule {} diff --git a/apps/api/v2/src/modules/destination-calendars/destination-calendars.repository.ts b/apps/api/v2/src/modules/destination-calendars/destination-calendars.repository.ts new file mode 100644 index 00000000000000..412a886bdc0566 --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/destination-calendars.repository.ts @@ -0,0 +1,34 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class DestinationCalendarsRepository { + constructor(private readonly dbWrite: PrismaWriteService) {} + + async updateCalendar( + integration: string, + externalId: string, + credentialId: number, + userId: number, + primaryEmail: string | null + ) { + return await this.dbWrite.prisma.destinationCalendar.upsert({ + update: { + integration, + externalId, + credentialId, + primaryEmail, + }, + create: { + integration, + externalId, + credentialId, + primaryEmail, + userId, + }, + where: { + userId: userId, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/destination-calendars/inputs/destination-calendars.input.ts b/apps/api/v2/src/modules/destination-calendars/inputs/destination-calendars.input.ts new file mode 100644 index 00000000000000..76cbc16055472b --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/inputs/destination-calendars.input.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsString, IsEnum } from "class-validator"; + +import { + APPLE_CALENDAR_TYPE, + GOOGLE_CALENDAR_TYPE, + OFFICE_365_CALENDAR_TYPE, +} from "@calcom/platform-constants"; + +export class DestinationCalendarsInputBodyDto { + @IsString() + @Expose() + @ApiProperty({ + example: APPLE_CALENDAR_TYPE, + description: "The calendar service you want to integrate, as returned by the /calendars endpoint", + enum: [APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE], + required: true, + }) + @IsEnum([APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE]) + readonly integration!: + | typeof APPLE_CALENDAR_TYPE + | typeof GOOGLE_CALENDAR_TYPE + | typeof OFFICE_365_CALENDAR_TYPE; + + @IsString() + @Expose() + @ApiProperty({ + example: "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/", + description: + "Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint", + type: "string", + required: true, + }) + readonly externalId!: string; +} diff --git a/apps/api/v2/src/modules/destination-calendars/outputs/destination-calendars.output.ts b/apps/api/v2/src/modules/destination-calendars/outputs/destination-calendars.output.ts new file mode 100644 index 00000000000000..c0ab8e9aba4c56 --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/outputs/destination-calendars.output.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class DestinationCalendarsOutputDto { + @IsInt() + @Expose() + readonly userId!: number; + + @IsString() + @Expose() + readonly integration!: string; + + @IsString() + @Expose() + readonly externalId!: string; + + @IsInt() + @Expose() + readonly credentialId!: number | null; +} + +export class DestinationCalendarsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => DestinationCalendarsOutputDto) + data!: DestinationCalendarsOutputDto; +} diff --git a/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts b/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts new file mode 100644 index 00000000000000..b80fe4beb6d474 --- /dev/null +++ b/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts @@ -0,0 +1,50 @@ +import { ConnectedCalendar, Calendar } from "@/ee/calendars/outputs/connected-calendars.output"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class DestinationCalendarsService { + constructor( + private readonly calendarsService: CalendarsService, + private readonly destinationCalendarsRepository: DestinationCalendarsRepository + ) {} + + async updateDestinationCalendars(integration: string, externalId: string, userId: number) { + const userCalendars = await this.calendarsService.getCalendars(userId); + const allCalendars = userCalendars.connectedCalendars + .map((cal: ConnectedCalendar) => cal.calendars ?? []) + .flat(); + const credentialId = allCalendars.find( + (cal: Calendar) => + cal.externalId === externalId && cal.integration === integration && cal.readOnly === false + )?.credentialId; + + if (!credentialId) { + throw new NotFoundException(`Could not find calendar ${externalId}`); + } + + const primaryEmail = + allCalendars.find((cal: Calendar) => cal.primary && cal.credentialId === credentialId)?.email ?? null; + + const { + integration: updatedCalendarIntegration, + externalId: updatedCalendarExternalId, + credentialId: updatedCalendarCredentialId, + userId: updatedCalendarUserId, + } = await this.destinationCalendarsRepository.updateCalendar( + integration, + externalId, + credentialId, + userId, + primaryEmail + ); + + return { + userId: updatedCalendarUserId, + integration: updatedCalendarIntegration, + externalId: updatedCalendarExternalId, + credentialId: updatedCalendarCredentialId, + }; + } +} diff --git a/apps/api/v2/src/modules/email/email.module.ts b/apps/api/v2/src/modules/email/email.module.ts new file mode 100644 index 00000000000000..f8e63b2b93e125 --- /dev/null +++ b/apps/api/v2/src/modules/email/email.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from "@nestjs/common"; + +import { EmailService } from "./email.service"; + +@Global() +@Module({ + imports: [], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/apps/api/v2/src/modules/email/email.service.ts b/apps/api/v2/src/modules/email/email.service.ts new file mode 100644 index 00000000000000..c689b1aa7c3e96 --- /dev/null +++ b/apps/api/v2/src/modules/email/email.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common"; + +import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries"; + +@Injectable() +export class EmailService { + public async sendSignupToOrganizationEmail({ + usernameOrEmail, + orgName, + orgId, + locale, + inviterName, + }: { + usernameOrEmail: string; + orgName: string; + orgId: number; + locale: string | null; + inviterName: string; + }) { + const translation = await getTranslation(locale || "en", "common"); + + await sendSignupToOrganizationEmail({ + usernameOrEmail, + team: { name: orgName, parent: null }, + inviterName: inviterName, + isOrg: true, + teamId: orgId, + translation, + }); + } +} diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts new file mode 100644 index 00000000000000..80f62ba3060fd8 --- /dev/null +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -0,0 +1,38 @@ +import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module"; +import { AtomsModule } from "@/modules/atoms/atoms.module"; +import { BillingModule } from "@/modules/billing/billing.module"; +import { ConferencingModule } from "@/modules/conferencing/conferencing.module"; +import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module"; +import { RouterModule } from "@/modules/router/router.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TimezoneModule } from "@/modules/timezones/timezones.module"; +import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { Module } from "@nestjs/common"; + +import { UsersModule } from "./users/users.module"; +import { WebhooksModule } from "./webhooks/webhooks.module"; + +@Module({ + imports: [ + OAuthClientModule, + BillingModule, + PlatformEndpointsModule, + TimezoneModule, + UsersModule, + WebhooksModule, + DestinationCalendarsModule, + AtomsModule, + StripeModule, + ConferencingModule, + OrganizationsTeamsBookingsModule, + RouterModule, + ], +}) +export class EndpointsModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { + // TODO: apply ratelimits + } +} diff --git a/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts new file mode 100644 index 00000000000000..868e76d5cf4e1c --- /dev/null +++ b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts @@ -0,0 +1,296 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + EventTypeWebhookOutputResponseDto, + EventTypeWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/event-type-webhook.output"; +import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { EventType, Webhook } from "@calcom/prisma/client"; + +describe("EventTypes WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = `event-types-webhooks-user-${randomString()}@api.com`; + let user: UserWithProfile; + let otherUser: UserWithProfile; + let eventType: EventType; + let eventType2: EventType; + let otherEventType: EventType; + + let eventTypeRepositoryFixture: EventTypesRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + + let webhook: EventTypeWebhookOutputResponseDto["data"]; + let webhook2: Webhook; + let otherWebhook: Webhook; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef); + eventTypeRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + otherUser = await userRepositoryFixture.create({ + email: `event-types-webhooks-other-user-${randomString()}@api.com`, + username: `event-types-webhooks-other-user-${randomString()}@api.com`, + }); + + eventType = await eventTypeRepositoryFixture.create( + { + title: "Event Type 1", + slug: `event-types-webhooks-event-type-${randomString()}`, + length: 60, + }, + user.id + ); + + eventType2 = await eventTypeRepositoryFixture.create( + { + title: "Event Type 2", + slug: `event-types-webhooks-event-type-${randomString()}`, + length: 60, + }, + user.id + ); + + otherEventType = await eventTypeRepositoryFixture.create( + { + title: "Other Event Type ", + slug: `event-types-webhooks-other-event-type-${randomString()}`, + length: 60, + }, + otherUser.id + ); + + otherWebhook = await webhookRepositoryFixture.create({ + id: "2mdfnn24", + subscriberUrl: "https://example.com", + eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + userRepositoryFixture.deleteByEmail(user.email); + userRepositoryFixture.deleteByEmail(otherUser.email); + webhookRepositoryFixture.delete(otherWebhook.id); + await app.close(); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/event-types/${eventType.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + eventTypeId: eventType.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/event-types/${eventType2.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + eventTypeId: eventType2.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + webhook2 = res.body.data; + }); + }); + + it("/webhooks (POST) should fail to create a webhook for an event-type that does not belong to user", () => { + return request(app.getHttpServer()) + .post(`/v2/event-types/${otherEventType.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(403); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH) should fail to patch a webhook for an event-type that does not belong to user", () => { + return request(app.getHttpServer()) + .patch(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(403); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + eventTypeId: eventType.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/event-types/${eventType.id}/webhooks/90284`).expect(404); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook of an eventType that does not belong to user", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to the eventType", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType.id}/webhooks/${otherWebhook.id}`) + .expect(400); + }); + + it("/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as EventTypeWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.eventTypeId).toBe(eventType.id); + }); + }); + }); + + it("/event-types/:eventTypeId/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType2.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as EventTypeWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.eventTypeId).toBe(eventType2.id); + }); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + eventTypeId: eventType.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + }); + }); + + it("/event-types/:eventTypeId/webhooks (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${eventType2.id}/webhooks`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: "1 webhooks deleted", + } satisfies DeleteManyWebhooksOutputResponseDto); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${eventType.id}/webhooks/1234453`) + .expect(404); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); +}); diff --git a/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts new file mode 100644 index 00000000000000..25ac6b8e0cfb36 --- /dev/null +++ b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts @@ -0,0 +1,135 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator"; +import { IsUserEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-user-event-type-webhook-guard"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + EventTypeWebhookOutputResponseDto, + EventTypeWebhookOutputDto, + EventTypeWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/event-type-webhook.output"; +import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output"; +import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe"; +import { EventTypeWebhooksService } from "@/modules/webhooks/services/event-type-webhooks.service"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + Controller, + Post, + Body, + UseGuards, + Get, + Param, + Query, + Delete, + Patch, + ParseIntPipe, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { Webhook } from "@prisma/client"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/event-types/:eventTypeId/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsUserEventTypeWebhookGuard) +@DocsTags("Event Types / Webhooks") +export class EventTypeWebhooksController { + constructor( + private readonly webhooksService: WebhooksService, + private readonly eventTypeWebhooksService: EventTypeWebhooksService + ) {} + + @Post("/") + @ApiOperation({ summary: "Create a webhook" }) + async createEventTypeWebhook( + @Body() body: CreateWebhookInputDto, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise { + const webhook = await this.eventTypeWebhooksService.createEventTypeWebhook( + eventTypeId, + new WebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Patch("/:webhookId") + @ApiOperation({ summary: "Update a webhook" }) + async updateEventTypeWebhook( + @Body() body: UpdateWebhookInputDto, + @Param("webhookId") webhookId: string + ): Promise { + const webhook = await this.webhooksService.updateWebhook( + webhookId, + new PartialWebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/:webhookId") + @ApiOperation({ summary: "Get a webhook" }) + async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise { + return { + status: SUCCESS_STATUS, + data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get all webhooks" }) + async getEventTypeWebhooks( + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @Query() pagination: SkipTakePagination + ): Promise { + const webhooks = await this.eventTypeWebhooksService.getEventTypeWebhooksPaginated( + eventTypeId, + pagination.skip ?? 0, + pagination.take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: webhooks.map((webhook) => + plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }) + ), + }; + } + + @Delete("/:webhookId") + @ApiOperation({ summary: "Delete a webhook" }) + async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise { + await this.webhooksService.deleteWebhook(webhook.id); + return { + status: SUCCESS_STATUS, + data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Delete("/") + @ApiOperation({ summary: "Delete all webhooks" }) + async deleteAllEventTypeWebhooks( + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise { + const data = await this.eventTypeWebhooksService.deleteAllEventTypeWebhooks(eventTypeId); + return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` }; + } +} diff --git a/apps/api/v2/src/modules/jwt/jwt.module.ts b/apps/api/v2/src/modules/jwt/jwt.module.ts new file mode 100644 index 00000000000000..ef12816e2fdf73 --- /dev/null +++ b/apps/api/v2/src/modules/jwt/jwt.module.ts @@ -0,0 +1,12 @@ +import { getEnv } from "@/env"; +import { JwtService } from "@/modules/jwt/jwt.service"; +import { Global, Module } from "@nestjs/common"; +import { JwtModule as NestJwtModule } from "@nestjs/jwt"; + +@Global() +@Module({ + imports: [NestJwtModule.register({ secret: getEnv("JWT_SECRET") })], + providers: [JwtService], + exports: [JwtService], +}) +export class JwtModule {} diff --git a/apps/api/v2/src/modules/jwt/jwt.service.ts b/apps/api/v2/src/modules/jwt/jwt.service.ts new file mode 100644 index 00000000000000..c50e53ddb4d9e9 --- /dev/null +++ b/apps/api/v2/src/modules/jwt/jwt.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common"; +import { JwtService as NestJwtService } from "@nestjs/jwt"; + +@Injectable() +export class JwtService { + constructor(private readonly nestJwtService: NestJwtService) {} + + signAccessToken(payload: Payload) { + const accessToken = this.sign({ type: "access_token", ...payload }); + return accessToken; + } + + signRefreshToken(payload: Payload) { + const refreshToken = this.sign({ type: "refresh_token", ...payload }); + return refreshToken; + } + + sign(payload: Payload) { + const issuedAtTime = this.getIssuedAtTime(); + + const token = this.nestJwtService.sign({ ...payload, iat: issuedAtTime }); + return token; + } + + getIssuedAtTime() { + // divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT speficication + return Math.floor(Date.now() / 1000); + } +} + +type Payload = Record; diff --git a/apps/api/v2/src/modules/memberships/memberships.module.ts b/apps/api/v2/src/modules/memberships/memberships.module.ts new file mode 100644 index 00000000000000..418e1f6d1605d7 --- /dev/null +++ b/apps/api/v2/src/modules/memberships/memberships.module.ts @@ -0,0 +1,10 @@ +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [MembershipsRepository], + exports: [MembershipsRepository], +}) +export class MembershipsModule {} diff --git a/apps/api/v2/src/modules/memberships/memberships.repository.ts b/apps/api/v2/src/modules/memberships/memberships.repository.ts new file mode 100644 index 00000000000000..2c289e92432b96 --- /dev/null +++ b/apps/api/v2/src/modules/memberships/memberships.repository.ts @@ -0,0 +1,75 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +import { MembershipRole } from "@calcom/prisma/client"; + +@Injectable() +export class MembershipsRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async findOrgUserMembership(organizationId: number, userId: number) { + const membership = await this.dbRead.prisma.membership.findUniqueOrThrow({ + where: { + userId_teamId: { + userId: userId, + teamId: organizationId, + }, + }, + }); + + return membership; + } + + async findMembershipByTeamId(teamId: number, userId: number) { + const membership = await this.dbRead.prisma.membership.findUnique({ + where: { + userId_teamId: { + userId: userId, + teamId: teamId, + }, + }, + }); + + return membership; + } + + async findUserMemberships(userId: number) { + const memberships = await this.dbRead.prisma.membership.findMany({ + where: { + userId, + }, + }); + + return memberships; + } + + async findMembershipByOrgId(orgId: number, userId: number) { + return this.findMembershipByTeamId(orgId, userId); + } + + async isUserOrganizationAdmin(userId: number, organizationId: number) { + const adminMembership = await this.dbRead.prisma.membership.findFirst({ + where: { + userId, + teamId: organizationId, + accepted: true, + OR: [{ role: "ADMIN" }, { role: "OWNER" }], + }, + }); + + return !!adminMembership; + } + + async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) { + const membership = await this.dbRead.prisma.membership.create({ + data: { + role, + teamId, + userId, + accepted, + }, + }); + + return membership; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts new file mode 100644 index 00000000000000..533c7af03dd04a --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -0,0 +1,585 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { Locales } from "@/lib/enums/locales"; +import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; +import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +const CLIENT_REDIRECT_URI = "http://localhost:4321"; + +describe("OAuth Client Users Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("secret header not set", () => { + it(`/POST`, () => { + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients/100/users") + .send({ email: "bob@gmail.com" }) + .expect(401); + }); + }); + + describe("Bearer access token not set", () => { + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/100/users/200").expect(401); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/100/users/200").expect(401); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/100/users/200").expect(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let schedulesRepositoryFixture: SchedulesRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let postResponseData: CreateManagedUserOutput["data"]; + + const platformAdminEmail = `oauth-client-users-admin-${randomString()}@api.com`; + let platformAdmin: User; + + const userEmail = `oauth-client-users-user-${randomString()}@api.com`; + const userTimeZone = "Europe/Rome"; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail }); + + organization = await teamRepositoryFixture.create({ + name: `oauth-client-users-organization-${randomString()}`, + isPlatform: true, + isOrganization: true, + }); + oAuthClient = await createOAuthClient(organization.id); + + await profilesRepositoryFixture.create({ + uid: "asd1qwwqeqw-asddsadasd", + username: platformAdminEmail, + organization: { connect: { id: organization.id } }, + user: { + connect: { id: platformAdmin.id }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: platformAdmin.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + }); + + it(`should fail /POST with incorrect timeZone`, async () => { + const requestBody: CreateManagedUserInput = { + email: userEmail, + timeZone: "incorrect-time-zone", + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(400); + }); + + it(`should fail /POST with incorrect timeFormat`, async () => { + const requestBody = { + email: userEmail, + timeZone: userTimeZone, + name: "Alice Smith", + timeFormat: 100, + }; + + await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(400); + }); + + it(`/POST`, async () => { + const requestBody: CreateManagedUserInput = { + email: userEmail, + timeZone: userTimeZone, + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + + postResponseData = responseBody.data; + + 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.name).toEqual(requestBody.name); + expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart); + expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat); + expect(responseBody.data.user.locale).toEqual(requestBody.locale); + expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.refreshToken).toBeDefined(); + + await userConnectedToOAuth(responseBody.data.user.email); + await userHasDefaultEventTypes(responseBody.data.user.id); + await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId); + await userHasOnlyOneSchedule(responseBody.data.user.id); + }); + + async function userConnectedToOAuth(userEmail: string) { + const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClient.id); + const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail); + + expect(oAuthUsers?.length).toEqual(1); + expect(newOAuthUser?.email).toEqual(userEmail); + } + + async function userHasDefaultEventTypes(userId: number) { + const defaultEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId); + + // note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes + expect(defaultEventTypes?.length).toEqual(4); + expect( + defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug) + ).toBeTruthy(); + expect( + defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug) + ).toBeTruthy(); + expect( + defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug) + ).toBeTruthy(); + expect( + defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug) + ).toBeTruthy(); + } + + async function userHasDefaultSchedule(userId: number, scheduleId: number | null) { + expect(scheduleId).toBeDefined(); + expect(scheduleId).not.toBeNull(); + + const user = await userRepositoryFixture.get(userId); + expect(user?.defaultScheduleId).toEqual(scheduleId); + + const schedule = scheduleId ? await schedulesRepositoryFixture.getById(scheduleId) : null; + expect(schedule?.userId).toEqual(userId); + } + + async function userHasOnlyOneSchedule(userId: number) { + const schedules = await schedulesRepositoryFixture.getByUserId(userId); + expect(schedules?.length).toEqual(1); + } + + it(`should fail /POST using already used managed user email`, async () => { + const requestBody: CreateManagedUserInput = { + email: userEmail, + timeZone: userTimeZone, + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(409); + + const responseBody: CreateManagedUserOutput = response.body; + const error = responseBody.error; + expect(error).toBeDefined(); + expect(error?.message).toEqual( + `User with the provided e-mail already exists. Existing user ID=${postResponseData.user.id}` + ); + }); + + it(`/GET: return list of managed users`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`) + .set("x-cal-secret-key", oAuthClient.secret) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + + const responseBody: GetManagedUsersOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toBeGreaterThan(0); + expect(responseBody.data[0].email).toEqual(postResponseData.user.email); + expect(responseBody.data[0].name).toEqual(postResponseData.user.name); + }); + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("x-cal-secret-key", oAuthClient.secret) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + + const responseBody: GetManagedUserOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userEmail)); + }); + + it(`/PUT/:id`, async () => { + const userUpdatedEmail = "pineapple-pizza@gmail.com"; + const body: UpdateManagedUserInput = { email: userUpdatedEmail, locale: Locales.PT_BR }; + + const response = await request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("x-cal-secret-key", oAuthClient.secret) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .send(body) + .expect(200); + + const responseBody: ApiSuccessResponse> = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)); + expect(responseBody.data.locale).toEqual(Locales.PT_BR); + }); + + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()) + .delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("x-cal-secret-key", oAuthClient.secret) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + }); + + function getOAuthUserEmail(oAuthClientId: string, userEmail: string) { + const [username, emailDomain] = userEmail.split("@"); + const email = `${username}+${oAuthClientId}@${emailDomain}`; + + return email; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await userRepositoryFixture.delete(postResponseData.user.id); + } catch (e) { + // User might have been deleted by the test + } + try { + await userRepositoryFixture.delete(platformAdmin.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); + + describe("User team even-types", () => { + let app: INestApplication; + + let oAuthClient1: PlatformOAuthClient; + let oAuthClient2: PlatformOAuthClient; + + let organization: Team; + let team1: Team; + let team2: Team; + let owner: User; + + let managedEventType1: EventType; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let postResponseData: CreateManagedUserOutput["data"]; + + const userEmail = `oauth-client-users-user-${randomString()}@api.com`; + const userTimeZone = "Europe/Rome"; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ + name: `oauth-client-users-organization-${randomString()}`, + isPlatform: true, + isOrganization: true, + }); + + owner = await userRepositoryFixture.create({ + email: `oauth-client-users-admin-${randomString()}@api.com`, + username: `oauth-client-users-admin-${randomString()}@api.com`, + organization: { connect: { id: organization.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${owner.id}`, + username: `oauth-client-users-admin-${randomString()}@api.com`, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: owner.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: owner.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + oAuthClient1 = await createOAuthClient(organization.id); + oAuthClient2 = await createOAuthClient(organization.id); + + team1 = await teamRepositoryFixture.create({ + name: "Testy org team", + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { connect: { id: oAuthClient1.id } }, + }); + + team2 = await teamRepositoryFixture.create({ + name: "Testy org team 2", + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { connect: { id: oAuthClient2.id } }, + }); + + // note(Lauris): team1 team event-types + await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + managedEventType1 = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "MANAGED", + team: { + connect: { id: team1.id }, + }, + title: "Managed Event Type", + slug: "managed-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + // note(Lauris): team2 team event-types + await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team2.id }, + }, + title: "Collective Event Type team 2", + slug: "collective-event-type-team-2", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "MANAGED", + team: { + connect: { id: team2.id }, + }, + title: "Managed Event Type team 2", + slug: "managed-event-type-team-2", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient1).toBeDefined(); + }); + + it(`should create managed user and update team event-types`, async () => { + const requestBody: CreateManagedUserInput = { + email: userEmail, + timeZone: userTimeZone, + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient1.id}/users`) + .set("x-cal-secret-key", oAuthClient1.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + postResponseData = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + await teamHasCorrectEventTypes(team1.id); + expect(responseBody.data.user.name).toEqual(requestBody.name); + }); + + async function teamHasCorrectEventTypes(teamId: number) { + const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId); + expect(eventTypes?.length).toEqual(2); + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient1.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.delete(owner.id); + try { + await userRepositoryFixture.delete(postResponseData.user.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts new file mode 100644 index 00000000000000..b93514640685f9 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -0,0 +1,219 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { Locales } from "@/lib/enums/locales"; +import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard"; +import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; +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 { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; +import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Post, + Logger, + UseGuards, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Delete, + Query, + NotFoundException, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { User, MembershipRole } from "@prisma/client"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { Pagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/oauth-clients/:clientId/users", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, OAuthClientGuard, OrganizationRolesGuard) +@DocsTags("Platform / Managed Users") +export class OAuthClientUsersController { + private readonly logger = new Logger("UserController"); + + constructor( + private readonly userRepository: UsersRepository, + private readonly oAuthClientUsersService: OAuthClientUsersService, + private readonly oauthRepository: OAuthClientRepository, + private readonly tokensRepository: TokensRepository + ) {} + + @Get("/") + @ApiOperation({ summary: "Get all managed users" }) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + async getManagedUsers( + @Param("clientId") oAuthClientId: string, + @Query() queryParams: Pagination + ): Promise { + this.logger.log(`getting managed users with data for OAuth Client with ID ${oAuthClientId}`); + const { offset, limit } = queryParams; + + const existingUsers = await this.userRepository.findManagedUsersByOAuthClientId( + oAuthClientId, + offset ?? 0, + limit ?? 50 + ); + + return { + status: SUCCESS_STATUS, + data: existingUsers.map((user) => this.getResponseUser(user)), + }; + } + + @Post("/") + @ApiOperation({ summary: "Create a managed user" }) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + async createUser( + @Param("clientId") oAuthClientId: string, + @Body() body: CreateManagedUserInput + ): Promise { + this.logger.log( + `Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}` + ); + const client = await this.oauthRepository.getOAuthClient(oAuthClientId); + + const isPlatformManaged = true; + const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser( + oAuthClientId, + body, + isPlatformManaged, + client?.organizationId + ); + + return { + status: SUCCESS_STATUS, + data: { + user: this.getResponseUser(user), + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(), + refreshToken: tokens.refreshToken, + }, + }; + } + + @Get("/:userId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get a managed user" }) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + async getUserById( + @Param("clientId") clientId: string, + @Param("userId") userId: number + ): Promise { + const user = await this.validateManagedUserOwnership(clientId, userId); + + return { + status: SUCCESS_STATUS, + data: this.getResponseUser(user), + }; + } + + @Patch("/:userId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update a managed user" }) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + async updateUser( + @Param("clientId") clientId: string, + @Param("userId") userId: number, + @Body() body: UpdateManagedUserInput + ): Promise { + await this.validateManagedUserOwnership(clientId, userId); + this.logger.log(`Updating user with ID ${userId}: ${JSON.stringify(body, null, 2)}`); + + const user = await this.oAuthClientUsersService.updateOAuthClientUser(clientId, userId, body); + + return { + status: SUCCESS_STATUS, + data: this.getResponseUser(user), + }; + } + + @Delete("/:userId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a managed user" }) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + async deleteUser( + @Param("clientId") clientId: string, + @Param("userId") userId: number + ): Promise { + const user = await this.validateManagedUserOwnership(clientId, userId); + await this.userRepository.delete(userId); + + this.logger.warn(`Deleting user with ID: ${userId}`); + + return { + status: SUCCESS_STATUS, + data: this.getResponseUser(user), + }; + } + + @Post("/:userId/force-refresh") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Force refresh tokens", + description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. + Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`, + }) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + async forceRefresh( + @Param("userId") userId: number, + @Param("clientId") oAuthClientId: string + ): Promise { + this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`); + + const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId); + + const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( + oAuthClientId, + id, + true + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken, + refreshToken, + accessTokenExpiresAt: accessTokenExpiresAt.valueOf(), + }, + }; + } + + private async validateManagedUserOwnership(clientId: string, userId: number): Promise { + const user = await this.userRepository.findByIdWithinPlatformScope(userId, clientId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} is not part of this OAuth client.`); + } + + return user; + } + + private getResponseUser(user: User): ManagedUserOutput { + return { + id: user.id, + email: user.email, + username: user.username, + name: user.name, + timeZone: user.timeZone, + weekStart: user.weekStart, + createdDate: user.createdDate, + timeFormat: user.timeFormat, + defaultScheduleId: user.defaultScheduleId, + locale: user.locale as Locales, + avatarUrl: user.avatarUrl, + }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts new file mode 100644 index 00000000000000..54c2e40e0f11b8 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts @@ -0,0 +1,39 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class CreateManagedUserData { + @ApiProperty({ + type: ManagedUserOutput, + }) + @ValidateNested() + @Type(() => ManagedUserOutput) + user!: ManagedUserOutput; + + @IsString() + accessToken!: string; + + @IsString() + refreshToken!: string; + + @IsNumber() + accessTokenExpiresAt!: number; +} + +export class CreateManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: CreateManagedUserData, + }) + @ValidateNested() + @Type(() => CreateManagedUserData) + data!: CreateManagedUserData; + + error?: Error; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output.ts new file mode 100644 index 00000000000000..0de2d8e2a7df57 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output.ts @@ -0,0 +1,16 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => ManagedUserOutput) + @ValidateNested() + data!: ManagedUserOutput; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output.ts new file mode 100644 index 00000000000000..cc341ebc6b0b3d --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output.ts @@ -0,0 +1,16 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => ManagedUserOutput) + @ValidateNested() + data!: ManagedUserOutput; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output.ts new file mode 100644 index 00000000000000..16b8b41c51da6c --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output.ts @@ -0,0 +1,17 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetManagedUsersOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => ManagedUserOutput) + @IsArray() + data!: ManagedUserOutput[]; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts new file mode 100644 index 00000000000000..16982d4d83db05 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts @@ -0,0 +1,49 @@ +import { Locales } from "@/lib/enums/locales"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsEnum, IsOptional, IsUrl } from "class-validator"; + +export class ManagedUserOutput { + @ApiProperty({ example: 1 }) + id!: number; + + @ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" }) + email!: string; + + @ApiProperty({ example: "alice", nullable: true }) + username!: string | null; + + @ApiProperty({ example: "alice", nullable: true }) + name!: string | null; + + @ApiProperty({ example: "America/New_York" }) + timeZone!: string; + + @ApiProperty({ example: "Sunday" }) + weekStart!: string; + + @ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" }) + @Transform(({ value }) => value.toISOString()) + createdDate!: Date; + + @ApiProperty({ type: Number, example: 12, nullable: true }) + timeFormat!: number | null; + + @ApiProperty({ type: Number, example: null, nullable: true }) + defaultScheduleId!: number | null; + + @IsEnum(Locales) + @IsOptional() + @ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales }) + locale?: Locales; + + @IsUrl() + @IsOptional() + @ApiPropertyOptional({ + type: String, + example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + description: `URL of the user's avatar image`, + nullable: true, + }) + avatarUrl?: string | null; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output.ts new file mode 100644 index 00000000000000..2d660a47def0f4 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output.ts @@ -0,0 +1,16 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => ManagedUserOutput) + @ValidateNested() + data!: ManagedUserOutput; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller.e2e-spec.ts new file mode 100644 index 00000000000000..fafc5792df3491 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller.e2e-spec.ts @@ -0,0 +1,282 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + OAuthClientWebhooksOutputResponseDto, + OAuthClientWebhookOutputResponseDto, +} from "@/modules/webhooks/outputs/oauth-client-webhook.output"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client"; + +describe("OAuth client WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = `oauth-client-webhooks-user-${randomString()}@api.com`; + const otherUserEmail = `oauth-client-webhooks-other-user-${randomString()}@api.com`; + let user: UserWithProfile; + let otherUser: UserWithProfile; + let oAuthClient: PlatformOAuthClient; + let otherOAuthClient: PlatformOAuthClient; + let org: Team; + let otherOrg: Team; + let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + let otherOAuthClientWebhook: Webhook; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let orgRepositoryFixture: OrganizationRepositoryFixture; + let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture; + + let webhook: OAuthClientWebhookOutputResponseDto["data"]; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef); + oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + otherUser = await userRepositoryFixture.create({ + email: otherUserEmail, + username: otherUserEmail, + }); + + org = await orgRepositoryFixture.create({ + name: `oauth-client-webhooks-organization-${randomString()}`, + isOrganization: true, + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: true, + }); + otherOrg = await orgRepositoryFixture.create({ + name: `oauth-client-webhooks-other-organization-${randomString()}`, + isOrganization: true, + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: true, + }); + await platformBillingRepositoryFixture.create(org.id); + await platformBillingRepositoryFixture.create(otherOrg.id); + await membershipRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + accepted: true, + }); + await membershipRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: otherUser.id } }, + team: { connect: { id: otherOrg.id } }, + accepted: true, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + movedFromUser: { + connect: { + id: user.id, + }, + }, + user: { + connect: { id: user.id }, + }, + }); + await profileRepositoryFixture.create({ + uid: `usr-${otherUser.id}`, + username: otherUserEmail, + organization: { + connect: { + id: otherOrg.id, + }, + }, + movedFromUser: { + connect: { + id: otherUser.id, + }, + }, + user: { + connect: { id: otherUser.id }, + }, + }); + + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + oAuthClient = await oAuthClientRepositoryFixture.create(org.id, data, secret); + otherOAuthClient = await oAuthClientRepositoryFixture.create(otherOrg.id, data, secret); + otherOAuthClientWebhook = await webhookRepositoryFixture.create({ + id: "123abc-123abc-123abc-123abc", + active: true, + payloadTemplate: "string", + subscriberUrl: "https://example.com", + eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + platformOAuthClient: { connect: { id: otherOAuthClient.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await orgRepositoryFixture.delete(org.id); + await userRepositoryFixture.deleteByEmail(otherUser.email); + await orgRepositoryFixture.delete(otherOrg.id); + await app.close(); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/oauth-clients/${oAuthClient.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + active: true, + oAuthClientId: oAuthClient.id, + payloadTemplate: "string", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + }, + } satisfies OAuthClientWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/oauth-clients/${oAuthClient.id}/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/oauth-clients/${oAuthClient.id}/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + oAuthClientId: oAuthClient.id, + }, + } satisfies OAuthClientWebhookOutputResponseDto); + }); + }); + + it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/oauth-clients/${oAuthClient.id}/webhooks/90284`).expect(404); + }); + + it("/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/oauth-clients/${oAuthClient.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as OAuthClientWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.oAuthClientId).toBe(oAuthClient.id); + }); + }); + }); + it("/webhooks (GET) should fail to get webhooks of OAuth client that doesn't belong to you", () => { + return request(app.getHttpServer()).get(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks`).expect(403); + }); + + it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/oauth-clients/${oAuthClient.id}/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + oAuthClientId: oAuthClient.id, + }, + } satisfies OAuthClientWebhookOutputResponseDto); + }); + }); + + it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an OAuth client that doesn't belong to you", () => { + return request(app.getHttpServer()) + .delete(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks/${otherOAuthClientWebhook.id}`) + .expect(403); + }); + + it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()) + .delete(`/v2/oauth-clients/${oAuthClient.id}/webhooks/1234453`) + .expect(404); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller.ts new file mode 100644 index 00000000000000..55b78e3fbe7a96 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller.ts @@ -0,0 +1,140 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-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 { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator"; +import { IsOAuthClientWebhookGuard } from "@/modules/webhooks/guards/is-oauth-client-webhook-guard"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + OAuthClientWebhookOutputResponseDto, + OAuthClientWebhookOutputDto, + OAuthClientWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/oauth-client-webhook.output"; +import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output"; +import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe"; +import { OAuthClientWebhooksService } from "@/modules/webhooks/services/oauth-clients-webhooks.service"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { Webhook, MembershipRole } from "@prisma/client"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +import { OAuthClientGuard } from "../../guards/oauth-client-guard"; + +@Controller({ + path: "/v2/oauth-clients/:clientId/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard) +@DocsTags("Platform / Webhooks") +export class OAuthClientWebhooksController { + constructor( + private readonly webhooksService: WebhooksService, + private readonly oAuthClientWebhooksService: OAuthClientWebhooksService + ) {} + + @Post("/") + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @ApiOperation({ summary: "Create a webhook" }) + async createOAuthClientWebhook( + @Body() body: CreateWebhookInputDto, + @Param("clientId") oAuthClientId: string + ): Promise { + const webhook = await this.oAuthClientWebhooksService.createOAuthClientWebhook( + oAuthClientId, + new WebhookInputPipe().transform(body) + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Patch("/:webhookId") + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @ApiOperation({ summary: "Update a webhook" }) + @UseGuards(IsOAuthClientWebhookGuard) + async updateOAuthClientWebhook( + @Body() body: UpdateWebhookInputDto, + @Param("webhookId") webhookId: string + ): Promise { + const webhook = await this.webhooksService.updateWebhook( + webhookId, + new PartialWebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/:webhookId") + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @ApiOperation({ summary: "Get a webhook" }) + @UseGuards(IsOAuthClientWebhookGuard) + async getOAuthClientWebhook(@GetWebhook() webhook: Webhook): Promise { + return { + status: SUCCESS_STATUS, + data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/") + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @ApiOperation({ summary: "Get all webhooks" }) + async getOAuthClientWebhooks( + @Param("clientId") oAuthClientId: string, + @Query() pagination: SkipTakePagination + ): Promise { + const webhooks = await this.oAuthClientWebhooksService.getOAuthClientWebhooksPaginated( + oAuthClientId, + pagination.skip ?? 0, + pagination.take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: webhooks.map((webhook) => + plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }) + ), + }; + } + + @Delete("/:webhookId") + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @ApiOperation({ summary: "Delete a webhook" }) + @UseGuards(IsOAuthClientWebhookGuard) + async deleteOAuthClientWebhook( + @GetWebhook() webhook: Webhook + ): Promise { + await this.webhooksService.deleteWebhook(webhook.id); + return { + status: SUCCESS_STATUS, + data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Delete("/") + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @ApiOperation({ summary: "Delete all webhooks" }) + async deleteAllOAuthClientWebhooks( + @Param("clientId") oAuthClientId: string + ): Promise { + const data = await this.oAuthClientWebhooksService.deleteAllOAuthClientWebhooks(oAuthClientId); + return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts new file mode 100644 index 00000000000000..8586b5a674e619 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts @@ -0,0 +1,379 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy"; +import { randomString } from "test/utils/randomString"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { CreateOAuthClientInput } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("OAuth Clients Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it(`/GET`, () => { + return request(appWithoutAuth.getHttpServer()).get("/api/v2/oauth-clients").expect(401); + }); + it(`/GET/:id`, () => { + return request(appWithoutAuth.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(401); + }); + it(`/POST`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth-clients").expect(401); + }); + it(`/PUT/:id`, () => { + return request(appWithoutAuth.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(401); + }); + it(`/DELETE/:id`, () => { + return request(appWithoutAuth.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("Organization is not platform", () => { + let usersFixtures: UserRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamFixtures: TeamRepositoryFixture; + let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture; + + let user: User; + let org: Team; + let app: INestApplication; + const userEmail = `oauth-clients-user-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + const strategy = moduleRef.get(NextAuthStrategy); + expect(strategy).toBeInstanceOf(NextAuthMockStrategy); + usersFixtures = new UserRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + teamFixtures = new TeamRepositoryFixture(moduleRef); + platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef); + + user = await usersFixtures.create({ + email: userEmail, + }); + org = await teamFixtures.create({ + name: `oauth-clients-organization-${randomString()}`, + isOrganization: true, + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: false, + }); + await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + await platformBillingRepositoryFixture.create(org.id); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); + }); + + afterAll(async () => { + await teamFixtures.delete(org.id); + await usersFixtures.delete(user.id); + await app.close(); + }); + }); + + describe("User Is Authenticated", () => { + let usersFixtures: UserRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamFixtures: TeamRepositoryFixture; + let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture; + + let user: User; + let org: Team; + let app: INestApplication; + const userEmail = `oauth-clients-user-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + const strategy = moduleRef.get(NextAuthStrategy); + expect(strategy).toBeInstanceOf(NextAuthMockStrategy); + usersFixtures = new UserRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + teamFixtures = new TeamRepositoryFixture(moduleRef); + platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef); + + user = await usersFixtures.create({ + email: userEmail, + }); + org = await teamFixtures.create({ + name: `oauth-clients-organization-${randomString()}`, + isOrganization: true, + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: true, + }); + await platformBillingRepositoryFixture.create(org.id); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("User is not part of an organization", () => { + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/POST`, () => { + return request(app.getHttpServer()).post("/api/v2/oauth-clients").expect(403); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(403); + }); + }); + + describe("User is part of an organization as Member", () => { + let membership: Membership; + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(200); + }); + it(`/GET/:id - oAuth client does not exist`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(404); + }); + it(`/POST`, () => { + return request(app.getHttpServer()).post("/api/v2/oauth-clients").expect(403); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(403); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + describe("User is part of an organization as Admin", () => { + let membership: Membership; + let client: { clientId: string; clientSecret: string }; + const oAuthClientName = `oauth-clients-admin-${randomString()}`; + + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + }); + + it(`/POST`, () => { + const body: CreateOAuthClientInput = { + name: oAuthClientName, + redirectUris: ["http://test-oauth-client.com"], + permissions: 32, + }; + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.clientId).toBeDefined(); + expect(responseBody.data.clientSecret).toBeDefined(); + client = { + clientId: responseBody.data.clientId, + clientSecret: responseBody.data.clientSecret, + }; + }); + }); + it(`/GET`, () => { + return request(app.getHttpServer()) + .get("/api/v2/oauth-clients") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeInstanceOf(Array); + expect(responseBody.data[0].name).toEqual(oAuthClientName); + }); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${client.clientId}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(oAuthClientName); + }); + }); + it(`/PUT/:id`, () => { + const clientUpdatedName = `oauth-clients-admin-updated-${randomString()}`; + const body: UpdateOAuthClientInput = { name: clientUpdatedName }; + return request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${client.clientId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(clientUpdatedName); + }); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.clientId}`).expect(200); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + describe("User is part of an organization as Owner", () => { + let membership: Membership; + let client: { clientId: string; clientSecret: string }; + const oAuthClientName = `oauth-clients-owner-${randomString()}`; + const oAuthClientPermissions = 32; + + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "OWNER", true); + }); + + it(`/POST`, () => { + const body: CreateOAuthClientInput = { + name: oAuthClientName, + redirectUris: ["http://test-oauth-client.com"], + permissions: 32, + }; + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.clientId).toBeDefined(); + expect(responseBody.data.clientSecret).toBeDefined(); + client = { + clientId: responseBody.data.clientId, + clientSecret: responseBody.data.clientSecret, + }; + }); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()) + .get("/api/v2/oauth-clients") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeInstanceOf(Array); + expect(responseBody.data[0].name).toEqual(oAuthClientName); + expect(responseBody.data[0].permissions).toEqual(oAuthClientPermissions); + }); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${client.clientId}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(oAuthClientName); + expect(responseBody.data.permissions).toEqual(oAuthClientPermissions); + }); + }); + it(`/PUT/:id`, () => { + const clientUpdatedName = `oauth-clients-owner-updated-${randomString()}`; + const body: UpdateOAuthClientInput = { name: clientUpdatedName }; + return request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${client.clientId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(clientUpdatedName); + }); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.clientId}`).expect(200); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + afterAll(async () => { + await teamFixtures.delete(org.id); + await usersFixtures.delete(user.id); + await platformBillingRepositoryFixture.deleteSubscriptionForTeam(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts new file mode 100644 index 00000000000000..ac61c7d048af68 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts @@ -0,0 +1,181 @@ +import { getEnv } from "@/env"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-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"; +import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard"; +import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Query, + Get, + Post, + Patch, + Delete, + Param, + HttpCode, + HttpStatus, + Logger, + UseGuards, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; +import { + ApiTags as DocsTags, + ApiExcludeController as DocsExcludeController, + ApiOperation as DocsOperation, + ApiCreatedResponse as DocsCreatedResponse, +} from "@nestjs/swagger"; +import { User, MembershipRole } 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.`; + +@Controller({ + path: "/v2/oauth-clients", + version: API_VERSIONS_VALUES, +}) +@UseGuards(NextAuthGuard, OrganizationRolesGuard) +export class OAuthClientsController { + private readonly logger = new Logger("OAuthClientController"); + + constructor( + private readonly oauthClientRepository: OAuthClientRepository, + private readonly userRepository: UsersRepository, + private readonly teamsRepository: OrganizationsRepository, + private usersService: UsersService + ) {} + + @Post("/") + @HttpCode(HttpStatus.CREATED) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @DocsCreatedResponse({ + description: "Create an OAuth client", + type: CreateOAuthClientResponseDto, + }) + async createOAuthClient( + @GetUser() user: UserWithProfile, + @Body() body: CreateOAuthClientInput + ): Promise { + const organizationId = this.usersService.getUserMainOrgId(user) as number; + this.logger.log( + `For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}` + ); + + const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId); + if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) { + throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client."); + } + + const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body); + + return { + status: SUCCESS_STATUS, + data: { + clientId: id, + clientSecret: secret, + }, + }; + } + + @Get("/") + @HttpCode(HttpStatus.OK) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async getOAuthClients(@GetUser() user: UserWithProfile): Promise { + const organizationId = this.usersService.getUserMainOrgId(user) as number; + + const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId); + return { status: SUCCESS_STATUS, data: clients }; + } + + @Get("/:clientId") + @HttpCode(HttpStatus.OK) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @UseGuards(OAuthClientGuard) + async getOAuthClientById(@Param("clientId") clientId: string): Promise { + const client = await this.oauthClientRepository.getOAuthClient(clientId); + if (!client) { + throw new NotFoundException(`OAuth client with ID ${clientId} not found`); + } + + return { status: SUCCESS_STATUS, data: client }; + } + + @Get("/:clientId/managed-users") + @HttpCode(HttpStatus.OK) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @UseGuards(OAuthClientGuard) + async getOAuthClientManagedUsersById( + @Param("clientId") clientId: string, + @Query() queryParams: Pagination + ): Promise { + 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) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @UseGuards(OAuthClientGuard) + async updateOAuthClient( + @Param("clientId") clientId: string, + @Body() body: UpdateOAuthClientInput + ): Promise { + this.logger.log(`For client ${clientId} updating OAuth Client with data: ${JSON.stringify(body)}`); + const client = await this.oauthClientRepository.updateOAuthClient(clientId, body); + + return { status: SUCCESS_STATUS, data: client }; + } + + @Delete("/:clientId") + @HttpCode(HttpStatus.OK) + @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @UseGuards(OAuthClientGuard) + async deleteOAuthClient(@Param("clientId") clientId: string): Promise { + this.logger.log(`Deleting OAuth Client with ID: ${clientId}`); + 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, + name: user.name, + timeZone: user.timeZone, + weekStart: user.weekStart, + createdDate: user.createdDate, + timeFormat: user.timeFormat, + defaultScheduleId: user.defaultScheduleId, + }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts new file mode 100644 index 00000000000000..c7eaee25846ae6 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS, REDIRECT_STATUS } from "@calcom/platform-constants"; + +class DataDto { + @ApiProperty({ + example: "clsx38nbl0001vkhlwin9fmt0", + }) + @IsString() + clientId!: string; + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi", + }) + @IsString() + clientSecret!: string; +} + +export class CreateOAuthClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + example: { + clientId: "clsx38nbl0001vkhlwin9fmt0", + clientSecret: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi", + }, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DataDto) + data!: DataDto; +} + +export class CreateOauthClientRedirect { + status!: typeof REDIRECT_STATUS; + + url!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts new file mode 100644 index 00000000000000..83a7f0cd98cef3 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + ValidateNested, + IsEnum, + IsString, + IsNumber, + IsOptional, + IsDate, + IsNotEmptyObject, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class PlatformOAuthClientDto { + @ApiProperty({ example: "clsx38nbl0001vkhlwin9fmt0" }) + @IsString() + id!: string; + + @ApiProperty({ example: "MyClient" }) + @IsString() + name!: string; + + @ApiProperty({ example: "secretValue" }) + @IsString() + secret!: string; + + @ApiProperty({ example: 3 }) + @IsNumber() + permissions!: number; + + @ApiPropertyOptional({ example: "https://example.com/logo.png" }) + @IsOptional() + @IsString() + logo!: string | null; + + @ApiProperty({ example: ["https://example.com/callback"] }) + @IsArray() + @IsString({ each: true }) + redirectUris!: string[]; + + @ApiProperty({ example: 1 }) + @IsNumber() + organizationId!: number; + + @ApiProperty({ example: "2024-03-23T08:33:21.851Z", type: Date }) + @IsDate() + createdAt!: Date; +} + +export class GetOAuthClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ type: PlatformOAuthClientDto }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => PlatformOAuthClientDto) + data!: PlatformOAuthClientDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts new file mode 100644 index 00000000000000..584729b9f06d3b --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts @@ -0,0 +1,21 @@ +import { PlatformOAuthClientDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, ValidateNested, IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetOAuthClientsResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: PlatformOAuthClientDto, + isArray: true, + }) + @ValidateNested({ each: true }) + @Type(() => PlatformOAuthClientDto) + @IsArray() + data!: PlatformOAuthClientDto[]; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts new file mode 100644 index 00000000000000..ac263a4f2c0b91 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts @@ -0,0 +1,190 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; +import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +describe("OAuthFlow Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it(`POST /oauth/:clientId/authorize missing Cookie with user`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/authorize").expect(401); + }); + + it(`POST /oauth/:clientId/exchange missing Authorization Bearer token`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/exchange").expect(400); + }); + + it(`POST /oauth/:clientId/refresh missing ${X_CAL_SECRET_KEY} header with secret`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/refresh").expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let usersRepositoryFixtures: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + + let user: User; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + + let authorizationCode: string | null; + let refreshToken: string; + + beforeAll(async () => { + const userEmail = `oauth-flow-user-${randomString()}@api.com`; + + const moduleRef: TestingModule = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + usersRepositoryFixtures = new UserRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + user = await usersRepositoryFixtures.create({ + email: userEmail, + }); + + organization = await organizationsRepositoryFixture.create({ + name: `oauth-flow-organization-${randomString()}`, + }); + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: user.id } }, + movedFromUser: { connect: { id: user.id } }, + organization: { connect: { id: organization.id } }, + }); + oAuthClient = await createOAuthClient(organization.id); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri.com"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oAuthClientsRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("Authorize Endpoint", () => { + it("POST /oauth/:clientId/authorize", async () => { + const body: OAuthAuthorizeInput = { + redirectUri: oAuthClient.redirectUris[0], + }; + + const REDIRECT_STATUS = 302; + + const response = await request(app.getHttpServer()) + .post(`/v2/oauth/${oAuthClient.id}/authorize`) + .send(body) + .expect(REDIRECT_STATUS); + + const baseUrl = "http://www.localhost/"; + const redirectUri = new URL(response.header.location, baseUrl); + authorizationCode = redirectUri.searchParams.get("code"); + + expect(authorizationCode).toBeDefined(); + }); + }); + + describe("Exchange Endpoint", () => { + it("POST /oauth/:clientId/exchange", async () => { + const authorizationToken = `Bearer ${authorizationCode}`; + const body: ExchangeAuthorizationCodeInput = { + clientSecret: oAuthClient.secret, + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/oauth/${oAuthClient.id}/exchange`) + .set("Authorization", authorizationToken) + .send(body) + .expect(200); + + expect(response.body?.data?.accessToken).toBeDefined(); + expect(response.body?.data?.refreshToken).toBeDefined(); + + refreshToken = response.body.data.refreshToken; + }); + }); + + describe("Refresh Token Endpoint", () => { + it("POST /oauth/:clientId/refresh", () => { + const secretKey = oAuthClient.secret; + const body = { + refreshToken, + }; + + return request(app.getHttpServer()) + .post(`/v2/oauth/${oAuthClient.id}/refresh`) + .set("x-cal-secret-key", secretKey) + .send(body) + .expect(200) + .then((response) => { + expect(response.body?.data?.accessToken).toBeDefined(); + expect(response.body?.data?.refreshToken).toBeDefined(); + }); + }); + }); + + afterAll(async () => { + await oAuthClientsRepositoryFixture.delete(oAuthClient.id); + await organizationsRepositoryFixture.delete(organization.id); + await usersRepositoryFixtures.delete(user.id); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts new file mode 100644 index 00000000000000..37ba6fc00ad2b6 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts @@ -0,0 +1,150 @@ +import { getEnv } from "@/env"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; +import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; +import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; +import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { + BadRequestException, + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Param, + Post, + Response, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags as DocsTags, + ApiExcludeController as DocsExcludeController, + ApiOperation as DocsOperation, + ApiOkResponse as DocsOkResponse, + ApiExcludeEndpoint as DocsExcludeEndpoint, + ApiBadRequestResponse as DocsBadRequestResponse, + ApiHeader as DocsHeader, + ApiOperation, +} from "@nestjs/swagger"; +import { Response as ExpressResponse } from "express"; + +import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/oauth/:clientId", + version: API_VERSIONS_VALUES, +}) +export class OAuthFlowController { + constructor( + private readonly oauthClientRepository: OAuthClientRepository, + private readonly tokensRepository: TokensRepository, + private readonly oAuthFlowService: OAuthFlowService + ) {} + + @Post("/authorize") + @HttpCode(HttpStatus.OK) + @UseGuards(NextAuthGuard) + @DocsExcludeEndpoint() + async authorize( + @Param("clientId") clientId: string, + @Body() body: OAuthAuthorizeInput, + @GetUser("id") userId: number, + @Response() res: ExpressResponse + ): Promise { + const oauthClient = await this.oauthClientRepository.getOAuthClient(clientId); + if (!oauthClient) { + throw new BadRequestException(`OAuth client with ID '${clientId}' not found`); + } + + if (!isOriginAllowed(body.redirectUri, oauthClient.redirectUris)) { + throw new BadRequestException("Invalid 'redirect_uri' value."); + } + + const alreadyAuthorized = await this.tokensRepository.getAuthorizationTokenByClientUserIds( + clientId, + userId + ); + + if (alreadyAuthorized) { + throw new BadRequestException( + `User with id=${userId} has already authorized client with id=${clientId}.` + ); + } + + const { id } = await this.tokensRepository.createAuthorizationToken(clientId, userId); + + return res.redirect(`${body.redirectUri}?code=${id}`); + } + + @Post("/exchange") + @HttpCode(HttpStatus.OK) + @DocsExcludeEndpoint() + async exchange( + @Headers("Authorization") authorization: string, + @Param("clientId") clientId: string, + @Body() body: ExchangeAuthorizationCodeInput + ): Promise { + const authorizeEndpointCode = authorization.replace("Bearer ", "").trim(); + if (!authorizeEndpointCode) { + throw new BadRequestException("Missing 'Bearer' Authorization header."); + } + + const { accessToken, refreshToken, accessTokenExpiresAt } = + await this.oAuthFlowService.exchangeAuthorizationToken( + authorizeEndpointCode, + clientId, + body.clientSecret + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken, + accessTokenExpiresAt: accessTokenExpiresAt.valueOf(), + refreshToken, + }, + }; + } + + @Post("/refresh") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @DocsTags("Platform / Managed Users") + @DocsHeader({ + name: X_CAL_SECRET_KEY, + description: "OAuth client secret key.", + required: true, + }) + @ApiOperation({ + summary: "Refresh managed user tokens", + description: `If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and + each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`, + }) + async refreshTokens( + @Param("clientId") clientId: string, + @Headers(X_CAL_SECRET_KEY) secretKey: string, + @Body() body: RefreshTokenInput + ): Promise { + const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken( + clientId, + secretKey, + body.refreshToken + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken: accessToken, + accessTokenExpiresAt: accessTokenExpiresAt.valueOf(), + refreshToken: refreshToken, + }, + }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts new file mode 100644 index 00000000000000..9a5f5f4bcbc05e --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class KeysDto { + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }) + @IsString() + accessToken!: string; + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }) + @IsString() + refreshToken!: string; + + @IsNumber() + accessTokenExpiresAt!: number; +} + +export class KeysResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: KeysDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => KeysDto) + data!: KeysDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-guard.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-guard.ts new file mode 100644 index 00000000000000..b06eaad2fb3291 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-guard.ts @@ -0,0 +1,39 @@ +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; +import { Request } from "express"; + +@Injectable() +export class OAuthClientGuard implements CanActivate { + constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user: ApiAuthGuardUser = request.user; + const organizationId = user ? this.usersService.getUserMainOrgId(user) : null; + const oAuthClientId = request.params.clientId; + + if (!oAuthClientId) { + throw new ForbiddenException("No OAuth client associated with the request."); + } + + if (!user || !organizationId) { + throw new ForbiddenException("No organization associated with the user."); + } + + const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId); + + if (!oAuthClient) { + throw new NotFoundException("OAuth client not found."); + } + + return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId); + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts new file mode 100644 index 00000000000000..62b7987310320a --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class OAuthAuthorizeInput { + @IsString() + redirectUri!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts new file mode 100644 index 00000000000000..938f8db08fe382 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class ExchangeAuthorizationCodeInput { + @IsString() + clientSecret!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts new file mode 100644 index 00000000000000..7e2cacfdac63f9 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts @@ -0,0 +1,8 @@ +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { IsString } from "class-validator"; + +export class RefreshTokenInput { + @IsString() + @DocsProperty({ description: "Managed user's refresh token." }) + refreshToken!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts new file mode 100644 index 00000000000000..5282efbddf9dad --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsArray, IsBoolean, IsOptional, IsString } from "class-validator"; + +export class UpdateOAuthClientInput { + @IsOptional() + @IsString() + @ApiPropertyOptional() + logo?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + name?: string; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @ApiPropertyOptional({ type: [String] }) + redirectUris?: string[]; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + bookingRedirectUri?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + bookingCancelRedirectUri?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + bookingRescheduleRedirectUri?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + areEmailsEnabled?: boolean; +} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts new file mode 100644 index 00000000000000..d757748d6259e2 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -0,0 +1,47 @@ +import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { BillingModule } from "@/modules/billing/billing.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; +import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller"; +import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; +import { Global, Module } from "@nestjs/common"; + +@Global() +@Module({ + imports: [ + PrismaModule, + RedisModule, + AuthModule, + UsersModule, + TokensModule, + MembershipsModule, + EventTypesModule_2024_04_15, + OrganizationsModule, + StripeModule, + BillingModule, + SchedulesModule_2024_04_15, + ], + providers: [ + OAuthClientRepository, + TokensRepository, + OAuthFlowService, + OAuthClientUsersService, + OrganizationsTeamsService, + ], + controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController], + exports: [OAuthClientRepository], +}) +export class OAuthClientModule {} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts new file mode 100644 index 00000000000000..dbbf2dbf21bcae --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts @@ -0,0 +1,126 @@ +import { JwtService } from "@/modules/jwt/jwt.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import type { PlatformOAuthClient } from "@prisma/client"; + +import type { CreateOAuthClientInput } from "@calcom/platform-types"; + +@Injectable() +export class OAuthClientRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private jwtService: JwtService + ) {} + + async createOAuthClient(organizationId: number, data: CreateOAuthClientInput) { + return this.dbWrite.prisma.platformOAuthClient.create({ + data: { + ...data, + secret: this.jwtService.sign(data), + organizationId, + }, + }); + } + + async getOAuthClient(clientId: string): Promise { + return this.dbRead.prisma.platformOAuthClient.findUnique({ + where: { id: clientId }, + }); + } + + async getOAuthClientWithAuthTokens(tokenId: string, clientId: string, clientSecret: string) { + return this.dbRead.prisma.platformOAuthClient.findUnique({ + where: { + id: clientId, + secret: clientSecret, + authorizationTokens: { + some: { + id: tokenId, + }, + }, + }, + include: { + authorizationTokens: { + where: { + id: tokenId, + }, + include: { + owner: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + } + + async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) { + return this.dbRead.prisma.platformOAuthClient.findFirst({ + where: { + id: clientId, + secret: clientSecret, + }, + include: { + refreshToken: { + where: { + secret: refreshToken, + }, + }, + }, + }); + } + + async getOrganizationOAuthClients(organizationId: number): Promise { + return this.dbRead.prisma.platformOAuthClient.findMany({ + where: { + organization: { + id: organizationId, + }, + }, + }); + } + + async updateOAuthClient( + clientId: string, + updateData: Partial + ): Promise { + return this.dbWrite.prisma.platformOAuthClient.update({ + where: { id: clientId }, + data: updateData, + }); + } + + async deleteOAuthClient(clientId: string): Promise { + return this.dbWrite.prisma.platformOAuthClient.delete({ + where: { id: clientId }, + }); + } + + async getByUserId(userId: number) { + return this.dbRead.prisma.platformOAuthClient.findFirst({ + where: { + users: { + some: { + id: userId, + }, + }, + }, + }); + } + + async getByTeamId(teamId: number) { + return this.dbRead.prisma.platformOAuthClient.findFirst({ + where: { + teams: { + some: { + id: teamId, + }, + }, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts new file mode 100644 index 00000000000000..338361ca78ac72 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -0,0 +1,119 @@ +import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; +import { User, CreationSource } from "@prisma/client"; + +import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries"; + +@Injectable() +export class OAuthClientUsersService { + constructor( + private readonly userRepository: UsersRepository, + private readonly tokensRepository: TokensRepository, + private readonly eventTypesService: EventTypesService_2024_04_15, + private readonly schedulesService: SchedulesService_2024_04_15, + private readonly organizationsTeamsService: OrganizationsTeamsService + ) {} + + async createOauthClientUser( + oAuthClientId: string, + body: CreateManagedUserInput, + isPlatformManaged: boolean, + organizationId?: number + ) { + const existingUser = await this.getExistingUserByEmail(oAuthClientId, body.email); + if (existingUser) { + throw new ConflictException( + `User with the provided e-mail already exists. Existing user ID=${existingUser.id}` + ); + } + + let user: User; + if (!organizationId) { + throw new BadRequestException("You cannot create a managed user outside of an organization"); + } else { + const email = this.getOAuthUserEmail(oAuthClientId, body.email); + user = ( + await createNewUsersConnectToOrgIfExists({ + invitations: [ + { + usernameOrEmail: email, + role: "MEMBER", + }, + ], + creationSource: CreationSource.API_V2, + teamId: organizationId, + isOrg: true, + parentId: null, + autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users", + orgConnectInfoByUsernameOrEmail: { + [email]: { + orgId: organizationId, + autoAccept: true, + }, + }, + isPlatformManaged, + timeFormat: body.timeFormat, + weekStart: body.weekStart, + timeZone: body.timeZone, + }) + )[0]; + await this.userRepository.addToOAuthClient(user.id, oAuthClientId); + const updatedUser = await this.userRepository.update(user.id, { + name: body.name, + locale: body.locale, + avatarUrl: body.avatarUrl, + }); + user.locale = updatedUser.locale; + user.name = updatedUser.name; + user.avatarUrl = updatedUser.avatarUrl; + } + + const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( + oAuthClientId, + user.id + ); + + await this.eventTypesService.createUserDefaultEventTypes(user.id); + + if (body.timeZone) { + const defaultSchedule = await this.schedulesService.createUserDefaultSchedule(user.id, body.timeZone); + user.defaultScheduleId = defaultSchedule.id; + } + + return { + user, + tokens: { + accessToken, + accessTokenExpiresAt, + refreshToken, + }, + }; + } + + async getExistingUserByEmail(oAuthClientId: string, email: string) { + const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email); + return await this.userRepository.findByEmail(oAuthEmail); + } + + async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) { + if (body.email) { + const emailWithOAuthId = this.getOAuthUserEmail(oAuthClientId, body.email); + body.email = emailWithOAuthId; + const newUsername = slugify(emailWithOAuthId); + await this.userRepository.updateUsername(userId, newUsername); + } + + return this.userRepository.update(userId, body); + } + + getOAuthUserEmail(oAuthClientId: string, userEmail: string) { + const [username, emailDomain] = userEmail.split("@"); + return `${username}+${oAuthClientId}@${emailDomain}`; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts new file mode 100644 index 00000000000000..ac0791ea2b590a --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts @@ -0,0 +1,174 @@ +import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common"; +import { DateTime } from "luxon"; + +import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; + +@Injectable() +export class OAuthFlowService { + private logger = new Logger("OAuthFlowService"); + + constructor( + private readonly tokensRepository: TokensRepository, + private readonly oAuthClientRepository: OAuthClientRepository, + private readonly redisService: RedisService + ) {} + + async propagateAccessToken(accessToken: string) { + try { + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + let expiry = await this.tokensRepository.getAccessTokenExpiryDate(accessToken); + + if (!expiry) { + this.logger.warn(`Token for ${ownerId} had no expiry time, assuming it's new.`); + expiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); + } + + const cacheKey = this._generateActKey(accessToken); + await this.redisService.redis.hmset(cacheKey, { + ownerId: ownerId, + expiresAt: expiry?.toJSON(), + }); + + await this.redisService.redis.expireat(cacheKey, Math.floor(expiry.getTime() / 1000)); + } catch (err) { + this.logger.error("Access Token Propagation Failed, falling back to DB...", err); + } + } + + async getOwnerId(accessToken: string) { + const cacheKey = this._generateOwnerIdKey(accessToken); + + try { + const ownerId = await this.redisService.redis.get(cacheKey); + if (ownerId) { + return Number.parseInt(ownerId); + } + } catch (err) { + this.logger.warn("Cache#getOwnerId fetch failed, falling back to DB..."); + } + + const ownerIdFromDb = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB"); + + // await in case of race conditions, but void it's return since cache writes shouldn't halt execution. + void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour + + return ownerIdFromDb; + } + + async validateAccessToken(secret: string) { + // status can be "CACHE_HIT" or "CACHE_MISS", MISS will most likely mean the token has expired + // but we need to check the SQL db for it anyways. + const { status, cacheKey } = await this.readFromCache(secret); + + if (status === "CACHE_HIT") { + return true; + } + + const tokenExpiresAt = await this.tokensRepository.getAccessTokenExpiryDate(secret); + + if (!tokenExpiresAt) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + if (new Date() > tokenExpiresAt) { + throw new TokenExpiredException(); + } + + // we can't use a Promise#all or similar here because we care about execution order + // however we can't allow caches to fail a validation hence the results are voided. + void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() })); + void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000))); + + return true; + } + + private async readFromCache(secret: string) { + const cacheKey = this._generateActKey(secret); + const tokenData = await this.redisService.redis.hgetall(cacheKey); + + if (tokenData && new Date() < new Date(tokenData.expiresAt)) { + return { status: "CACHE_HIT", cacheKey }; + } + + return { status: "CACHE_MISS", cacheKey }; + } + + async exchangeAuthorizationToken( + tokenId: string, + clientId: string, + clientSecret: string + ): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> { + const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens( + tokenId, + clientId, + clientSecret + ); + + if (!oauthClient) { + throw new BadRequestException("Invalid OAuth Client."); + } + + const authorizationToken = oauthClient.authorizationTokens[0]; + + if (!authorizationToken || !authorizationToken.owner.id) { + throw new BadRequestException("Invalid Authorization Token."); + } + + const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( + clientId, + authorizationToken.owner.id + ); + await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id); + void this.propagateAccessToken(accessToken); // void result, ignored. + + return { + accessToken, + accessTokenExpiresAt, + refreshToken, + }; + } + + async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) { + const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret( + clientId, + clientSecret, + tokenSecret + ); + + if (!oauthClient) { + throw new BadRequestException("Invalid OAuthClient credentials."); + } + + const currentRefreshToken = oauthClient.refreshToken[0]; + + if (!currentRefreshToken) { + throw new BadRequestException("Invalid refresh token"); + } + + const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens( + clientId, + currentRefreshToken.secret, + currentRefreshToken.userId + ); + + return { + accessToken: accessToken.secret, + accessTokenExpiresAt: accessToken.expiresAt, + refreshToken: refreshToken.secret, + }; + } + + private _generateActKey(accessToken: string) { + return `act_${accessToken}`; + } + + private _generateOwnerIdKey(accessToken: string) { + return `owner_${accessToken}`; + } +} diff --git a/apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts b/apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts new file mode 100644 index 00000000000000..05ed3c78bca3f6 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts @@ -0,0 +1,30 @@ +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +@Injectable() +export class IsUserOOO implements CanActivate { + constructor(private oooRepo: UserOOORepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const oooId: string = request.params.oooId; + const userId: string = request.params.userId; + + if (!userId) { + throw new ForbiddenException("No user id found in request params."); + } + + if (!oooId) { + throw new ForbiddenException("No ooo entry id found in request params."); + } + + const ooo = await this.oooRepo.getUserOOOByIdAndUserId(Number(oooId), Number(userId)); + + if (ooo) { + return true; + } + + throw new ForbiddenException("This OOO entry does not belong to this user."); + } +} diff --git a/apps/api/v2/src/modules/ooo/inputs/ooo.input.ts b/apps/api/v2/src/modules/ooo/inputs/ooo.input.ts new file mode 100644 index 00000000000000..94c94333ccf0a0 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/inputs/ooo.input.ts @@ -0,0 +1,128 @@ +import { BadRequestException } from "@nestjs/common"; +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsDate, IsInt, IsOptional, IsString, IsEnum, isDate } from "class-validator"; + +import { SkipTakePagination } from "@calcom/platform-types"; + +export enum OutOfOfficeReason { + UNSPECIFIED = "unspecified", + VACATION = "vacation", + TRAVEL = "travel", + SICK_LEAVE = "sick", + PUBLIC_HOLIDAY = "public_holiday", +} + +export type OutOfOfficeReasonType = `${OutOfOfficeReason}`; + +const isDateString = (dateString: string) => { + try { + const isoDateRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{3})?Z$/; + return isoDateRegex.test(dateString); + } catch { + throw new BadRequestException("Invalid Date."); + } +}; + +export class CreateOutOfOfficeEntryDto { + @Transform(({ value }: { value: string }) => { + if (isDateString(value)) { + const date = new Date(value); + date.setUTCHours(0, 0, 0, 0); + return date; + } + throw new BadRequestException("Invalid Date."); + }) + @IsDate() + @ApiProperty({ + description: "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-01T00:00:00.000Z", + }) + start!: Date; + + @Transform(({ value }: { value: string }) => { + if (isDateString(value)) { + const date = new Date(value); + date.setUTCHours(23, 59, 59, 999); + return date; + } + throw new BadRequestException("Invalid Date."); + }) + @IsDate() + @ApiProperty({ + description: "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-10T23:59:59.999Z", + }) + end!: Date; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: "Optional notes for the out of office entry.", + example: "Vacation in Hawaii", + }) + notes?: string; + + @IsInt() + @IsOptional() + @ApiPropertyOptional({ + description: "The ID of the user covering for the out of office period, if applicable.", + example: 2, + }) + toUserId?: number; + + @IsEnum(OutOfOfficeReason) + @IsOptional() + @ApiPropertyOptional({ + description: "the reason for the out of office entry, if applicable", + example: "vacation", + enum: OutOfOfficeReason, + }) + reason?: OutOfOfficeReasonType; +} + +export class UpdateOutOfOfficeEntryDto extends PartialType(CreateOutOfOfficeEntryDto) {} + +export enum SortOrder { + asc = "asc", + desc = "desc", +} +type SortOrderType = keyof typeof SortOrder; + +export class GetOutOfOfficeEntryFiltersDTO extends SkipTakePagination { + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortStart must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their start time in ascending or descending order.", + example: "?sortStart=asc OR ?sortStart=desc", + enum: SortOrder, + }) + sortStart?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortEnd must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their end time in ascending or descending order.", + example: "?sortEnd=asc OR ?sortEnd=desc", + enum: SortOrder, + }) + sortEnd?: SortOrderType; +} + +export class GetOrgUsersOutOfOfficeEntryFiltersDTO extends GetOutOfOfficeEntryFiltersDTO { + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter ooo entries by the user email address. user must be within your organization.", + example: "example@domain.com", + }) + email?: string; +} diff --git a/apps/api/v2/src/modules/ooo/outputs/ooo.output.ts b/apps/api/v2/src/modules/ooo/outputs/ooo.output.ts new file mode 100644 index 00000000000000..7cb39c1d93c038 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/outputs/ooo.output.ts @@ -0,0 +1,100 @@ +import { OutOfOfficeReason } from "@/modules/ooo/inputs/ooo.input"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested, IsString, IsDateString, IsOptional } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UserOooOutputDto { + @IsInt() + @Expose() + @ApiProperty({ + description: "The ID of the user.", + example: 2, + }) + readonly userId!: number; + + @IsInt() + @IsOptional() + @ApiPropertyOptional({ + description: "The ID of the user covering for the out of office period, if applicable.", + example: 2, + }) + @Expose() + readonly toUserId?: number; + + @IsInt() + @Expose() + @ApiProperty({ + description: "The ID of the ooo entry.", + example: 2, + }) + readonly id!: number; + + @IsString() + @Expose() + @ApiProperty({ + description: "The UUID of the ooo entry.", + example: 2, + }) + readonly uuid!: string; + + @IsDateString() + @ApiProperty({ + description: "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-01T00:00:00.000Z", + }) + @Expose() + start!: Date; + + @IsDateString() + @ApiProperty({ + description: "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-10T23:59:59.999Z", + }) + @Expose() + end!: Date; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: "Optional notes for the out of office entry.", + example: "Vacation in Hawaii", + }) + @Expose() + notes?: string; + + @IsEnum(OutOfOfficeReason) + @IsOptional() + @ApiPropertyOptional({ + description: "the reason for the out of office entry, if applicable", + example: "vacation", + enum: OutOfOfficeReason, + }) + @Expose() + reason?: OutOfOfficeReason; +} + +export class UserOooOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => UserOooOutputDto) + data!: UserOooOutputDto; +} + +export class UserOoosOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => UserOooOutputDto) + data!: UserOooOutputDto[]; +} diff --git a/apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts b/apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts new file mode 100644 index 00000000000000..64077339fc1127 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts @@ -0,0 +1,107 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { Prisma } from "@calcom/prisma/client"; + +import { PrismaWriteService } from "../../prisma/prisma-write.service"; + +type OOOInputData = Omit & { + toUserId?: number; + userId: number; + reasonId?: number; +}; + +@Injectable() +export class UserOOORepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createUserOOO(data: OOOInputData) { + const uuid = uuidv4(); + return this.dbWrite.prisma.outOfOfficeEntry.create({ + data: { ...data, uuid }, + include: { reason: true }, + }); + } + + async updateUserOOO(oooId: number, data: Partial) { + return this.dbWrite.prisma.outOfOfficeEntry.update({ + where: { id: oooId }, + data, + include: { reason: true }, + }); + } + + async getUserOOOById(oooId: number) { + return this.dbRead.prisma.outOfOfficeEntry.findFirst({ + where: { id: oooId }, + include: { reason: true }, + }); + } + + async getUserOOOByIdAndUserId(oooId: number, userId: number) { + return this.dbRead.prisma.outOfOfficeEntry.findFirst({ + where: { id: oooId, userId }, + include: { reason: true }, + }); + } + + async getUserOOOPaginated( + userId: number, + skip: number, + take: number, + sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" } + ) { + return this.dbRead.prisma.outOfOfficeEntry.findMany({ + where: { userId }, + skip, + take, + include: { reason: true }, + ...(sort?.sortStart && { orderBy: { start: sort.sortStart } }), + ...(sort?.sortEnd && { orderBy: { end: sort.sortEnd } }), + }); + } + + async deleteUserOOO(oooId: number) { + return this.dbWrite.prisma.outOfOfficeEntry.delete({ + where: { id: oooId }, + include: { reason: true }, + }); + } + + async findExistingOooRedirect(userId: number, start: Date, end: Date, toUserId?: number) { + const existingOutOfOfficeEntry = await this.dbRead.prisma.outOfOfficeEntry.findFirst({ + select: { + userId: true, + toUserId: true, + }, + where: { + ...(toUserId && { userId: toUserId }), + toUserId: userId, + // Check for time overlap or collision + OR: [ + // Outside of range + { + AND: [{ start: { lte: end } }, { end: { gte: start } }], + }, + // Inside of range + { + AND: [{ start: { gte: start } }, { end: { lte: end } }], + }, + ], + }, + }); + + return existingOutOfOfficeEntry; + } + + async getOooByUserIdAndTime(userId: number, start: Date, end: Date) { + return await this.dbRead.prisma.outOfOfficeEntry.findFirst({ + where: { + userId, + start, + end, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/ooo/services/ooo.service.ts b/apps/api/v2/src/modules/ooo/services/ooo.service.ts new file mode 100644 index 00000000000000..0bc489b9b60a36 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/services/ooo.service.ts @@ -0,0 +1,142 @@ +import { + CreateOutOfOfficeEntryDto, + UpdateOutOfOfficeEntryDto, + OutOfOfficeReason, + GetOutOfOfficeEntryFiltersDTO, + SortOrder, +} from "@/modules/ooo/inputs/ooo.input"; +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; + +import { OutOfOfficeEntry } from "@calcom/prisma/client"; + +const OOO_REASON_ID_TO_REASON = { + 1: OutOfOfficeReason["UNSPECIFIED"], + 2: OutOfOfficeReason["VACATION"], + 3: OutOfOfficeReason["TRAVEL"], + 4: OutOfOfficeReason["SICK_LEAVE"], + 5: OutOfOfficeReason["PUBLIC_HOLIDAY"], +}; + +const OOO_REASON_TO_REASON_ID = { + [OutOfOfficeReason["UNSPECIFIED"]]: 1, + [OutOfOfficeReason["VACATION"]]: 2, + [OutOfOfficeReason["TRAVEL"]]: 3, + [OutOfOfficeReason["SICK_LEAVE"]]: 4, + [OutOfOfficeReason["PUBLIC_HOLIDAY"]]: 5, +}; + +@Injectable() +export class UserOOOService { + constructor( + private readonly oooRepository: UserOOORepository, + private readonly usersRepository: UsersRepository + ) {} + + formatOooReason(ooo: OutOfOfficeEntry) { + return { + ...ooo, + reason: ooo.reasonId + ? OOO_REASON_ID_TO_REASON[ooo.reasonId as keyof typeof OOO_REASON_ID_TO_REASON] + : OOO_REASON_ID_TO_REASON[1], + }; + } + + isStartBeforeEnd(start?: Date, end?: Date) { + if ((end && !start) || (start && !end)) { + throw new BadRequestException("Please specify both ooo start and end time."); + } + + if (start && end) { + if (start.getTime() > end.getTime()) { + throw new BadRequestException("Start date must be before end date."); + } + } + return true; + } + + async checkUserEligibleForRedirect(userId: number, toUserId?: number) { + if (toUserId) { + const user = await this.usersRepository.findUserOOORedirectEligible(userId, toUserId); + if (!user) { + throw new BadRequestException("Cannot redirect to this user."); + } + } + } + + async checkExistingOooRedirect(userId: number, start?: Date, end?: Date, toUserId?: number) { + if (start && end) { + const existingOooRedirect = await this.oooRepository.findExistingOooRedirect( + userId, + start, + end, + toUserId + ); + + if (existingOooRedirect) { + throw new BadRequestException("Booking redirect infinite not allowed."); + } + } + } + + async checkDuplicateOOOEntry(userId: number, start?: Date, end?: Date) { + if (start && end) { + const duplicateEntry = await this.oooRepository.getOooByUserIdAndTime(userId, start, end); + + if (duplicateEntry) { + throw new ConflictException("Ooo entry already exists."); + } + } + } + + checkRedirectToSelf(userId: number, toUserId?: number) { + if (toUserId && toUserId === userId) { + throw new BadRequestException("Cannot redirect to self."); + } + } + + async checkIsValidOOO(userId: number, ooo: CreateOutOfOfficeEntryDto | UpdateOutOfOfficeEntryDto) { + this.isStartBeforeEnd(ooo.start, ooo.end); + await this.checkExistingOooRedirect(userId, ooo.start, ooo.end, ooo.toUserId); + await this.checkDuplicateOOOEntry(userId, ooo.start, ooo.end); + await this.checkRedirectToSelf(userId, ooo.toUserId); + await this.checkUserEligibleForRedirect(userId, ooo.toUserId); + } + + async createUserOOO(userId: number, body: CreateOutOfOfficeEntryDto) { + await this.checkIsValidOOO(userId, body); + const { reason, ...rest } = body; + const ooo = await this.oooRepository.createUserOOO({ + ...rest, + userId, + reasonId: OOO_REASON_TO_REASON_ID[reason ?? OutOfOfficeReason["UNSPECIFIED"]], + }); + return this.formatOooReason(ooo); + } + + async updateUserOOO(userId: number, oooId: number, body: UpdateOutOfOfficeEntryDto) { + await this.checkIsValidOOO(userId, body); + const { reason, ...rest } = body; + const data = reason + ? { ...rest, reasonId: OOO_REASON_TO_REASON_ID[reason ?? OutOfOfficeReason["UNSPECIFIED"]] } + : rest; + const ooo = await this.oooRepository.updateUserOOO(oooId, data); + return this.formatOooReason(ooo); + } + + async deleteUserOOO(oooId: number) { + const ooo = await this.oooRepository.deleteUserOOO(oooId); + return this.formatOooReason(ooo); + } + + async getUserOOOPaginated( + userId: number, + skip: number, + take: number, + sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" } + ) { + const ooos = await this.oooRepository.getUserOOOPaginated(userId, skip, take, sort); + return ooos.map((ooo) => this.formatOooReason(ooo)); + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.ts new file mode 100644 index 00000000000000..b16f0504a5aceb --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.ts @@ -0,0 +1,251 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input"; +import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input"; +import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { User, Team, Membership } from "@calcom/prisma/client"; + +describe("Organizations Attributes Options Endpoints", () => { + describe("User lacks required role", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + + const userEmail = `organization-attributes-options-member-${randomString()}@api.com`; + let user: User; + let org: Team; + let membership: Membership; + let attributeId: string; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organization-attributes-options-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + + // Create an attribute for testing + attributeId = "test-attribute-id"; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should not be able to create attribute option", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`) + .send({ + name: "Option 1", + value: "option1", + }) + .expect(403); + }); + + it("should not be able to delete attribute option", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/1`) + .expect(403); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); + + describe("User has required role", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let attributeRepositoryFixture: AttributeRepositoryFixture; + + const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`; + let user: User; + let org: Team; + let membership: Membership; + let attributeId: string; + let createdOption: any; + + const createOptionInput: CreateOrganizationAttributeOptionInput = { + value: "option1", + slug: "option1", + }; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organization-attributes-options-admin-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + + // Create an attribute for testing + const attribute = await attributeRepositoryFixture.create({ + name: "Test Attribute", + team: { connect: { id: org.id } }, + type: "TEXT", + slug: "test-attribute", + }); + attributeId = attribute.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should create attribute option", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`) + .send(createOptionInput) + .expect(201) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + createdOption = response.body.data; + expect(createdOption.value).toEqual(createOptionInput.value); + expect(createdOption.slug).toEqual(createOptionInput.slug); + }); + }); + + it("should get attribute options", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const options = response.body.data; + expect(options.length).toEqual(1); + expect(options[0].value).toEqual(createOptionInput.value); + expect(options[0].slug).toEqual(createOptionInput.slug); + }); + }); + + it("should update attribute option", async () => { + const updateOptionInput: UpdateOrganizationAttributeOptionInput = { + value: "updated-option-value", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`) + .send(updateOptionInput) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const updatedOption = response.body.data; + expect(updatedOption.value).toEqual(updateOptionInput.value); + }); + }); + + it("should assign attribute option to user", async () => { + const assignInput: AssignOrganizationAttributeOptionToUserInput = { + attributeId: attributeId, + attributeOptionId: createdOption.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes/options/${user.id}`) + .send(assignInput) + .expect(201) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + expect(response.body.data).toBeTruthy(); + }); + }); + + it("should get attribute options for user", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/options/${user.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const userOptions = response.body.data; + expect(userOptions.length).toEqual(1); + expect(userOptions[0].id).toEqual(createdOption.id); + }); + }); + + it("should unassign attribute option from user", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + expect(response.body.data).toBeTruthy(); + }); + }); + + it("should delete attribute option", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + expect(response.body.data).toBeTruthy(); + }); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts new file mode 100644 index 00000000000000..c7d75c8fd43f69 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts @@ -0,0 +1,214 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input"; +import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { User, Team, Membership } from "@calcom/prisma/client"; + +describe("Organizations Attributes Endpoints", () => { + describe("User lacks required role", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + + const userEmail = `organization-attributes-member-${randomString()}@api.com`; + let user: User; + let org: Team; + let membership: Membership; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organization-attributes-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should not be able to create attribute for org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes`) + .send({ + key: "department", + value: "engineering", + }) + .expect(403); + }); + + it("should not be able to delete attribute for org", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/attributes/1`).expect(403); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); + + describe("User has required role", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + + const userEmail = `organization-attributes-admin-${randomString()}@api.com`; + let user: User; + let org: Team; + let membership: Membership; + + let createdAttribute: any; + + const createAttributeInput: CreateOrganizationAttributeInput = { + name: "department", + slug: "department", + type: "TEXT", + options: [], + enabled: true, + }; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organization-attributes-admin-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should create attribute for org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes`) + .send(createAttributeInput) + .expect(201) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + createdAttribute = response.body.data; + expect(createdAttribute.type).toEqual(createAttributeInput.type); + expect(createdAttribute.slug).toEqual(createAttributeInput.slug); + expect(createdAttribute.enabled).toEqual(createAttributeInput.enabled); + }); + }); + + it("should get org attributes", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const attributes = response.body.data; + expect(attributes.length).toEqual(1); + expect(attributes[0].name).toEqual(createAttributeInput.name); + expect(attributes[0].slug).toEqual(createAttributeInput.slug); + expect(attributes[0].type).toEqual(createAttributeInput.type); + expect(attributes[0].enabled).toEqual(createAttributeInput.enabled); + }); + }); + + it("should get single org attribute", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const attribute = response.body.data; + expect(attribute.name).toEqual(createAttributeInput.name); + expect(attribute.slug).toEqual(createAttributeInput.slug); + expect(attribute.type).toEqual(createAttributeInput.type); + expect(attribute.enabled).toEqual(createAttributeInput.enabled); + }); + }); + + it("should update org attribute", async () => { + const updateAttributeInput: UpdateOrganizationAttributeInput = { + name: "marketing", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`) + .send(updateAttributeInput) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const updatedAttribute = response.body.data; + expect(updatedAttribute.name).toEqual(updateAttributeInput.name); + }); + }); + + it("should delete org attribute", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + expect(response.body.data).toBeTruthy(); + }); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts new file mode 100644 index 00000000000000..14fce71f99dab4 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts @@ -0,0 +1,176 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input"; +import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input"; +import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts"; +import { + AssignOptionUserOutput, + UnassignOptionUserOutput, +} from "@/modules/organizations/outputs/attributes/options/assign-option-user.output"; +import { CreateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/create-option.output"; +import { DeleteAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/delete-option.output"; +import { GetOptionUserOutput } from "@/modules/organizations/outputs/attributes/options/get-option-user.output"; +import { GetAllAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/get-option.output"; +import { UpdateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/update-option.output"; +import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service"; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/organizations/:orgId", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Attributes / Options") +export class OrganizationsOptionsAttributesController { + constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/attributes/:attributeId/options") + @ApiOperation({ summary: "Create an attribute option" }) + async createOrganizationAttributeOption( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string, + @Body() bodyAttribute: CreateOrganizationAttributeOptionInput + ): Promise { + const attributeOption = + await this.organizationsAttributesOptionsService.createOrganizationAttributeOption( + orgId, + attributeId, + bodyAttribute + ); + return { + status: SUCCESS_STATUS, + data: attributeOption, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Delete("/attributes/:attributeId/options/:optionId") + @ApiOperation({ summary: "Delete an attribute option" }) + async deleteOrganizationAttributeOption( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string, + @Param("optionId") optionId: string + ): Promise { + const attributeOption = + await this.organizationsAttributesOptionsService.deleteOrganizationAttributeOption( + orgId, + attributeId, + optionId + ); + return { + status: SUCCESS_STATUS, + data: attributeOption, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Patch("/attributes/:attributeId/options/:optionId") + @ApiOperation({ summary: "Update an attribute option" }) + async updateOrganizationAttributeOption( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string, + @Param("optionId") optionId: string, + @Body() bodyAttribute: UpdateOrganizationAttributeOptionInput + ): Promise { + const attributeOption = + await this.organizationsAttributesOptionsService.updateOrganizationAttributeOption( + orgId, + attributeId, + optionId, + bodyAttribute + ); + return { + status: SUCCESS_STATUS, + data: attributeOption, + }; + } + + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Get("/attributes/:attributeId/options") + @ApiOperation({ summary: "Get all attribute options" }) + async getOrganizationAttributeOptions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string + ): Promise { + const attributeOptions = await this.organizationsAttributesOptionsService.getOrganizationAttributeOptions( + orgId, + attributeId + ); + return { + status: SUCCESS_STATUS, + data: attributeOptions, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/attributes/options/:userId") + @ApiOperation({ summary: "Assign an attribute to a user" }) + async assignOrganizationAttributeOptionToUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number, + @Body() bodyAttribute: AssignOrganizationAttributeOptionToUserInput + ): Promise { + const attributeOption = + await this.organizationsAttributesOptionsService.assignOrganizationAttributeOptionToUser( + orgId, + userId, + bodyAttribute + ); + return { + status: SUCCESS_STATUS, + data: attributeOption, + }; + } + + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Delete("/attributes/options/:userId/:attributeOptionId") + @ApiOperation({ summary: "Unassign an attribute from a user" }) + async unassignOrganizationAttributeOptionFromUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number, + @Param("attributeOptionId") attributeOptionId: string + ): Promise { + const attributeOption = + await this.organizationsAttributesOptionsService.unassignOrganizationAttributeOptionFromUser( + orgId, + userId, + attributeOptionId + ); + return { + status: SUCCESS_STATUS, + data: attributeOption, + }; + } + + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Get("/attributes/options/:userId") + @ApiOperation({ summary: "Get all attribute options for a user" }) + async getOrganizationAttributeOptionsForUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number + ): Promise { + const attributeOptions = + await this.organizationsAttributesOptionsService.getOrganizationAttributeOptionsForUser(orgId, userId); + return { + status: SUCCESS_STATUS, + data: attributeOptions, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts new file mode 100644 index 00000000000000..7cb6270f8ed58d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts @@ -0,0 +1,136 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input"; +import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input"; +import { CreateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/create-organization-attributes.output"; +import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/delete-organization-attributes.output"; +import { + GetOrganizationAttributesOutput, + GetSingleAttributeOutput, +} from "@/modules/organizations/outputs/attributes/get-organization-attributes.output"; +import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/update-organization-attributes.output"; +import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Attributes") +export class OrganizationsAttributesController { + constructor(private readonly organizationsAttributesService: OrganizationAttributesService) {} + // Gets all attributes for an organization + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Get("/attributes") + @ApiOperation({ summary: "Get all attributes" }) + async getOrganizationAttributes( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const attributes = await this.organizationsAttributesService.getOrganizationAttributes(orgId, skip, take); + + return { + status: SUCCESS_STATUS, + data: attributes, + }; + } + + // Gets a single attribute for an organization + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Get("/attributes/:attributeId") + @ApiOperation({ summary: "Get an attribute" }) + async getOrganizationAttribute( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string + ): Promise { + const attribute = await this.organizationsAttributesService.getOrganizationAttribute(orgId, attributeId); + return { + status: SUCCESS_STATUS, + data: attribute, + }; + } + + // Creates an attribute for an organization + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/attributes") + @ApiOperation({ summary: "Create an attribute" }) + async createOrganizationAttribute( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() bodyAttribute: CreateOrganizationAttributeInput + ): Promise { + const attribute = await this.organizationsAttributesService.createOrganizationAttribute( + orgId, + bodyAttribute + ); + return { + status: SUCCESS_STATUS, + data: attribute, + }; + } + + // Updates an attribute for an organization + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Patch("/attributes/:attributeId") + @ApiOperation({ summary: "Update an attribute" }) + async updateOrganizationAttribute( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string, + @Body() bodyAttribute: UpdateOrganizationAttributeInput + ): Promise { + const attribute = await this.organizationsAttributesService.updateOrganizationAttribute( + orgId, + attributeId, + bodyAttribute + ); + return { + status: SUCCESS_STATUS, + data: attribute, + }; + } + + // Deletes an attribute for an organization + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Delete("/attributes/:attributeId") + @ApiOperation({ summary: "Delete an attribute" }) + async deleteOrganizationAttribute( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string + ): Promise { + const attribute = await this.organizationsAttributesService.deleteOrganizationAttribute( + orgId, + attributeId + ); + return { + status: SUCCESS_STATUS, + data: attribute, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts new file mode 100644 index 00000000000000..433dcc88dfec83 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts @@ -0,0 +1,248 @@ +import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input"; +import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer"; +import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service"; +import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service"; +import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service"; +import { CreateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/create-team-event-type.output"; +import { DeleteTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/delete-team-event-type.output"; +import { GetTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/get-team-event-type.output"; +import { GetTeamEventTypesOutput } from "@/modules/teams/event-types/outputs/get-team-event-types.output"; +import { UpdateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/update-team-event-type.output"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Post, + Param, + ParseIntPipe, + Body, + Patch, + Delete, + HttpCode, + HttpStatus, + NotFoundException, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { handleCreatePhoneCall } from "@calcom/platform-libraries"; +import { + CreateTeamEventTypeInput_2024_06_14, + GetTeamEventTypesQuery_2024_06_14, + SkipTakePagination, + TeamEventTypeOutput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; + +export type EventTypeHandlerResponse = { + data: DatabaseTeamEventType[] | DatabaseTeamEventType; + status: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +}; + +@Controller({ + path: "/v2/organizations/:orgId", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Orgs / Event Types") +export class OrganizationsEventTypesController { + constructor( + private readonly organizationsEventTypesService: OrganizationsEventTypesService, + private readonly inputService: InputOrganizationsEventTypesService, + private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe + ) {} + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Post("/teams/:teamId/event-types") + @ApiOperation({ summary: "Create an event type" }) + async createTeamEventType( + @GetUser() user: UserWithProfile, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("orgId", ParseIntPipe) orgId: number, + @Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14 + ): Promise { + const transformedBody = await this.inputService.transformAndValidateCreateTeamEventTypeInput( + user.id, + teamId, + bodyEventType + ); + + const eventType = await this.organizationsEventTypesService.createTeamEventType( + user, + teamId, + orgId, + transformedBody + ); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventType), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/teams/:teamId/event-types/:eventTypeId") + @ApiOperation({ summary: "Get an event type" }) + async getTeamEventType( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("eventTypeId") eventTypeId: number + ): Promise { + const eventType = await this.organizationsEventTypesService.getTeamEventType(teamId, eventTypeId); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + status: SUCCESS_STATUS, + data: (await this.outputTeamEventTypesResponsePipe.transform( + eventType + )) as TeamEventTypeOutput_2024_06_14, + }; + } + + @Roles("TEAM_ADMIN") + @Post("/teams/:teamId/event-types/:eventTypeId/create-phone-call") + @UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard) + @ApiOperation({ summary: "Create a phone call" }) + async createPhoneCall( + @Param("eventTypeId") eventTypeId: number, + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreatePhoneCallInput, + @GetUser() user: UserWithProfile + ): Promise { + const data = await handleCreatePhoneCall({ + user: { + id: user.id, + timeZone: user.timeZone, + profile: { organization: { id: orgId } }, + }, + input: { ...body, eventTypeId }, + }); + + return { + status: SUCCESS_STATUS, + data, + }; + } + + @UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard) + @Get("/teams/:teamId/event-types") + @ApiOperation({ summary: "Get a team event type" }) + async getTeamEventTypes( + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: GetTeamEventTypesQuery_2024_06_14 + ): Promise { + const { eventSlug, hostsLimit } = queryParams; + + if (eventSlug) { + const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug( + teamId, + eventSlug, + hostsLimit + ); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventType ? [eventType] : []), + }; + } + + const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventTypes), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/teams/event-types") + @ApiOperation({ summary: "Get all team event types" }) + async getTeamsEventTypes( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes( + orgId, + skip, + take + ); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventTypes), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Patch("/teams/:teamId/event-types/:eventTypeId") + @ApiOperation({ summary: "Update a team event type" }) + async updateTeamEventType( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @GetUser() user: UserWithProfile, + @Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14 + ): Promise { + const transformedBody = await this.inputService.transformAndValidateUpdateTeamEventTypeInput( + user.id, + eventTypeId, + teamId, + bodyEventType + ); + + const eventType = await this.organizationsEventTypesService.updateTeamEventType( + eventTypeId, + teamId, + transformedBody, + user + ); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventType), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Delete("/teams/:teamId/event-types/:eventTypeId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a team event type" }) + async deleteTeamEventType( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise { + const eventType = await this.organizationsEventTypesService.deleteTeamEventType(teamId, eventTypeId); + + return { + status: SUCCESS_STATUS, + data: { + id: eventTypeId, + title: eventType.title, + }, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts new file mode 100644 index 00000000000000..ddb05aca85b459 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts @@ -0,0 +1,846 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { SchedulingType, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + BookingWindowPeriodInputTypeEnum_2024_06_14, + BookerLayoutsInputEnum_2024_06_14, + ConfirmationPolicyEnum, + NoticeThresholdUnitEnum, +} from "@calcom/platform-enums"; +import { + ApiSuccessResponse, + CreateTeamEventTypeInput_2024_06_14, + Host, + TeamEventTypeOutput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +describe("Organizations Event Types Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + let org: Team; + let team: Team; + let falseTestOrg: Team; + let falseTestTeam: Team; + + const userEmail = `organizations-event-types-admin-${randomString()}@api.com`; + let userAdmin: User; + + const teammate1Email = `organizations-event-types-teammate1-${randomString()}@api.com`; + const teammate2Email = `organizations-event-types-teammate2-${randomString()}@api.com`; + const falseTestUserEmail = `organizations-event-types-false-user-${randomString()}@api.com`; + let teammate1: User; + let teammate2: User; + let falseTestUser: User; + + let collectiveEventType: TeamEventTypeOutput_2024_06_14; + let managedEventType: TeamEventTypeOutput_2024_06_14; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + userAdmin = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + role: "ADMIN", + }); + + teammate1 = await userRepositoryFixture.create({ + email: teammate1Email, + username: teammate1Email, + }); + + teammate2 = await userRepositoryFixture.create({ + email: teammate2Email, + username: teammate2Email, + }); + + falseTestUser = await userRepositoryFixture.create({ + email: falseTestUserEmail, + username: falseTestUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-event-types-organization-${randomString()}`, + isOrganization: true, + }); + + falseTestOrg = await organizationsRepositoryFixture.create({ + name: `organizations-event-types-false-org-${randomString()}`, + isOrganization: true, + }); + + team = await teamsRepositoryFixture.create({ + name: `organizations-event-types-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + falseTestTeam = await teamsRepositoryFixture.create({ + name: `organizations-event-types-false-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: falseTestOrg.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userAdmin.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userAdmin.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: userAdmin.id } }, + team: { connect: { id: org.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammate1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammate2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: falseTestUser.id } }, + team: { connect: { id: falseTestTeam.id } }, + accepted: true, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(userAdmin).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should not be able to create event-type for team outside org", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation", + slug: "coding-consultation", + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + schedulingType: "COLLECTIVE", + hosts: [ + { + userId: teammate1.id, + mandatory: true, + priority: "high", + }, + ], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`) + .send(body) + .expect(404); + }); + + it("should not be able to create event-type for user outside org", async () => { + const userId = falseTestUser.id; + + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation", + slug: "coding-consultation", + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + schedulingType: "COLLECTIVE", + hosts: [ + { + userId, + mandatory: true, + priority: "high", + }, + ], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`) + .send(body) + .expect(404); + }); + + it("should create a collective team event-type", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + successRedirectUrl: "https://masterchief.com/argentina/flan/video/1234", + title: "Coding consultation collective", + slug: `organizations-event-types-collective-${randomString()}`, + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + bookingFields: [ + { + type: "select", + label: "select which language is your codebase in", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + }, + ], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + schedulingType: "collective", + hosts: [ + { + userId: teammate1.id, + }, + { + userId: teammate2.id, + }, + ], + bookingLimitsCount: { + day: 2, + week: 5, + }, + onlyShowFirstAvailableSlot: true, + bookingLimitsDuration: { + day: 60, + week: 100, + }, + offsetStart: 30, + bookingWindow: { + type: BookingWindowPeriodInputTypeEnum_2024_06_14.calendarDays, + value: 30, + rolling: true, + }, + bookerLayouts: { + enabledLayouts: [ + BookerLayoutsInputEnum_2024_06_14.column, + BookerLayoutsInputEnum_2024_06_14.month, + BookerLayoutsInputEnum_2024_06_14.week, + ], + defaultLayout: BookerLayoutsInputEnum_2024_06_14.month, + }, + + confirmationPolicy: { + type: ConfirmationPolicyEnum.TIME, + noticeThreshold: { + count: 60, + unit: NoticeThresholdUnitEnum.MINUTES, + }, + blockUnconfirmedBookingsInBooker: true, + }, + requiresBookerEmailVerification: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + lockTimeZoneToggleOnBookingPage: true, + color: { + darkThemeHex: "#292929", + lightThemeHex: "#fafafa", + }, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`) + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(body.title); + expect(data.hosts.length).toEqual(2); + expect(data.schedulingType).toEqual("COLLECTIVE"); + evaluateHost(body.hosts[0], data.hosts[0]); + evaluateHost(body.hosts[1], data.hosts[1]); + expect(data.bookingLimitsCount).toEqual(body.bookingLimitsCount); + expect(data.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); + expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); + expect(data.offsetStart).toEqual(body.offsetStart); + expect(data.bookingWindow).toEqual(body.bookingWindow); + expect(data.bookerLayouts).toEqual(body.bookerLayouts); + expect(data.confirmationPolicy).toEqual(body.confirmationPolicy); + expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification); + expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes); + expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails); + expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage); + expect(data.color).toEqual(body.color); + expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234"); + collectiveEventType = responseBody.data; + }); + }); + + it("should create a managed team event-type", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation managed", + slug: `organizations-event-types-managed-${randomString()}`, + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + schedulingType: "MANAGED", + hosts: [ + { + userId: teammate1.id, + mandatory: true, + priority: "high", + }, + { + userId: teammate2.id, + mandatory: false, + priority: "low", + }, + ], + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(3); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate2EventTypes.length).toEqual(1); + expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual( + 1 + ); + + const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id); + expect(responseTeamEvent).toBeDefined(); + if (!responseTeamEvent) { + throw new Error("Team event not found"); + } + + const responseTeammate1Event = responseBody.data.find((event) => event.ownerId === teammate1.id); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + const responseTeammate2Event = responseBody.data.find((event) => event.ownerId === teammate2.id); + expect(responseTeammate2Event).toBeDefined(); + expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + managedEventType = responseTeamEvent; + }); + }); + + it("should not get an event-type of team outside org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`) + .expect(404); + }); + + it("should not get a non existing event-type", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`) + .expect(404); + }); + + it("should get a team event-type", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(collectiveEventType.title); + expect(data.hosts.length).toEqual(2); + evaluateHost(collectiveEventType.hosts[0], data.hosts[0]); + evaluateHost(collectiveEventType.hosts[1], data.hosts[1]); + + collectiveEventType = responseBody.data; + }); + }); + + it("should not get event-types of team outside org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`) + .expect(404); + }); + + it("should get team event-types", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(2); + + const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE"); + const eventTypeManaged = data.find((eventType) => eventType.schedulingType === "MANAGED"); + + expect(eventTypeCollective?.title).toEqual(collectiveEventType.title); + expect(eventTypeCollective?.hosts.length).toEqual(2); + + expect(eventTypeManaged?.title).toEqual(managedEventType.title); + expect(eventTypeManaged?.hosts.length).toEqual(2); + evaluateHost(collectiveEventType.hosts[0], eventTypeCollective?.hosts[0]); + evaluateHost(collectiveEventType.hosts[1], eventTypeCollective?.hosts[1]); + }); + }); + + it("should not be able to update event-type for incorrect team", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + title: "Clean code consultation", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`) + .send(body) + .expect(404); + }); + + it("should not be able to update non existing event-type", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + title: "Clean code consultation", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`) + .send(body) + .expect(400); + }); + + it("should update collective event-type", async () => { + const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [ + { + userId: teammate1.id, + }, + ]; + + const body: UpdateTeamEventTypeInput_2024_06_14 = { + hosts: newHosts, + successRedirectUrl: "https://new-url-success.com", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const eventType = responseBody.data; + expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com"); + expect(eventType.title).toEqual(collectiveEventType.title); + expect(eventType.hosts.length).toEqual(1); + evaluateHost(eventType.hosts[0], newHosts[0]); + }); + }); + + it("should update managed event-type", async () => { + const newTitle = "Coding consultation managed updated"; + const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [ + { + userId: teammate1.id, + mandatory: true, + priority: "medium", + }, + ]; + + const body: UpdateTeamEventTypeInput_2024_06_14 = { + title: newTitle, + hosts: newHosts, + successRedirectUrl: "https://new-url-success-managed.com", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(2); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + const managedTeamEventTypes = teamEventTypes.filter( + (eventType) => eventType.schedulingType === "MANAGED" + ); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate1EventTypes[0].title).toEqual(newTitle); + expect(teammate2EventTypes.length).toEqual(0); + expect(managedTeamEventTypes.length).toEqual(1); + expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false); + expect( + teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title + ).toEqual(newTitle); + + const responseTeamEvent = responseBody.data.find( + (eventType) => eventType.schedulingType === "MANAGED" + ); + expect(responseTeamEvent).toBeDefined(); + expect(responseTeamEvent?.title).toEqual(newTitle); + expect(responseTeamEvent?.assignAllTeamMembers).toEqual(false); + + const responseTeammate1Event = responseBody.data.find( + (eventType) => eventType.ownerId === teammate1.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.title).toEqual(newTitle); + + managedEventType = responseBody.data[0]; + expect(managedEventType.successRedirectUrl).toEqual("https://new-url-success-managed.com"); + }); + }); + + it("should be able to configure phone-only event type", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + bookingFields: [ + { + type: "email", + required: false, + label: "Email", + }, + { + type: "phone", + slug: "attendeePhoneNumber", + required: true, + label: "Phone number", + hidden: true, + }, + ], + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const data = responseBody.data; + expect(data.bookingFields).toEqual([ + { + isDefault: true, + type: "name", + slug: "name", + required: true, + disableOnPrefill: false, + }, + { + isDefault: true, + type: "email", + slug: "email", + required: false, + label: "Email", + disableOnPrefill: false, + }, + { + isDefault: true, + type: "radioInput", + slug: "location", + required: false, + hidden: false, + }, + { + isDefault: true, + type: "phone", + slug: "attendeePhoneNumber", + required: true, + hidden: true, + label: "Phone number", + disableOnPrefill: false, + }, + { + isDefault: true, + type: "text", + slug: "title", + required: true, + disableOnPrefill: false, + hidden: true, + }, + { + isDefault: true, + type: "textarea", + slug: "notes", + required: false, + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + type: "multiemail", + slug: "guests", + required: false, + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + type: "textarea", + slug: "rescheduleReason", + required: false, + disableOnPrefill: false, + hidden: false, + }, + ]); + }); + }); + + it("should assign all members to managed event-type", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + assignAllTeamMembers: true, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(3); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + const managedTeamEventTypes = teamEventTypes.filter( + (eventType) => eventType.schedulingType === "MANAGED" + ); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate2EventTypes.length).toEqual(1); + expect(managedTeamEventTypes.length).toEqual(1); + expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true); + + const responseTeamEvent = responseBody.data.find( + (eventType) => eventType.schedulingType === "MANAGED" + ); + expect(responseTeamEvent).toBeDefined(); + expect(responseTeamEvent?.teamId).toEqual(team.id); + expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true); + + const responseTeammate1Event = responseBody.data.find( + (eventType) => eventType.ownerId === teammate1.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + const responseTeammate2Event = responseBody.data.find( + (eventType) => eventType.ownerId === teammate2.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + if (responseTeamEvent) { + managedEventType = responseTeamEvent; + } + }); + }); + + it("should not delete event-type of team outside org", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`) + .expect(404); + }); + + it("should delete event-type not part of the team", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/99999`) + .expect(404); + }); + + it("should delete collective event-type", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`) + .expect(200); + }); + + it("should delete managed event-type", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`) + .expect(200); + }); + + it("should return event type with default bookingFields if they are not defined", async () => { + const eventTypeInput = { + title: "unknown field event type two", + slug: `organizations-event-types-unknown-${randomString()}`, + description: "unknown field event type description two", + length: 40, + hidden: false, + locations: [], + schedulingType: SchedulingType.ROUND_ROBIN, + }; + const eventType = await eventTypesRepositoryFixture.createTeamEventType({ + ...eventTypeInput, + team: { connect: { id: team.id } }, + }); + + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}`) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const fetchedEventType = responseBody.data; + + expect(fetchedEventType.bookingFields).toEqual([ + { isDefault: true, required: true, slug: "name", type: "name", disableOnPrefill: false }, + { isDefault: true, required: true, slug: "email", type: "email", disableOnPrefill: false }, + { + disableOnPrefill: false, + isDefault: true, + type: "phone", + slug: "attendeePhoneNumber", + required: false, + hidden: true, + }, + { + isDefault: true, + type: "radioInput", + slug: "location", + required: false, + hidden: false, + }, + { + isDefault: true, + required: true, + slug: "title", + type: "text", + disableOnPrefill: false, + hidden: true, + }, + { + isDefault: true, + required: false, + slug: "notes", + type: "textarea", + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + required: false, + slug: "guests", + type: "multiemail", + disableOnPrefill: false, + hidden: false, + }, + { + isDefault: true, + required: false, + slug: "rescheduleReason", + type: "textarea", + disableOnPrefill: false, + hidden: false, + }, + ]); + }); + }); + + function evaluateHost(expected: Host, received: Host | undefined) { + expect(expected.userId).toEqual(received?.userId); + expect(expected.mandatory).toEqual(received?.mandatory); + expect(expected.priority).toEqual(received?.priority); + } + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userAdmin.email); + await userRepositoryFixture.deleteByEmail(teammate1.email); + await userRepositoryFixture.deleteByEmail(teammate2.email); + await userRepositoryFixture.deleteByEmail(falseTestUser.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(falseTestTeam.id); + await organizationsRepositoryFixture.delete(org.id); + await organizationsRepositoryFixture.delete(falseTestOrg.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts new file mode 100644 index 00000000000000..0de6c14d23c988 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts @@ -0,0 +1,312 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; +import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { Membership, Team } from "@calcom/prisma/client"; + +describe("Organizations Memberships Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let membership: Membership; + let membership2: Membership; + let membershipCreatedViaApi: Membership; + + const userEmail = `organizations-memberships-admin-${randomString()}@api.com`; + const userEmail2 = `organizations-memberships-member-${randomString()}@api.com`; + const invitedUserEmail = `organizations-memberships-invited-${randomString()}@api.com`; + + let user: User; + let user2: User; + + let userToInviteViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + user2 = await userRepositoryFixture.create({ + email: userEmail2, + username: userEmail2, + }); + + userToInviteViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-memberships-organization-${randomString()}`, + isOrganization: true, + }); + + membership = await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + membership2 = await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: org.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all the memberships of the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(membership.id); + expect(responseBody.data[1].id).toEqual(membership2.id); + }); + }); + + it("should get all the memberships of the org paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(membership2.id); + expect(responseBody.data[0].userId).toEqual(user2.id); + }); + }); + + it("should fail if org does not exist", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/120494059/memberships`).expect(403); + }); + + it("should get the membership of the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships/${membership.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membership.id); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + it("should create the membership of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/memberships`) + .send({ + userId: userToInviteViaApi.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgMembershipDto) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.teamId).toEqual(org.id); + expect(membershipCreatedViaApi.role).toEqual("MEMBER"); + expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id); + }); + }); + + it("should update the membership of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`) + .send({ + role: "OWNER", + } satisfies UpdateOrgMembershipDto) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.role).toEqual("OWNER"); + }); + }); + + it("should delete the membership of the org we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); + }); + }); + + it("should fail to get the membership of the org we just deleted", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(404); + }); + + it("should fail if the membership does not exist", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships/123132145`) + .expect(404); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(user2.email); + await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); + +describe("Organizations Memberships Endpoints", () => { + describe("User Authentication - User is Org Member", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let membership: Membership; + + const userEmail = `organizations-memberships-member-${randomString()}@api.com`; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-memberships-organization-${randomString()}`, + isOrganization: true, + }); + + membership = await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should deny get all the memberships of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/memberships`).expect(403); + }); + + it("should deny get all the memberships of the org paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`) + .expect(403); + }); + + it("should deny get the membership of the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/memberships/${membership.id}`) + .expect(403); + }); + + it("should deny create the membership for the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/memberships`) + .send({ + role: "OWNER", + userId: user.id, + accepted: true, + } satisfies CreateOrgMembershipDto) + .expect(403); + }); + + it("should deny update the membership of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/memberships/${membership.id}`) + .send({ + role: "MEMBER", + } satisfies Partial) + .expect(403); + }); + + it("should deny delete the membership of the org we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/memberships/${membership.id}`) + .expect(403); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts new file mode 100644 index 00000000000000..058a02a0a93d4a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts @@ -0,0 +1,142 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; +import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input"; +import { CreateOrgMembershipOutput } from "@/modules/organizations/outputs/organization-membership/create-membership.output"; +import { DeleteOrgMembership } from "@/modules/organizations/outputs/organization-membership/delete-membership.output"; +import { GetAllOrgMemberships } from "@/modules/organizations/outputs/organization-membership/get-all-memberships.output"; +import { GetOrgMembership } from "@/modules/organizations/outputs/organization-membership/get-membership.output"; +import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output"; +import { UpdateOrgMembership } from "@/modules/organizations/outputs/organization-membership/update-membership.output"; +import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/memberships", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Memberships") +export class OrganizationsMembershipsController { + constructor(private organizationsMembershipService: OrganizationsMembershipService) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Get("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get all memberships" }) + async getAllMemberships( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const memberships = await this.organizationsMembershipService.getPaginatedOrgMemberships( + orgId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: memberships.map((membership) => + plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }) + ), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a membership" }) + async createMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreateOrgMembershipDto + ): Promise { + const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsMembershipInOrg) + @Get("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get a membership" }) + async getOrgMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsMembershipInOrg) + @Delete("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a membership" }) + async deleteMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } + + @UseGuards(IsMembershipInOrg) + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Patch("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update a membership" }) + async updateMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("membershipId", ParseIntPipe) membershipId: number, + @Body() body: UpdateOrgMembershipDto + ): Promise { + const membership = await this.organizationsMembershipService.updateOrgMembership( + orgId, + membershipId, + body + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer.ts b/apps/api/v2/src/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer.ts new file mode 100644 index 00000000000000..091bdee2ff1609 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer.ts @@ -0,0 +1,38 @@ +import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service"; +import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service"; +import { Injectable, PipeTransform } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; + +import { TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +@Injectable() +export class OutputTeamEventTypesResponsePipe implements PipeTransform { + constructor(private readonly outputOrganizationsEventTypesService: OutputOrganizationsEventTypesService) {} + + private async transformEventType(item: DatabaseTeamEventType): Promise { + return plainToClass( + TeamEventTypeOutput_2024_06_14, + await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item, true), + { strategy: "exposeAll" } + ); + } + + // Implementing function overloading to ensure correct return types based on input type: + async transform(value: DatabaseTeamEventType[]): Promise; + + async transform(value: DatabaseTeamEventType): Promise; + + async transform( + value: DatabaseTeamEventType | DatabaseTeamEventType[] + ): Promise; + + async transform( + value: DatabaseTeamEventType | DatabaseTeamEventType[] + ): Promise { + if (Array.isArray(value)) { + return await Promise.all(value.map((item) => this.transformEventType(item))); + } else { + return await this.transformEventType(value); + } + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts new file mode 100644 index 00000000000000..e0320008495d55 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts @@ -0,0 +1,159 @@ +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; +import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service"; +import { + Controller, + UseGuards, + Get, + Post, + Param, + ParseIntPipe, + Body, + Patch, + Delete, + HttpCode, + HttpStatus, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + CreateScheduleInput_2024_06_11, + CreateScheduleOutput_2024_06_11, + DeleteScheduleOutput_2024_06_11, + GetScheduleOutput_2024_06_11, + GetSchedulesOutput_2024_06_11, + UpdateScheduleInput_2024_06_11, + UpdateScheduleOutput_2024_06_11, +} from "@calcom/platform-types"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Schedules") +export class OrganizationsSchedulesController { + constructor( + private schedulesService: SchedulesService_2024_06_11, + private organizationScheduleService: OrganizationsSchedulesService + ) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Get("/schedules") + @ApiOperation({ summary: "Get all schedules" }) + async getOrganizationSchedules( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + + const schedules = await this.organizationScheduleService.getOrganizationSchedules(orgId, skip, take); + + return { + status: SUCCESS_STATUS, + data: schedules, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @Post("/users/:userId/schedules") + @DocsTags("Orgs / Users / Schedules") + @ApiOperation({ summary: "Create a schedule" }) + async createUserSchedule( + @Param("userId", ParseIntPipe) userId: number, + @Body() bodySchedule: CreateScheduleInput_2024_06_11 + ): Promise { + const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule); + + return { + status: SUCCESS_STATUS, + data: schedule, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @Get("/users/:userId/schedules/:scheduleId") + @DocsTags("Orgs / Users / Schedules") + @ApiOperation({ summary: "Get a schedule" }) + async getUserSchedule( + @Param("userId", ParseIntPipe) userId: number, + @Param("scheduleId") scheduleId: number + ): Promise { + const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + data: schedule, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @Get("/users/:userId/schedules") + @DocsTags("Orgs / Users / Schedules") + @ApiOperation({ summary: "Get all schedules" }) + async getUserSchedules( + @Param("userId", ParseIntPipe) userId: number + ): Promise { + const schedules = await this.schedulesService.getUserSchedules(userId); + + return { + status: SUCCESS_STATUS, + data: schedules, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @Patch("/users/:userId/schedules/:scheduleId") + @DocsTags("Orgs / Users / Schedules") + @ApiOperation({ summary: "Update a schedule" }) + async updateUserSchedule( + @Param("userId", ParseIntPipe) userId: number, + @Param("scheduleId", ParseIntPipe) scheduleId: number, + @Body() bodySchedule: UpdateScheduleInput_2024_06_11 + ): Promise { + const updatedSchedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule); + + return { + status: SUCCESS_STATUS, + data: updatedSchedule, + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @Delete("/users/:userId/schedules/:scheduleId") + @HttpCode(HttpStatus.OK) + @DocsTags("Orgs / Users / Schedules") + @ApiOperation({ summary: "Delete a schedule" }) + async deleteUserSchedule( + @Param("userId", ParseIntPipe) userId: number, + @Param("scheduleId", ParseIntPipe) scheduleId: number + ): Promise { + await this.schedulesService.deleteUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts new file mode 100644 index 00000000000000..0a9b6b014777e3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts @@ -0,0 +1,343 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + CreateScheduleInput_2024_06_11, + CreateScheduleOutput_2024_06_11, + GetScheduleOutput_2024_06_11, + GetSchedulesOutput_2024_06_11, + ScheduleAvailabilityInput_2024_06_11, + ScheduleOutput_2024_06_11, + UpdateScheduleInput_2024_06_11, + UpdateScheduleOutput_2024_06_11, +} from "@calcom/platform-types"; +import { User, Team, Membership, Profile } from "@calcom/prisma/client"; + +describe("Organizations Schedules Endpoints", () => { + describe("User lacks required role", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + + const userEmail = `organizations-schedules-member-${randomString()}@api.com`; + let user: User; + let org: Team; + let membership: Membership; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-schedules-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should not be able to create schedule for org user", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${user.id}/schedules`) + .send({ + name: "work", + timeZone: "Europe/Rome", + isDefault: true, + }) + .expect(403); + }); + + it("should not be able to get org schedules", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/schedules`).expect(403); + }); + + it("should mot be able to get user schedules", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`) + .expect(403); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); + + describe("User has required role", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const userEmail = `organizations-schedules-admin-${randomString()}@api.com`; + const userEmail2 = `organizations-schedules-member-${randomString()}@api.com`; + let user: User; + let user2: User; + let org: Team; + let membership: Membership; + let membership2: Membership; + let profile: Profile; + let profile2: Profile; + + let createdSchedule: ScheduleOutput_2024_06_11; + + const createScheduleInput: CreateScheduleInput_2024_06_11 = { + name: "work", + timeZone: "Europe/Rome", + isDefault: true, + }; + + const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [ + { + days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + startTime: "09:00", + endTime: "17:00", + }, + ]; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-schedules-admin-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + profile = await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should create schedule for org user", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${user.id}/schedules`) + .send(createScheduleInput) + .expect(201) + .then(async (response) => { + const responseBody: CreateScheduleOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + createdSchedule = response.body.data; + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1); + + const scheduleOwner = createdSchedule.ownerId + ? await userRepositoryFixture.get(createdSchedule.ownerId) + : null; + expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id); + }); + }); + + function outputScheduleMatchesExpected( + outputSchedule: ScheduleOutput_2024_06_11 | null, + expected: CreateScheduleInput_2024_06_11 & { + availability: CreateScheduleInput_2024_06_11["availability"]; + } & { + overrides: CreateScheduleInput_2024_06_11["overrides"]; + }, + expectedAvailabilityLength: number + ) { + expect(outputSchedule).toBeTruthy(); + expect(outputSchedule?.name).toEqual(expected.name); + expect(outputSchedule?.timeZone).toEqual(expected.timeZone); + expect(outputSchedule?.isDefault).toEqual(expected.isDefault); + expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength); + + const outputScheduleAvailability = outputSchedule?.availability[0]; + expect(outputScheduleAvailability).toBeDefined(); + expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days); + expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime); + expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime); + + expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides)); + } + + it("should get org schedules", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/schedules`) + .expect(200) + .then(async (response) => { + const responseBody: GetSchedulesOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const schedules = response.body.data; + expect(schedules.length).toEqual(1); + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + + outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1); + }); + }); + + it("should get org user schedule", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`) + .expect(200) + .then(async (response) => { + const responseBody: GetScheduleOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const schedule = response.body.data; + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + + outputScheduleMatchesExpected(schedule, expectedSchedule, 1); + }); + }); + + it("should get user schedules", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`) + .expect(200) + .then(async (response) => { + const responseBody: GetSchedulesOutput_2024_06_11 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const schedules = response.body.data; + expect(schedules.length).toEqual(1); + + const expectedSchedule = { + ...createScheduleInput, + availability: defaultAvailability, + overrides: [], + }; + + outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1); + }); + }); + + it("should update user schedule name", async () => { + const newScheduleName = "updated-schedule-name"; + + const body: UpdateScheduleInput_2024_06_11 = { + name: newScheduleName, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`) + .send(body) + .expect(200) + .then((response: any) => { + const responseData: UpdateScheduleOutput_2024_06_11 = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + const responseSchedule = responseData.data; + + const expectedSchedule = { ...createdSchedule, name: newScheduleName }; + outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1); + + createdSchedule = responseSchedule; + }); + }); + + it("should delete user schedule", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`) + .expect(200); + }); + + afterAll(async () => { + await profileRepositoryFixture.delete(profile.id); + await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts new file mode 100644 index 00000000000000..423fdffc523fc8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input.ts @@ -0,0 +1,179 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + ArrayMinSize, + ArrayNotEmpty, + IsArray, + IsEnum, + IsInt, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +enum Status { + upcoming = "upcoming", + recurring = "recurring", + past = "past", + cancelled = "cancelled", + unconfirmed = "unconfirmed", +} +type StatusType = keyof typeof Status; + +enum SortOrder { + asc = "asc", + desc = "desc", +} +type SortOrderType = keyof typeof SortOrder; + +export class GetOrganizationsTeamsBookingsInput_2024_08_13 { + // note(Lauris): filters + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((status: string) => status.trim()); + } + return value; + }) + @ArrayNotEmpty({ message: "status cannot be empty." }) + @IsEnum(Status, { + each: true, + message: "Invalid status. Allowed are upcoming, recurring, past, cancelled, unconfirmed", + }) + @ApiProperty({ + required: false, + description: + "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + example: "?status=upcoming,past", + enum: Status, + isArray: true, + }) + status?: StatusType[]; + + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by the attendee's email address.", + example: "example@domain.com", + }) + attendeeEmail?: string; + + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by the attendee's name.", + example: "John Doe", + }) + attendeeName?: string; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((eventTypeId: string) => parseInt(eventTypeId)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "eventTypeIds must contain at least 1 event type id" }) + @ApiProperty({ + type: String, + required: false, + description: + "Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.", + example: "?eventTypeIds=100,200", + }) + eventTypeIds?: number[]; + + @IsInt() + @IsOptional() + @Type(() => Number) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by event type id belonging to the team.", + example: "?eventTypeId=100", + }) + eventTypeId?: number; + + @IsOptional() + @IsISO8601({ strict: true }, { message: "fromDate must be a valid ISO 8601 date." }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings with start after this date string.", + example: "?afterStart=2025-03-07T10:00:00.000Z", + }) + afterStart?: string; + + @IsOptional() + @IsISO8601({ strict: true }, { message: "toDate must be a valid ISO 8601 date." }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings with end before this date string.", + example: "?beforeEnd=2025-03-07T11:00:00.000Z", + }) + beforeEnd?: string; + + // note(Lauris): sort + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortStart must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their start time in ascending or descending order.", + example: "?sortStart=asc OR ?sortStart=desc", + enum: SortOrder, + }) + sortStart?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortEnd must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their end time in ascending or descending order.", + example: "?sortEnd=asc OR ?sortEnd=desc", + enum: SortOrder, + }) + sortEnd?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortCreated must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: + "Sort results by their creation time (when booking was made) in ascending or descending order.", + example: "?sortCreated=asc OR ?sortCreated=desc", + enum: SortOrder, + }) + sortCreated?: SortOrderType; + + // note(Lauris): pagination + @ApiProperty({ required: false, description: "The number of items to return", example: 10 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(1) + @Max(250) + @IsOptional() + take?: number; + + @ApiProperty({ required: false, description: "The number of items to skip", example: 0 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(0) + @IsOptional() + skip?: number; +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts new file mode 100644 index 00000000000000..638c590558e832 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts @@ -0,0 +1,364 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { + CAL_API_VERSION_HEADER, + SUCCESS_STATUS, + VERSION_2024_08_13, + X_CAL_CLIENT_ID, + X_CAL_SECRET_KEY, +} from "@calcom/platform-constants"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Organizations TeamsBookings Endpoints 2024-08-13", () => { + describe("Organization Team bookings", () => { + let app: INestApplication; + let organization: Team; + let team1: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const teamUserEmail = "orgUser1team1@api.com"; + const teamUserEmail2 = "orgUser2team1@api.com"; + let teamUser: User; + let teamUser2: User; + + let team1EventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamUserEmail, + Test.createTestingModule({ + imports: [AppModule, OrganizationsTeamsBookingsModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" }); + oAuthClient = await createOAuthClient(organization.id); + + team1 = await teamRepositoryFixture.create({ + name: "team 1", + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser = await userRepositoryFixture.create({ + email: teamUserEmail, + locale: "it", + name: "orgUser1team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser2 = await userRepositoryFixture.create({ + email: teamUserEmail2, + locale: "es", + name: "orgUser2team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(teamUser.id, userSchedule); + await schedulesService.createUserSchedule(teamUser2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser.id}`, + username: teamUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser2.id}`, + username: teamUserEmail2, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team1EventTypeId = team1EventType.id; + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("create team bookings", () => { + it("should create a team 1 booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: team1EventTypeId, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Madrid", + language: "es", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team1EventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + describe("get team bookings", () => { + it("should should get bookings by teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].eventTypeId).toEqual(team1EventTypeId); + }); + }); + + it("should get bookings by teamId and eventTypeId", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=${team1EventTypeId}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].eventTypeId).toEqual(team1EventTypeId); + }); + }); + + it("should not get bookings by teamId and non existing eventTypeId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(0); + }); + }); + + it("should not get bookings by non existing teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/teams/90909/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(404); + }); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(teamUser.email); + await userRepositoryFixture.deleteByEmail(teamUserEmail2); + await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts new file mode 100644 index 00000000000000..1a8a5fe71748e5 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller.ts @@ -0,0 +1,47 @@ +import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { GetOrganizationsTeamsBookingsInput_2024_08_13 } from "@/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/bookings", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Teams / Bookings") +export class OrganizationsTeamsBookingsController { + constructor(private readonly bookingsService: BookingsService_2024_08_13) {} + + @Get("/") + @ApiOperation({ summary: "Get organization team bookings" }) + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @HttpCode(HttpStatus.OK) + async getAllOrgTeamBookings( + @Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13, + @Param("teamId", ParseIntPipe) teamId: number, + @GetUser() user: UserWithProfile + ): Promise { + const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts new file mode 100644 index 00000000000000..58d88dcbe0b5e4 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module.ts @@ -0,0 +1,16 @@ +import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsTeamsBookingsController } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule], + providers: [OrganizationsRepository, OrganizationsTeamsRepository], + controllers: [OrganizationsTeamsBookingsController], +}) +export class OrganizationsTeamsBookingsModule {} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts new file mode 100644 index 00000000000000..a37680127269fd --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts @@ -0,0 +1,390 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { EventType, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { Membership, Team } from "@calcom/prisma/client"; + +describe("Organizations Teams Memberships Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let orgTeam: Team; + let nonOrgTeam: Team; + let teamEventType: EventType; + let managedEventType: EventType; + let membership: Membership; + let membership2: Membership; + let membershipCreatedViaApi: OrgTeamMembershipOutputDto; + + const userEmail = `organizations-teams-memberships-admin-${randomString()}@api.com`; + const userEmail2 = `organizations-teams-memberships-member-${randomString()}@api.com`; + const nonOrgUserEmail = `organizations-teams-memberships-non-org-${randomString()}@api.com`; + const invitedUserEmail = `organizations-teams-memberships-invited-${randomString()}@api.com`; + + let user: User; + let user2: User; + let nonOrgUser: User; + + let userToInviteViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + user2 = await userRepositoryFixture.create({ + email: userEmail2, + username: userEmail2, + }); + + nonOrgUser = await userRepositoryFixture.create({ + email: nonOrgUserEmail, + username: nonOrgUserEmail, + }); + + userToInviteViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-memberships-organization-${randomString()}`, + isOrganization: true, + }); + + orgTeam = await teamsRepositoryFixture.create({ + name: `organizations-teams-memberships-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + teamEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: orgTeam.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + managedEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "MANAGED", + team: { + connect: { id: orgTeam.id }, + }, + title: "Managed Event Type", + slug: "managed-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + nonOrgTeam = await teamsRepositoryFixture.create({ + name: `organizations-teams-memberships-non-org-team-${randomString()}`, + isOrganization: false, + }); + + membership = await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + membership2 = await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: userToInviteViaApi.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: nonOrgUser.id } }, + team: { connect: { id: nonOrgTeam.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user2.id}`, + username: userEmail2, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user2.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userToInviteViaApi.id}`, + username: invitedUserEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userToInviteViaApi.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all the memberships of the org's team", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(membership.id); + expect(responseBody.data[1].id).toEqual(membership2.id); + expect(responseBody.data.length).toEqual(2); + }); + }); + + it("should fail to get all the memberships of team which is not in the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`) + .expect(404); + }); + + it("should get all the memberships of the org's team paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(membership2.id); + expect(responseBody.data[0].userId).toEqual(user2.id); + expect(responseBody.data.length).toEqual(1); + }); + }); + + it("should fail if org does not exist", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/120494059/teams/${orgTeam.id}/memberships`) + .expect(403); + }); + + it("should get the membership of the org's team", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membership.id); + expect(responseBody.data.userId).toEqual(user.id); + expect(responseBody.data.user.email).toEqual(user.email); + }); + }); + + it("should fail to get the membership of a team not in the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships/${membership.id}`) + .expect(404); + }); + + it("should fail to create the membership of a team not in the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`) + .send({ + userId: userToInviteViaApi.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(404); + }); + + it("should have created the membership of the org's team and assigned team wide events", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .send({ + userId: userToInviteViaApi.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id); + expect(membershipCreatedViaApi.role).toEqual("MEMBER"); + expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id); + expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email); + userHasCorrectEventTypes(membershipCreatedViaApi.userId); + }); + }); + + async function userHasCorrectEventTypes(userId: number) { + const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(orgTeam.id); + expect(managedEventTypes?.length).toEqual(1); + expect(teamEventTypes?.length).toEqual(2); + const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug); + expect(collectiveEvenType).toBeTruthy(); + const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId); + expect(userHost).toBeTruthy(); + expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy(); + } + + it("should fail to create the membership of the org's team for a non org user", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .send({ + userId: nonOrgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(422); + }); + + it("should update the membership of the org's team", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) + .send({ + role: "OWNER", + } satisfies UpdateOrgTeamMembershipDto) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.role).toEqual("OWNER"); + }); + }); + + it("should delete the membership of the org's team we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); + }); + }); + + it("should fail to get the membership of the org's team we just deleted", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(404); + }); + + it("should fail if the membership does not exist", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/123132145`) + .expect(404); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser.email); + await userRepositoryFixture.deleteByEmail(user2.email); + await organizationsRepositoryFixture.delete(org.id); + await teamsRepositoryFixture.delete(nonOrgTeam.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts new file mode 100644 index 00000000000000..523a3b89ed693b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts @@ -0,0 +1,196 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { + OrgTeamMembershipOutputDto, + OrgTeamMembershipsOutputResponseDto, + OrgTeamMembershipOutputResponseDto, +} from "@/modules/organizations/outputs/organization-teams-memberships.output"; +import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service"; +import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpStatus, + HttpCode, + UnprocessableEntityException, + Logger, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/memberships", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Teams / Memberships") +export class OrganizationsTeamsMembershipsController { + private logger = new Logger("OrganizationsTeamsMembershipsController"); + + constructor( + private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService, + private teamsEventTypesService: TeamsEventTypesService, + private readonly organizationsRepository: OrganizationsRepository + ) {} + + @Get("/") + @ApiOperation({ summary: "Get all memberships" }) + @UseGuards() + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @HttpCode(HttpStatus.OK) + async getAllOrgTeamMemberships( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const orgTeamMemberships = await this.organizationsTeamsMembershipsService.getPaginatedOrgTeamMemberships( + orgId, + teamId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: orgTeamMemberships.map((membership) => + plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }) + ), + }; + } + + @Get("/:membershipId") + @ApiOperation({ summary: "Get a membership" }) + @UseGuards() + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @HttpCode(HttpStatus.OK) + async getOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const orgTeamMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership( + orgId, + teamId, + membershipId + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @Delete("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a membership" }) + async deleteOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.organizationsTeamsMembershipsService.deleteOrgTeamMembership( + orgId, + teamId, + membershipId + ); + + await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId); + + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @Patch("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update a membership" }) + async updateOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number, + @Body() data: UpdateOrgTeamMembershipDto + ): Promise { + const currentMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership( + orgId, + teamId, + membershipId + ); + const updatedMembership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership( + orgId, + teamId, + membershipId, + data + ); + + if (!currentMembership.accepted && updatedMembership.accepted) { + try { + await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId); + } catch (err) { + this.logger.error("Could not update new team member eventTypes", err); + } + } + + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a membership" }) + async createOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Body() data: CreateOrgTeamMembershipDto + ): Promise { + const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(data.userId)); + + if (!user) { + throw new UnprocessableEntityException("User is not part of the Organization"); + } + + const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data); + if (membership.accepted) { + try { + await updateNewTeamMemberEventTypes(user.id, teamId); + } catch (err) { + this.logger.error("Could not update new team member eventTypes", err); + } + } + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts new file mode 100644 index 00000000000000..74c8f7391bc7ec --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts @@ -0,0 +1,654 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { OrgMeTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Organizations Team Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let team: Team; + let team2: Team; + let teamCreatedViaApi: Team; + let teamCreatedViaApi2: Team; + + const userEmail = `organizations-teams-admin-${randomString()}@api.com`; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-organization-${randomString()}`, + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + team = await teamsRepositoryFixture.create({ + name: `organizations-teams-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + team2 = await teamsRepositoryFixture.create({ + name: `organizations-teams-team2-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(teamsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all the teams of the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(team.id); + expect(responseBody.data[1].id).toEqual(team2.id); + }); + }); + + it("should get all the teams of the org paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(team2.id); + }); + }); + + it("should fail if org does not exist", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/120494059/teams`).expect(403); + }); + + it("should get the team of the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${team.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(team.id); + expect(responseBody.data.parentId).toEqual(team.parentId); + }); + }); + + it("should create the team of the org", async () => { + const teamName = `organizations-teams-api-team1-${randomString()}`; + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: teamName, + slug: "team-created-via-api", + bio: "This is our test team created via API", + } satisfies CreateOrgTeamDto) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + teamCreatedViaApi = responseBody.data; + expect(teamCreatedViaApi.name).toEqual(teamName); + expect(teamCreatedViaApi.slug).toEqual("team-created-via-api"); + expect(teamCreatedViaApi.bio).toEqual("This is our test team created via API"); + expect(teamCreatedViaApi.parentId).toEqual(org.id); + const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId( + user.id, + teamCreatedViaApi.id + ); + expect(membership?.role ?? "").toEqual("OWNER"); + expect(membership?.accepted).toEqual(true); + }); + }); + + it("should get all the teams of the authenticated org member", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/me`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)).toBeDefined(); + expect(responseBody.data.some((t) => t.accepted)).toBeTruthy(); + expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)?.role).toBe("OWNER"); + }); + }); + + it("should update the team of the org", async () => { + const updatedTeamName = `organizations-teams-api-team1-${randomString()}-updated`; + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`) + .send({ + name: updatedTeamName, + weekStart: "Monday", + logoUrl: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + bannerUrl: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + } satisfies CreateOrgTeamDto) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + teamCreatedViaApi = responseBody.data; + expect(teamCreatedViaApi.name).toEqual(updatedTeamName); + expect(teamCreatedViaApi.weekStart).toEqual("Monday"); + expect(teamCreatedViaApi.logoUrl).toEqual( + "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png" + ); + expect(teamCreatedViaApi.bannerUrl).toEqual( + "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png" + ); + expect(teamCreatedViaApi.parentId).toEqual(org.id); + }); + }); + + it("should delete the team of the org we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(teamCreatedViaApi.id); + expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId); + }); + }); + + it("should fail to get the team of the org we just deleted", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`) + .expect(404); + }); + + it("should fail if the team does not exist", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404); + }); + + it("should create the team of the org without auto-accepting creator", async () => { + const teamName = `organizations-teams-api-team2-${randomString()}`; + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: teamName, + autoAcceptCreator: false, + } satisfies CreateOrgTeamDto) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + teamCreatedViaApi2 = responseBody.data; + expect(teamCreatedViaApi2.name).toEqual(teamName); + expect(teamCreatedViaApi2.parentId).toEqual(org.id); + const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId( + user.id, + teamCreatedViaApi2.id + ); + expect(membership?.role ?? "").toEqual("OWNER"); + expect(membership?.accepted).toEqual(false); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(team2.id); + await teamsRepositoryFixture.delete(teamCreatedViaApi2.id); + await teamsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); + +describe("Organizations Team Endpoints", () => { + describe("User Authentication - User is Org Member", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let team: Team; + let team2: Team; + + const userEmail = `organizations-teams-member-${randomString()}@api.com`; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-organization-${randomString()}`, + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + team = await teamsRepositoryFixture.create({ + name: `organizations-teams-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + team2 = await teamsRepositoryFixture.create({ + name: `organizations-teams-team2-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(teamsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should deny get all the teams of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); + }); + + it("should deny get all the teams of the org paginated", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); + }); + + it("should deny get the team of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403); + }); + + it("should deny create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: `organizations-teams-api-team1-${randomString()}`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}`) + .send({ + name: `organizations-teams-api-team1-${randomString()}-updated`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny delete the team of the org we created via api", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(team2.id); + await teamsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); + +describe("Organizations Team Endpoints", () => { + describe("User Authentication - User is Team Owner", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let team: Team; + let team2: Team; + + const userEmail = `organizations-teams-owner-${randomString()}@api.com`; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-organization-${randomString()}`, + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + team = await teamsRepositoryFixture.create({ + name: `organizations-teams-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + team2 = await teamsRepositoryFixture.create({ + name: `organizations-teams-team2-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: team.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: team2.id } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should deny get all the teams of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); + }); + + it("should deny get all the teams of the org paginated", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); + }); + + it("should get the team of the org for which the user is team owner", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200); + }); + + it("should deny create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: `organizations-teams-api-team1-${randomString()}`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}`) + .send({ + name: `organizations-teams-api-team1-${randomString()}-updated`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny delete the team of the org we created via api", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(team2.id); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); + +describe("Organizations Team Endpoints", () => { + describe("Platform teams", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let orgRepositoryFixture: OrganizationRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + + let oAuthClient1: PlatformOAuthClient; + let oAuthClient2: PlatformOAuthClient; + let org: Team; + let team1: Team; + let team2: Team; + + const userEmail = `organizations-teams-platform-owner-${randomString()}@api.com`; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await orgRepositoryFixture.create({ + name: `organizations-teams-platform-organization-${randomString()}`, + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + oAuthClient1 = await createOAuthClient(org.id); + oAuthClient2 = await createOAuthClient(org.id); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + return await oauthClientRepositoryFixture.create(organizationId, data, secret); + } + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(teamsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should create first oAuth client team", async () => { + const teamName = `organizations-teams-platform-api-team1-${randomString()}`; + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .set(X_CAL_CLIENT_ID, oAuthClient1.id) + .send({ + name: teamName, + } satisfies CreateOrgTeamDto) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + team1 = responseBody.data; + expect(team1.name).toEqual(teamName); + expect(team1.parentId).toEqual(org.id); + + const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id); + + expect(membership?.role ?? "").toEqual("OWNER"); + expect(membership?.accepted).toEqual(true); + }); + }); + + it("should create second oAuth client team", async () => { + const teamName = `organizations-teams-platform-api-team2-${randomString()}`; + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .set(X_CAL_CLIENT_ID, oAuthClient2.id) + .set(X_CAL_SECRET_KEY, oAuthClient2.secret) + .send({ + name: teamName, + } satisfies CreateOrgTeamDto) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + team2 = responseBody.data; + expect(team2.name).toEqual(teamName); + expect(team2.parentId).toEqual(org.id); + + const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team2.id); + + expect(membership?.role ?? "").toEqual("OWNER"); + expect(membership?.accepted).toEqual(true); + }); + }); + + it("should get all the platform teams correctly tied to OAuth clients", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams`) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(team1.id); + expect(responseBody.data[1].id).toEqual(team2.id); + + const oAuthClientTeams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient1.id); + expect(oAuthClientTeams.length).toEqual(1); + const oAuthClientTeam = oAuthClientTeams[0]; + expect(oAuthClientTeam.id).toEqual(team1.id); + expect(oAuthClientTeam.name).toEqual(team1.name); + + const oAuthClient2Teams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient2.id); + expect(oAuthClient2Teams.length).toEqual(1); + const oAuthClientTeam2 = oAuthClient2Teams[0]; + expect(oAuthClientTeam2.id).toEqual(team2.id); + expect(oAuthClientTeam2.name).toEqual(team2.name); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team1.id); + await teamsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts new file mode 100644 index 00000000000000..e9f9b798ed48d7 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts @@ -0,0 +1,162 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input"; +import { + OrgMeTeamOutputDto, + OrgMeTeamsOutputResponseDto, + OrgTeamOutputResponseDto, + OrgTeamsOutputResponseDto, +} from "@/modules/organizations/outputs/organization-team.output"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + Headers, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +import { OrgTeamOutputDto } from "@calcom/platform-types"; +import { SkipTakePagination } from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +@Controller({ + path: "/v2/organizations/:orgId/teams", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Teams") +export class OrganizationsTeamsController { + constructor(private organizationsTeamsService: OrganizationsTeamsService) {} + + @Get() + @DocsTags("Teams") + @ApiOperation({ summary: "Get all teams" }) + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + async getAllTeams( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250); + return { + status: SUCCESS_STATUS, + data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })), + }; + } + + @Get("/me") + @ApiOperation({ summary: "Get teams membership for user" }) + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + async getMyTeams( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination, + @GetUser() user: UserWithProfile + ): Promise { + const { skip, take } = queryParams; + const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams( + orgId, + user.id, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: teams.map((team) => { + const me = team.members.find((member) => member.userId === user.id); + return plainToClass( + OrgMeTeamOutputDto, + me ? { ...team, role: me.role, accepted: me.accepted } : team, + { strategy: "excludeAll" } + ); + }), + }; + } + + @UseGuards(IsTeamInOrg) + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @Get("/:teamId") + @ApiOperation({ summary: "Get a team" }) + async getTeam(@GetTeam() team: Team): Promise { + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @UseGuards(IsTeamInOrg) + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Delete("/:teamId") + @ApiOperation({ summary: "Delete a team" }) + async deleteTeam( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number + ): Promise { + const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @UseGuards(IsTeamInOrg) + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Patch("/:teamId") + @ApiOperation({ summary: "Update a team" }) + async updateTeam( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: UpdateOrgTeamDto + ): Promise { + const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @Post() + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @ApiOperation({ summary: "Create a team" }) + async createTeam( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreateOrgTeamDto, + @GetUser() user: UserWithProfile, + @Headers(X_CAL_CLIENT_ID) oAuthClientId?: string + ): Promise { + const team = oAuthClientId + ? await this.organizationsTeamsService.createPlatformOrgTeam(orgId, oAuthClientId, body, user) + : await this.organizationsTeamsService.createOrgTeam(orgId, body, user); + + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts new file mode 100644 index 00000000000000..cf826f497ed1b6 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts @@ -0,0 +1,43 @@ +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard"; +import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Teams / Schedules") +export class OrganizationsTeamsSchedulesController { + constructor(private schedulesService: SchedulesService_2024_06_11) {} + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrgTeam) + @Get("/users/:userId/schedules") + @DocsTags("Orgs / Teams / Users / Schedules") + @ApiOperation({ summary: "Get schedules of a team member" }) + async getUserSchedules( + @Param("userId", ParseIntPipe) userId: number + ): Promise { + const schedules = await this.schedulesService.getUserSchedules(userId); + + return { + status: SUCCESS_STATUS, + data: schedules, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts new file mode 100644 index 00000000000000..4a423152474bc1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts @@ -0,0 +1,227 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types"; +import { Team, Schedule } from "@calcom/prisma/client"; + +describe("Organizations Teams Schedules Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let orgTeam: Team; + let nonOrgTeam: Team; + let user2Schedule: Schedule; + + const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`; + const userEmail2 = `organizations-teams-schedules-member-${randomString()}@api.com`; + const nonOrgUserEmail = `organizations-teams-schedules-non-org-${randomString()}@api.com`; + const invitedUserEmail = `organizations-teams-schedules-invited-${randomString()}@api.com`; + + let user: User; + let user2: User; + let nonOrgUser: User; + + let userToInviteViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + user2 = await userRepositoryFixture.create({ + email: userEmail2, + username: userEmail2, + }); + + user2Schedule = await scheduleRepositoryFixture.create({ + user: { + connect: { + id: user2.id, + }, + }, + name: `organizations-teams-schedules-user2-schedule-${randomString()}`, + timeZone: "America/New_York", + }); + + nonOrgUser = await userRepositoryFixture.create({ + email: nonOrgUserEmail, + username: nonOrgUserEmail, + }); + + userToInviteViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-schedules-organization-${randomString()}`, + isOrganization: true, + }); + + orgTeam = await teamsRepositoryFixture.create({ + name: `organizations-teams-schedules-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + nonOrgTeam = await teamsRepositoryFixture.create({ + name: `organizations-teams-schedules-non-org-team-${randomString()}`, + isOrganization: false, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: userToInviteViaApi.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: nonOrgUser.id } }, + team: { connect: { id: nonOrgTeam.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user2.id}`, + username: userEmail2, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user2.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userToInviteViaApi.id}`, + username: invitedUserEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userToInviteViaApi.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all the schedule of the org's team's member", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.find((d) => d.id === user2Schedule.id)?.name).toEqual(user2Schedule.name); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser.email); + await userRepositoryFixture.deleteByEmail(user2.email); + await organizationsRepositoryFixture.delete(org.id); + await teamsRepositoryFixture.delete(nonOrgTeam.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller.ts new file mode 100644 index 00000000000000..d44c5d39541225 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller.ts @@ -0,0 +1,142 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; +import { IsUserOOO } from "@/modules/ooo/guards/is-user-ooo"; +import { + CreateOutOfOfficeEntryDto, + UpdateOutOfOfficeEntryDto, + GetOutOfOfficeEntryFiltersDTO, + GetOrgUsersOutOfOfficeEntryFiltersDTO, +} from "@/modules/ooo/inputs/ooo.input"; +import { + UserOooOutputDto, + UserOooOutputResponseDto, + UserOoosOutputResponseDto, +} from "@/modules/ooo/outputs/ooo.output"; +import { UserOOOService } from "@/modules/ooo/services/ooo.service"; +import { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service"; +import { + Controller, + UseGuards, + Get, + Post, + Patch, + Delete, + Param, + ParseIntPipe, + Body, + UseInterceptors, + Query, +} from "@nestjs/common"; +import { ClassSerializerInterceptor } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/organizations/:orgId", + version: API_VERSIONS_VALUES, +}) +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@UseGuards(IsOrgGuard) +@DocsTags("Orgs / Users / OOO") +export class OrganizationsUsersOOOController { + constructor( + private readonly userOOOService: UserOOOService, + private readonly orgUsersOOOService: OrgUsersOOOService + ) {} + + @Get("/users/:userId/ooo") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @ApiOperation({ summary: "Get all ooo entries of a user" }) + async getOrganizationUserOOO( + @Param("userId", ParseIntPipe) userId: number, + @Query() query: GetOutOfOfficeEntryFiltersDTO + ): Promise { + const { skip, take, ...rest } = query ?? { skip: 0, take: 250 }; + const ooos = await this.userOOOService.getUserOOOPaginated(userId, skip ?? 0, take ?? 250, rest); + + return { + status: SUCCESS_STATUS, + data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })), + }; + } + + @Post("/users/:userId/ooo") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @ApiOperation({ summary: "Create an ooo entry for user" }) + async createOrganizationUserOOO( + @Param("userId", ParseIntPipe) userId: number, + @Body() input: CreateOutOfOfficeEntryDto + ): Promise { + const ooo = await this.userOOOService.createUserOOO(userId, input); + return { + status: SUCCESS_STATUS, + data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }), + }; + } + + @Patch("/users/:userId/ooo/:oooId") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg, IsUserOOO) + @ApiOperation({ summary: "Update ooo entry of a user" }) + async updateOrganizationUserOOO( + @Param("userId", ParseIntPipe) userId: number, + @Param("oooId", ParseIntPipe) oooId: number, + + @Body() input: UpdateOutOfOfficeEntryDto + ): Promise { + const ooo = await this.userOOOService.updateUserOOO(userId, oooId, input); + return { + status: SUCCESS_STATUS, + data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }), + }; + } + + @Delete("/users/:userId/ooo/:oooId") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg, IsUserOOO) + @ApiOperation({ summary: "Delete ooo entry of a user" }) + async deleteOrganizationUserOOO( + @Param("oooId", ParseIntPipe) oooId: number + ): Promise { + const ooo = await this.userOOOService.deleteUserOOO(oooId); + return { + status: SUCCESS_STATUS, + data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }), + }; + } + + @Get("/ooo") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @ApiOperation({ summary: "Get all OOO entries of org users" }) + async getOrganizationUsersOOO( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO + ): Promise { + const { skip, take, email, ...rest } = query ?? { skip: 0, take: 250 }; + const ooos = await this.orgUsersOOOService.getOrgUsersOOOPaginated(orgId, skip ?? 0, take ?? 250, rest, { + email, + }); + + return { + status: SUCCESS_STATUS, + data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts new file mode 100644 index 00000000000000..11314ef74b046d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts @@ -0,0 +1,446 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { UserOooOutputDto } from "@/modules/ooo/outputs/ooo.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { Team } from "@calcom/prisma/client"; + +describe("Organizations User OOO Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + let oooCreatedViaApiId: number; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + let org: Team; + let team: Team; + let falseTestOrg: Team; + let falseTestTeam: Team; + + const userEmail = `organizations-users-ooo-admin-${randomString()}@api.com`; + let userAdmin: User; + + const teammate1Email = `organizations-users-ooo-member1-${randomString()}@api.com`; + const teammate2Email = `organizations-users-ooo-member2-${randomString()}@api.com`; + const falseTestUserEmail = `organizations-users-ooo-false-user-${randomString()}@api.com`; + let teammate1: User; + let teammate2: User; + let falseTestUser: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + userAdmin = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + role: "ADMIN", + }); + + teammate1 = await userRepositoryFixture.create({ + email: teammate1Email, + username: teammate1Email, + }); + + teammate2 = await userRepositoryFixture.create({ + email: teammate2Email, + username: teammate2Email, + }); + + falseTestUser = await userRepositoryFixture.create({ + email: falseTestUserEmail, + username: falseTestUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-users-ooo-organization-${randomString()}`, + isOrganization: true, + }); + + falseTestOrg = await organizationsRepositoryFixture.create({ + name: `organizations-users-ooo-false-org-${randomString()}`, + isOrganization: true, + }); + + team = await teamsRepositoryFixture.create({ + name: `organizations-users-ooo-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + falseTestTeam = await teamsRepositoryFixture.create({ + name: `organizations-users-ooo-false-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: falseTestOrg.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userAdmin.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userAdmin.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teammate1.id}`, + username: teammate1Email, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: teammate1.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: userAdmin.id } }, + team: { connect: { id: org.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammate1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammate2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: falseTestUser.id } }, + team: { connect: { id: falseTestTeam.id } }, + accepted: true, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(userAdmin).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should create a ooo entry without redirect", async () => { + const body = { + start: "2025-05-01T01:00:00.000Z", + end: "2025-05-10T13:59:59.999Z", + notes: "ooo numero uno", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(201) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("unspecified"); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-05-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-05-10T23:59:59.999Z"); + oooCreatedViaApiId = data.id; + }); + }); + + it("should create a ooo entry with redirect", async () => { + const body = { + start: "2025-08-01T01:00:00.000Z", + end: "2025-10-10T13:59:59.999Z", + notes: "ooo numero dos with redirect", + toUserId: teammate2.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(201) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("unspecified"); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-08-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-10-10T23:59:59.999Z"); + expect(data.toUserId).toEqual(teammate2.id); + }); + }); + + it("should fail to create a ooo entry with start after end", async () => { + const body = { + start: "2025-07-01T00:00:00.000Z", + end: "2025-05-10T23:59:59.999Z", + notes: "ooo numero uno duplicate", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(400); + }); + + it("should fail to create a duplicate ooo entry", async () => { + const body = { + start: "2025-05-01T00:00:00.000Z", + end: "2025-05-10T23:59:59.999Z", + notes: "ooo numero uno duplicate", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(409); + }); + + it("should fail to create an ooo entry that redirects to self", async () => { + const body = { + start: "2025-05-02T00:00:00.000Z", + end: "2025-05-03T23:59:59.999Z", + notes: "ooo infinite redirect", + toUserId: teammate1.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(400); + }); + + it("should fail to create an ooo entry that redirects to member outside of org", async () => { + const body = { + start: "2025-05-02T00:00:00.000Z", + end: "2025-05-03T23:59:59.999Z", + notes: "ooo invalid redirect", + toUserId: falseTestUser.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(400); + }); + + it("should update a ooo entry without redirect", async () => { + const body = { + start: "2025-06-01T01:00:00.000Z", + end: "2025-06-10T13:59:59.999Z", + notes: "ooo numero uno", + reason: "vacation", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("vacation"); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-06-10T23:59:59.999Z"); + }); + }); + + it("should fail to update a ooo entry with redirect outside of org", async () => { + const body = { + toUserId: falseTestUser.id, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(400); + }); + + it("should fail to update a ooo entry with start after end ", async () => { + const body = { + start: "2025-07-01T00:00:00.000Z", + end: "2025-05-10T23:59:59.999Z", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(400); + }); + + it("should fail to update a ooo entry with redirect to self ", async () => { + const body = { + toUserId: teammate1.id, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(400); + }); + + it("should fail to update a ooo entry with duplicate time", async () => { + const body = { + start: "2025-06-01T01:00:00.000Z", + end: "2025-06-10T13:59:59.999Z", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(409); + }); + + it("should update a ooo entry without redirect", async () => { + const body = { + toUserId: teammate2.id, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("vacation"); + expect(data.toUserId).toEqual(teammate2.id); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-06-10T23:59:59.999Z"); + }); + }); + + it("should get 2 ooo entries", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/ooo?sortEnd=desc&email=${teammate1Email}`) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto[]; + expect(data.length).toEqual(2); + const oooUno = data.find((ooo) => ooo.id === oooCreatedViaApiId); + expect(oooUno).toBeDefined(); + if (oooUno) { + expect(oooUno.reason).toEqual("vacation"); + expect(oooUno.toUserId).toEqual(teammate2.id); + expect(oooUno.userId).toEqual(teammate1.id); + expect(oooUno.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(oooUno.end).toEqual("2025-06-10T23:59:59.999Z"); + } + // test sort + expect(data[1].id).toEqual(oooCreatedViaApiId); + }); + }); + + it("should get 2 ooo entries", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto[]; + expect(data.length).toEqual(2); + const oooUno = data.find((ooo) => ooo.id === oooCreatedViaApiId); + expect(oooUno).toBeDefined(); + if (oooUno) { + expect(oooUno.reason).toEqual("vacation"); + expect(oooUno.toUserId).toEqual(teammate2.id); + expect(oooUno.userId).toEqual(teammate1.id); + expect(oooUno.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(oooUno.end).toEqual("2025-06-10T23:59:59.999Z"); + } + }); + }); + + it("should delete ooo entry", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .expect(200); + }); + + it("user should have 1 ooo entries", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const data = responseBody.data as UserOooOutputDto[]; + expect(data.length).toEqual(1); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userAdmin.email); + await userRepositoryFixture.deleteByEmail(teammate1.email); + await userRepositoryFixture.deleteByEmail(teammate2.email); + await userRepositoryFixture.deleteByEmail(falseTestUser.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(falseTestTeam.id); + await organizationsRepositoryFixture.delete(org.id); + await organizationsRepositoryFixture.delete(falseTestOrg.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository.ts new file mode 100644 index 00000000000000..c00ecd27f87a47 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository.ts @@ -0,0 +1,33 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrgUsersOOORepository { + constructor(private readonly dbRead: PrismaReadService) {} + async getOrgUsersOOOPaginated( + orgId: number, + skip: number, + take: number, + sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }, + filters?: { email?: string } + ) { + console.log({ sort, filters }); + return this.dbRead.prisma.outOfOfficeEntry.findMany({ + where: { + user: { + ...(filters?.email && { email: filters.email }), + profiles: { + some: { + organizationId: orgId, + }, + }, + }, + }, + skip, + take, + include: { reason: true }, + ...(sort?.sortStart && { orderBy: { start: sort.sortStart } }), + ...(sort?.sortEnd && { orderBy: { end: sort.sortEnd } }), + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service.ts new file mode 100644 index 00000000000000..ccf3e7c4d20c8d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service.ts @@ -0,0 +1,26 @@ +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { UserOOOService } from "@/modules/ooo/services/ooo.service"; +import { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrgUsersOOOService { + constructor( + private readonly oooRepository: UserOOORepository, + private readonly oooUserService: UserOOOService, + private readonly usersRepository: UsersRepository, + private readonly orgUsersOOORepository: OrgUsersOOORepository + ) {} + + async getOrgUsersOOOPaginated( + orgId: number, + skip: number, + take: number, + sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }, + filters?: { email?: string } + ) { + const ooos = await this.orgUsersOOORepository.getOrgUsersOOOPaginated(orgId, skip, take, sort, filters); + return ooos.map((ooo) => this.oooUserService.formatOooReason(ooo)); + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts new file mode 100644 index 00000000000000..ebabb65aa9a268 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts @@ -0,0 +1,146 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; +import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input"; +import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input"; +import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input"; +import { + GetOrganizationUsersResponseDTO, + GetOrgUsersWithProfileOutput, +} from "@/modules/organizations/outputs/get-organization-users.output"; +import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output"; +import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Post, + Patch, + Delete, + Param, + ParseIntPipe, + Body, + UseInterceptors, + Query, +} from "@nestjs/common"; +import { ClassSerializerInterceptor } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { Team } from "@calcom/prisma/client"; + +@Controller({ + path: "/v2/organizations/:orgId/users", + version: API_VERSIONS_VALUES, +}) +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@UseGuards(IsOrgGuard) +@DocsTags("Orgs / Users") +export class OrganizationsUsersController { + constructor(private readonly organizationsUsersService: OrganizationsUsersService) {} + + @Get() + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @ApiOperation({ summary: "Get all users" }) + async getOrganizationsUsers( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() query: GetOrganizationsUsersInput + ): Promise { + const users = await this.organizationsUsersService.getUsers( + orgId, + query.emails, + query.skip ?? 0, + query.take ?? 250 + ); + + return { + status: SUCCESS_STATUS, + data: users.map((user) => + plainToInstance( + GetOrgUsersWithProfileOutput, + { ...user, profile: user?.profiles?.[0] ?? {} }, + { strategy: "excludeAll" } + ) + ), + }; + } + + @Post() + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @ApiOperation({ summary: "Create a user" }) + async createOrganizationUser( + @Param("orgId", ParseIntPipe) orgId: number, + @GetOrg() org: Team, + @Body() input: CreateOrganizationUserInput, + @GetUser() inviter: UserWithProfile + ): Promise { + const user = await this.organizationsUsersService.createUser( + org, + input, + inviter.name ?? inviter.username ?? inviter.email + ); + return { + status: SUCCESS_STATUS, + data: plainToInstance( + GetOrgUsersWithProfileOutput, + { ...user, profile: user?.profiles?.[0] ?? {} }, + { strategy: "excludeAll" } + ), + }; + } + + @Patch("/:userId") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @ApiOperation({ summary: "Update a user" }) + async updateOrganizationUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number, + @GetOrg() org: Team, + @Body() input: UpdateOrganizationUserInput + ): Promise { + const user = await this.organizationsUsersService.updateUser(orgId, userId, input); + return { + status: SUCCESS_STATUS, + data: plainToInstance( + GetOrgUsersWithProfileOutput, + { ...user, profile: user?.profiles?.[0] ?? {} }, + { strategy: "excludeAll" } + ), + }; + } + + @Delete("/:userId") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @ApiOperation({ summary: "Delete a user" }) + async deleteOrganizationUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number + ): Promise { + const user = await this.organizationsUsersService.deleteUser(orgId, userId); + return { + status: SUCCESS_STATUS, + data: plainToInstance( + GetOrgUsersWithProfileOutput, + { ...user, profile: user?.profiles?.[0] ?? {} }, + { strategy: "excludeAll" } + ), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts new file mode 100644 index 00000000000000..3ed330655e8b93 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts @@ -0,0 +1,502 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { EmailService } from "@/modules/email/email.service"; +import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/outputs/get-organization-users.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { User, Team, EventType } from "@calcom/prisma/client"; + +describe("Organizations Users Endpoints", () => { + describe("Member role", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const userEmail = `organizations-users-member-${randomString()}@api.com`; + let user: User; + let org: Team; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-users-organization-${randomString()}`, + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should not be able to find org users", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`).expect(403); + }); + + it("should not be able to create a new org user", async () => { + return request(app.getHttpServer()).post(`/v2/organizations/${org.id}/users`).expect(403); + }); + + it("should not be able to update an org user", async () => { + return request(app.getHttpServer()).patch(`/v2/organizations/${org.id}/users/${user.id}`).expect(403); + }); + + it("should not be able to delete an org user", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/users/${user.id}`).expect(403); + }); + + afterAll(async () => { + // await membershipFixtures.delete(membership.id); + await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + + await app.close(); + }); + }); + describe("Admin role", () => { + let app: INestApplication; + let profileRepositoryFixture: ProfileRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + + const userEmail = `organizations-users-admin-${randomString()}@api.com`; + const nonMemberEmail = `organizations-users-non-member-${randomString()}@api.com`; + let user: User; + let org: Team; + let createdUser: User; + + const orgMembersData = [ + { + email: `organizations-users-member1-${randomString()}@api.com`, + username: `organizations-users-member1-${randomString()}@api.com`, + }, + { + email: `organizations-users-member2-${randomString()}@api.com`, + username: `organizations-users-member2-${randomString()}@api.com`, + }, + { + email: `organizations-users-member3-${randomString()}@api.com`, + username: `organizations-users-member3-${randomString()}@api.com`, + }, + ]; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-users-admin-organization-${randomString()}`, + isOrganization: true, + }); + + await userRepositoryFixture.create({ + email: nonMemberEmail, + username: "non-member", + }); + + const orgMembers = await Promise.all( + orgMembersData.map((member) => + userRepositoryFixture.create({ + email: member.email, + username: member.username, + organization: { connect: { id: org.id } }, + }) + ) + ); + // create profiles of orgMember like they would be when being invied to the org + await Promise.all( + orgMembers.map((member) => + profileRepositoryFixture.create({ + uid: `usr-${member.id}`, + username: member.username ?? `usr-${member.id}`, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: member.id, + }, + }, + }) + ) + ); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + await Promise.all( + orgMembers.map((member) => membershipFixtures.addUserToOrg(member, org, "MEMBER", true)) + ); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all org users", async () => { + const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`); + + const userData = body.data as GetOrgUsersWithProfileOutput[]; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(4); + console.log( + "profiles", + { userData }, + userData.map((u) => u.profile) + ); + expect(userData.find((u) => u.profile.username === orgMembersData[0].username)).toBeDefined(); + expect(userData.find((u) => u.profile.username === orgMembersData[1].username)).toBeDefined(); + expect(userData.find((u) => u.profile.username === orgMembersData[2].username)).toBeDefined(); + + expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0); + }); + + it("should only get users with the specified email", async () => { + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + emails: userEmail, + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data as GetOrgUsersWithProfileOutput[]; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(1); + + expect(userData.filter((user) => user.email === userEmail).length).toBe(1); + expect(userData.find((u) => u.profile.username === user.username)).toBeDefined(); + }); + + it("should get users within the specified emails array", async () => { + const orgMemberEmail = orgMembersData[0].email; + + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + emails: [userEmail, orgMemberEmail], + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(2); + + expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1); + expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1); + }); + + it("should update an org user", async () => { + const { body } = await request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${user.id}`) + .send({ + theme: "light", + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data as User; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.theme).toBe("light"); + }); + + it("should create a new org user", async () => { + const newOrgUser = { + email: `organizations-users-new-member-${randomString()}@api.com`, + organizationRole: "MEMBER", + autoAccept: true, + }; + + const emailSpy = jest + .spyOn(EmailService.prototype, "sendSignupToOrganizationEmail") + .mockImplementation(() => Promise.resolve()); + const { body } = await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users`) + .send({ + email: newOrgUser.email, + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.email).toBe(newOrgUser.email); + expect(emailSpy).toHaveBeenCalledWith({ + usernameOrEmail: newOrgUser.email, + orgName: org.name, + orgId: org.id, + inviterName: userEmail, + locale: null, + }); + createdUser = userData; + }); + + it("should delete an org user", async () => { + const { body } = await request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/users/${createdUser.id}`) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data as User; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.id).toBe(createdUser.id); + }); + + afterAll(async () => { + // await membershipFixtures.delete(membership.id); + await Promise.all([ + userRepositoryFixture.deleteByEmail(user.email), + userRepositoryFixture.deleteByEmail(nonMemberEmail), + ...orgMembersData.map((member) => userRepositoryFixture.deleteByEmail(member.email)), + ]); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); + + describe("Member event-types", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const authEmail = `organizations-users-auth-${randomString()}@api.com`; + let user: User; + let org: Team; + let team: Team; + let managedEventType: EventType; + let createdUser: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + authEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-users-organization-${randomString()}`, + isOrganization: true, + }); + + team = await teamsRepositoryFixture.create({ + name: `organizations-users-team-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + user = await userRepositoryFixture.create({ + email: authEmail, + username: authEmail, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: authEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + + await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + managedEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "MANAGED", + team: { + connect: { id: team.id }, + }, + title: "Managed Event Type", + slug: "managed-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should create a new org user with team event-types", async () => { + const newOrgUser = { + email: `organizations-users-new-member-${randomString()}@api.com`, + organizationRole: "MEMBER", + autoAccept: true, + }; + + const { body } = await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users`) + .send({ + email: newOrgUser.email, + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + expect(body.status).toBe(SUCCESS_STATUS); + createdUser = userData; + teamHasCorrectEventTypes(team.id); + }); + + async function teamHasCorrectEventTypes(teamId: number) { + const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId); + expect(eventTypes?.length).toEqual(2); + } + + afterAll(async () => { + // await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(createdUser.email); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.controller.ts b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.controller.ts new file mode 100644 index 00000000000000..2875379d542042 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.controller.ts @@ -0,0 +1,152 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { IsWebhookInOrg } from "@/modules/auth/guards/organizations/is-webhook-in-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service"; +import { CreateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + TeamWebhookOutputDto as OrgWebhookOutputDto, + TeamWebhookOutputResponseDto as OrgWebhookOutputResponseDto, + TeamWebhooksOutputResponseDto as OrgWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/team-webhook.output"; +import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Webhooks") +export class OrganizationsWebhooksController { + constructor( + private organizationsWebhooksService: OrganizationsWebhooksService, + private webhooksService: WebhooksService + ) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Get("/") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get all webhooks" }) + async getAllOrganizationWebhooks( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const webhooks = await this.organizationsWebhooksService.getWebhooksPaginated( + orgId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: webhooks.map((webhook) => + plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }) + ), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a webhook" }) + async createOrganizationWebhook( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreateWebhookInputDto + ): Promise { + const webhook = await this.organizationsWebhooksService.createWebhook( + orgId, + new WebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsWebhookInOrg) + @Get("/:webhookId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get a webhook" }) + async getOrganizationWebhook(@Param("webhookId") webhookId: string): Promise { + const webhook = await this.organizationsWebhooksService.getWebhook(webhookId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsWebhookInOrg) + @Delete("/:webhookId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a webhook" }) + async deleteWebhook(@Param("webhookId") webhookId: string): Promise { + const webhook = await this.webhooksService.deleteWebhook(webhookId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsWebhookInOrg) + @Patch("/:webhookId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update a webhook" }) + async updateOrgWebhook( + @Param("webhookId") webhookId: string, + @Body() body: UpdateWebhookInputDto + ): Promise { + const webhook = await this.organizationsWebhooksService.updateWebhook( + webhookId, + new PartialWebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.e2e-spec.ts new file mode 100644 index 00000000000000..7edf39e965ceaa --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.e2e-spec.ts @@ -0,0 +1,212 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + TeamWebhookOutputResponseDto, + TeamWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/team-webhook.output"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { Team, Webhook } from "@calcom/prisma/client"; + +describe("WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = `organizations-webhooks-admin-${randomString()}@api.com`; + let org: Team; + + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let webhook: TeamWebhookOutputResponseDto["data"]; + let otherWebhook: Webhook; + let user: UserWithProfile; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-webhooks-organization-${randomString()}`, + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + otherWebhook = await webhookRepositoryFixture.create({ + id: "2mdfnn2", + subscriberUrl: "https://example.com", + eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + userRepositoryFixture.deleteByEmail(user.email); + webhookRepositoryFixture.delete(otherWebhook.id); + await app.close(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("/organizations/:orgId/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + process.stdout.write(JSON.stringify(res.body)); + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + teamId: org.id, + }, + } satisfies TeamWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/organizations/:orgId/webhooks (POST) should fail to create a webhook that already has same orgId / subcriberUrl combo", () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(409); + }); + + it("/organizations/:orgId/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + teamId: org.id, + }, + } satisfies TeamWebhookOutputResponseDto); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET) should say forbidden to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/webhooks/90284`).expect(403); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to org", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); + + it("/organizations/:orgId/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as TeamWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.teamId).toBe(org.id); + }); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + teamId: org.id, + }, + } satisfies TeamWebhookOutputResponseDto); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/webhooks/12993`).expect(403); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to org", () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts new file mode 100644 index 00000000000000..56a8fe4c06fcfb --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts @@ -0,0 +1,21 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class AssignOrganizationAttributeOptionToUserInput { + @IsOptional() + @IsString() + @IsNotEmpty() + @ApiPropertyOptional() + readonly value?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @ApiPropertyOptional() + readonly attributeOptionId?: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + readonly attributeId!: string; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts new file mode 100644 index 00000000000000..d292e5238c650e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts @@ -0,0 +1,41 @@ +import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { AttributeType } from "@prisma/client"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +export class CreateOrganizationAttributeInput { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly name!: string; + + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly slug!: string; + + @IsEnum(AttributeType) + @IsNotEmpty() + @ApiProperty({ enum: AttributeType }) + readonly type!: AttributeType; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateOrganizationAttributeOptionInput) + @ApiProperty({ type: [CreateOrganizationAttributeOptionInput] }) + readonly options!: CreateOrganizationAttributeOptionInput[]; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + readonly enabled?: boolean; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts new file mode 100644 index 00000000000000..e45142e8463ad3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class CreateOrganizationAttributeOptionInput { + @IsString() + @IsNotEmpty() + readonly value!: string; + + @IsString() + @IsNotEmpty() + readonly slug!: string; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts new file mode 100644 index 00000000000000..ffcf4156ad9ff1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsString, IsOptional } from "class-validator"; + +export class UpdateOrganizationAttributeOptionInput { + @IsString() + @IsOptional() + @ApiPropertyOptional() + readonly value?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + readonly slug?: string; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts new file mode 100644 index 00000000000000..6e0483f41867dc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { AttributeType } from "@prisma/client"; +import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator"; + +export class UpdateOrganizationAttributeInput { + @IsString() + @IsOptional() + @ApiPropertyOptional() + readonly name?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + readonly slug?: string; + + @IsEnum(AttributeType) + @IsOptional() + @ApiPropertyOptional({ enum: AttributeType }) + readonly type?: AttributeType; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + readonly enabled?: boolean; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-membership.input.ts new file mode 100644 index 00000000000000..29d16c1f94c19e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-membership.input.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator"; + +export class CreateOrgMembershipDto { + @IsInt() + @ApiProperty() + readonly userId!: number; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: false }) + readonly accepted?: boolean = false; + + @IsEnum(MembershipRole) + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: false }) + readonly disableImpersonation?: boolean = false; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts new file mode 100644 index 00000000000000..f455b2753d0d95 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator"; + +export class CreateOrgTeamMembershipDto { + @IsInt() + @ApiProperty() + readonly userId!: number; + + @IsOptional() + @ApiPropertyOptional({ type: Boolean, default: false }) + @IsBoolean() + readonly accepted?: boolean = false; + + @IsEnum(MembershipRole) + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @ApiPropertyOptional({ type: Boolean, default: false }) + @IsBoolean() + readonly disableImpersonation?: boolean = false; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts new file mode 100644 index 00000000000000..e72e9511382063 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts @@ -0,0 +1,3 @@ +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; + +export class CreateOrgTeamDto extends CreateTeamInput {} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts new file mode 100644 index 00000000000000..2e8bbac85dc42e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts @@ -0,0 +1,21 @@ +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator"; + +export class CreateOrganizationUserInput extends CreateUserInput { + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, default: "en" }) + locale = "en"; + + @IsOptional() + @IsEnum(MembershipRole) + @ApiPropertyOptional({ enum: MembershipRole, default: MembershipRole.MEMBER }) + organizationRole: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: true }) + autoAccept = true; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts b/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts new file mode 100644 index 00000000000000..6dfc79f57e9710 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts @@ -0,0 +1,3 @@ +import { GetUsersInput } from "@/modules/users/inputs/get-users.input"; + +export class GetOrganizationsUsersInput extends GetUsersInput {} diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-membership.input.ts new file mode 100644 index 00000000000000..d21119613c2dad --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-membership.input.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum } from "class-validator"; + +export class UpdateOrgMembershipDto { + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly accepted?: boolean; + + @IsOptional() + @IsEnum(MembershipRole) + @ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role?: MembershipRole; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly disableImpersonation?: boolean; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts new file mode 100644 index 00000000000000..704ac7b8243ce3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum } from "class-validator"; + +export class UpdateOrgTeamMembershipDto { + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly accepted?: boolean; + + @IsOptional() + @IsEnum(MembershipRole) + @ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role?: MembershipRole; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly disableImpersonation?: boolean; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts new file mode 100644 index 00000000000000..a69070ab97a67b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts @@ -0,0 +1,3 @@ +import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input"; + +export class UpdateOrgTeamDto extends UpdateTeamDto {} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts new file mode 100644 index 00000000000000..1cba9543ca7a65 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts @@ -0,0 +1,3 @@ +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; + +export class UpdateOrganizationUserInput extends UpdateUserInput {} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts new file mode 100644 index 00000000000000..4991f1e9acaaee --- /dev/null +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -0,0 +1,133 @@ +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; +import { EmailModule } from "@/modules/email/email.module"; +import { EmailService } from "@/modules/email/email.service"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { UserOOOService } from "@/modules/ooo/services/ooo.service"; +import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller"; +import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller"; +import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller"; +import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller"; +import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer"; +import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller"; +import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller"; +import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller"; +import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller"; +import { OrganizationsUsersOOOController } from "@/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller"; +import { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository"; +import { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service"; +import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller"; +import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository"; +import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository"; +import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository"; +import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository"; +import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository"; +import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository"; +import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository"; +import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service"; +import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service"; +import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service"; +import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service"; +import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service"; +import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service"; +import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service"; +import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service"; +import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service"; +import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + PrismaModule, + StripeModule, + SchedulesModule_2024_06_11, + UsersModule, + RedisModule, + EmailModule, + EventTypesModule_2024_06_14, + TeamsEventTypesModule, + TeamsModule, + ], + providers: [ + OrganizationsRepository, + OrganizationsTeamsRepository, + OrganizationsService, + OrganizationsTeamsService, + MembershipsRepository, + OrganizationsSchedulesService, + OrganizationSchedulesRepository, + OrganizationsUsersRepository, + OrganizationsUsersService, + EmailService, + OrganizationsMembershipRepository, + OrganizationsMembershipService, + OrganizationsEventTypesService, + InputOrganizationsEventTypesService, + OutputOrganizationsEventTypesService, + OrganizationsEventTypesRepository, + OrganizationsTeamsMembershipsRepository, + OrganizationsTeamsMembershipsService, + OrganizationAttributesService, + OrganizationAttributeOptionService, + OrganizationAttributeOptionRepository, + OrganizationAttributesRepository, + OrganizationsWebhooksRepository, + OrganizationsWebhooksService, + WebhooksRepository, + WebhooksService, + OutputTeamEventTypesResponsePipe, + UserOOOService, + UserOOORepository, + OrgUsersOOOService, + OrgUsersOOORepository, + ], + exports: [ + OrganizationsService, + OrganizationsRepository, + OrganizationsTeamsRepository, + OrganizationsUsersRepository, + OrganizationsUsersService, + OrganizationsMembershipRepository, + OrganizationsMembershipService, + OrganizationsTeamsMembershipsRepository, + OrganizationsTeamsMembershipsService, + OrganizationAttributesService, + OrganizationAttributeOptionService, + OrganizationAttributeOptionRepository, + OrganizationAttributesRepository, + OrganizationsWebhooksRepository, + OrganizationsWebhooksService, + WebhooksRepository, + WebhooksService, + OrganizationsEventTypesService, + ], + controllers: [ + OrganizationsTeamsController, + OrganizationsSchedulesController, + OrganizationsUsersController, + OrganizationsMembershipsController, + OrganizationsEventTypesController, + OrganizationsTeamsMembershipsController, + OrganizationsAttributesController, + OrganizationsOptionsAttributesController, + OrganizationsWebhooksController, + OrganizationsTeamsSchedulesController, + OrganizationsUsersOOOController, + ], +}) +export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts new file mode 100644 index 00000000000000..1af8e365b884a3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -0,0 +1,134 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly stripeService: StripeService + ) {} + + async findById(organizationId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: organizationId, + isOrganization: true, + }, + }); + } + + async findByIdIncludeBilling(orgId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: orgId, + }, + include: { + platformBilling: true, + }, + }); + } + + async createNewBillingRelation(orgId: number, plan?: PlatformPlan) { + const { id } = await this.stripeService.getStripe().customers.create({ + metadata: { + createdBy: "oauth_client_no_csid", // mark in case this is needed in the future. + }, + }); + + await this.dbWrite.prisma.team.update({ + where: { + id: orgId, + }, + data: { + platformBilling: { + create: { + customerId: id, + plan: plan ? plan.toString() : "none", + }, + }, + }, + }); + + return id; + } + + async findTeamIdFromClientId(clientId: string) { + return this.dbRead.prisma.team.findFirstOrThrow({ + where: { + platformOAuthClient: { + some: { + id: clientId, + }, + }, + }, + select: { + id: true, + }, + }); + } + + async findPlatformOrgFromUserId(userId: number) { + return this.dbRead.prisma.team.findFirstOrThrow({ + where: { + orgProfiles: { + some: { + userId: userId, + }, + }, + isPlatform: true, + isOrganization: true, + }, + select: { + id: true, + isPlatform: true, + isOrganization: true, + }, + }); + } + + async findOrgUser(organizationId: number, userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + profiles: { + some: { + organizationId, + }, + }, + }, + }); + } + + async findOrgTeamUser(organizationId: number, teamId: number, userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + profiles: { + some: { + organizationId, + }, + }, + teams: { + some: { + teamId: teamId, + }, + }, + }, + }); + } + + async fetchOrgAdminApiStatus(organizationId: number) { + return this.dbRead.prisma.organizationSettings.findUnique({ + where: { + organizationId, + }, + select: { + isAdminAPIEnabled: true, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts new file mode 100644 index 00000000000000..bdf05d3f4053f3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator"; + +export const AttributeType = { + TEXT: "TEXT", + NUMBER: "NUMBER", + SINGLE_SELECT: "SINGLE_SELECT", + MULTI_SELECT: "MULTI_SELECT", +} as const; + +export type AttributeType = (typeof AttributeType)[keyof typeof AttributeType]; + +export class Attribute { + @IsString() + @ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_123" }) + id!: string; + + @IsInt() + @ApiProperty({ + type: Number, + required: true, + description: "The team ID associated with the attribute", + example: 1, + }) + teamId!: number; + + @ApiProperty({ + type: String, + required: true, + description: "The type of the attribute", + enum: AttributeType, + }) + @IsEnum(AttributeType) + type!: AttributeType; + + @IsString() + @ApiProperty({ + type: String, + required: true, + description: "The name of the attribute", + example: "Attribute Name", + }) + name!: string; + + @IsString() + @ApiProperty({ + type: String, + required: true, + description: "The slug of the attribute", + example: "attribute-name", + }) + slug!: string; + + @IsBoolean() + @ApiProperty({ + type: Boolean, + required: true, + description: "Whether the attribute is enabled and displayed on their profile", + example: true, + }) + enabled!: boolean; + + @IsBoolean() + @ApiProperty({ + type: Boolean, + required: false, + description: "Whether users can edit the relation", + example: true, + }) + usersCanEditRelation!: boolean; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts new file mode 100644 index 00000000000000..21bf06f1b769fc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class BaseOutputDTO { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts new file mode 100644 index 00000000000000..aa2ff2f547fad8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts @@ -0,0 +1,11 @@ +import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output"; +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class CreateOrganizationAttributesOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => Attribute) + data!: Attribute; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts new file mode 100644 index 00000000000000..5aa36314c95d6b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts @@ -0,0 +1,11 @@ +import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output"; +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class DeleteOrganizationAttributesOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => Attribute) + data!: Attribute; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts new file mode 100644 index 00000000000000..d77ac0028407b1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts @@ -0,0 +1,22 @@ +import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output"; +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsOptional, ValidateNested } from "class-validator"; + +export class GetSingleAttributeOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @IsOptional() + @Type(() => Attribute) + @ApiProperty({ type: Attribute, nullable: true }) + data!: Attribute | null; +} + +export class GetOrganizationAttributesOutput extends BaseOutputDTO { + @Expose() + @ValidateNested({ each: true }) + @Type(() => Attribute) + @ApiProperty({ type: [Attribute] }) + data!: Attribute[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts new file mode 100644 index 00000000000000..fdb8fae13d6e10 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts @@ -0,0 +1,33 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested } from "class-validator"; + +class AssignOptionUserOutputData { + @IsString() + @ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" }) + id!: string; + + @IsString() + @ApiProperty({ type: Number, required: true, description: "The ID form the org membership for the user" }) + memberId!: number; + + @IsString() + @ApiProperty({ type: String, required: true, description: "The value of the option" }) + attributeOptionId!: string; +} + +export class AssignOptionUserOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => AssignOptionUserOutputData) + data!: AssignOptionUserOutputData; +} + +export class UnassignOptionUserOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => AssignOptionUserOutputData) + data!: AssignOptionUserOutputData; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts new file mode 100644 index 00000000000000..81f83e67f12853 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts @@ -0,0 +1,11 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class CreateAttributeOptionOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => OptionOutput) + data!: OptionOutput; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts new file mode 100644 index 00000000000000..6b6203e8b0b7d7 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts @@ -0,0 +1,11 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class DeleteAttributeOptionOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => OptionOutput) + data!: OptionOutput; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts new file mode 100644 index 00000000000000..6f29020fd3118c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts @@ -0,0 +1,29 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested } from "class-validator"; + +class GetOptionUserOutputData { + @IsString() + @ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" }) + id!: string; + + @IsString() + @ApiProperty({ type: String, required: true, description: "The ID of the attribute" }) + attributeId!: string; + + @IsString() + @ApiProperty({ type: String, required: true, description: "The value of the option" }) + value!: string; + + @IsString() + @ApiProperty({ type: String, required: true, description: "The slug of the option" }) + slug!: string; +} + +export class GetOptionUserOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => GetOptionUserOutputData) + data!: GetOptionUserOutputData[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts new file mode 100644 index 00000000000000..11b9cd506632a2 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts @@ -0,0 +1,11 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class GetAllAttributeOptionOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => OptionOutput) + data!: OptionOutput[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts new file mode 100644 index 00000000000000..f3c202fc0a93ef --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts @@ -0,0 +1,36 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator"; + +export class OptionOutput { + @IsString() + @ApiProperty({ + type: String, + required: true, + description: "The ID of the option", + example: "attr_option_id", + }) + id!: string; + + @IsString() + @ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" }) + attributeId!: string; + + @IsString() + @ApiProperty({ + type: String, + required: true, + description: "The value of the option", + example: "option_value", + }) + value!: string; + + @IsString() + @ApiProperty({ + type: String, + required: true, + description: "The slug of the option", + example: "option-slug", + }) + slug!: string; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts new file mode 100644 index 00000000000000..14b85aa2de52e7 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts @@ -0,0 +1,11 @@ +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class UpdateAttributeOptionOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => OptionOutput) + data!: OptionOutput; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts new file mode 100644 index 00000000000000..3aa5b2c9b45cf9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts @@ -0,0 +1,11 @@ +import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output"; +import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output"; +import { Expose, Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +export class UpdateOrganizationAttributesOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => Attribute) + data!: Attribute; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts b/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts new file mode 100644 index 00000000000000..5bb90c95d4c0fc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts @@ -0,0 +1,64 @@ +import { GetUserOutput } from "@/modules/users/outputs/get-users.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class ProfileOutput { + @IsInt() + @Expose() + @ApiProperty({ type: Number, required: true, description: "The ID of the profile of user", example: 1 }) + id!: number; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + required: true, + description: "The ID of the organization of user", + example: 1, + }) + organizationId!: number; + + @IsInt() + @Expose() + @ApiProperty({ type: Number, required: true, description: "The IDof the user", example: 1 }) + userId!: number; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The username of the user within the organization context", + example: "john_doe", + }) + username!: string; +} +export class GetOrgUsersWithProfileOutput extends GetUserOutput { + @ApiProperty({ + description: "organization user profile, contains user data within the organizaton context", + }) + @Expose() + @ValidateNested() + @IsArray() + @Type(() => ProfileOutput) + profile!: ProfileOutput; +} + +export class GetOrganizationUsersResponseDTO { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + data!: GetOrgUsersWithProfileOutput[]; +} + +export class GetOrganizationUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + data!: GetOrgUsersWithProfileOutput; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/create-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/create-membership.output.ts new file mode 100644 index 00000000000000..5a129c98246878 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/create-membership.output.ts @@ -0,0 +1,20 @@ +import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateOrgMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgMembershipOutputDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgMembershipOutputDto) + data!: OrgMembershipOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/delete-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/delete-membership.output.ts new file mode 100644 index 00000000000000..2f3005ba950e28 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/delete-membership.output.ts @@ -0,0 +1,13 @@ +import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteOrgMembership { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + data!: OrgMembershipOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-all-memberships.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-all-memberships.output.ts new file mode 100644 index 00000000000000..3114c2c899fa31 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-all-memberships.output.ts @@ -0,0 +1,21 @@ +import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetAllOrgMemberships { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgMembershipOutputDto, + }) + @IsNotEmptyObject() + @ValidateNested({ each: true }) + @Type(() => OrgMembershipOutputDto) + @IsArray() + data!: OrgMembershipOutputDto[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-membership.output.ts new file mode 100644 index 00000000000000..416a3a6bb4a95b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-membership.output.ts @@ -0,0 +1,20 @@ +import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetOrgMembership { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgMembershipOutputDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgMembershipOutputDto) + data!: OrgMembershipOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/membership.output.ts new file mode 100644 index 00000000000000..1628087dd13442 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/membership.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; + +export class OrgMembershipOutputDto { + @IsInt() + @Expose() + @ApiProperty() + readonly id!: number; + + @IsInt() + @Expose() + @ApiProperty() + readonly userId!: number; + + @IsInt() + @Expose() + @ApiProperty() + readonly teamId!: number; + + @IsBoolean() + @Expose() + @ApiProperty() + readonly accepted!: boolean; + + @IsString() + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + @Expose() + readonly role!: MembershipRole; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiPropertyOptional() + readonly disableImpersonation?: boolean; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/update-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/update-membership.output.ts new file mode 100644 index 00000000000000..d9718221699771 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/update-membership.output.ts @@ -0,0 +1,20 @@ +import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateOrgMembership { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OrgMembershipOutputDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OrgMembershipOutputDto) + data!: OrgMembershipOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts new file mode 100644 index 00000000000000..a652cd6dfbc098 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { OrgTeamOutputDto } from "@calcom/platform-types"; + +export class OrgMeTeamOutputDto extends OrgTeamOutputDto { + @IsString() + @Expose() + readonly accepted!: boolean; + + @ApiProperty({ + example: MembershipRole.MEMBER, + enum: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER], + }) + @IsEnum([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @Expose() + readonly role!: MembershipRole; +} + +export class OrgTeamsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamOutputDto) + data!: OrgTeamOutputDto[]; +} + +export class OrgMeTeamsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamOutputDto) + data!: OrgTeamOutputDto[]; +} + +export class OrgTeamOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamOutputDto) + data!: OrgTeamOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts new file mode 100644 index 00000000000000..1404dd1a949035 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts @@ -0,0 +1,95 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class MembershipUserOutputDto { + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly avatarUrl?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly username?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly name?: string; + + @IsBoolean() + @Expose() + @ApiProperty() + readonly email!: string; +} + +export class OrgTeamMembershipOutputDto { + @IsInt() + @Expose() + @ApiProperty() + readonly id!: number; + + @IsInt() + @Expose() + @ApiProperty() + readonly userId!: number; + + @IsInt() + @Expose() + @ApiProperty() + readonly teamId!: number; + + @IsBoolean() + @Expose() + @ApiProperty() + readonly accepted!: boolean; + + @IsString() + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + @Expose() + readonly role!: MembershipRole; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiPropertyOptional() + readonly disableImpersonation?: boolean; + + @ValidateNested() + @Type(() => MembershipUserOutputDto) + @Expose() + @ApiProperty({ type: MembershipUserOutputDto }) + user!: MembershipUserOutputDto; +} + +export class OrgTeamMembershipsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamMembershipOutputDto) + @IsArray() + @ApiProperty({ type: [OrgTeamMembershipOutputDto] }) + data!: OrgTeamMembershipOutputDto[]; +} + +export class OrgTeamMembershipOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamMembershipOutputDto) + @ApiProperty({ type: OrgTeamMembershipOutputDto }) + data!: OrgTeamMembershipOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts new file mode 100644 index 00000000000000..150965bdf0269b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts @@ -0,0 +1,160 @@ +import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input"; +import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts"; +import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; + +import { slugify } from "@calcom/platform-libraries"; + +@Injectable() +export class OrganizationAttributeOptionRepository { + private readonly logger = new Logger("OrganizationAttributeOptionRepository"); + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly organizationsMembershipsService: OrganizationsMembershipService + ) {} + + async createOrganizationAttributeOption( + organizationId: number, + attributeId: string, + data: CreateOrganizationAttributeOptionInput + ) { + return this.dbWrite.prisma.attributeOption.create({ + data: { + ...data, + attributeId, + }, + }); + } + + async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) { + try { + const deletedAttributeOption = await this.dbWrite.prisma.attributeOption.delete({ + where: { + id: optionId, + attributeId, + }, + }); + return deletedAttributeOption; + } catch (error: any) { + if (error.code === "P2025") { + // P2025 is the Prisma error code for "Record to delete does not exist." + this.logger.warn(`Attribute option not found: ${optionId}`); + throw new NotFoundException("Attribute option not found"); + } + throw error; + } + } + + async updateOrganizationAttributeOption( + organizationId: number, + attributeId: string, + optionId: string, + data: UpdateOrganizationAttributeOptionInput + ) { + return this.dbWrite.prisma.attributeOption.update({ + where: { + id: optionId, + attributeId, + }, + data, + }); + } + + async getOrganizationAttributeOptions(organizationId: number, attributeId: string) { + return this.dbRead.prisma.attributeOption.findMany({ + where: { + attributeId, + }, + }); + } + + async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) { + const options = this.dbRead.prisma.attributeOption.findMany({ + where: { + attribute: { + teamId: organizationId, + }, + assignedUsers: { + some: { + member: { + userId, + }, + }, + }, + }, + }); + + return options; + } + + async assignOrganizationAttributeOptionToUser({ + organizationId, + membershipId, + attributeId, + value, + attributeOptionId, + }: { + organizationId: number; + membershipId: number; + attributeId: string; + value?: string; + attributeOptionId?: string; + }) { + let _attributeOptionId = attributeOptionId; + + if (value && !attributeOptionId) { + _attributeOptionId = await this.createDynamicAttributeOption(organizationId, attributeId, value); + } + + if (!_attributeOptionId) throw new Error("Attribute option not found"); + + return this.dbWrite.prisma.attributeToUser.create({ + data: { + attributeOptionId: _attributeOptionId, + memberId: membershipId, + }, + }); + } + + private async createDynamicAttributeOption( + organizationId: number, + attributeId: string, + value: string + ): Promise { + const attributeOption = await this.createOrganizationAttributeOption(organizationId, attributeId, { + value: value, + slug: slugify(value), + }); + return attributeOption.id; + } + + async unassignOrganizationAttributeOptionFromUser( + organizationId: number, + userId: number, + attributeOptionId: string + ) { + const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId( + organizationId, + userId + ); + + if (!membership) throw new Error("Membership not found"); + + try { + const deletedAttributeToUser = await this.dbWrite.prisma.attributeToUser.delete({ + where: { memberId_attributeOptionId: { memberId: membership.id, attributeOptionId } }, + }); + return deletedAttributeToUser; + } catch (error: any) { + if (error.code === "P2025") { + // P2025 is the Prisma error code for "Record to delete does not exist." + this.logger.warn(`Attribute option not found: ${attributeOptionId} for user ${userId}`); + throw new NotFoundException("Attribute does not belong to this user"); + } + throw error; + } + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts new file mode 100644 index 00000000000000..0b3a7314d345d9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts @@ -0,0 +1,77 @@ +import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input"; +import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationAttributesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) { + const { options, ...attributeData } = data; + + const attribute = await this.dbWrite.prisma.attribute.create({ + data: { + ...attributeData, + teamId: organizationId, + }, + }); + + if (attribute.type === "SINGLE_SELECT" || attribute.type === "MULTI_SELECT") { + // TODO: move this to attribute option service + await this.dbWrite.prisma.attributeOption.createMany({ + data: options.map((option) => ({ + ...option, + attributeId: attribute.id, + })), + }); + } + return attribute; + } + + async getOrganizationAttribute(organizationId: number, attributeId: string) { + const attribute = await this.dbRead.prisma.attribute.findUnique({ + where: { + id: attributeId, + teamId: organizationId, + }, + }); + return attribute; + } + + async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) { + const attributes = await this.dbRead.prisma.attribute.findMany({ + where: { + teamId: organizationId, + }, + skip, + take, + }); + return attributes; + } + async updateOrganizationAttribute( + organizationId: number, + attributeId: string, + data: UpdateOrganizationAttributeInput + ) { + const attribute = await this.dbWrite.prisma.attribute.update({ + where: { + id: attributeId, + teamId: organizationId, + }, + data, + }); + return attribute; + } + + async deleteOrganizationAttribute(organizationId: number, attributeId: string) { + const attribute = await this.dbWrite.prisma.attribute.delete({ + where: { + id: attributeId, + teamId: organizationId, + }, + }); + return attribute; + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts new file mode 100644 index 00000000000000..5d337bc57708dd --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts @@ -0,0 +1,19 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsEventTypesRepository { + constructor(private readonly dbRead: PrismaReadService) {} + async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) { + return this.dbRead.prisma.eventType.findMany({ + where: { + team: { + parentId: orgId, + }, + }, + skip, + take, + include: { users: true, schedule: true, hosts: true, destinationCalendar: true }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts new file mode 100644 index 00000000000000..eca3f5afddcb6d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts @@ -0,0 +1,61 @@ +import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; + +@Injectable() +export class OrganizationsMembershipRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findOrgMembership(organizationId: number, membershipId: number) { + return this.dbRead.prisma.membership.findUnique({ + where: { + id: membershipId, + teamId: organizationId, + }, + }); + } + + async findOrgMembershipByUserId(organizationId: number, userId: number) { + return this.dbRead.prisma.membership.findFirst({ + where: { + teamId: organizationId, + userId, + }, + }); + } + + async deleteOrgMembership(organizationId: number, membershipId: number) { + return this.dbWrite.prisma.membership.delete({ + where: { + id: membershipId, + teamId: organizationId, + }, + }); + } + + async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) { + return this.dbWrite.prisma.membership.create({ + data: { ...data, teamId: organizationId }, + }); + } + + async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) { + return this.dbWrite.prisma.membership.update({ + data: { ...data }, + where: { id: membershipId, teamId: organizationId }, + }); + } + + async findOrgMembershipsPaginated(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.membership.findMany({ + where: { + teamId: organizationId, + }, + skip, + take, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-schedules.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-schedules.repository.ts new file mode 100644 index 00000000000000..e652a85df79654 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-schedules.repository.ts @@ -0,0 +1,23 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationSchedulesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getSchedulesByUserIds(userIds: number[], skip: number, take: number) { + return this.dbRead.prisma.schedule.findMany({ + where: { + userId: { + in: userIds, + }, + }, + include: { + availability: true, + }, + skip, + take, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts new file mode 100644 index 00000000000000..e75b37ac89fa70 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts @@ -0,0 +1,74 @@ +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsMembershipsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findOrgTeamMembershipsPaginated(organizationId: number, teamId: number, skip: number, take: number) { + return await this.dbRead.prisma.membership.findMany({ + where: { + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + skip, + take, + }); + } + + async findOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { + return this.dbRead.prisma.membership.findUnique({ + where: { + id: membershipId, + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } + async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) { + return this.dbWrite.prisma.membership.delete({ + where: { + id: membershipId, + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + }); + } + + async updateOrgTeamMembershipById( + organizationId: number, + teamId: number, + membershipId: number, + data: UpdateOrgTeamMembershipDto + ) { + return this.dbWrite.prisma.membership.update({ + data: { ...data }, + where: { + id: membershipId, + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } + + async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { + return this.dbWrite.prisma.membership.create({ + data: { ...data, teamId: teamId }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts new file mode 100644 index 00000000000000..b92e056c0aa2f9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts @@ -0,0 +1,98 @@ +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findOrgTeam(organizationId: number, teamId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: teamId, + isOrganization: false, + parentId: organizationId, + }, + }); + } + + async findOrgTeams(organizationId: number) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + }, + }); + } + + async deleteOrgTeam(organizationId: number, teamId: number) { + return this.dbWrite.prisma.team.delete({ + where: { + id: teamId, + isOrganization: false, + parentId: organizationId, + }, + }); + } + + async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) { + return this.dbWrite.prisma.team.create({ + data: { ...data, parentId: organizationId }, + }); + } + + async createPlatformOrgTeam(organizationId: number, oAuthClientId: string, data: CreateOrgTeamDto) { + return this.dbWrite.prisma.team.create({ + data: { + ...data, + parentId: organizationId, + createdByOAuthClientId: oAuthClientId, + }, + }); + } + + async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + createdByOAuthClientId: oAuthClientId, + }, + }); + } + + async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) { + return this.dbWrite.prisma.team.update({ + data: { ...data }, + where: { id: teamId, parentId: organizationId, isOrganization: false }, + }); + } + + async findOrgTeamsPaginated(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + }, + skip, + take, + }); + } + + async findOrgUserTeamsPaginated(organizationId: number, userId: number, skip: number, take: number) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + members: { + some: { + userId, + }, + }, + }, + include: { + members: { select: { accepted: true, userId: true, role: true } }, + }, + skip, + take, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts new file mode 100644 index 00000000000000..d2d007ccae35e3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts @@ -0,0 +1,112 @@ +import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input"; +import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { CreationSource } from "@prisma/client"; + +@Injectable() +export class OrganizationsUsersRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + private filterOnOrgMembership(orgId: number) { + return { + profiles: { + some: { + organizationId: orgId, + }, + }, + }; + } + + async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) { + return await this.dbRead.prisma.user.findMany({ + where: { + ...this.filterOnOrgMembership(orgId), + ...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}), + }, + include: { + profiles: { + where: { + organizationId: orgId, + }, + }, + }, + skip, + take, + }); + } + + async getOrganizationUserByUsername(orgId: number, username: string) { + return await this.dbRead.prisma.user.findFirst({ + where: { + username, + ...this.filterOnOrgMembership(orgId), + }, + include: { + profiles: { + where: { + organizationId: orgId, + }, + }, + }, + }); + } + + async getOrganizationUserByEmail(orgId: number, email: string) { + return await this.dbRead.prisma.user.findFirst({ + where: { + email, + ...this.filterOnOrgMembership(orgId), + }, + include: { + profiles: { + where: { + organizationId: orgId, + }, + }, + }, + }); + } + + async createOrganizationUser(orgId: number, createUserBody: CreateOrganizationUserInput) { + const createdUser = await this.dbWrite.prisma.user.create({ + data: { ...createUserBody, creationSource: CreationSource.API_V2 }, + }); + + return createdUser; + } + + async updateOrganizationUser(orgId: number, userId: number, updateUserBody: UpdateOrganizationUserInput) { + return await this.dbWrite.prisma.user.update({ + where: { + id: userId, + organizationId: orgId, + }, + data: updateUserBody, + include: { + profiles: { + where: { + organizationId: orgId, + }, + }, + }, + }); + } + + async deleteUser(orgId: number, userId: number) { + return await this.dbWrite.prisma.user.delete({ + where: { + id: userId, + organizationId: orgId, + }, + include: { + profiles: { + where: { + organizationId: orgId, + }, + }, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-webhooks.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-webhooks.repository.ts new file mode 100644 index 00000000000000..1caec8fd69c936 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-webhooks.repository.ts @@ -0,0 +1,71 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { Webhook } from "@calcom/prisma/client"; + +type WebhookInputData = Pick< + Webhook, + "payloadTemplate" | "eventTriggers" | "subscriberUrl" | "secret" | "active" +>; +@Injectable() +export class OrganizationsWebhooksRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findWebhookByUrl(organizationId: number, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { teamId: organizationId, subscriberUrl }, + }); + } + + async findWebhook(organizationId: number, webhookId: string) { + return this.dbRead.prisma.webhook.findUnique({ + where: { + id: webhookId, + teamId: organizationId, + }, + }); + } + + async findWebhooks(organizationId: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { + teamId: organizationId, + }, + }); + } + + async deleteWebhook(organizationId: number, webhookId: string) { + return this.dbRead.prisma.webhook.delete({ + where: { + id: webhookId, + teamId: organizationId, + }, + }); + } + + async createWebhook(organizationId: number, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, teamId: organizationId }, + }); + } + + async updateWebhook(organizationId: number, webhookId: string, data: Partial) { + return this.dbRead.prisma.webhook.update({ + data: { ...data }, + where: { id: webhookId, teamId: organizationId }, + }); + } + + async findWebhooksPaginated(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { + teamId: organizationId, + }, + skip, + take, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts new file mode 100644 index 00000000000000..c0f73b34b391f5 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts @@ -0,0 +1,114 @@ +import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input"; +import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input"; +import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts"; +import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository"; +import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service"; +import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service"; +import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; + +const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]); + +@Injectable() +export class OrganizationAttributeOptionService { + private readonly logger = new Logger("OrganizationAttributeOptionService"); + constructor( + private readonly organizationAttributeOptionRepository: OrganizationAttributeOptionRepository, + private readonly organizationAttributesService: OrganizationAttributesService, + private readonly organizationsMembershipsService: OrganizationsMembershipService + ) {} + + async createOrganizationAttributeOption( + organizationId: number, + attributeId: string, + data: CreateOrganizationAttributeOptionInput + ) { + return this.organizationAttributeOptionRepository.createOrganizationAttributeOption( + organizationId, + attributeId, + data + ); + } + + async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) { + return this.organizationAttributeOptionRepository.deleteOrganizationAttributeOption( + organizationId, + attributeId, + optionId + ); + } + + async updateOrganizationAttributeOption( + organizationId: number, + attributeId: string, + optionId: string, + data: UpdateOrganizationAttributeOptionInput + ) { + return this.organizationAttributeOptionRepository.updateOrganizationAttributeOption( + organizationId, + attributeId, + optionId, + data + ); + } + + async getOrganizationAttributeOptions(organizationId: number, attributeId: string) { + return this.organizationAttributeOptionRepository.getOrganizationAttributeOptions( + organizationId, + attributeId + ); + } + + async assignOrganizationAttributeOptionToUser( + organizationId: number, + userId: number, + data: AssignOrganizationAttributeOptionToUserInput + ) { + const attribute = await this.organizationAttributesService.getOrganizationAttribute( + organizationId, + data.attributeId + ); + + if (!attribute) { + throw new NotFoundException("Attribute not found"); + } + + const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId( + organizationId, + userId + ); + + if (!membership || !membership.accepted) + throw new NotFoundException("User is not a member of the organization"); + + if (!TYPE_SUPPORTS_VALUE.has(attribute.type) && data.value) { + throw new BadRequestException("Attribute type does not support value"); + } + + return this.organizationAttributeOptionRepository.assignOrganizationAttributeOptionToUser({ + organizationId, + membershipId: membership.id, + value: data.value, + attributeId: data.attributeId, + attributeOptionId: data.attributeOptionId, + }); + } + + async unassignOrganizationAttributeOptionFromUser( + organizationId: number, + userId: number, + attributeOptionId: string + ) { + return this.organizationAttributeOptionRepository.unassignOrganizationAttributeOptionFromUser( + organizationId, + userId, + attributeOptionId + ); + } + + async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) { + return this.organizationAttributeOptionRepository.getOrganizationAttributeOptionsForUser( + organizationId, + userId + ); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts new file mode 100644 index 00000000000000..a7a67215c4af88 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts @@ -0,0 +1,55 @@ +import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input"; +import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input"; +import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationAttributesService { + constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {} + + async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) { + const attribute = await this.organizationAttributesRepository.createOrganizationAttribute( + organizationId, + data + ); + return attribute; + } + + async getOrganizationAttribute(organizationId: number, attributeId: string) { + const attribute = await this.organizationAttributesRepository.getOrganizationAttribute( + organizationId, + attributeId + ); + return attribute; + } + + async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) { + const attributes = await this.organizationAttributesRepository.getOrganizationAttributes( + organizationId, + skip, + take + ); + return attributes; + } + + async updateOrganizationAttribute( + organizationId: number, + attributeId: string, + data: UpdateOrganizationAttributeInput + ) { + const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute( + organizationId, + attributeId, + data + ); + return attribute; + } + + async deleteOrganizationAttribute(organizationId: number, attributeId: string) { + const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute( + organizationId, + attributeId + ); + return attribute; + } +} diff --git a/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts new file mode 100644 index 00000000000000..430fbb85f1f18a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts @@ -0,0 +1,254 @@ +import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; + +import { + CreateTeamEventTypeInput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, + HostPriority, +} from "@calcom/platform-types"; +import { SchedulingType } from "@calcom/prisma/client"; + +@Injectable() +export class InputOrganizationsEventTypesService { + constructor( + private readonly inputEventTypesService: InputEventTypesService_2024_06_14, + private readonly teamsRepository: TeamsRepository, + private readonly usersRepository: UsersRepository, + private readonly teamsEventTypesRepository: TeamsEventTypesRepository + ) {} + async transformAndValidateCreateTeamEventTypeInput( + userId: number, + teamId: number, + inputEventType: CreateTeamEventTypeInput_2024_06_14 + ) { + await this.validateHosts(teamId, inputEventType.hosts); + + const transformedBody = await this.transformInputCreateTeamEventType(teamId, inputEventType); + + await this.inputEventTypesService.validateEventTypeInputs({ + seatsPerTimeSlot: transformedBody.seatsPerTimeSlot, + locations: transformedBody.locations, + requiresConfirmation: transformedBody.requiresConfirmation, + eventName: transformedBody.eventName, + }); + + transformedBody.destinationCalendar && + (await this.inputEventTypesService.validateInputDestinationCalendar( + userId, + transformedBody.destinationCalendar + )); + + transformedBody.useEventTypeDestinationCalendarEmail && + (await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId)); + + return transformedBody; + } + + async transformAndValidateUpdateTeamEventTypeInput( + userId: number, + eventTypeId: number, + teamId: number, + inputEventType: UpdateTeamEventTypeInput_2024_06_14 + ) { + await this.validateHosts(teamId, inputEventType.hosts); + + const transformedBody = await this.transformInputUpdateTeamEventType(eventTypeId, teamId, inputEventType); + + await this.inputEventTypesService.validateEventTypeInputs({ + eventTypeId: eventTypeId, + seatsPerTimeSlot: transformedBody.seatsPerTimeSlot, + locations: transformedBody.locations, + requiresConfirmation: transformedBody.requiresConfirmation, + eventName: transformedBody.eventName, + }); + + transformedBody.destinationCalendar && + (await this.inputEventTypesService.validateInputDestinationCalendar( + userId, + transformedBody.destinationCalendar + )); + + transformedBody.useEventTypeDestinationCalendarEmail && + (await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId)); + + return transformedBody; + } + + async transformInputCreateTeamEventType( + teamId: number, + inputEventType: CreateTeamEventTypeInput_2024_06_14 + ) { + const { hosts, assignAllTeamMembers, ...rest } = inputEventType; + + const eventType = this.inputEventTypesService.transformInputCreateEventType(rest); + const children = await this.getChildEventTypesForManagedEventType(null, inputEventType, teamId); + + const metadata = + rest.schedulingType === "MANAGED" + ? { managedEventConfig: {}, ...eventType.metadata } + : eventType.metadata; + + const teamEventType = { + ...eventType, + // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children + hosts: !(rest.schedulingType === "MANAGED") + ? assignAllTeamMembers + ? await this.getAllTeamMembers(teamId, inputEventType.schedulingType) + : this.transformInputHosts(hosts, inputEventType.schedulingType) + : undefined, + assignAllTeamMembers, + metadata, + children, + }; + + return teamEventType; + } + + async transformInputUpdateTeamEventType( + eventTypeId: number, + teamId: number, + inputEventType: UpdateTeamEventTypeInput_2024_06_14 + ) { + const { hosts, assignAllTeamMembers, ...rest } = inputEventType; + + const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId); + const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId); + + if (!dbEventType) { + throw new BadRequestException("Event type to update not found"); + } + + const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId); + const teamEventType = { + ...eventType, + // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children + hosts: !children + ? assignAllTeamMembers + ? await this.getAllTeamMembers(teamId, dbEventType.schedulingType) + : this.transformInputHosts(hosts, dbEventType.schedulingType) + : undefined, + assignAllTeamMembers, + children, + }; + + return teamEventType; + } + + async getChildEventTypesForManagedEventType( + eventTypeId: number | null, + inputEventType: UpdateTeamEventTypeInput_2024_06_14, + teamId: number + ) { + let eventType = null; + if (eventTypeId) { + eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId); + if (!eventType || eventType.schedulingType !== "MANAGED") { + return undefined; + } + } + + const ownersIds = await this.getOwnersIdsForManagedEventType(teamId, inputEventType, eventType); + const owners = await this.getOwnersForManagedEventType(ownersIds); + + return owners.map((owner) => { + return { + hidden: false, + owner, + }; + }); + } + + async getOwnersIdsForManagedEventType( + teamId: number, + inputEventType: UpdateTeamEventTypeInput_2024_06_14, + eventType: { children: { userId: number | null }[] } | null + ) { + if (inputEventType.assignAllTeamMembers) { + return await this.teamsRepository.getTeamMembersIds(teamId); + } + + // note(Lauris): when API user updates managed event type users + if (inputEventType.hosts) { + return inputEventType.hosts.map((host) => host.userId); + } + + // note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update + return eventType?.children.map((child) => child.userId).filter((id) => !!id) as number[]; + } + + async getOwnersForManagedEventType(userIds: number[]) { + const users = await this.usersRepository.findByIdsWithEventTypes(userIds); + + return users.map((user) => { + const nonManagedEventTypes = user.eventTypes.filter((eventType) => !eventType.parentId); + return { + id: user.id, + name: user.name || user.email, + email: user.email, + // note(Lauris): managed event types slugs have to be excluded otherwise checkExistentEventTypes within handleChildrenEventTypes.ts will incorrectly delete managed user event type. + eventTypeSlugs: nonManagedEventTypes.map((eventType) => eventType.slug), + }; + }); + } + + async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) { + const membersIds = await this.teamsRepository.getTeamMembersIds(teamId); + const isFixed = schedulingType === "COLLECTIVE" ? true : false; + + return membersIds.map((id) => ({ + userId: id, + isFixed, + priority: 2, + })); + } + + transformInputHosts( + inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined, + schedulingType: SchedulingType | null + ) { + if (!inputHosts) { + return undefined; + } + + const defaultPriority = "medium"; + const defaultIsFixed = false; + + return inputHosts.map((host) => ({ + userId: host.userId, + isFixed: schedulingType === "COLLECTIVE" ? true : host.mandatory || defaultIsFixed, + priority: getPriorityValue( + schedulingType === "COLLECTIVE" ? "medium" : host.priority || defaultPriority + ), + })); + } + + async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) { + if (hosts && hosts.length) { + const membersIds = await this.teamsRepository.getTeamMembersIds(teamId); + const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId)); + if (invalidHosts.length) { + throw new NotFoundException(`Invalid hosts: ${invalidHosts.join(", ")}`); + } + } + } +} + +function getPriorityValue(priority: keyof typeof HostPriority): number { + switch (priority) { + case "lowest": + return 0; + case "low": + return 1; + case "medium": + return 2; + case "high": + return 3; + case "highest": + return 4; + default: + throw new Error("Invalid HostPriority label"); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts new file mode 100644 index 00000000000000..e6af4fa48a09cf --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts @@ -0,0 +1,99 @@ +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository"; +import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, Logger } from "@nestjs/common"; + +import { createEventType } from "@calcom/platform-libraries"; +import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types"; + +@Injectable() +export class OrganizationsEventTypesService { + private readonly logger = new Logger("OrganizationsEventTypesService"); + + constructor( + private readonly dbWrite: PrismaWriteService, + private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository, + private readonly teamsEventTypesService: TeamsEventTypesService, + private readonly membershipsRepository: MembershipsRepository, + private readonly usersService: UsersService + ) {} + + async createTeamEventType( + user: UserWithProfile, + teamId: number, + orgId: number, + body: InputTeamEventTransformed_2024_06_14 + ): Promise { + const eventTypeUser = await this.getUserToCreateTeamEvent(user, orgId); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { hosts, children, destinationCalendar, ...rest } = body; + + const { eventType: eventTypeCreated } = await createEventType({ + input: { teamId: teamId, ...rest }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user); + } + + async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) { + const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId); + const profileId = + this.usersService.getUserProfileByOrgId(user, organizationId)?.id || + this.usersService.getUserMainProfile(user)?.id; + return { + id: user.id, + role: user.role, + organizationId: user.organizationId, + organization: { isOrgAdmin }, + profile: { id: profileId || null }, + metadata: user.metadata, + }; + } + + async getTeamEventType(teamId: number, eventTypeId: number): Promise { + return this.teamsEventTypesService.getTeamEventType(teamId, eventTypeId); + } + + async getTeamEventTypeBySlug( + teamId: number, + eventTypeSlug: string, + hostsLimit?: number + ): Promise { + return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug, hostsLimit); + } + + async getTeamEventTypes(teamId: number): Promise { + return await this.teamsEventTypesService.getTeamEventTypes(teamId); + } + + async getOrganizationsTeamsEventTypes( + orgId: number, + skip = 0, + take = 250 + ): Promise { + return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take); + } + + async updateTeamEventType( + eventTypeId: number, + teamId: number, + body: InputTeamEventTransformed_2024_06_14, + user: UserWithProfile + ): Promise { + return this.teamsEventTypesService.updateTeamEventType(eventTypeId, teamId, body, user); + } + + async deleteTeamEventType(teamId: number, eventTypeId: number) { + return this.teamsEventTypesService.deleteTeamEventType(teamId, eventTypeId); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts new file mode 100644 index 00000000000000..d4ae9a5f93fee8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts @@ -0,0 +1,179 @@ +import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client"; +import { SchedulingType, Team } from "@prisma/client"; + +import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types"; + +type EventTypeRelations = { + users: User[]; + schedule: Schedule | null; + hosts: Host[]; + destinationCalendar?: DestinationCalendar | null; + team?: Pick< + Team, + "bannerUrl" | "name" | "logoUrl" | "slug" | "weekStart" | "brandColor" | "darkBrandColor" | "theme" + > | null; +}; +export type DatabaseTeamEventType = EventType & EventTypeRelations; + +type Input = Pick< + DatabaseTeamEventType, + | "id" + | "length" + | "title" + | "description" + | "disableGuests" + | "slotInterval" + | "minimumBookingNotice" + | "beforeEventBuffer" + | "afterEventBuffer" + | "slug" + | "schedulingType" + | "requiresConfirmation" + | "price" + | "currency" + | "lockTimeZoneToggleOnBookingPage" + | "seatsPerTimeSlot" + | "forwardParamsSuccessRedirect" + | "successRedirectUrl" + | "seatsShowAvailabilityCount" + | "isInstantEvent" + | "locations" + | "bookingFields" + | "recurringEvent" + | "metadata" + | "users" + | "scheduleId" + | "hosts" + | "teamId" + | "userId" + | "parentId" + | "assignAllTeamMembers" + | "bookingLimits" + | "durationLimits" + | "onlyShowFirstAvailableSlot" + | "offsetStart" + | "periodType" + | "periodDays" + | "periodCountCalendarDays" + | "periodStartDate" + | "periodEndDate" + | "requiresBookerEmailVerification" + | "hideCalendarNotes" + | "lockTimeZoneToggleOnBookingPage" + | "eventTypeColor" + | "seatsShowAttendees" + | "requiresConfirmationWillBlockSlot" + | "eventName" + | "useEventTypeDestinationCalendarEmail" + | "hideCalendarEventDetails" + | "team" +>; + +@Injectable() +export class OutputOrganizationsEventTypesService { + constructor( + private readonly outputEventTypesService: OutputEventTypesService_2024_06_14, + private readonly teamsEventTypesRepository: TeamsEventTypesRepository, + private readonly usersRepository: UsersRepository + ) {} + + async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) { + const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType( + 0, + databaseEventType, + isOrgTeamEvent + ); + const hosts = + databaseEventType.schedulingType === "MANAGED" + ? await this.getManagedEventTypeHosts(databaseEventType.id) + : await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType); + + return { + ...rest, + hosts, + teamId, + ownerId: userId, + parentEventTypeId: parentId, + schedulingType: databaseEventType.schedulingType, + assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined, + team: { + id: teamId, + name: databaseEventType?.team?.name, + slug: databaseEventType?.team?.slug, + bannerUrl: databaseEventType?.team?.bannerUrl, + logoUrl: databaseEventType?.team?.logoUrl, + weekStart: databaseEventType?.team?.weekStart, + brandColor: databaseEventType?.team?.brandColor, + darkBrandColor: databaseEventType?.team?.darkBrandColor, + theme: databaseEventType?.team?.theme, + }, + }; + } + + async getManagedEventTypeHosts(eventTypeId: number) { + const children = await this.teamsEventTypesRepository.getEventTypeChildren(eventTypeId); + const transformedHosts: TeamEventTypeResponseHost[] = []; + for (const child of children) { + if (child.userId) { + const user = await this.usersRepository.findById(child.userId); + transformedHosts.push({ userId: child.userId, name: user?.name || "" }); + } + } + return transformedHosts; + } + + async transformHosts( + databaseHosts: Host[], + schedulingType: SchedulingType | null + ): Promise { + if (!schedulingType) return []; + + const transformedHosts: TeamEventTypeResponseHost[] = []; + const databaseUsers = await this.usersRepository.findByIds(databaseHosts.map((host) => host.userId)); + + for (const databaseHost of databaseHosts) { + const databaseUser = databaseUsers.find((u) => u.id === databaseHost.userId); + if (schedulingType === "ROUND_ROBIN") { + // note(Lauris): round robin is the only team event where mandatory (isFixed) and priority are used + transformedHosts.push({ + userId: databaseHost.userId, + name: databaseUser?.name || "", + mandatory: databaseHost.isFixed, + priority: getPriorityLabel(databaseHost.priority || 2), + avatarUrl: databaseUser?.avatarUrl, + }); + } else { + transformedHosts.push({ + userId: databaseHost.userId, + name: databaseUser?.name || "", + avatarUrl: databaseUser?.avatarUrl, + }); + } + } + + return transformedHosts; + } +} + +function getPriorityLabel(priority: number): keyof typeof HostPriority { + switch (priority) { + case 0: + return "lowest"; + case 1: + return "low"; + case 2: + return "medium"; + case 3: + return "high"; + case 4: + return "highest"; + default: + throw new Error("Invalid HostPriority value"); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts new file mode 100644 index 00000000000000..7ca9490d2a23d9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts @@ -0,0 +1,57 @@ +import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; +import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository"; +import { Injectable } from "@nestjs/common"; + +import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; + +@Injectable() +export class OrganizationsMembershipService { + constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {} + + async getOrgMembership(organizationId: number, membershipId: number) { + const membership = await this.organizationsMembershipRepository.findOrgMembership( + organizationId, + membershipId + ); + return membership; + } + + async getOrgMembershipByUserId(organizationId: number, userId: number) { + const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( + organizationId, + userId + ); + return membership; + } + + async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) { + const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated( + organizationId, + skip, + take + ); + return memberships; + } + + async deleteOrgMembership(organizationId: number, membershipId: number) { + const membership = await this.organizationsMembershipRepository.deleteOrgMembership( + organizationId, + membershipId + ); + return membership; + } + + async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) { + const membership = await this.organizationsMembershipRepository.updateOrgMembership( + organizationId, + membershipId, + data + ); + return membership; + } + + async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) { + const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data); + return membership; + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-schedules.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-schedules.service.ts new file mode 100644 index 00000000000000..7dfd39f0330f1c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-schedules.service.ts @@ -0,0 +1,30 @@ +import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service"; +import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types"; + +@Injectable() +export class OrganizationsSchedulesService { + constructor( + private readonly organizationSchedulesService: OrganizationSchedulesRepository, + private readonly outputSchedulesService: OutputSchedulesService_2024_06_11, + private readonly usersRepository: UsersRepository + ) {} + + async getOrganizationSchedules(organizationId: number, skip = 0, take = 250) { + const users = await this.usersRepository.getOrganizationUsers(organizationId); + const usersIds = users.map((user) => user.id); + + const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take); + + const responseSchedules: ScheduleOutput_2024_06_11[] = []; + + for (const schedule of schedules) { + responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule)); + } + + return responseSchedules; + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts new file mode 100644 index 00000000000000..8eb65f30cc7cfb --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts @@ -0,0 +1,68 @@ +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsMembershipsService { + constructor( + private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository + ) {} + + async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { + const teamMembership = await this.organizationsTeamsMembershipsRepository.createOrgTeamMembership( + teamId, + data + ); + return teamMembership; + } + + async getPaginatedOrgTeamMemberships(organizationId: number, teamId: number, skip = 0, take = 250) { + const teamMemberships = + await this.organizationsTeamsMembershipsRepository.findOrgTeamMembershipsPaginated( + organizationId, + teamId, + skip, + take + ); + return teamMemberships; + } + + async getOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { + const teamMemberships = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership( + organizationId, + teamId, + membershipId + ); + + if (!teamMemberships) { + throw new NotFoundException("Organization's Team membership not found"); + } + + return teamMemberships; + } + + async updateOrgTeamMembership( + organizationId: number, + teamId: number, + membershipId: number, + data: UpdateOrgTeamMembershipDto + ) { + const teamMembership = await this.organizationsTeamsMembershipsRepository.updateOrgTeamMembershipById( + organizationId, + teamId, + membershipId, + data + ); + return teamMembership; + } + + async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { + const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById( + organizationId, + teamId, + membershipId + ); + return teamMembership; + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts new file mode 100644 index 00000000000000..391163a1c11b12 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts @@ -0,0 +1,70 @@ +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsService { + constructor( + private readonly organizationsTeamRepository: OrganizationsTeamsRepository, + private readonly membershipsRepository: MembershipsRepository + ) {} + + async getPaginatedOrgUserTeams(organizationId: number, userId: number, skip = 0, take = 250) { + const teams = await this.organizationsTeamRepository.findOrgUserTeamsPaginated( + organizationId, + userId, + skip, + take + ); + return teams; + } + + async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) { + const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take); + return teams; + } + + async deleteOrgTeam(organizationId: number, teamId: number) { + const team = await this.organizationsTeamRepository.deleteOrgTeam(organizationId, teamId); + return team; + } + + async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) { + const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data); + return team; + } + + async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) { + const { autoAcceptCreator, ...rest } = data; + + const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest); + + if (user.role !== "ADMIN") { + await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator); + } + return team; + } + + async createPlatformOrgTeam( + organizationId: number, + oAuthClientId: string, + data: CreateOrgTeamDto, + user: UserWithProfile + ) { + const { autoAcceptCreator, ...rest } = data; + + const team = await this.organizationsTeamRepository.createPlatformOrgTeam( + organizationId, + oAuthClientId, + rest + ); + + if (user.role !== "ADMIN") { + await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator); + } + return team; + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts new file mode 100644 index 00000000000000..0fe739eb828f73 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts @@ -0,0 +1,121 @@ +import { EmailService } from "@/modules/email/email.service"; +import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input"; +import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input"; +import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { Injectable, ConflictException } from "@nestjs/common"; +import { Team, CreationSource } from "@prisma/client"; +import { plainToInstance } from "class-transformer"; + +import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries"; + +@Injectable() +export class OrganizationsUsersService { + constructor( + private readonly organizationsUsersRepository: OrganizationsUsersRepository, + private readonly organizationsTeamsService: OrganizationsTeamsService, + private readonly emailService: EmailService + ) {} + + async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) { + const emailArray = !emailInput ? [] : emailInput; + + const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails( + orgId, + emailArray, + skip, + take + ); + + return users; + } + + async createUser(org: Team, userCreateBody: CreateOrganizationUserInput, inviterName: string) { + // Check if email exists in the system + const userEmailCheck = await this.organizationsUsersRepository.getOrganizationUserByEmail( + org.id, + userCreateBody.email + ); + + if (userEmailCheck) throw new ConflictException("A user already exists with that email"); + + // Check if username is already in use in the org + if (userCreateBody.username) { + await this.checkForUsernameConflicts(org.id, userCreateBody.username); + } + + const usernameOrEmail = userCreateBody.username ? userCreateBody.username : userCreateBody.email; + + // Create new org user + const createdUserCall = await createNewUsersConnectToOrgIfExists({ + invitations: [ + { + usernameOrEmail: usernameOrEmail, + role: userCreateBody.organizationRole, + }, + ], + teamId: org.id, + creationSource: CreationSource.API_V2, + isOrg: true, + parentId: null, + autoAcceptEmailDomain: "not-required-for-this-endpoint", + orgConnectInfoByUsernameOrEmail: { + [usernameOrEmail]: { + orgId: org.id, + autoAccept: userCreateBody.autoAccept, + }, + }, + }); + + const createdUser = createdUserCall[0]; + + // Update user fields that weren't included in createNewUsersConnectToOrgIfExists + const updateUserBody = plainToInstance(CreateUserInput, userCreateBody, { strategy: "excludeAll" }); + + // Update new user with other userCreateBody params + const user = await this.organizationsUsersRepository.updateOrganizationUser( + org.id, + createdUser.id, + updateUserBody + ); + + // Need to send email to new user to create password + await this.emailService.sendSignupToOrganizationEmail({ + usernameOrEmail, + orgName: org.name, + orgId: org.id, + locale: user?.locale, + inviterName, + }); + + return user; + } + + async updateUser(orgId: number, userId: number, userUpdateBody: UpdateOrganizationUserInput) { + if (userUpdateBody.username) { + await this.checkForUsernameConflicts(orgId, userUpdateBody.username); + } + + const user = await this.organizationsUsersRepository.updateOrganizationUser( + orgId, + userId, + userUpdateBody + ); + return user; + } + + async deleteUser(orgId: number, userId: number) { + const user = await this.organizationsUsersRepository.deleteUser(orgId, userId); + return user; + } + + async checkForUsernameConflicts(orgId: number, username: string) { + const isUsernameTaken = await this.organizationsUsersRepository.getOrganizationUserByUsername( + orgId, + username + ); + + if (isUsernameTaken) throw new ConflictException("Username is already taken"); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-webhooks.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-webhooks.service.ts new file mode 100644 index 00000000000000..574ada6ae38372 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-webhooks.service.ts @@ -0,0 +1,45 @@ +import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository"; +import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class OrganizationsWebhooksService { + constructor( + private readonly organizationsWebhooksRepository: OrganizationsWebhooksRepository, + private readonly webhooksRepository: WebhooksRepository + ) {} + + async createWebhook(orgId: number, body: PipedInputWebhookType) { + const existingWebhook = await this.organizationsWebhooksRepository.findWebhookByUrl( + orgId, + body.subscriberUrl + ); + if (existingWebhook) { + throw new ConflictException("Webhook with this subscriber url already exists for this user"); + } + + return this.organizationsWebhooksRepository.createWebhook(orgId, { + ...body, + payloadTemplate: body.payloadTemplate ?? null, + secret: body.secret ?? null, + }); + } + + async getWebhooksPaginated(orgId: number, skip: number, take: number) { + return this.organizationsWebhooksRepository.findWebhooksPaginated(orgId, skip, take); + } + + async getWebhook(webhookId: string) { + const webhook = await this.webhooksRepository.getWebhookById(webhookId); + if (!webhook) { + throw new NotFoundException(`Webhook (${webhookId}) not found`); + } + return webhook; + } + + async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) { + return this.webhooksRepository.updateWebhook(webhookId, body); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations.service.ts b/apps/api/v2/src/modules/organizations/services/organizations.service.ts new file mode 100644 index 00000000000000..a32ed9e38b503a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations.service.ts @@ -0,0 +1,12 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsService { + constructor(private readonly organizationsRepository: OrganizationsRepository) {} + + async isPlatform(organizationId: number) { + const organization = await this.organizationsRepository.findById(organizationId); + return organization?.isPlatform; + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma-read.service.ts b/apps/api/v2/src/modules/prisma/prisma-read.service.ts new file mode 100644 index 00000000000000..a63d64fec355ac --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma-read.service.ts @@ -0,0 +1,35 @@ +import type { OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaReadService implements OnModuleInit, OnModuleDestroy { + private logger = new Logger("PrismaReadService"); + + public prisma: PrismaClient; + + constructor(readonly configService: ConfigService) { + const dbUrl = configService.get("db.readUrl", { infer: true }); + + this.prisma = new PrismaClient({ + datasources: { + db: { + url: dbUrl, + }, + }, + }); + } + + async onModuleInit() { + this.prisma.$connect(); + } + + async onModuleDestroy() { + try { + await this.prisma.$disconnect(); + } catch (error) { + this.logger.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma-write.service.ts b/apps/api/v2/src/modules/prisma/prisma-write.service.ts new file mode 100644 index 00000000000000..bd3bc6e3cda9e7 --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma-write.service.ts @@ -0,0 +1,35 @@ +import { OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaWriteService implements OnModuleInit, OnModuleDestroy { + private logger = new Logger("PrismaWriteService"); + + public prisma: PrismaClient; + + constructor(readonly configService: ConfigService) { + const dbUrl = configService.get("db.writeUrl", { infer: true }); + + this.prisma = new PrismaClient({ + datasources: { + db: { + url: dbUrl, + }, + }, + }); + } + + async onModuleInit() { + this.prisma.$connect(); + } + + async onModuleDestroy() { + try { + await this.prisma.$disconnect(); + } catch (error) { + this.logger.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma.module.ts b/apps/api/v2/src/modules/prisma/prisma.module.ts new file mode 100644 index 00000000000000..e9a66a5fc41da9 --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Module } from "@nestjs/common"; + +@Module({ + providers: [PrismaReadService, PrismaWriteService], + exports: [PrismaReadService, PrismaWriteService], +}) +export class PrismaModule {} diff --git a/apps/api/v2/src/modules/profiles/profiles.module.ts b/apps/api/v2/src/modules/profiles/profiles.module.ts new file mode 100644 index 00000000000000..547ad3510e7786 --- /dev/null +++ b/apps/api/v2/src/modules/profiles/profiles.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { ProfilesRepository } from "@/modules/profiles/profiles.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ProfilesRepository], + exports: [ProfilesRepository], +}) +export class ProfilesModule {} diff --git a/apps/api/v2/src/modules/profiles/profiles.repository.ts b/apps/api/v2/src/modules/profiles/profiles.repository.ts new file mode 100644 index 00000000000000..c3ba8fc1508ee3 --- /dev/null +++ b/apps/api/v2/src/modules/profiles/profiles.repository.ts @@ -0,0 +1,20 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class ProfilesRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async getPlatformOwnerUserId(organizationId: number) { + const profile = await this.dbRead.prisma.profile.findFirst({ + where: { + organizationId, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return profile?.userId; + } +} diff --git a/apps/api/v2/src/modules/proxy/proxy.guard.ts b/apps/api/v2/src/modules/proxy/proxy.guard.ts new file mode 100644 index 00000000000000..16074913d46d3c --- /dev/null +++ b/apps/api/v2/src/modules/proxy/proxy.guard.ts @@ -0,0 +1,10 @@ +import { Injectable } from "@nestjs/common"; +import { ThrottlerGuard } from "@nestjs/throttler"; + +@Injectable() +export class ThrottlerBehindProxyGuard extends ThrottlerGuard { + // TODO: adapt if required for CF / AWS / FlightControl proxying. + protected async getTracker(req: Record): Promise { + return req.ips.length ? req.ips[0] : req.ip; + } +} diff --git a/apps/api/v2/src/modules/redis/redis.module.ts b/apps/api/v2/src/modules/redis/redis.module.ts new file mode 100644 index 00000000000000..bbe6472fc50b4d --- /dev/null +++ b/apps/api/v2/src/modules/redis/redis.module.ts @@ -0,0 +1,8 @@ +import { RedisService } from "@/modules/redis/redis.service"; +import { Module } from "@nestjs/common"; + +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/apps/api/v2/src/modules/redis/redis.service.ts b/apps/api/v2/src/modules/redis/redis.service.ts new file mode 100644 index 00000000000000..d683c50a359c81 --- /dev/null +++ b/apps/api/v2/src/modules/redis/redis.service.ts @@ -0,0 +1,25 @@ +import { AppConfig } from "@/config/type"; +import { Injectable, OnModuleDestroy, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Redis } from "ioredis"; + +@Injectable() +export class RedisService implements OnModuleDestroy { + public redis: Redis; + private readonly logger = new Logger("RedisService"); + + constructor(readonly configService: ConfigService) { + const dbUrl = configService.get("db.redisUrl", { infer: true }); + if (!dbUrl) throw new Error("Misconfigured Redis, halting."); + + this.redis = new Redis(dbUrl); + } + + async onModuleDestroy() { + try { + await this.redis.quit(); + } catch (err) { + this.logger.error(err); + } + } +} diff --git a/apps/api/v2/src/modules/router/controllers/router.controller.ts b/apps/api/v2/src/modules/router/controllers/router.controller.ts new file mode 100644 index 00000000000000..7ead58090ffbb5 --- /dev/null +++ b/apps/api/v2/src/modules/router/controllers/router.controller.ts @@ -0,0 +1,39 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common"; +import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; +import { Request } from "express"; + +import { getRoutedUrl } from "@calcom/platform-libraries"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/router", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Router controller") +@DocsExcludeController(true) +export class RouterController { + @Post("/forms/:formId/submit") + async getRoutingFormResponse( + @Req() request: Request, + @Param("formId") formId: string, + @Body() body?: Record + ): Promise & { redirect: boolean })> { + const params = Object.fromEntries(new URLSearchParams(body ?? {})); + const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } }); + + if (routedUrlData?.notFound) { + throw new NotFoundException("Route not found. Please check the provided form parameter."); + } + + if (routedUrlData?.redirect?.destination) { + return { status: "success", data: routedUrlData?.redirect?.destination, redirect: true }; + } + + if (routedUrlData?.props) { + return { status: "success", data: { message: routedUrlData?.props?.message ?? "" }, redirect: false }; + } + + return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false }; + } +} diff --git a/apps/api/v2/src/modules/router/router.module.ts b/apps/api/v2/src/modules/router/router.module.ts new file mode 100644 index 00000000000000..f7725675f4c543 --- /dev/null +++ b/apps/api/v2/src/modules/router/router.module.ts @@ -0,0 +1,11 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RouterController } from "@/modules/router/controllers/router.controller"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [], + exports: [], + controllers: [RouterController], +}) +export class RouterModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts new file mode 100644 index 00000000000000..bdbc561f343461 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts @@ -0,0 +1,156 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { SelectedCalendarOutputResponseDto } from "@/modules/selected-calendars/outputs/selected-calendars.output"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { CalendarsServiceMock } from "test/mocks/calendars-service-mock"; + +import { APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Selected Calendars Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let appleCalendarCredentials: Credential; + let user: User; + let accessTokenSecret: string; + let refreshTokenSecret: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + appleCalendarCredentials = await credentialsRepositoryFixture.create( + APPLE_CALENDAR_TYPE, + {}, + user.id, + APPLE_CALENDAR_ID + ); + await app.init(); + jest + .spyOn(CalendarsService.prototype, "getCalendars") + .mockImplementation(CalendarsServiceMock.prototype.getCalendars); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`POST /v2/selected-calendars: it should respond with a 201 returning back the user added selected calendar`, async () => { + const body = { + integration: appleCalendarCredentials.type, + externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/", + credentialId: appleCalendarCredentials.id, + }; + + await request(app.getHttpServer()) + .post("/v2/selected-calendars") + .set("Authorization", `Bearer ${accessTokenSecret}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: SelectedCalendarOutputResponseDto = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.credentialId).toEqual(body.credentialId); + expect(responseBody.data.integration).toEqual(body.integration); + expect(responseBody.data.externalId).toEqual(body.externalId); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + it(`DELETE /v2/selected-calendars: it should respond with a 200 returning back the user deleted selected calendar`, async () => { + const integration = appleCalendarCredentials.type; + const externalId = + "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/"; + const credentialId = appleCalendarCredentials.id; + + await request(app.getHttpServer()) + .delete( + `/v2/selected-calendars?credentialId=${credentialId}&integration=${integration}&externalId=${externalId}` + ) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .expect(200) + .then(async (response) => { + const responseBody: SelectedCalendarOutputResponseDto = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.credentialId).toEqual(credentialId); + expect(responseBody.data.externalId).toEqual(externalId); + expect(responseBody.data.integration).toEqual(integration); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts new file mode 100644 index 00000000000000..373cda9bffd7c7 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts @@ -0,0 +1,78 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { + SelectedCalendarsInputDto, + SelectedCalendarsQueryParamsInputDto, +} from "@/modules/selected-calendars/inputs/selected-calendars.input"; +import { + SelectedCalendarOutputResponseDto, + SelectedCalendarOutputDto, +} from "@/modules/selected-calendars/outputs/selected-calendars.output"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Body, Controller, Post, UseGuards, Delete, Query } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/selected-calendars", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Selected Calendars") +export class SelectedCalendarsController { + constructor( + private readonly calendarsRepository: CalendarsRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly calendarsService: CalendarsService + ) {} + + @Post("/") + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Add a selected calendar" }) + async addSelectedCalendar( + @Body() input: SelectedCalendarsInputDto, + @GetUser() user: UserWithProfile + ): Promise { + const { integration, externalId, credentialId } = input; + await this.calendarsService.checkCalendarCredentials(Number(credentialId), user.id); + + const newlyAddedCalendarEntry = await this.selectedCalendarsRepository.addUserSelectedCalendar( + user.id, + integration, + externalId, + credentialId + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(SelectedCalendarOutputDto, newlyAddedCalendarEntry, { strategy: "excludeAll" }), + }; + } + + @Delete("/") + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Delete a selected calendar" }) + async removeSelectedCalendar( + @Query() queryParams: SelectedCalendarsQueryParamsInputDto, + @GetUser() user: UserWithProfile + ): Promise { + const { integration, externalId, credentialId } = queryParams; + await this.calendarsService.checkCalendarCredentials(Number(credentialId), user.id); + + const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar( + user.id, + integration, + externalId + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(SelectedCalendarOutputDto, removedCalendarEntry, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts b/apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts new file mode 100644 index 00000000000000..1339cc36d78821 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts @@ -0,0 +1,23 @@ +import { IsInt, IsString } from "class-validator"; + +export class SelectedCalendarsInputDto { + @IsString() + readonly integration!: string; + + @IsString() + readonly externalId!: string; + + @IsInt() + readonly credentialId!: number; +} + +export class SelectedCalendarsQueryParamsInputDto { + @IsString() + readonly integration!: string; + + @IsString() + readonly externalId!: string; + + @IsString() + readonly credentialId!: string; +} diff --git a/apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts b/apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts new file mode 100644 index 00000000000000..1461274a99a2d8 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class SelectedCalendarOutputDto { + @IsInt() + @Expose() + readonly userId!: number; + + @IsString() + @Expose() + readonly integration!: string; + + @IsString() + @Expose() + readonly externalId!: string; + + @IsInt() + @Expose() + readonly credentialId!: number | null; +} + +export class SelectedCalendarOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => SelectedCalendarOutputDto) + data!: SelectedCalendarOutputDto; +} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts new file mode 100644 index 00000000000000..0537eb4f8b03f8 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts @@ -0,0 +1,24 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ + SelectedCalendarsRepository, + CalendarsRepository, + CalendarsService, + UsersRepository, + CredentialsRepository, + AppsRepository, + ], + controllers: [SelectedCalendarsController], + exports: [SelectedCalendarsRepository], +}) +export class SelectedCalendarsModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts new file mode 100644 index 00000000000000..03134c56a870b6 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -0,0 +1,114 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +// It ensures that we work on userLevel calendars only +const ensureUserLevelWhere = { + eventTypeId: null, +}; + +@Injectable() +export class SelectedCalendarsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async upsertSelectedCalendar( + externalId: string, + credentialId: number, + userId: number, + integration: string + ) { + // Why we can't use .upsert here, see server/repository/selectedCalendar.ts#upsert + const existingUserSelectedCalendar = await this.getUserSelectedCalendar(userId, integration, externalId); + const data = { + userId, + externalId, + credentialId, + integration, + ...ensureUserLevelWhere, + }; + + if (existingUserSelectedCalendar) { + return this.dbWrite.prisma.selectedCalendar.update({ + where: { + id: existingUserSelectedCalendar.id, + }, + data, + }); + } + + return this.dbWrite.prisma.selectedCalendar.create({ + data, + }); + } + + getUserSelectedCalendars(userId: number) { + // It would be unique result but we can't use .findUnique here because of eventTypeId being nullable + return this.dbRead.prisma.selectedCalendar.findMany({ + where: { + userId, + ...ensureUserLevelWhere, + }, + }); + } + + getUserSelectedCalendar(userId: number, integration: string, externalId: string) { + return this.dbRead.prisma.selectedCalendar.findFirst({ + where: { + userId, + externalId, + integration, + ...ensureUserLevelWhere, + }, + }); + } + + async addUserSelectedCalendar( + userId: number, + integration: string, + externalId: string, + credentialId: number + ) { + const existingUserSelectedCalendar = await this.getUserSelectedCalendar(userId, integration, externalId); + + if (existingUserSelectedCalendar) { + return; + } + + return await this.dbWrite.prisma.selectedCalendar.create({ + data: { + userId, + integration, + externalId, + credentialId, + ...ensureUserLevelWhere, + }, + }); + } + + async removeUserSelectedCalendar(userId: number, integration: string, externalId: string) { + // Using deleteMany because userId_externalId_integration_eventTypeId is a unique constraint but with eventTypeId being nullable, causing it to be not used as a unique constraint + const records = await this.dbWrite.prisma.selectedCalendar.findMany({ + where: { + userId, + externalId, + integration, + ...ensureUserLevelWhere, + }, + }); + + // Make the behaviour same as .delete which throws error if no record is found + if (records.length === 0) { + throw new Error("No SelectedCalendar found."); + } + + if (records.length > 1) { + throw new Error("Multiple SelecteCalendars found. Skipping deletion"); + } + + return await this.dbWrite.prisma.selectedCalendar.delete({ + where: { + id: records[0].id, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts new file mode 100644 index 00000000000000..4a975d5066cbf7 --- /dev/null +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts @@ -0,0 +1,499 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SlotsModule } from "@/modules/slots/slots.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +const expectedSlotsUTC = { + slots: { + "2050-09-05": [ + { time: "2050-09-05T07:00:00.000Z" }, + { time: "2050-09-05T08:00:00.000Z" }, + { time: "2050-09-05T09:00:00.000Z" }, + { time: "2050-09-05T10:00:00.000Z" }, + { time: "2050-09-05T11:00:00.000Z" }, + { time: "2050-09-05T12:00:00.000Z" }, + { time: "2050-09-05T13:00:00.000Z" }, + { time: "2050-09-05T14:00:00.000Z" }, + ], + "2050-09-06": [ + { time: "2050-09-06T07:00:00.000Z" }, + { time: "2050-09-06T08:00:00.000Z" }, + { time: "2050-09-06T09:00:00.000Z" }, + { time: "2050-09-06T10:00:00.000Z" }, + { time: "2050-09-06T11:00:00.000Z" }, + { time: "2050-09-06T12:00:00.000Z" }, + { time: "2050-09-06T13:00:00.000Z" }, + { time: "2050-09-06T14:00:00.000Z" }, + ], + "2050-09-07": [ + { time: "2050-09-07T07:00:00.000Z" }, + { time: "2050-09-07T08:00:00.000Z" }, + { time: "2050-09-07T09:00:00.000Z" }, + { time: "2050-09-07T10:00:00.000Z" }, + { time: "2050-09-07T11:00:00.000Z" }, + { time: "2050-09-07T12:00:00.000Z" }, + { time: "2050-09-07T13:00:00.000Z" }, + { time: "2050-09-07T14:00:00.000Z" }, + ], + "2050-09-08": [ + { time: "2050-09-08T07:00:00.000Z" }, + { time: "2050-09-08T08:00:00.000Z" }, + { time: "2050-09-08T09:00:00.000Z" }, + { time: "2050-09-08T10:00:00.000Z" }, + { time: "2050-09-08T11:00:00.000Z" }, + { time: "2050-09-08T12:00:00.000Z" }, + { time: "2050-09-08T13:00:00.000Z" }, + { time: "2050-09-08T14:00:00.000Z" }, + ], + "2050-09-09": [ + { time: "2050-09-09T07:00:00.000Z" }, + { time: "2050-09-09T08:00:00.000Z" }, + { time: "2050-09-09T09:00:00.000Z" }, + { time: "2050-09-09T10:00:00.000Z" }, + { time: "2050-09-09T11:00:00.000Z" }, + { time: "2050-09-09T12:00:00.000Z" }, + { time: "2050-09-09T13:00:00.000Z" }, + { time: "2050-09-09T14:00:00.000Z" }, + ], + }, +}; + +const expectedSlotsRome = { + slots: { + "2050-09-05": [ + { time: "2050-09-05T09:00:00.000+02:00" }, + { time: "2050-09-05T10:00:00.000+02:00" }, + { time: "2050-09-05T11:00:00.000+02:00" }, + { time: "2050-09-05T12:00:00.000+02:00" }, + { time: "2050-09-05T13:00:00.000+02:00" }, + { time: "2050-09-05T14:00:00.000+02:00" }, + { time: "2050-09-05T15:00:00.000+02:00" }, + { time: "2050-09-05T16:00:00.000+02:00" }, + ], + "2050-09-06": [ + { time: "2050-09-06T09:00:00.000+02:00" }, + { time: "2050-09-06T10:00:00.000+02:00" }, + { time: "2050-09-06T11:00:00.000+02:00" }, + { time: "2050-09-06T12:00:00.000+02:00" }, + { time: "2050-09-06T13:00:00.000+02:00" }, + { time: "2050-09-06T14:00:00.000+02:00" }, + { time: "2050-09-06T15:00:00.000+02:00" }, + { time: "2050-09-06T16:00:00.000+02:00" }, + ], + "2050-09-07": [ + { time: "2050-09-07T09:00:00.000+02:00" }, + { time: "2050-09-07T10:00:00.000+02:00" }, + { time: "2050-09-07T11:00:00.000+02:00" }, + { time: "2050-09-07T12:00:00.000+02:00" }, + { time: "2050-09-07T13:00:00.000+02:00" }, + { time: "2050-09-07T14:00:00.000+02:00" }, + { time: "2050-09-07T15:00:00.000+02:00" }, + { time: "2050-09-07T16:00:00.000+02:00" }, + ], + "2050-09-08": [ + { time: "2050-09-08T09:00:00.000+02:00" }, + { time: "2050-09-08T10:00:00.000+02:00" }, + { time: "2050-09-08T11:00:00.000+02:00" }, + { time: "2050-09-08T12:00:00.000+02:00" }, + { time: "2050-09-08T13:00:00.000+02:00" }, + { time: "2050-09-08T14:00:00.000+02:00" }, + { time: "2050-09-08T15:00:00.000+02:00" }, + { time: "2050-09-08T16:00:00.000+02:00" }, + ], + "2050-09-09": [ + { time: "2050-09-09T09:00:00.000+02:00" }, + { time: "2050-09-09T10:00:00.000+02:00" }, + { time: "2050-09-09T11:00:00.000+02:00" }, + { time: "2050-09-09T12:00:00.000+02:00" }, + { time: "2050-09-09T13:00:00.000+02:00" }, + { time: "2050-09-09T14:00:00.000+02:00" }, + { time: "2050-09-09T15:00:00.000+02:00" }, + { time: "2050-09-09T16:00:00.000+02:00" }, + ], + }, +}; + +const expectedSlotsUTCRange = { + slots: { + "2050-09-05": [ + { startTime: "2050-09-05T07:00:00.000Z", endTime: "2050-09-05T08:00:00.000Z" }, + { startTime: "2050-09-05T08:00:00.000Z", endTime: "2050-09-05T09:00:00.000Z" }, + { startTime: "2050-09-05T09:00:00.000Z", endTime: "2050-09-05T10:00:00.000Z" }, + { startTime: "2050-09-05T10:00:00.000Z", endTime: "2050-09-05T11:00:00.000Z" }, + { startTime: "2050-09-05T11:00:00.000Z", endTime: "2050-09-05T12:00:00.000Z" }, + { startTime: "2050-09-05T12:00:00.000Z", endTime: "2050-09-05T13:00:00.000Z" }, + { startTime: "2050-09-05T13:00:00.000Z", endTime: "2050-09-05T14:00:00.000Z" }, + { startTime: "2050-09-05T14:00:00.000Z", endTime: "2050-09-05T15:00:00.000Z" }, + ], + "2050-09-06": [ + { startTime: "2050-09-06T07:00:00.000Z", endTime: "2050-09-06T08:00:00.000Z" }, + { startTime: "2050-09-06T08:00:00.000Z", endTime: "2050-09-06T09:00:00.000Z" }, + { startTime: "2050-09-06T09:00:00.000Z", endTime: "2050-09-06T10:00:00.000Z" }, + { startTime: "2050-09-06T10:00:00.000Z", endTime: "2050-09-06T11:00:00.000Z" }, + { startTime: "2050-09-06T11:00:00.000Z", endTime: "2050-09-06T12:00:00.000Z" }, + { startTime: "2050-09-06T12:00:00.000Z", endTime: "2050-09-06T13:00:00.000Z" }, + { startTime: "2050-09-06T13:00:00.000Z", endTime: "2050-09-06T14:00:00.000Z" }, + { startTime: "2050-09-06T14:00:00.000Z", endTime: "2050-09-06T15:00:00.000Z" }, + ], + "2050-09-07": [ + { startTime: "2050-09-07T07:00:00.000Z", endTime: "2050-09-07T08:00:00.000Z" }, + { startTime: "2050-09-07T08:00:00.000Z", endTime: "2050-09-07T09:00:00.000Z" }, + { startTime: "2050-09-07T09:00:00.000Z", endTime: "2050-09-07T10:00:00.000Z" }, + { startTime: "2050-09-07T10:00:00.000Z", endTime: "2050-09-07T11:00:00.000Z" }, + { startTime: "2050-09-07T11:00:00.000Z", endTime: "2050-09-07T12:00:00.000Z" }, + { startTime: "2050-09-07T12:00:00.000Z", endTime: "2050-09-07T13:00:00.000Z" }, + { startTime: "2050-09-07T13:00:00.000Z", endTime: "2050-09-07T14:00:00.000Z" }, + { startTime: "2050-09-07T14:00:00.000Z", endTime: "2050-09-07T15:00:00.000Z" }, + ], + "2050-09-08": [ + { startTime: "2050-09-08T07:00:00.000Z", endTime: "2050-09-08T08:00:00.000Z" }, + { startTime: "2050-09-08T08:00:00.000Z", endTime: "2050-09-08T09:00:00.000Z" }, + { startTime: "2050-09-08T09:00:00.000Z", endTime: "2050-09-08T10:00:00.000Z" }, + { startTime: "2050-09-08T10:00:00.000Z", endTime: "2050-09-08T11:00:00.000Z" }, + { startTime: "2050-09-08T11:00:00.000Z", endTime: "2050-09-08T12:00:00.000Z" }, + { startTime: "2050-09-08T12:00:00.000Z", endTime: "2050-09-08T13:00:00.000Z" }, + { startTime: "2050-09-08T13:00:00.000Z", endTime: "2050-09-08T14:00:00.000Z" }, + { startTime: "2050-09-08T14:00:00.000Z", endTime: "2050-09-08T15:00:00.000Z" }, + ], + "2050-09-09": [ + { startTime: "2050-09-09T07:00:00.000Z", endTime: "2050-09-09T08:00:00.000Z" }, + { startTime: "2050-09-09T08:00:00.000Z", endTime: "2050-09-09T09:00:00.000Z" }, + { startTime: "2050-09-09T09:00:00.000Z", endTime: "2050-09-09T10:00:00.000Z" }, + { startTime: "2050-09-09T10:00:00.000Z", endTime: "2050-09-09T11:00:00.000Z" }, + { startTime: "2050-09-09T11:00:00.000Z", endTime: "2050-09-09T12:00:00.000Z" }, + { startTime: "2050-09-09T12:00:00.000Z", endTime: "2050-09-09T13:00:00.000Z" }, + { startTime: "2050-09-09T13:00:00.000Z", endTime: "2050-09-09T14:00:00.000Z" }, + { startTime: "2050-09-09T14:00:00.000Z", endTime: "2050-09-09T15:00:00.000Z" }, + ], + }, +}; + +const expectedSlotsRomeRange = { + slots: { + "2050-09-05": [ + { startTime: "2050-09-05T09:00:00.000+02:00", endTime: "2050-09-05T10:00:00.000+02:00" }, + { startTime: "2050-09-05T10:00:00.000+02:00", endTime: "2050-09-05T11:00:00.000+02:00" }, + { startTime: "2050-09-05T11:00:00.000+02:00", endTime: "2050-09-05T12:00:00.000+02:00" }, + { startTime: "2050-09-05T12:00:00.000+02:00", endTime: "2050-09-05T13:00:00.000+02:00" }, + { startTime: "2050-09-05T13:00:00.000+02:00", endTime: "2050-09-05T14:00:00.000+02:00" }, + { startTime: "2050-09-05T14:00:00.000+02:00", endTime: "2050-09-05T15:00:00.000+02:00" }, + { startTime: "2050-09-05T15:00:00.000+02:00", endTime: "2050-09-05T16:00:00.000+02:00" }, + { startTime: "2050-09-05T16:00:00.000+02:00", endTime: "2050-09-05T17:00:00.000+02:00" }, + ], + "2050-09-06": [ + { startTime: "2050-09-06T09:00:00.000+02:00", endTime: "2050-09-06T10:00:00.000+02:00" }, + { startTime: "2050-09-06T10:00:00.000+02:00", endTime: "2050-09-06T11:00:00.000+02:00" }, + { startTime: "2050-09-06T11:00:00.000+02:00", endTime: "2050-09-06T12:00:00.000+02:00" }, + { startTime: "2050-09-06T12:00:00.000+02:00", endTime: "2050-09-06T13:00:00.000+02:00" }, + { startTime: "2050-09-06T13:00:00.000+02:00", endTime: "2050-09-06T14:00:00.000+02:00" }, + { startTime: "2050-09-06T14:00:00.000+02:00", endTime: "2050-09-06T15:00:00.000+02:00" }, + { startTime: "2050-09-06T15:00:00.000+02:00", endTime: "2050-09-06T16:00:00.000+02:00" }, + { startTime: "2050-09-06T16:00:00.000+02:00", endTime: "2050-09-06T17:00:00.000+02:00" }, + ], + "2050-09-07": [ + { startTime: "2050-09-07T09:00:00.000+02:00", endTime: "2050-09-07T10:00:00.000+02:00" }, + { startTime: "2050-09-07T10:00:00.000+02:00", endTime: "2050-09-07T11:00:00.000+02:00" }, + { startTime: "2050-09-07T11:00:00.000+02:00", endTime: "2050-09-07T12:00:00.000+02:00" }, + { startTime: "2050-09-07T12:00:00.000+02:00", endTime: "2050-09-07T13:00:00.000+02:00" }, + { startTime: "2050-09-07T13:00:00.000+02:00", endTime: "2050-09-07T14:00:00.000+02:00" }, + { startTime: "2050-09-07T14:00:00.000+02:00", endTime: "2050-09-07T15:00:00.000+02:00" }, + { startTime: "2050-09-07T15:00:00.000+02:00", endTime: "2050-09-07T16:00:00.000+02:00" }, + { startTime: "2050-09-07T16:00:00.000+02:00", endTime: "2050-09-07T17:00:00.000+02:00" }, + ], + "2050-09-08": [ + { startTime: "2050-09-08T09:00:00.000+02:00", endTime: "2050-09-08T10:00:00.000+02:00" }, + { startTime: "2050-09-08T10:00:00.000+02:00", endTime: "2050-09-08T11:00:00.000+02:00" }, + { startTime: "2050-09-08T11:00:00.000+02:00", endTime: "2050-09-08T12:00:00.000+02:00" }, + { startTime: "2050-09-08T12:00:00.000+02:00", endTime: "2050-09-08T13:00:00.000+02:00" }, + { startTime: "2050-09-08T13:00:00.000+02:00", endTime: "2050-09-08T14:00:00.000+02:00" }, + { startTime: "2050-09-08T14:00:00.000+02:00", endTime: "2050-09-08T15:00:00.000+02:00" }, + { startTime: "2050-09-08T15:00:00.000+02:00", endTime: "2050-09-08T16:00:00.000+02:00" }, + { startTime: "2050-09-08T16:00:00.000+02:00", endTime: "2050-09-08T17:00:00.000+02:00" }, + ], + "2050-09-09": [ + { startTime: "2050-09-09T09:00:00.000+02:00", endTime: "2050-09-09T10:00:00.000+02:00" }, + { startTime: "2050-09-09T10:00:00.000+02:00", endTime: "2050-09-09T11:00:00.000+02:00" }, + { startTime: "2050-09-09T11:00:00.000+02:00", endTime: "2050-09-09T12:00:00.000+02:00" }, + { startTime: "2050-09-09T12:00:00.000+02:00", endTime: "2050-09-09T13:00:00.000+02:00" }, + { startTime: "2050-09-09T13:00:00.000+02:00", endTime: "2050-09-09T14:00:00.000+02:00" }, + { startTime: "2050-09-09T14:00:00.000+02:00", endTime: "2050-09-09T15:00:00.000+02:00" }, + { startTime: "2050-09-09T15:00:00.000+02:00", endTime: "2050-09-09T16:00:00.000+02:00" }, + { startTime: "2050-09-09T16:00:00.000+02:00", endTime: "2050-09-09T17:00:00.000+02:00" }, + ], + }, +}; + +describe("Slots Endpoints", () => { + describe("Individual user slots", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let schedulesService: SchedulesService_2024_06_11; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + + const userEmail = `slots-${randomString()}-controller-e2e@api.com`; + const userName = "bob"; + let user: User; + let eventTypeId: number; + let eventTypeSlug: string; + let reservedSlotUid: string; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [ + AppModule, + PrismaModule, + UsersModule, + TokensModule, + SchedulesModule_2024_06_11, + SlotsModule, + ], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_06_11); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + name: "bob slot", + username: userName, + }); + + // nxte(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone + const userSchedule = await schedulesService.createUserSchedule(user.id, { + name: `slots-schedule-${randomString()}-slots.controller.e2e-spec`, + timeZone: "Europe/Rome", + isDefault: true, + }); + + const eventType = await eventTypesRepositoryFixture.create( + { + title: `slots-event-type-${randomString()}-slots.controller.e2e-spec`, + slug: `slots-event-type-${randomString()}-slots.controller.e2e-spec`, + length: 60, + locations: [], + }, + user.id + ); + eventTypeId = eventType.id; + eventTypeSlug = eventType.slug; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should get slots in UTC by event type id", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsUTC, + }); + }); + }); + + it("should get slots in specified time zone by event type id", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsRome, + }); + }); + }); + + it("should get slots in UTC by event type id in range format", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&slotFormat=range` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsUTCRange, + }); + }); + }); + + it("should get slots in specified time zone by event type id in range format", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome&slotFormat=range` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsRomeRange, + }); + }); + }); + + it("should get slots in UTC by event type slug", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsUTC, + }); + }); + }); + + it("should get slots in specified time zone by event type slug", async () => { + return request(app.getHttpServer()) + .get( + `/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome` + ) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + status: SUCCESS_STATUS, + data: expectedSlotsRome, + }); + }); + }); + + it("should reserve a slot and it should not appear in available slots", async () => { + const slotStartTime = "2050-09-05T10:00:00.000Z"; + const reserveResponse = await request(app.getHttpServer()) + .post(`/api/v2/slots/reserve`) + .send({ + eventTypeId, + slotUtcStartDate: slotStartTime, + slotUtcEndDate: "2050-09-05T11:00:00.000Z", + }) + .expect(201); + + const reserveResponseBody = reserveResponse.body; + expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS); + const uid: string = reserveResponseBody.data; + expect(uid).toBeDefined(); + if (!uid) { + throw new Error("Reserved slot uid is undefined"); + } + reservedSlotUid = uid; + + const response = await request(app.getHttpServer()) + .get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`) + .expect(200); + + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots.slots); + expect(days.length).toEqual(5); + + const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter( + (slot) => slot.time !== slotStartTime + ); + expect(slots).toEqual({ + slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 }, + }); + }); + + it("should delete reserved slot", async () => { + await request(app.getHttpServer()) + .delete(`/api/v2/slots/selected-slot?uid=${reservedSlotUid}`) + .expect(200); + }); + + it("should do a booking and slot should not be available at that time", async () => { + const startTime = "2050-09-05T11:00:00.000Z"; + await bookingsRepositoryFixture.create({ + uid: `booking-uid-${eventTypeId}`, + title: "booking title", + startTime, + endTime: "2050-09-05T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const response = await request(app.getHttpServer()) + .get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`) + .expect(200); + + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots.slots); + expect(days.length).toEqual(5); + + const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter( + (slot) => slot.time !== startTime + ); + expect(slots).toEqual({ + slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 }, + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts new file mode 100644 index 00000000000000..feb96b0740eda2 --- /dev/null +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -0,0 +1,180 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { SlotsOutputService } from "@/modules/slots/services/slots-output.service"; +import { SlotsService } from "@/modules/slots/services/slots.service"; +import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common"; +import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { Response as ExpressResponse, Request as ExpressRequest } from "express"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { getAvailableSlots } from "@calcom/platform-libraries"; +import type { AvailableSlotsType } from "@calcom/platform-libraries"; +import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types"; +import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/slots", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Slots") +export class SlotsController { + constructor( + private readonly slotsService: SlotsService, + private readonly slotsOutputService: SlotsOutputService + ) {} + + @Post("/reserve") + @ApiCreatedResponse({ + description: "Successful response returning uid of reserved slot.", + schema: { + type: "object", + properties: { + status: { type: "string", example: "success" }, + data: { + type: "object", + properties: { + uid: { type: "string", example: "e2a7bcf9-cc7b-40a0-80d3-657d391775a6" }, + }, + }, + }, + }, + }) + @ApiOperation({ summary: "Reserve a slot" }) + async reserveSlot( + @Body() body: ReserveSlotInput, + @Res({ passthrough: true }) res: ExpressResponse, + @Req() req: ExpressRequest + ): Promise> { + const uid = await this.slotsService.reserveSlot(body, req.cookies?.uid); + + res.cookie("uid", uid); + return { + status: SUCCESS_STATUS, + data: uid, + }; + } + + @Delete("/selected-slot") + @ApiOkResponse({ + description: "Response deleting reserved slot by uid.", + schema: { + type: "object", + properties: { + status: { type: "string", example: "success" }, + }, + }, + }) + @ApiOperation({ summary: "Delete a selected slot" }) + async deleteSelectedSlot( + @Query() params: RemoveSelectedSlotInput, + @Req() req: ExpressRequest + ): Promise { + const uid = req.cookies?.uid || params.uid; + + await this.slotsService.deleteSelectedslot(uid); + + return { + status: SUCCESS_STATUS, + }; + } + + @Get("/available") + @ApiOkResponse({ + description: "Available time slots retrieved successfully", + schema: { + type: "object", + properties: { + status: { type: "string", example: "success" }, + data: { + type: "object", + properties: { + slots: { + type: "object", + additionalProperties: { + type: "array", + items: { + type: "object", + oneOf: [ + { + properties: { + time: { + type: "string", + format: "date-time", + example: "2024-09-25T08:00:00.000Z", + }, + }, + }, + { + properties: { + startTime: { + type: "string", + format: "date-time", + example: "2024-09-25T08:00:00.000Z", + }, + endTime: { + type: "string", + format: "date-time", + example: "2024-09-25T08:30:00.000Z", + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + example: { + status: "success", + data: { + slots: { + // Default format (when slotFormat is 'time' or not provided) + "2024-09-25": [{ time: "2024-09-25T08:00:00.000Z" }, { time: "2024-09-25T08:15:00.000Z" }], + // Alternative format (when slotFormat is 'range') + "2024-09-26": [ + { + startTime: "2024-09-26T08:00:00.000Z", + endTime: "2024-09-26T08:30:00.000Z", + }, + { + startTime: "2024-09-26T08:15:00.000Z", + endTime: "2024-09-26T08:45:00.000Z", + }, + ], + }, + }, + }, + }, + }) + @ApiOperation({ summary: "Get available slots" }) + async getAvailableSlots( + @Query() query: GetAvailableSlotsInput, + @Req() req: ExpressRequest + ): Promise> { + const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId); + const availableSlots = await getAvailableSlots({ + input: { + ...query, + isTeamEvent, + }, + ctx: { + req, + }, + }); + + const { slots } = await this.slotsOutputService.getOutputSlots( + availableSlots, + query.duration, + query.eventTypeId, + query.slotFormat, + query.timeZone + ); + + return { + data: { + slots, + }, + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/modules/slots/services/slots-output.service.ts b/apps/api/v2/src/modules/slots/services/slots-output.service.ts new file mode 100644 index 00000000000000..6bfb7261ac8023 --- /dev/null +++ b/apps/api/v2/src/modules/slots/services/slots-output.service.ts @@ -0,0 +1,96 @@ +import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository"; +import { Injectable, BadRequestException } from "@nestjs/common"; +import { DateTime } from "luxon"; + +import { SlotFormat } from "@calcom/platform-enums"; + +type TimeSlots = { slots: Record }; +type RangeSlots = { slots: Record }; + +@Injectable() +export class SlotsOutputService { + constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {} + + async getOutputSlots( + availableSlots: TimeSlots, + duration?: number, + eventTypeId?: number, + slotFormat?: SlotFormat, + timeZone?: string + ): Promise { + if (!slotFormat) { + return timeZone ? this.setTimeZone(availableSlots, timeZone) : availableSlots; + } + + const formattedSlots = await this.formatSlots(availableSlots, duration, eventTypeId, slotFormat); + return timeZone ? this.setTimeZoneRange(formattedSlots, timeZone) : formattedSlots; + } + + private setTimeZone(slots: TimeSlots, timeZone: string): TimeSlots { + const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => { + acc[date] = daySlots.map((slot) => ({ + time: DateTime.fromISO(slot.time).setZone(timeZone).toISO() || "unknown-time", + })); + return acc; + }, {} as Record); + + return { slots: formattedSlots }; + } + + private setTimeZoneRange(slots: RangeSlots, timeZone: string): RangeSlots { + const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => { + acc[date] = daySlots.map((slot) => ({ + startTime: DateTime.fromISO(slot.startTime).setZone(timeZone).toISO() || "unknown-start-time", + endTime: DateTime.fromISO(slot.endTime).setZone(timeZone).toISO() || "unknown-end-time", + })); + return acc; + }, {} as Record); + + return { slots: formattedSlots }; + } + + private async formatSlots( + availableSlots: TimeSlots, + duration?: number, + eventTypeId?: number, + slotFormat?: SlotFormat + ): Promise { + if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) { + throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'"); + } + + const slotDuration = await this.getDuration(duration, eventTypeId); + + const slots = Object.entries(availableSlots.slots).reduce< + Record + >((acc, [date, slots]) => { + acc[date] = (slots as { time: string }[]).map((slot) => { + const startTime = new Date(slot.time); + const endTime = new Date(startTime.getTime() + slotDuration * 60000); + return { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }; + }); + return acc; + }, {}); + + return { slots }; + } + + private async getDuration(duration?: number, eventTypeId?: number): Promise { + if (duration) { + return duration; + } + + if (eventTypeId) { + const eventType = await this.eventTypesRepository.getEventTypeWithDuration(eventTypeId); + if (!eventType) { + throw new Error("Event type not found"); + } + return eventType.length; + } + + throw new Error("duration or eventTypeId is required"); + } +} diff --git a/apps/api/v2/src/modules/slots/services/slots.service.ts b/apps/api/v2/src/modules/slots/services/slots.service.ts new file mode 100644 index 00000000000000..6ea24916de2d2c --- /dev/null +++ b/apps/api/v2/src/modules/slots/services/slots.service.ts @@ -0,0 +1,59 @@ +import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository"; +import { SlotsRepository } from "@/modules/slots/slots.repository"; +import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; +import { DateTime } from "luxon"; +import { v4 as uuid } from "uuid"; + +import { SlotFormat } from "@calcom/platform-enums"; +import { ReserveSlotInput } from "@calcom/platform-types"; + +@Injectable() +export class SlotsService { + constructor( + private readonly eventTypeRepo: EventTypesRepository_2024_04_15, + private readonly slotsRepo: SlotsRepository + ) {} + + async reserveSlot(input: ReserveSlotInput, headerUid?: string) { + const uid = headerUid || uuid(); + const eventType = await this.eventTypeRepo.getEventTypeWithSeats(input.eventTypeId); + if (!eventType) { + throw new NotFoundException("Event Type not found"); + } + + let shouldReserveSlot = true; + if (eventType.seatsPerTimeSlot) { + const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid); + const bookingAttendeesLength = bookingWithAttendees?.attendees?.length; + if (bookingAttendeesLength) { + const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength; + if (seatsLeft < 1) shouldReserveSlot = false; + } else { + shouldReserveSlot = false; + } + } + + if (eventType && shouldReserveSlot) { + await Promise.all( + eventType.users.map((user) => + this.slotsRepo.upsertSelectedSlot(user.id, input, uid, eventType.seatsPerTimeSlot !== null) + ) + ); + } + + return uid; + } + + async deleteSelectedslot(uid?: string) { + if (!uid) return; + + return this.slotsRepo.deleteSelectedSlots(uid); + } + + async checkIfIsTeamEvent(eventTypeId?: number) { + if (!eventTypeId) return false; + + const event = await this.eventTypeRepo.getEventTypeById(eventTypeId); + return !!event?.teamId; + } +} diff --git a/apps/api/v2/src/modules/slots/slots.module.ts b/apps/api/v2/src/modules/slots/slots.module.ts new file mode 100644 index 00000000000000..bad7120e8157e6 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots.module.ts @@ -0,0 +1,15 @@ +import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SlotsController } from "@/modules/slots/controllers/slots.controller"; +import { SlotsOutputService } from "@/modules/slots/services/slots-output.service"; +import { SlotsService } from "@/modules/slots/services/slots.service"; +import { SlotsRepository } from "@/modules/slots/slots.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, EventTypesModule_2024_04_15], + providers: [SlotsRepository, SlotsService, SlotsOutputService], + controllers: [SlotsController], + exports: [SlotsService], +}) +export class SlotsModule {} diff --git a/apps/api/v2/src/modules/slots/slots.repository.ts b/apps/api/v2/src/modules/slots/slots.repository.ts new file mode 100644 index 00000000000000..8ef589f9f87515 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots.repository.ts @@ -0,0 +1,53 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { DateTime } from "luxon"; + +import { MINUTES_TO_BOOK } from "@calcom/platform-libraries"; +import { ReserveSlotInput } from "@calcom/platform-types"; + +@Injectable() +export class SlotsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getBookingWithAttendees(bookingUid?: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { attendees: true }, + }); + } + + async upsertSelectedSlot(userId: number, input: ReserveSlotInput, uid: string, isSeat: boolean) { + const { slotUtcEndDate, slotUtcStartDate, eventTypeId } = input; + + const releaseAt = DateTime.utc() + .plus({ minutes: parseInt(MINUTES_TO_BOOK) }) + .toISO(); + return this.dbWrite.prisma.selectedSlots.upsert({ + where: { + selectedSlotUnique: { userId, slotUtcStartDate, slotUtcEndDate, uid }, + }, + update: { + slotUtcEndDate, + slotUtcStartDate, + releaseAt, + eventTypeId, + }, + create: { + userId, + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid, + releaseAt, + isSeat, + }, + }); + } + + async deleteSelectedSlots(uid: string) { + return this.dbWrite.prisma.selectedSlots.deleteMany({ + where: { uid: { equals: uid } }, + }); + } +} diff --git a/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts new file mode 100644 index 00000000000000..2333346f179e3c --- /dev/null +++ b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts @@ -0,0 +1,117 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { + StripConnectOutputDto, + StripConnectOutputResponseDto, + StripCredentialsCheckOutputResponseDto, + StripCredentialsSaveOutputResponseDto, +} from "@/modules/stripe/outputs/stripe.output"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Query, + UseGuards, + Get, + HttpCode, + HttpStatus, + Redirect, + Req, + BadRequestException, + Headers, + Param, +} from "@nestjs/common"; +import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; +import { Request } from "express"; +import { stringify } from "querystring"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/stripe", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Stripe") +export class StripeController { + constructor(private readonly stripeService: StripeService) {} + + @Get("/connect") + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get stripe connect URL" }) + async redirect( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @GetUser() user: UserWithProfile, + @Query("redir") redir?: string | null, + @Query("errorRedir") errorRedir?: string | null, + @Query("teamId") teamId?: string | null + ): Promise { + const origin = req.headers.origin; + const accessToken = authorization.replace("Bearer ", ""); + + const state = { + onErrorReturnTo: !!errorRedir ? errorRedir : origin, + fromApp: false, + returnTo: !!redir ? redir : origin, + accessToken, + teamId: Number(teamId) ?? null, + }; + + const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl( + JSON.stringify(state), + user.email, + user.name + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }), + }; + } + + @Get("/save") + @UseGuards() + @Redirect(undefined, 301) + @ApiOperation({ summary: "Save stripe credentials" }) + async save( + @Query("state") state: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined + ): Promise { + const accessToken = JSON.parse(state).accessToken; + + // user cancels flow + if (error === "access_denied") { + return { url: getOnErrorReturnToValueFromQueryState(state) }; + } + + if (error) { + throw new BadRequestException(stringify({ error, error_description })); + } + + return await this.stripeService.saveStripeAccount(state, code, accessToken); + } + + @Get("/check") + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Check stripe connection" }) + async check(@GetUser() user: UserWithProfile): Promise { + return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id); + } + + @Get("/check/:teamId") + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Check team stripe connection" }) + async checkTeamStripeConnection( + @Param("teamId") teamId: string + ): Promise { + return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId)); + } +} diff --git a/apps/api/v2/src/modules/stripe/inputs/stripe.input.ts b/apps/api/v2/src/modules/stripe/inputs/stripe.input.ts new file mode 100644 index 00000000000000..0590eff1c2a224 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/inputs/stripe.input.ts @@ -0,0 +1,7 @@ +import { IsString, IsOptional } from "class-validator"; + +export class StripeConnectQueryParamsInputDto { + @IsString() + @IsOptional() + readonly redir?: string; +} diff --git a/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts b/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts new file mode 100644 index 00000000000000..ca9cb0a2634bd1 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class StripConnectOutputDto { + @IsString() + @Expose() + readonly authUrl!: string; +} + +export class StripConnectOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => StripConnectOutputDto) + data!: StripConnectOutputDto; +} + +export class StripCredentialsCheckOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS }) + status!: typeof SUCCESS_STATUS; +} + +export class StripCredentialsSaveOutputResponseDto { + @IsString() + @Expose() + readonly url!: string; +} diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts new file mode 100644 index 00000000000000..e504cde0319a1a --- /dev/null +++ b/apps/api/v2/src/modules/stripe/stripe.module.ts @@ -0,0 +1,18 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { StripeController } from "@/modules/stripe/controllers/stripe.controller"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +@Module({ + imports: [ConfigModule, PrismaModule, UsersModule], + exports: [StripeService], + providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository], + controllers: [StripeController], +}) +export class StripeModule {} diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts new file mode 100644 index 00000000000000..24127678d3da57 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/stripe.service.ts @@ -0,0 +1,280 @@ +import { AppConfig } from "@/config/type"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; +import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance"; +import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Injectable, + NotFoundException, + BadRequestException, + UnauthorizedException, + InternalServerErrorException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Prisma, Credential, User } from "@prisma/client"; +import Stripe from "stripe"; +import { z } from "zod"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas"; + +import stringify = require("qs-stringify"); + +type IntegrationOAuthCallbackState = { + accessToken: string; + returnTo: string; + onErrorReturnTo: string; + fromApp: boolean; + teamId?: number | null; +}; + +@Injectable() +export class StripeService { + private stripe: Stripe; + private redirectUri = `${this.config.get("api.url")}/stripe/save`; + private webAppUrl = this.config.get("app.baseUrl"); + private environment = this.config.get("env.type"); + private teamMonthlyPriceId = this.config.get("stripe.teamMonthlyPriceId"); + + constructor( + configService: ConfigService, + private readonly config: ConfigService, + private readonly appsRepository: AppsRepository, + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository, + private readonly membershipRepository: MembershipsRepository, + private readonly usersRepository: UsersRepository + ) { + this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", { + apiVersion: "2020-08-27", + }); + } + + getStripe() { + return this.stripe; + } + + async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) { + const { client_id } = await this.getStripeAppKeys(); + + const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = { + client_id, + scope: "read_write", + response_type: "code", + stripe_user: { + email: userEmail, + first_name: userName || undefined, + /** We need this so E2E don't fail for international users */ + country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined, + }, + redirect_uri: this.redirectUri, + state: state, + }; + + const params = z.record(z.any()).parse(stripeConnectParams); + const query = stringify(params); + const url = `https://connect.stripe.com/oauth/authorize?${query}`; + + return url; + } + + async getStripeAppKeys() { + const app = await this.appsRepository.getAppBySlug("stripe"); + + const { client_id, client_secret } = stripeKeysResponseSchema.parse(app?.keys); + + if (!client_id) { + throw new NotFoundException("Stripe app not found"); + } + + if (!client_secret) { + throw new NotFoundException("Stripe app not found"); + } + + return { client_id, client_secret }; + } + + async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> { + const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state); + + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const response = await stripeInstance.oauth.token({ + grant_type: "authorization_code", + code: code?.toString(), + }); + + const data: StripeData = { ...response, default_currency: "" }; + if (response["stripe_user_id"]) { + const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]); + data["default_currency"] = account.default_currency; + } + + if (oAuthCallbackState.teamId) { + await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId); + + await this.appsRepository.createTeamAppCredential( + "stripe_payment", + data as unknown as Prisma.InputJsonObject, + oAuthCallbackState.teamId, + "stripe" + ); + + return { url: getReturnToValueFromQueryState(state) }; + } + + await this.appsRepository.createAppCredential( + "stripe_payment", + data as unknown as Prisma.InputJsonObject, + userId, + "stripe" + ); + + return { url: getReturnToValueFromQueryState(state) }; + } + + async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId); + + return await this.validateStripeCredentials(stripeCredentials); + } + + async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId); + + return await this.validateStripeCredentials(stripeCredentials); + } + + async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) { + const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId); + const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER"; + + if (!hasAdminAccessToTeam) { + throw new BadRequestException("You must be team owner or admin to do this"); + } + } + + async validateStripeCredentials( + credentials?: Credential | null + ): Promise<{ status: typeof SUCCESS_STATUS }> { + if (!credentials) { + throw new NotFoundException("Credentials for stripe not found."); + } + + if (credentials.invalid) { + throw new BadRequestException("Invalid stripe credentials."); + } + + const stripeKey = JSON.stringify(credentials.key); + const stripeKeyObject = JSON.parse(stripeKey); + + const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id); + + // both of these should be true for an account to be fully active + if (!stripeAccount.payouts_enabled || !stripeAccount.charges_enabled) { + throw new BadRequestException("Stripe account is not an active account"); + } + + return { + status: SUCCESS_STATUS, + }; + } + + async generateTeamCheckoutSession(pendingPaymentTeamId: number, ownerId: number) { + const stripe = this.getStripe(); + const customer = await this.getStripeCustomerIdFromUserId(ownerId); + + if (!customer) { + throw new BadRequestException("Failed to create a customer on Stripe."); + } + + const session = await stripe.checkout.sessions.create({ + customer, + mode: "subscription", + allow_promotion_codes: true, + success_url: `${this.webAppUrl}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${this.webAppUrl}/settings/my-account/profile`, + line_items: [ + { + /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ + price: this.teamMonthlyPriceId, + /**Initially it will be just the team owner */ + quantity: 1, + }, + ], + customer_update: { + address: "auto", + }, + // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode + automatic_tax: { + enabled: this.environment === "production", + }, + metadata: { + pendingPaymentTeamId, + ownerId, + dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe + }, + }); + + if (!session.url) { + throw new InternalServerErrorException({ + message: "Failed generating a Stripe checkout session URL.", + }); + } + + return session; + } + + async getStripeCustomerIdFromUserId(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.email) return null; + const customerId = await this.getStripeCustomerId(user); + if (!customerId) { + return this.createStripeCustomerId(user); + } + + return customerId; + } + + async getStripeCustomerId(user: Pick) { + if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) { + return (user?.metadata as Prisma.JsonObject).stripeCustomerId as string; + } + return null; + } + + async createStripeCustomerId(user: Pick) { + let customerId: string; + + const stripe = this.getStripe(); + try { + const customersResponse = await stripe.customers.list({ + email: user.email, + limit: 1, + }); + + customerId = customersResponse.data[0].id; + } catch (error) { + const customer = await stripe.customers.create({ email: user.email }); + customerId = customer.id; + } + + await this.usersRepository.updateByEmail(user.email, { + metadata: { + ...(user.metadata as Prisma.JsonObject), + stripeCustomerId: customerId, + }, + }); + + return customerId; + } +} diff --git a/apps/api/v2/src/modules/stripe/utils/getReturnToValueFromQueryState.ts b/apps/api/v2/src/modules/stripe/utils/getReturnToValueFromQueryState.ts new file mode 100644 index 00000000000000..a905d153b0d516 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/utils/getReturnToValueFromQueryState.ts @@ -0,0 +1,19 @@ +export const getReturnToValueFromQueryState = (queryState: string | string[] | undefined) => { + let returnTo = ""; + try { + returnTo = JSON.parse(`${queryState}`).returnTo; + } catch (error) { + console.info("No 'returnTo' in req.query.state"); + } + return returnTo; +}; + +export const getOnErrorReturnToValueFromQueryState = (queryState: string | string[] | undefined) => { + let returnTo = ""; + try { + returnTo = JSON.parse(`${queryState}`).onErrorReturnTo; + } catch (error) { + console.info("No 'onErrorReturnTo' in req.query.state"); + } + return returnTo; +}; diff --git a/apps/api/v2/src/modules/stripe/utils/newStripeInstance.ts b/apps/api/v2/src/modules/stripe/utils/newStripeInstance.ts new file mode 100644 index 00000000000000..1784b78305403b --- /dev/null +++ b/apps/api/v2/src/modules/stripe/utils/newStripeInstance.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe"; + +const stripeApiKey = process.env.STRIPE_API_KEY || ""; +export const stripeInstance = new Stripe(stripeApiKey, { + apiVersion: "2020-08-27", +}); diff --git a/apps/api/v2/src/modules/stripe/utils/stripeDataSchemas.ts b/apps/api/v2/src/modules/stripe/utils/stripeDataSchemas.ts new file mode 100644 index 00000000000000..77fada616cb1b7 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/utils/stripeDataSchemas.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const stripeOAuthTokenSchema = z.object({ + access_token: z.string().optional(), + scope: z.string().optional(), + livemode: z.boolean().optional(), + token_type: z.literal("bearer").optional(), + refresh_token: z.string().optional(), + stripe_user_id: z.string().optional(), + stripe_publishable_key: z.string().optional(), +}); + +export const stripeDataSchema = stripeOAuthTokenSchema.extend({ + default_currency: z.string(), +}); + +export type StripeData = z.infer; + +export const stripeKeysResponseSchema = z.object({ + client_id: z.string().startsWith("ca_").min(1), + client_secret: z.string().startsWith("sk_").min(1), + public_key: z.string().startsWith("pk_").min(1), + webhook_secret: z.string().startsWith("whsec_").min(1), +}); diff --git a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts new file mode 100644 index 00000000000000..8905d7151d3726 --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts @@ -0,0 +1,571 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + BookingWindowPeriodInputTypeEnum_2024_06_14, + BookerLayoutsInputEnum_2024_06_14, + ConfirmationPolicyEnum, + NoticeThresholdUnitEnum, +} from "@calcom/platform-enums"; +import { + ApiSuccessResponse, + CreateTeamEventTypeInput_2024_06_14, + Host, + TeamEventTypeOutput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +describe("Organizations Event Types Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + let team: Team; + let falseTestTeam: Team; + + const userEmail = `teams-event-types-user-${randomString()}@api.com`; + let userAdmin: User; + + const teammate1Email = `teams-event-types-teammate1-${randomString()}@api.com`; + const teammate2Email = `teams-event-types-teammate2-${randomString()}@api.com`; + const falseTestUserEmail = `teams-event-types-false-user-${randomString()}@api.com`; + let teamMember1: User; + let teamMember2: User; + let falseTestUser: User; + + let collectiveEventType: TeamEventTypeOutput_2024_06_14; + let managedEventType: TeamEventTypeOutput_2024_06_14; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + userAdmin = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + role: "ADMIN", + }); + + teamMember1 = await userRepositoryFixture.create({ + email: teammate1Email, + username: teammate1Email, + }); + + teamMember2 = await userRepositoryFixture.create({ + email: teammate2Email, + username: teammate2Email, + }); + + falseTestUser = await userRepositoryFixture.create({ + email: falseTestUserEmail, + username: falseTestUserEmail, + }); + + team = await teamsRepositoryFixture.create({ + name: `teams-event-types-team-${randomString()}`, + isOrganization: false, + }); + + falseTestTeam = await teamsRepositoryFixture.create({ + name: `teams-event-types-false-team-${randomString()}`, + isOrganization: false, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: userAdmin.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamMember1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamMember2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: falseTestUser.id } }, + team: { connect: { id: falseTestTeam.id } }, + accepted: true, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should not be able to create event-type for user outside team", async () => { + const userId = falseTestUser.id; + + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation", + slug: "coding-consultation", + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + schedulingType: "COLLECTIVE", + hosts: [ + { + userId, + mandatory: true, + priority: "high", + }, + ], + }; + + return request(app.getHttpServer()).post(`/v2/teams/${team.id}/event-types`).send(body).expect(404); + }); + + it("should create a collective team event-type", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: `teams-event-types-collective-${randomString()}`, + slug: `teams-event-types-collective-${randomString()}`, + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + bookingFields: [ + { + type: "select", + label: "select which language is your codebase in", + slug: "select-language", + required: true, + placeholder: "select language", + options: ["javascript", "python", "cobol"], + }, + ], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + schedulingType: "collective", + hosts: [ + { + userId: teamMember1.id, + }, + { + userId: teamMember2.id, + }, + ], + bookingLimitsCount: { + day: 2, + week: 5, + }, + onlyShowFirstAvailableSlot: true, + bookingLimitsDuration: { + day: 60, + week: 100, + }, + offsetStart: 30, + bookingWindow: { + type: BookingWindowPeriodInputTypeEnum_2024_06_14.calendarDays, + value: 30, + rolling: true, + }, + bookerLayouts: { + enabledLayouts: [ + BookerLayoutsInputEnum_2024_06_14.column, + BookerLayoutsInputEnum_2024_06_14.month, + BookerLayoutsInputEnum_2024_06_14.week, + ], + defaultLayout: BookerLayoutsInputEnum_2024_06_14.month, + }, + + confirmationPolicy: { + type: ConfirmationPolicyEnum.TIME, + noticeThreshold: { + count: 60, + unit: NoticeThresholdUnitEnum.MINUTES, + }, + blockUnconfirmedBookingsInBooker: true, + }, + requiresBookerEmailVerification: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + lockTimeZoneToggleOnBookingPage: true, + color: { + darkThemeHex: "#292929", + lightThemeHex: "#fafafa", + }, + }; + + return request(app.getHttpServer()) + .post(`/v2/teams/${team.id}/event-types`) + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(body.title); + expect(data.hosts.length).toEqual(2); + expect(data.schedulingType).toEqual("COLLECTIVE"); + evaluateHost(body.hosts[0], data.hosts[0]); + evaluateHost(body.hosts[1], data.hosts[1]); + expect(data.bookingLimitsCount).toEqual(body.bookingLimitsCount); + expect(data.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); + expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); + expect(data.offsetStart).toEqual(body.offsetStart); + expect(data.bookingWindow).toEqual(body.bookingWindow); + expect(data.bookerLayouts).toEqual(body.bookerLayouts); + expect(data.confirmationPolicy).toEqual(body.confirmationPolicy); + expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification); + expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes); + expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails); + expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage); + expect(data.color).toEqual(body.color); + + collectiveEventType = responseBody.data; + }); + }); + + it("should create a managed team event-type", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: `teams-event-types-managed-${randomString()}`, + slug: `teams-event-types-managed-${randomString()}`, + description: "Our team will review your codebase.", + lengthInMinutes: 60, + locations: [ + { + type: "integration", + integration: "cal-video", + }, + ], + schedulingType: "MANAGED", + hosts: [ + { + userId: teamMember1.id, + mandatory: true, + priority: "high", + }, + { + userId: teamMember2.id, + mandatory: false, + priority: "low", + }, + ], + }; + + return request(app.getHttpServer()) + .post(`/v2/teams/${team.id}/event-types`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(3); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember1.id); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate1EventTypes[0].title).toEqual(body.title); + expect(teammate2EventTypes.length).toEqual(1); + expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual( + 1 + ); + + const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id); + expect(responseTeamEvent).toBeDefined(); + if (!responseTeamEvent) { + throw new Error("Team event not found"); + } + + const responseTeammate1Event = responseBody.data.find((event) => event.ownerId === teamMember1.id); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + const responseTeammate2Event = responseBody.data.find((event) => event.ownerId === teamMember2.id); + expect(responseTeammate2Event).toBeDefined(); + expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + managedEventType = responseTeamEvent; + }); + }); + + it("should not get a non existing event-type", async () => { + return request(app.getHttpServer()).get(`/v2/teams/${team.id}/event-types/999999`).expect(404); + }); + + it("should get a team event-type", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(collectiveEventType.title); + expect(data.hosts.length).toEqual(2); + evaluateHost(collectiveEventType.hosts[0], data.hosts[0]); + evaluateHost(collectiveEventType.hosts[1], data.hosts[1]); + + collectiveEventType = responseBody.data; + }); + }); + + it("should get team event-types", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/event-types`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(2); + + const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE"); + const eventTypeManaged = data.find((eventType) => eventType.schedulingType === "MANAGED"); + + expect(eventTypeCollective?.title).toEqual(collectiveEventType.title); + expect(eventTypeCollective?.hosts.length).toEqual(2); + + expect(eventTypeManaged?.title).toEqual(managedEventType.title); + expect(eventTypeManaged?.hosts.length).toEqual(2); + evaluateHost(collectiveEventType.hosts[0], eventTypeCollective?.hosts[0]); + evaluateHost(collectiveEventType.hosts[1], eventTypeCollective?.hosts[1]); + }); + }); + + it("should not be able to update non existing event-type", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + title: "Clean code consultation", + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team.id}/event-types/999999`) + .send(body) + .expect(400); + }); + + it("should update collective event-type", async () => { + const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [ + { + userId: teamMember1.id, + }, + ]; + + const body: UpdateTeamEventTypeInput_2024_06_14 = { + hosts: newHosts, + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const eventType = responseBody.data; + expect(eventType.title).toEqual(collectiveEventType.title); + expect(eventType.hosts.length).toEqual(1); + evaluateHost(eventType.hosts[0], newHosts[0]); + }); + }); + + it("should update managed event-type", async () => { + const newTitle = `teams-event-types-managed-updated-${randomString()}`; + const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [ + { + userId: teamMember1.id, + mandatory: true, + priority: "medium", + }, + ]; + + const body: UpdateTeamEventTypeInput_2024_06_14 = { + title: newTitle, + hosts: newHosts, + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(2); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember1.id); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + const managedTeamEventTypes = teamEventTypes.filter( + (eventType) => eventType.schedulingType === "MANAGED" + ); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate1EventTypes[0].title).toEqual(newTitle); + expect(teammate2EventTypes.length).toEqual(0); + expect(managedTeamEventTypes.length).toEqual(1); + expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false); + expect( + teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title + ).toEqual(newTitle); + + const responseTeamEvent = responseBody.data.find( + (eventType) => eventType.schedulingType === "MANAGED" + ); + expect(responseTeamEvent).toBeDefined(); + expect(responseTeamEvent?.title).toEqual(newTitle); + expect(responseTeamEvent?.assignAllTeamMembers).toEqual(false); + + const responseTeammate1Event = responseBody.data.find( + (eventType) => eventType.ownerId === teamMember1.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.title).toEqual(newTitle); + + managedEventType = responseBody.data[0]; + }); + }); + + it("should assign all members to managed event-type", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + assignAllTeamMembers: true, + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + // note(Lauris): we expect 4 because we have 2 team members, 1 team admin and 4th is the event object itself. + expect(data.length).toEqual(4); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember1.id); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + const managedTeamEventTypes = teamEventTypes.filter( + (eventType) => eventType.schedulingType === "MANAGED" + ); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate2EventTypes.length).toEqual(1); + expect(managedTeamEventTypes.length).toEqual(1); + expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true); + + const responseTeamEvent = responseBody.data.find( + (eventType) => eventType.schedulingType === "MANAGED" + ); + expect(responseTeamEvent).toBeDefined(); + expect(responseTeamEvent?.teamId).toEqual(team.id); + expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true); + + const responseTeammate1Event = responseBody.data.find( + (eventType) => eventType.ownerId === teamMember1.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + const responseTeammate2Event = responseBody.data.find( + (eventType) => eventType.ownerId === teamMember2.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + if (responseTeamEvent) { + managedEventType = responseTeamEvent; + } + }); + }); + + it("should delete event-type not part of the team", async () => { + return request(app.getHttpServer()).delete(`/v2/teams/${team.id}/event-types/99999`).expect(404); + }); + + it("should delete collective event-type", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`) + .expect(200); + }); + + it("should delete managed event-type", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team.id}/event-types/${managedEventType.id}`) + .expect(200); + }); + + function evaluateHost(expected: Host, received: Host | undefined) { + expect(expected.userId).toEqual(received?.userId); + expect(expected.mandatory).toEqual(received?.mandatory); + expect(expected.priority).toEqual(received?.priority); + } + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userAdmin.email); + await userRepositoryFixture.deleteByEmail(teamMember1.email); + await userRepositoryFixture.deleteByEmail(teamMember2.email); + await userRepositoryFixture.deleteByEmail(falseTestUser.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(falseTestTeam.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.ts b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.ts new file mode 100644 index 00000000000000..c441c96f483162 --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.ts @@ -0,0 +1,208 @@ +import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input"; +import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer"; +import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service"; +import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service"; +import { CreateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/create-team-event-type.output"; +import { DeleteTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/delete-team-event-type.output"; +import { GetTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/get-team-event-type.output"; +import { GetTeamEventTypesOutput } from "@/modules/teams/event-types/outputs/get-team-event-types.output"; +import { UpdateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/update-team-event-type.output"; +import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Post, + Param, + ParseIntPipe, + Body, + Patch, + Delete, + HttpCode, + HttpStatus, + NotFoundException, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { handleCreatePhoneCall } from "@calcom/platform-libraries"; +import { + CreateTeamEventTypeInput_2024_06_14, + GetTeamEventTypesQuery_2024_06_14, + SkipTakePagination, + TeamEventTypeOutput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; + +export type EventTypeHandlerResponse = { + data: DatabaseTeamEventType[] | DatabaseTeamEventType; + status: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +}; + +@Controller({ + path: "/v2/teams/:teamId/event-types", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Teams / Event Types") +export class TeamsEventTypesController { + constructor( + private readonly teamsEventTypesService: TeamsEventTypesService, + private readonly inputService: InputOrganizationsEventTypesService, + private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe + ) {} + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, RolesGuard) + @Post("/") + @ApiOperation({ summary: "Create an event type" }) + async createTeamEventType( + @GetUser() user: UserWithProfile, + @Param("teamId", ParseIntPipe) teamId: number, + @Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14 + ): Promise { + const transformedBody = await this.inputService.transformAndValidateCreateTeamEventTypeInput( + user.id, + teamId, + bodyEventType + ); + + const eventType = await this.teamsEventTypesService.createTeamEventType(user, teamId, transformedBody); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventType), + }; + } + + @Roles("TEAM_ADMIN") + @UseGuards(ApiAuthGuard, RolesGuard) + @Get("/:eventTypeId") + @ApiOperation({ summary: "Get an event type" }) + async getTeamEventType( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("eventTypeId") eventTypeId: number + ): Promise { + const eventType = await this.teamsEventTypesService.getTeamEventType(teamId, eventTypeId); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + status: SUCCESS_STATUS, + data: (await this.outputTeamEventTypesResponsePipe.transform( + eventType + )) as TeamEventTypeOutput_2024_06_14, + }; + } + + @Roles("TEAM_ADMIN") + @Post("/:eventTypeId/create-phone-call") + @UseGuards(ApiAuthGuard, RolesGuard) + @ApiOperation({ summary: "Create a phone call" }) + async createPhoneCall( + @Param("eventTypeId") eventTypeId: number, + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreatePhoneCallInput, + @GetUser() user: UserWithProfile + ): Promise { + const data = await handleCreatePhoneCall({ + user: { + id: user.id, + timeZone: user.timeZone, + profile: { organization: { id: orgId } }, + }, + input: { ...body, eventTypeId }, + }); + + return { + status: SUCCESS_STATUS, + data, + }; + } + + @Get("/") + @ApiOperation({ summary: "Get a team event type" }) + async getTeamEventTypes( + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: GetTeamEventTypesQuery_2024_06_14 + ): Promise { + const { eventSlug } = queryParams; + + if (eventSlug) { + const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventType ? [eventType] : []), + }; + } + + const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventTypes), + }; + } + + @Roles("TEAM_ADMIN") + @UseGuards(ApiAuthGuard, RolesGuard) + @Patch("/:eventTypeId") + @ApiOperation({ summary: "Update a team event type" }) + async updateTeamEventType( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @GetUser() user: UserWithProfile, + @Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14 + ): Promise { + const transformedBody = await this.inputService.transformAndValidateUpdateTeamEventTypeInput( + user.id, + eventTypeId, + teamId, + bodyEventType + ); + + const eventType = await this.teamsEventTypesService.updateTeamEventType( + eventTypeId, + teamId, + transformedBody, + user + ); + + return { + status: SUCCESS_STATUS, + data: await this.outputTeamEventTypesResponsePipe.transform(eventType), + }; + } + + @Roles("TEAM_ADMIN") + @UseGuards(ApiAuthGuard, RolesGuard) + @Delete("/:eventTypeId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a team event type" }) + async deleteTeamEventType( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise { + const eventType = await this.teamsEventTypesService.deleteTeamEventType(teamId, eventTypeId); + + return { + status: SUCCESS_STATUS, + data: { + id: eventTypeId, + title: eventType.title, + }, + }; + } +} diff --git a/apps/api/v2/src/modules/teams/event-types/outputs/create-team-event-type.output.ts b/apps/api/v2/src/modules/teams/event-types/outputs/create-team-event-type.output.ts new file mode 100644 index 00000000000000..960a7d84660ea7 --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/outputs/create-team-event-type.output.ts @@ -0,0 +1,21 @@ +import { ApiProperty, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class CreateTeamEventTypeOutput extends ApiResponseWithoutData { + @IsNotEmptyObject() + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) }, + { + type: "array", + items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) }, + }, + ], + }) + @Type(() => TeamEventTypeOutput_2024_06_14) + data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[]; +} diff --git a/apps/api/v2/src/modules/teams/event-types/outputs/delete-team-event-type.output.ts b/apps/api/v2/src/modules/teams/event-types/outputs/delete-team-event-type.output.ts new file mode 100644 index 00000000000000..300728c55fb03a --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/outputs/delete-team-event-type.output.ts @@ -0,0 +1,10 @@ +import { Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class DeleteTeamEventTypeOutput extends ApiResponseWithoutData { + @ValidateNested() + @Type(() => TeamEventTypeOutput_2024_06_14) + data!: Pick; +} diff --git a/apps/api/v2/src/modules/teams/event-types/outputs/get-team-event-type.output.ts b/apps/api/v2/src/modules/teams/event-types/outputs/get-team-event-type.output.ts new file mode 100644 index 00000000000000..3206c66a584f7b --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/outputs/get-team-event-type.output.ts @@ -0,0 +1,10 @@ +import { Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class GetTeamEventTypeOutput extends ApiResponseWithoutData { + @ValidateNested() + @Type(() => TeamEventTypeOutput_2024_06_14) + data!: TeamEventTypeOutput_2024_06_14; +} diff --git a/apps/api/v2/src/modules/teams/event-types/outputs/get-team-event-types.output.ts b/apps/api/v2/src/modules/teams/event-types/outputs/get-team-event-types.output.ts new file mode 100644 index 00000000000000..939c74c369f11d --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/outputs/get-team-event-types.output.ts @@ -0,0 +1,10 @@ +import { Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; + +import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class GetTeamEventTypesOutput extends ApiResponseWithoutData { + @ValidateNested({ each: true }) + @Type(() => TeamEventTypeOutput_2024_06_14) + data!: TeamEventTypeOutput_2024_06_14[]; +} diff --git a/apps/api/v2/src/modules/teams/event-types/outputs/update-team-event-type.output.ts b/apps/api/v2/src/modules/teams/event-types/outputs/update-team-event-type.output.ts new file mode 100644 index 00000000000000..403280b7a51aec --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/outputs/update-team-event-type.output.ts @@ -0,0 +1,21 @@ +import { ApiProperty, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; + +export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData { + @IsNotEmptyObject() + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) }, + { + type: "array", + items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) }, + }, + ], + }) + @Type(() => TeamEventTypeOutput_2024_06_14) + data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[]; +} diff --git a/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts b/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts new file mode 100644 index 00000000000000..41158b1357983d --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts @@ -0,0 +1,156 @@ +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service"; +import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, NotFoundException, Logger } from "@nestjs/common"; + +import { createEventType, updateEventType } from "@calcom/platform-libraries"; +import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types"; + +@Injectable() +export class TeamsEventTypesService { + private readonly logger = new Logger("TeamsEventTypesService"); + + constructor( + private readonly eventTypesService: EventTypesService_2024_06_14, + private readonly dbWrite: PrismaWriteService, + private readonly teamsEventTypesRepository: TeamsEventTypesRepository, + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly usersService: UsersService + ) {} + + async createTeamEventType( + user: UserWithProfile, + teamId: number, + body: InputTeamEventTransformed_2024_06_14 + ): Promise { + const eventTypeUser = await this.getUserToCreateTeamEvent(user); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { hosts, children, destinationCalendar, ...rest } = body; + + const { eventType: eventTypeCreated } = await createEventType({ + input: { teamId: teamId, ...rest }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + return this.updateTeamEventType(eventTypeCreated.id, teamId, body, user); + } + + async validateEventTypeExists(teamId: number, eventTypeId: number) { + const eventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + } + + async getUserToCreateTeamEvent(user: UserWithProfile) { + const profileId = this.usersService.getUserMainProfile(user)?.id; + + return { + id: user.id, + role: user.role, + organizationId: null, + organization: { id: null, isOrgAdmin: false, metadata: {}, requestedSlug: null }, + profile: { id: profileId || null }, + metadata: user.metadata, + }; + } + + async getTeamEventType(teamId: number, eventTypeId: number): Promise { + const eventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId); + + if (!eventType) { + return null; + } + + return eventType; + } + + async getTeamEventTypeBySlug( + teamId: number, + eventTypeSlug: string, + hostsLimit?: number + ): Promise { + const eventType = await this.teamsEventTypesRepository.getTeamEventTypeBySlug( + teamId, + eventTypeSlug, + hostsLimit + ); + + if (!eventType) { + return null; + } + + return eventType; + } + + async getTeamEventTypes(teamId: number): Promise { + return await this.teamsEventTypesRepository.getTeamEventTypes(teamId); + } + + async updateTeamEventType( + eventTypeId: number, + teamId: number, + body: InputTeamEventTransformed_2024_06_14, + user: UserWithProfile + ): Promise { + await this.validateEventTypeExists(teamId, eventTypeId); + const eventTypeUser = await this.eventTypesService.getUserToUpdateEvent(user); + + await updateEventType({ + input: { id: eventTypeId, ...body }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + if (eventType.schedulingType !== "MANAGED") { + return eventType; + } + + const childrenEventTypes = await this.teamsEventTypesRepository.getEventTypeChildren(eventType.id); + + return [eventType, ...childrenEventTypes]; + } + + async deleteTeamEventType(teamId: number, eventTypeId: number) { + const existingEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId); + + if (!existingEventType) { + throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`); + } + + return this.eventTypesRepository.deleteEventType(eventTypeId); + } + + async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) { + try { + await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId); + await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId); + } catch (err) { + this.logger.error("Could not remove user from all team event-types.", { + error: err, + userId, + teamId, + }); + } + } +} diff --git a/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts b/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts new file mode 100644 index 00000000000000..10e5556a7c946b --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts @@ -0,0 +1,36 @@ +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service"; +import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TeamsEventTypesController } from "@/modules/teams/event-types/controllers/teams-event-types.controller"; +import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + PrismaModule, + RedisModule, + MembershipsModule, + EventTypesModule_2024_06_14, + UsersModule, + TeamsModule, + ], + providers: [ + TeamsEventTypesRepository, + TeamsEventTypesService, + InputOrganizationsEventTypesService, + OrganizationsTeamsRepository, + OutputTeamEventTypesResponsePipe, + OutputOrganizationsEventTypesService, + ], + exports: [TeamsEventTypesRepository, TeamsEventTypesService], + controllers: [TeamsEventTypesController], +}) +export class TeamsEventTypesModule {} diff --git a/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts b/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts new file mode 100644 index 00000000000000..d72deb62bc097b --- /dev/null +++ b/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts @@ -0,0 +1,120 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class TeamsEventTypesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getTeamEventType(teamId: number, eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + id: eventTypeId, + teamId, + }, + include: { users: true, schedule: true, hosts: true, destinationCalendar: true }, + }); + } + + async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string, hostsLimit?: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + teamId_slug: { + teamId, + slug: eventTypeSlug, + }, + }, + include: { + users: true, + schedule: true, + hosts: hostsLimit + ? { + take: hostsLimit, + } + : true, + destinationCalendar: true, + team: { + select: { + bannerUrl: true, + name: true, + logoUrl: true, + slug: true, + weekStart: true, + brandColor: true, + darkBrandColor: true, + theme: true, + }, + }, + }, + }); + } + + async getTeamEventTypes(teamId: number) { + return this.dbRead.prisma.eventType.findMany({ + where: { + teamId, + }, + include: { + users: true, + schedule: true, + hosts: true, + destinationCalendar: true, + team: { + select: { + bannerUrl: true, + name: true, + logoUrl: true, + slug: true, + weekStart: true, + brandColor: true, + darkBrandColor: true, + theme: true, + }, + }, + }, + }); + } + + async getEventTypeById(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + include: { users: true, schedule: true, hosts: true, destinationCalendar: true }, + }); + } + + async getEventTypeChildren(eventTypeId: number) { + return this.dbRead.prisma.eventType.findMany({ + where: { parentId: eventTypeId }, + include: { users: true, schedule: true, hosts: true, destinationCalendar: true }, + }); + } + + async getEventTypeByIdWithChildren(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + include: { children: true }, + }); + } + + async deleteUserManagedTeamEventTypes(userId: number, teamId: number) { + return this.dbWrite.prisma.eventType.deleteMany({ + where: { + parent: { + teamId, + }, + userId, + }, + }); + } + + async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) { + return this.dbWrite.prisma.host.deleteMany({ + where: { + userId, + eventType: { + teamId, + }, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts new file mode 100644 index 00000000000000..4934e643d3fd71 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts @@ -0,0 +1,301 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output"; +import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output"; +import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { EventType, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { api } from "@calcom/app-store/alby"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { Membership, Team } from "@calcom/prisma/client"; + +describe("Teams Memberships Endpoints", () => { + describe("User Authentication - User is Team Admin", () => { + let app: INestApplication; + + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let team: Team; + let teamEventType: EventType; + let managedEventType: EventType; + let teamAdminMembership: Membership; + let teamMemberMembership: Membership; + let membershipCreatedViaApi: TeamMembershipOutput; + + const teamAdminEmail = `alice-admin-${randomString()}@api.com`; + const teamMemberEmail = `bob-member-${randomString()}@api.com`; + const nonTeamUserEmail = `charlie-outsider-${randomString()}@api`; + + const invitedUserEmail = `david-invited-${randomString()}@api.com`; + + let teamAdmin: User; + let teamMember: User; + let nonTeamUser: User; + + let teammateInvitedViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamAdminEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + teamAdmin = await userRepositoryFixture.create({ + email: teamAdminEmail, + username: teamAdminEmail, + }); + teamMember = await userRepositoryFixture.create({ + email: teamMemberEmail, + username: teamMemberEmail, + }); + nonTeamUser = await userRepositoryFixture.create({ + email: nonTeamUserEmail, + username: nonTeamUserEmail, + }); + teammateInvitedViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + team = await teamsRepositoryFixture.create({ + name: `Team-${randomString()}`, + isOrganization: false, + }); + + teamEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + managedEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "MANAGED", + team: { + connect: { id: team.id }, + }, + title: "Managed Event Type", + slug: "managed-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + teamAdminMembership = await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: teamAdmin.id } }, + team: { connect: { id: team.id } }, + }); + + teamMemberMembership = await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamMember.id } }, + team: { connect: { id: team.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamAdmin.id}`, + username: teamAdminEmail, + organization: { + connect: { + id: team.id, + }, + }, + user: { + connect: { + id: teamAdmin.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamMember.id}`, + username: teamMemberEmail, + organization: { + connect: { + id: team.id, + }, + }, + user: { + connect: { + id: teamMember.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should get all the memberships of the team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(2); + expect(responseBody.data[0].id).toEqual(teamAdminMembership.id); + expect(responseBody.data[1].id).toEqual(teamMemberMembership.id); + }); + }); + + it("should not be able to access memberships if not part of the team", async () => { + return request(app.getHttpServer()).get(`/v2/teams/9999/memberships`).expect(403); + }); + + it("should get all the memberships of the org's team paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(teamMemberMembership.id); + expect(responseBody.data[0].userId).toEqual(teamMember.id); + expect(responseBody.data.length).toEqual(1); + }); + }); + + it("should get membership of the team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships/${teamAdminMembership.id}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(teamAdminMembership.id); + expect(responseBody.data.userId).toEqual(teamAdmin.id); + }); + }); + + it("should have created the membership of the org's team and assigned team wide events", async () => { + const createTeamMembershipBody: CreateTeamMembershipInput = { + userId: teammateInvitedViaApi.id, + accepted: true, + role: "MEMBER", + }; + + return request(app.getHttpServer()) + .post(`/v2/teams/${team.id}/memberships`) + .send(createTeamMembershipBody) + .expect(201) + .then((response) => { + const responseBody: CreateTeamMembershipOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.teamId).toEqual(team.id); + expect(membershipCreatedViaApi.role).toEqual("MEMBER"); + expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id); + userHasCorrectEventTypes(membershipCreatedViaApi.userId); + }); + }); + + async function userHasCorrectEventTypes(userId: number) { + const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + expect(managedEventTypes?.length).toEqual(1); + expect(teamEventTypes?.length).toEqual(2); + const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug); + expect(collectiveEvenType).toBeTruthy(); + const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId); + expect(userHost).toBeTruthy(); + expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy(); + } + + it("should update the membership of the org's team", async () => { + const updateTeamMembershipBody: UpdateTeamMembershipInput = { + role: "OWNER", + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`) + .send(updateTeamMembershipBody) + .expect(200) + .then((response) => { + const responseBody: UpdateTeamMembershipOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.role).toEqual("OWNER"); + }); + }); + + it("should delete the membership of the org's team we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); + }); + }); + + it("should fail to get the membership of the org's team we just deleted", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(404); + }); + + it("should fail if the membership does not exist", async () => { + return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(teamAdmin.email); + await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email); + await userRepositoryFixture.deleteByEmail(nonTeamUser.email); + await userRepositoryFixture.deleteByEmail(teamMember.email); + await teamsRepositoryFixture.delete(team.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts new file mode 100644 index 00000000000000..2d1ed365044458 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts @@ -0,0 +1,159 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output"; +import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output"; +import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output"; +import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, + Logger, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/teams/:teamId/memberships", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, RolesGuard) +@DocsTags("Teams / Memberships") +export class TeamsMembershipsController { + private logger = new Logger("TeamsMembershipsController"); + + constructor( + private teamsMembershipsService: TeamsMembershipsService, + private teamsEventTypesService: TeamsEventTypesService + ) {} + + @Roles("TEAM_ADMIN") + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a membership" }) + async createTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: CreateTeamMembershipInput + ): Promise { + const membership = await this.teamsMembershipsService.createTeamMembership(teamId, body); + if (membership.accepted) { + try { + await updateNewTeamMemberEventTypes(body.userId, teamId); + } catch (err) { + this.logger.error("Could not update new team member eventTypes", err); + } + } + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), + }; + } + + @Get("/:membershipId") + @ApiOperation({ summary: "Get a membership" }) + @Roles("TEAM_ADMIN") + @HttpCode(HttpStatus.OK) + async getTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const orgTeamMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId); + + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get all memberships" }) + @Roles("TEAM_ADMIN") + @HttpCode(HttpStatus.OK) + async getTeamMemberships( + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships( + teamId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: orgTeamMemberships.map((membership) => + plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }) + ), + }; + } + + @Roles("TEAM_ADMIN") + @Patch("/:membershipId") + @ApiOperation({ summary: "Create a membership" }) + async updateTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number, + @Body() body: UpdateTeamMembershipInput + ): Promise { + const membership = await this.teamsMembershipsService.updateTeamMembership(teamId, membershipId, body); + + const currentMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId); + + const updatedMembership = await this.teamsMembershipsService.updateTeamMembership( + teamId, + membershipId, + body + ); + + if (!currentMembership.accepted && updatedMembership.accepted) { + try { + await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId); + } catch (err) { + this.logger.error("Could not update new team member eventTypes", err); + } + } + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @Delete("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a membership" }) + async deleteTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId); + + await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId); + + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/teams/memberships/inputs/create-team-membership.input.ts b/apps/api/v2/src/modules/teams/memberships/inputs/create-team-membership.input.ts new file mode 100644 index 00000000000000..0e12f97d9fbbcb --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/inputs/create-team-membership.input.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator"; + +export class CreateTeamMembershipInput { + @IsInt() + @ApiProperty({ type: Number }) + readonly userId!: number; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: false }) + readonly accepted?: boolean = false; + + @IsOptional() + @IsEnum(MembershipRole) + @ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"], default: "MEMBER" }) + readonly role: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: false }) + readonly disableImpersonation?: boolean = false; +} diff --git a/apps/api/v2/src/modules/teams/memberships/inputs/update-team-membership.input.ts b/apps/api/v2/src/modules/teams/memberships/inputs/update-team-membership.input.ts new file mode 100644 index 00000000000000..16addbfad20cbd --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/inputs/update-team-membership.input.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum } from "class-validator"; + +export class UpdateTeamMembershipInput { + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean }) + readonly accepted?: boolean; + + @IsOptional() + @IsEnum(MembershipRole) + @ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role?: MembershipRole; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean }) + readonly disableImpersonation?: boolean; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/create-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/create-team-membership.output.ts new file mode 100644 index 00000000000000..41bc5611ba583c --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/create-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/delete-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/delete-team-membership.output.ts new file mode 100644 index 00000000000000..40cde0710b810b --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/delete-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/get-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-membership.output.ts new file mode 100644 index 00000000000000..4cfc4c44795c31 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/get-team-memberships.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-memberships.output.ts new file mode 100644 index 00000000000000..14dc8a8f981d2f --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-memberships.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetTeamMembershipsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput[]; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts new file mode 100644 index 00000000000000..67f19c6282606a --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; + +export class TeamMembershipOutput { + @IsInt() + @Expose() + @ApiProperty() + readonly id!: number; + + @IsInt() + @Expose() + @ApiProperty() + readonly userId!: number; + + @IsInt() + @Expose() + @ApiProperty() + readonly teamId!: number; + + @IsBoolean() + @Expose() + @ApiProperty() + readonly accepted!: boolean; + + @IsString() + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + @Expose() + readonly role!: MembershipRole; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiPropertyOptional() + readonly disableImpersonation?: boolean; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/update-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/update-team-membership.output.ts new file mode 100644 index 00000000000000..a839d593bfcaff --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/update-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts new file mode 100644 index 00000000000000..328ac6151784a6 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts @@ -0,0 +1,50 @@ +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class TeamsMembershipsService { + constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {} + + async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) { + const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data); + return teamMembership; + } + + async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) { + const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated( + teamId, + skip, + take + ); + return teamMemberships; + } + + async getTeamMembership(teamId: number, membershipId: number) { + const teamMemberships = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId); + + if (!teamMemberships) { + throw new NotFoundException("Organization's Team membership not found"); + } + + return teamMemberships; + } + + async updateTeamMembership(teamId: number, membershipId: number, data: UpdateTeamMembershipInput) { + const teamMembership = await this.teamsMembershipsRepository.updateTeamMembershipById( + teamId, + membershipId, + data + ); + return teamMembership; + } + + async deleteTeamMembership(teamId: number, membershipId: number) { + const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById( + teamId, + membershipId + ); + return teamMembership; + } +} diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts new file mode 100644 index 00000000000000..40e53915a3918f --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts @@ -0,0 +1,16 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; +import { TeamsMembershipsController } from "@/modules/teams/memberships/controllers/teams-memberships.controller"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule], + providers: [TeamsMembershipsRepository, TeamsMembershipsService], + controllers: [TeamsMembershipsController], +}) +export class TeamsMembershipsModule {} diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts new file mode 100644 index 00000000000000..e7004aa32aa827 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts @@ -0,0 +1,69 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class TeamsMembershipsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) { + return this.dbWrite.prisma.membership.create({ + data: { ...data, teamId: teamId }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } + + async findTeamMembershipsPaginated(teamId: number, skip: number, take: number) { + return await this.dbRead.prisma.membership.findMany({ + where: { + teamId: teamId, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + skip, + take, + }); + } + + async findTeamMembership(teamId: number, membershipId: number) { + return this.dbRead.prisma.membership.findUnique({ + where: { + id: membershipId, + teamId: teamId, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } + + async findTeamMembershipsByNameAndUser(teamName: string, userId: number) { + return this.dbRead.prisma.membership.findFirst({ + where: { + team: { + name: teamName, + }, + userId, + }, + }); + } + + async deleteTeamMembershipById(teamId: number, membershipId: number) { + return this.dbWrite.prisma.membership.delete({ + where: { + id: membershipId, + teamId: teamId, + }, + }); + } + + async updateTeamMembershipById(teamId: number, membershipId: number, data: UpdateTeamMembershipInput) { + return this.dbWrite.prisma.membership.update({ + data: { ...data }, + where: { + id: membershipId, + teamId: teamId, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } +} diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts new file mode 100644 index 00000000000000..21f88cdf0eaf1a --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts @@ -0,0 +1,231 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; +import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input"; +import { CreateTeamOutput } from "@/modules/teams/teams/outputs/teams/create-team.output"; +import { GetTeamOutput } from "@/modules/teams/teams/outputs/teams/get-team.output"; +import { GetTeamsOutput } from "@/modules/teams/teams/outputs/teams/get-teams.output"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "next-auth"; +import Stripe from "stripe"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +describe("Teams endpoint", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + + const aliceEmail = `alice-${randomString()}@api.com`; + let alice: User; + let aliceApiKey: string; + + const bobEmail = `bob-${randomString()}@api.com`; + let bob: User; + let bobApiKey: string; + + let team1: TeamOutputDto; + let team2: TeamOutputDto; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, TeamsModule], + }).compile(); + + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(() => ({} as unknown as Stripe)); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + alice = await userRepositoryFixture.create({ + email: aliceEmail, + }); + + bob = await userRepositoryFixture.create({ + email: bobEmail, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(alice.id, null); + aliceApiKey = keyString; + + const { keyString: bobKeyString } = await apiKeysRepositoryFixture.createApiKey(bob.id, null); + bobApiKey = bobKeyString; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("User has membership in created team", () => { + it("should create a team", async () => { + const body: CreateTeamInput = { + name: `teams-dog-${randomString()}`, + }; + + return request(app.getHttpServer()) + .post("/v2/teams") + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(201) + .then(async (response) => { + const responseBody: CreateTeamOutput = response.body; + const responseData = responseBody.data as TeamOutputDto; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseData).toBeDefined(); + expect(responseData.id).toBeDefined(); + expect(responseData.name).toEqual(body.name); + team1 = responseData; + }); + }); + + it("should create a team", async () => { + const body: CreateTeamInput = { + name: `teams-cats-${randomString()}`, + }; + + return request(app.getHttpServer()) + .post("/v2/teams") + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(201) + .then(async (response) => { + const responseBody: CreateTeamOutput = response.body; + const responseData = responseBody.data as TeamOutputDto; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseData).toBeDefined(); + expect(responseData.id).toBeDefined(); + expect(responseData.name).toEqual(body.name); + team2 = responseData; + }); + }); + + it("should get a team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team1.id}`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(team1.name); + }); + }); + + it("should get teams", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(2); + expect(responseBody.data[0].id).toBeDefined(); + expect(responseBody.data[0].name).toEqual(team1.name); + expect(responseBody.data[1].id).toBeDefined(); + expect(responseBody.data[1].name).toEqual(team2.name); + }); + }); + + it("should update a team", async () => { + const body: UpdateTeamDto = { + name: `teams-dogs-shepherds-${randomString()}`, + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team1.id}`) + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(body.name); + team1 = responseBody.data; + }); + }); + }); + + describe("User does not have membership in created team", () => { + it("should not be able to get a team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team2.id}`) + .set({ Authorization: `Bearer cal_test_${bobApiKey}` }) + .expect(403); + }); + }); + + describe("User does not have sufficient membership in created team", () => { + it("should not be able to delete a team", async () => { + await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: bob.id } }, + team: { connect: { id: team2.id } }, + accepted: true, + }); + return request(app.getHttpServer()) + .delete(`/v2/teams/${team2.id}`) + .set({ Authorization: `Bearer cal_test_${bobApiKey}` }) + .expect(403); + }); + }); + + describe("Delete teams", () => { + it("should delete team", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team1.id}`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(team1.name); + }); + }); + + it("should delete team", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team2.id}`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(team2.name); + }); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(aliceEmail); + await userRepositoryFixture.deleteByEmail(bobEmail); + await teamRepositoryFixture.delete(team1.id); + await teamRepositoryFixture.delete(team2.id); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts new file mode 100644 index 00000000000000..9947214c852fa5 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts @@ -0,0 +1,105 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input"; +import { OrgTeamOutputResponseDto } from "@/modules/organizations/outputs/organization-team.output"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; +import { CreateTeamOutput } from "@/modules/teams/teams/outputs/teams/create-team.output"; +import { GetTeamOutput } from "@/modules/teams/teams/outputs/teams/get-team.output"; +import { GetTeamsOutput } from "@/modules/teams/teams/outputs/teams/get-teams.output"; +import { UpdateTeamOutput } from "@/modules/teams/teams/outputs/teams/update-team.output"; +import { TeamsService } from "@/modules/teams/teams/services/teams.service"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Param, ParseIntPipe, Delete, Patch, Post, Body } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/teams", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard) +@DocsTags("Teams") +export class TeamsController { + constructor(private teamsService: TeamsService, private teamsRepository: TeamsRepository) {} + + @Post() + @ApiOperation({ summary: "Create a team" }) + async createTeam( + @Body() body: CreateTeamInput, + @GetUser() user: UserWithProfile + ): Promise { + const team = await this.teamsService.createTeam(body, user.id); + + if ("paymentLink" in team) { + return { + status: SUCCESS_STATUS, + data: { + pendingTeam: plainToClass(TeamOutputDto, team.pendingTeam, { strategy: "excludeAll" }), + paymentLink: team.paymentLink, + message: team.message, + }, + }; + } + + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @Get("/:teamId") + @ApiOperation({ summary: "Get a team" }) + @UseGuards(RolesGuard) + @Roles("TEAM_MEMBER") + async getTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise { + const team = await this.teamsRepository.getById(teamId); + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get teams" }) + async getTeams(@GetUser("id") userId: number): Promise { + const teams = await this.teamsService.getUserTeams(userId); + return { + status: SUCCESS_STATUS, + data: teams.map((team) => plainToClass(TeamOutputDto, team, { strategy: "excludeAll" })), + }; + } + + @Patch("/:teamId") + @ApiOperation({ summary: "Update a team" }) + @UseGuards(RolesGuard) + @Roles("TEAM_OWNER") + async updateTeam( + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: UpdateOrgTeamDto + ): Promise { + const team = await this.teamsService.updateTeam(teamId, body); + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @UseGuards(RolesGuard) + @Delete("/:teamId") + @ApiOperation({ summary: "Delete a team" }) + @Roles("TEAM_OWNER") + async deleteTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise { + const team = await this.teamsRepository.delete(teamId); + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts new file mode 100644 index 00000000000000..c28a92609c5f54 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts @@ -0,0 +1,118 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class CreateTeamInput { + @IsString() + @Length(1) + @ApiProperty({ description: "Name of the team", example: "CalTeam", required: true }) + readonly name!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" }) + readonly slug?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + description: `URL of the teams logo image`, + }) + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: false }) + readonly hideBranding?: boolean = false; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hideBookATeamMember?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly theme?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + description: `URL of the teams banner image which is shown on booker`, + required: false, + }) + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "America/New_York", + description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`, + required: false, + default: "Europe/London", + }) + readonly timeZone?: string = "Europe/London"; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "Monday", + default: "Sunday", + }) + readonly weekStart?: string = "Sunday"; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: true }) + readonly autoAcceptCreator?: boolean = true; +} diff --git a/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts new file mode 100644 index 00000000000000..49685659e9a237 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts @@ -0,0 +1,119 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class UpdateTeamDto { + @IsString() + @Length(1) + @ApiPropertyOptional({ description: "Name of the team", example: "CalTeam" }) + readonly name?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" }) + readonly slug?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + description: `URL of the teams logo image`, + }) + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hideBranding?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hideBookATeamMember?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly theme?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + description: `URL of the teams banner image which is shown on booker`, + }) + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "America/New_York", + description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`, + }) + readonly timeZone?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "Monday", + }) + readonly weekStart?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly bookingLimits?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly includeManagedEventsInLimits?: boolean; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts new file mode 100644 index 00000000000000..b17ca06671d08f --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts @@ -0,0 +1,34 @@ +import { ApiProperty, getSchemaPath } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +class Output { + @Expose() + @IsString() + message!: string; + + @Expose() + @IsUrl() + paymentLink!: string; + + @Expose() + @ValidateNested() + pendingTeam!: TeamOutputDto; +} + +export class CreateTeamOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }], + description: "Either an Output object or a TeamOutputDto.", + }) + @Expose() + @ValidateNested() + data!: Output | TeamOutputDto; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts new file mode 100644 index 00000000000000..066604be9a4f6c --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +export class GetTeamOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => TeamOutputDto) + data!: TeamOutputDto; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts new file mode 100644 index 00000000000000..66f55aeb7111e6 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +export class GetTeamsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => TeamOutputDto) + data!: TeamOutputDto[]; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts new file mode 100644 index 00000000000000..2b0f4b493aecab --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +export class UpdateTeamOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => TeamOutputDto) + data!: TeamOutputDto; +} diff --git a/apps/api/v2/src/modules/teams/teams/services/teams.service.ts b/apps/api/v2/src/modules/teams/teams/services/teams.service.ts new file mode 100644 index 00000000000000..191296d96f7bd2 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/services/teams.service.ts @@ -0,0 +1,71 @@ +import { StripeService } from "@/modules/stripe/stripe.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; +import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Injectable() +export class TeamsService { + private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled"); + + constructor( + private readonly teamsRepository: TeamsRepository, + private readonly teamsMembershipsRepository: TeamsMembershipsRepository, + private readonly stripeService: StripeService, + private readonly configService: ConfigService + ) {} + + async createTeam(input: CreateTeamInput, ownerId: number) { + const { autoAcceptCreator, ...teamData } = input; + + const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser( + input.name, + ownerId + ); + if (existingTeam) { + throw new BadRequestException({ + message: `You already have created a team with name=${input.name}`, + }); + } + + if (!this.isTeamBillingEnabled) { + const team = await this.teamsRepository.create(teamData); + await this.teamsMembershipsRepository.createTeamMembership(team.id, { + userId: ownerId, + role: "OWNER", + accepted: !!autoAcceptCreator, + }); + return team; + } + + const pendingTeam = await this.teamsRepository.create({ ...teamData, pendingPayment: true }); + + const checkoutSession = await this.stripeService.generateTeamCheckoutSession(pendingTeam.id, ownerId); + + if (!checkoutSession.url) { + await this.teamsRepository.delete(pendingTeam.id); + throw new InternalServerErrorException({ + message: `Failed generating team Stripe checkout session URL which is why team creation was cancelled. Please contact support.`, + }); + } + + return { + message: + "Your team will be created once we receive your payment. Please complete the payment using the payment link.", + paymentLink: checkoutSession.url, + pendingTeam, + }; + } + + async getUserTeams(userId: number) { + const teams = await this.teamsRepository.getTeamsUserIsMemberOf(userId); + return teams; + } + + async updateTeam(teamId: number, data: UpdateTeamDto) { + const team = await this.teamsRepository.update(teamId, data); + return team; + } +} diff --git a/apps/api/v2/src/modules/teams/teams/teams.module.ts b/apps/api/v2/src/modules/teams/teams/teams.module.ts new file mode 100644 index 00000000000000..440229cc6a15be --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/teams.module.ts @@ -0,0 +1,18 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller"; +import { TeamsService } from "@/modules/teams/teams/services/teams.service"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, MembershipsModule, RedisModule, StripeModule], + providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository, TeamsMembershipsService], + controllers: [TeamsController], + exports: [TeamsRepository], +}) +export class TeamsModule {} diff --git a/apps/api/v2/src/modules/teams/teams/teams.repository.ts b/apps/api/v2/src/modules/teams/teams/teams.repository.ts new file mode 100644 index 00000000000000..fd2a8094c3f77d --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/teams.repository.ts @@ -0,0 +1,73 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { Prisma } from "@calcom/prisma/client"; + +@Injectable() +export class TeamsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async create(team: Prisma.TeamCreateInput) { + return this.dbWrite.prisma.team.create({ + data: team, + }); + } + + async getById(teamId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { id: teamId }, + }); + } + + async getByIds(teamIds: number[]) { + return this.dbRead.prisma.team.findMany({ + where: { + id: { + in: teamIds, + }, + }, + }); + } + + async getTeamMembersIds(teamId: number) { + const team = await this.dbRead.prisma.team.findUnique({ + where: { + id: teamId, + }, + include: { + members: true, + }, + }); + if (!team) { + return []; + } + + return team.members.map((member) => member.userId); + } + + async getTeamsUserIsMemberOf(userId: number) { + return this.dbRead.prisma.team.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + }); + } + + async update(teamId: number, team: Prisma.TeamUpdateInput) { + return this.dbWrite.prisma.team.update({ + where: { id: teamId }, + data: team, + }); + } + + async delete(teamId: number) { + return this.dbWrite.prisma.team.delete({ + where: { id: teamId }, + }); + } +} diff --git a/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts new file mode 100644 index 00000000000000..281c87b15f1213 --- /dev/null +++ b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts @@ -0,0 +1,28 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { TimezonesService } from "@/modules/timezones/services/timezones.service"; +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { CityTimezones } from "@calcom/platform-libraries"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/timezones", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Timezones") +export class TimezonesController { + constructor(private readonly timezonesService: TimezonesService) {} + + @Get("/") + @ApiOperation({ summary: "Get all timezones" }) + async getTimeZones(): Promise> { + const timeZones = await this.timezonesService.getCityTimeZones(); + + return { + status: SUCCESS_STATUS, + data: timeZones, + }; + } +} diff --git a/apps/api/v2/src/modules/timezones/services/timezones.service.ts b/apps/api/v2/src/modules/timezones/services/timezones.service.ts new file mode 100644 index 00000000000000..be623bebdcf559 --- /dev/null +++ b/apps/api/v2/src/modules/timezones/services/timezones.service.ts @@ -0,0 +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 { + private cacheKey = "cityTimezones"; + + constructor(private readonly redisService: RedisService) {} + + async getCityTimeZones(): Promise { + 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; + } + } +} diff --git a/apps/api/v2/src/modules/timezones/timezones.module.ts b/apps/api/v2/src/modules/timezones/timezones.module.ts new file mode 100644 index 00000000000000..e5089ac2252c0f --- /dev/null +++ b/apps/api/v2/src/modules/timezones/timezones.module.ts @@ -0,0 +1,12 @@ +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: [RedisModule], + providers: [TimezonesService], + controllers: [TimezonesController], + exports: [TimezonesService], +}) +export class TimezoneModule {} diff --git a/apps/api/v2/src/modules/tokens/tokens.module.ts b/apps/api/v2/src/modules/tokens/tokens.module.ts new file mode 100644 index 00000000000000..d700cc77541949 --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [TokensRepository], + exports: [TokensRepository], +}) +export class TokensModule {} diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts new file mode 100644 index 00000000000000..448b5fde64b3ec --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -0,0 +1,164 @@ +import { JwtService } from "@/modules/jwt/jwt.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { PlatformAuthorizationToken } from "@prisma/client"; +import { DateTime } from "luxon"; + +@Injectable() +export class TokensRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly jwtService: JwtService + ) {} + + async createAuthorizationToken(clientId: string, userId: number): Promise { + return this.dbWrite.prisma.platformAuthorizationToken.create({ + data: { + client: { + connect: { + id: clientId, + }, + }, + owner: { + connect: { + id: userId, + }, + }, + }, + }); + } + + async invalidateAuthorizationToken(tokenId: string) { + return this.dbWrite.prisma.platformAuthorizationToken.delete({ + where: { + id: tokenId, + }, + }); + } + + async getAuthorizationTokenByClientUserIds(clientId: string, userId: number) { + return this.dbRead.prisma.platformAuthorizationToken.findFirst({ + where: { + platformOAuthClientId: clientId, + userId: userId, + }, + }); + } + + async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) { + if (deleteOld) { + try { + await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, userId: ownerId, expiresAt: { lte: new Date() } }, + }), + this.dbWrite.prisma.refreshToken.deleteMany({ + where: { + client: { id: clientId }, + userId: ownerId, + }, + }), + ]); + } catch (err) { + // discard. + } + } + + const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.create({ + data: { + secret: this.jwtService.signAccessToken({ clientId, ownerId }), + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: ownerId } }, + }, + }), + this.dbWrite.prisma.refreshToken.create({ + data: { + secret: this.jwtService.signRefreshToken({ clientId, ownerId }), + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: ownerId } }, + }, + }), + ]); + + return { + accessToken: accessToken.secret, + accessTokenExpiresAt: accessToken.expiresAt, + refreshToken: refreshToken.secret, + }; + } + + async getAccessTokenExpiryDate(accessTokenSecret: string) { + const accessToken = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessTokenSecret, + }, + select: { + expiresAt: true, + }, + }); + return accessToken?.expiresAt; + } + + async getAccessTokenOwnerId(accessTokenSecret: string) { + const accessToken = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessTokenSecret, + }, + select: { + userId: true, + }, + }); + + return accessToken?.userId; + } + + async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) { + const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, expiresAt: { lte: new Date() } }, + }), + this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }), + this.dbWrite.prisma.accessToken.create({ + data: { + secret: this.jwtService.signAccessToken({ clientId, userId: tokenUserId }), + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: tokenUserId } }, + }, + }), + this.dbWrite.prisma.refreshToken.create({ + data: { + secret: this.jwtService.signRefreshToken({ clientId, userId: tokenUserId }), + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: tokenUserId } }, + }, + }), + ]); + return { accessToken, refreshToken }; + } + + async getAccessTokenClient(accessToken: string) { + const token = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessToken, + }, + select: { + client: true, + }, + }); + + return token?.client; + } +} diff --git a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts new file mode 100644 index 00000000000000..27faa7940b64b3 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts @@ -0,0 +1,54 @@ +import { Locales } from "@/lib/enums/locales"; +import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl } 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" }) + email!: string; + + @IsString() + @ApiProperty({ example: "Alice Smith", description: "Managed user's name is used in emails" }) + name!: string; + + @IsOptional() + @IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" }) + @ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" }) + timeFormat?: TimeFormat; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + example: "Monday", + enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + }) + weekStart?: WeekDay; + + @IsTimeZone() + @IsOptional() + @CapitalizeTimeZone() + @ApiPropertyOptional({ + example: "America/New_York", + description: `Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have + a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked. + It will default to Europe/London if not passed.`, + }) + timeZone?: string; + + @IsEnum(Locales) + @IsOptional() + @ApiProperty({ example: Locales.EN, enum: Locales }) + locale?: Locales; + + @IsUrl() + @IsOptional() + @ApiPropertyOptional({ + type: String, + example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + description: `URL of the user's avatar image`, + }) + avatarUrl?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/create-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-user.input.ts new file mode 100644 index 00000000000000..03c7a27054fa2d --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/create-user.input.ts @@ -0,0 +1,131 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Transform } from "class-transformer"; +import { + IsBoolean, + IsEmail, + IsHexColor, + IsNumber, + IsOptional, + IsString, + Validate, + Min, +} from "class-validator"; + +import { AvatarValidator } from "../validators/avatarValidator"; +import { LocaleValidator } from "../validators/localeValidator"; +import { ThemeValidator } from "../validators/themeValidator"; +import { TimeFormatValidator } from "../validators/timeFormatValidator"; +import { TimeZoneValidator } from "../validators/timeZoneValidator"; +import { WeekdayValidator } from "../validators/weekdayValidator"; + +export class CreateUserInput { + @ApiProperty({ type: String, description: "User email address", example: "user@example.com" }) + @IsEmail() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.toLowerCase(); + } + }) + @Expose() + email!: string; + + @ApiProperty({ type: String, required: false, description: "Username", example: "user123" }) + @IsOptional() + @IsString() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.toLowerCase(); + } + }) + @Expose() + username?: string; + + @ApiProperty({ type: String, required: false, description: "Preferred weekday", example: "Monday" }) + @IsOptional() + @IsString() + @Validate(WeekdayValidator) + @Expose() + weekday?: string; + + @ApiProperty({ + type: String, + required: false, + description: "Brand color in HEX format", + example: "#FFFFFF", + }) + @IsOptional() + @IsHexColor() + @Expose() + brandColor?: string; + + @ApiProperty({ + type: String, + required: false, + description: "Dark brand color in HEX format", + example: "#000000", + }) + @IsOptional() + @IsHexColor() + @Expose() + darkBrandColor?: string; + + @ApiProperty({ type: Boolean, required: false, description: "Hide branding", example: false }) + @IsOptional() + @IsBoolean() + @Expose() + hideBranding?: boolean; + + @ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" }) + @IsOptional() + @IsString() + @Validate(TimeZoneValidator) + @Expose() + timeZone?: string; + + @ApiProperty({ type: String, required: false, description: "Theme", example: "dark" }) + @IsOptional() + @IsString() + @Validate(ThemeValidator) + @Expose() + theme?: string | null; + + @ApiProperty({ type: String, required: false, description: "Application theme", example: "light" }) + @IsOptional() + @IsString() + @Validate(ThemeValidator) + @Expose() + appTheme?: string | null; + + @ApiProperty({ type: Number, required: false, description: "Time format", example: 24 }) + @IsOptional() + @IsNumber() + @Validate(TimeFormatValidator) + @Expose() + timeFormat?: number; + + @ApiProperty({ type: Number, required: false, description: "Default schedule ID", example: 1, minimum: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Expose() + defaultScheduleId?: number; + + @ApiProperty({ type: String, required: false, description: "Locale", example: "en", default: "en" }) + @IsOptional() + @IsString() + @Validate(LocaleValidator) + @Expose() + locale?: string | null = "en"; + + @ApiProperty({ + type: String, + required: false, + description: "Avatar URL", + example: "https://example.com/avatar.jpg", + }) + @IsOptional() + @IsString() + @Validate(AvatarValidator) + @Expose() + avatarUrl?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/get-users.input.ts b/apps/api/v2/src/modules/users/inputs/get-users.input.ts new file mode 100644 index 00000000000000..5d7908bb3c6c33 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/get-users.input.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsNumber, IsOptional, Max, Min, Validate } from "class-validator"; + +import { IsEmailStringOrArray } from "../validators/isEmailStringOrArray"; + +export class GetUsersInput { + @ApiProperty({ required: false, description: "The number of items to return", example: 10 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(1) + @Max(1000) + @IsOptional() + take?: number; + + @ApiProperty({ required: false, description: "The number of items to skip", example: 0 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(0) + @IsOptional() + skip?: number; + + @IsOptional() + @Validate(IsEmailStringOrArray) + @Transform(({ value }: { value: string | string[] }) => { + return typeof value === "string" ? [value] : value; + }) + @ApiPropertyOptional({ + type: [String], + description: "The email address or an array of email addresses to filter by", + }) + emails?: string[]; +} diff --git a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts new file mode 100644 index 00000000000000..c8643e308d4cc1 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts @@ -0,0 +1,56 @@ +import { Locales } from "@/lib/enums/locales"; +import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; +import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator"; + +export class UpdateManagedUserInput { + @IsString() + @IsOptional() + @ApiPropertyOptional() + email?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + name?: string; + + @IsOptional() + @IsIn([12, 24]) + @ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" }) + timeFormat?: TimeFormat; + + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + defaultScheduleId?: number; + + @IsOptional() + @IsString() + @IsIn(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]) + @ApiPropertyOptional({ + example: "Monday", + enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + }) + weekStart?: WeekDay; + + @IsTimeZone() + @IsOptional() + @CapitalizeTimeZone() + @ApiPropertyOptional() + timeZone?: string; + + @IsEnum(Locales) + @IsOptional() + @ApiPropertyOptional({ example: Locales.EN, enum: Locales }) + locale?: Locales; + + @IsUrl() + @IsOptional() + @ApiPropertyOptional({ + type: String, + example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + description: `URL of the user's avatar image`, + }) + avatarUrl?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/update-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-user.input.ts new file mode 100644 index 00000000000000..501bd7e1d453d5 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/update-user.input.ts @@ -0,0 +1,4 @@ +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { PartialType } from "@nestjs/mapped-types"; + +export class UpdateUserInput extends PartialType(CreateUserInput) {} diff --git a/apps/api/v2/src/modules/users/outputs/get-users.output.ts b/apps/api/v2/src/modules/users/outputs/get-users.output.ts new file mode 100644 index 00000000000000..fdec53748b95a0 --- /dev/null +++ b/apps/api/v2/src/modules/users/outputs/get-users.output.ts @@ -0,0 +1,239 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator"; + +export class GetUserOutput { + @IsInt() + @Expose() + @ApiProperty({ type: Number, required: true, description: "The ID of the user", example: 1 }) + id!: number; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The username of the user", + example: "john_doe", + }) + username!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The name of the user", + example: "John Doe", + }) + name!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + required: true, + description: "The email of the user", + example: "john@example.com", + }) + email!: string; + + @IsDateString() + @Expose() + @ApiProperty({ + type: Date, + nullable: true, + required: false, + description: "The date when the email was verified", + example: "2022-01-01T00:00:00Z", + }) + emailVerified!: Date | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The bio of the user", + example: "I am a software developer", + }) + bio!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The URL of the user's avatar", + example: "https://example.com/avatar.jpg", + }) + avatarUrl!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + required: true, + description: "The time zone of the user", + example: "America/New_York", + }) + timeZone!: string; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + required: true, + description: "The week start day of the user", + example: "Monday", + }) + weekStart!: string; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The app theme of the user", + example: "light", + }) + appTheme!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The theme of the user", + example: "default", + }) + theme!: string | null; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + nullable: true, + required: false, + description: "The ID of the default schedule for the user", + example: 1, + }) + defaultScheduleId!: number | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The locale of the user", + example: "en-US", + }) + locale!: string | null; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + nullable: true, + required: false, + description: "The time format of the user", + example: 12, + }) + timeFormat!: number | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + type: Boolean, + required: true, + description: "Whether to hide branding for the user", + example: false, + }) + hideBranding!: boolean; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The brand color of the user", + example: "#ffffff", + }) + brandColor!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The dark brand color of the user", + example: "#000000", + }) + darkBrandColor!: string | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + type: Boolean, + nullable: true, + required: false, + description: "Whether dynamic booking is allowed for the user", + example: true, + }) + allowDynamicBooking!: boolean | null; + + @IsDateString() + @Expose() + @ApiProperty({ + type: Date, + required: true, + description: "The date when the user was created", + example: "2022-01-01T00:00:00Z", + }) + createdDate!: Date; + + @IsBoolean() + @Expose() + @ApiProperty({ + type: Boolean, + nullable: true, + required: false, + description: "Whether the user is verified", + example: true, + }) + verified!: boolean | null; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + nullable: true, + required: false, + description: "The ID of the user who invited this user", + example: 1, + }) + invitedTo!: number | null; +} + +export class GetUsersOutput { + @ValidateNested() + @Type(() => GetUserOutput) + @IsArray() + @ApiProperty({ + type: [GetUserOutput], + required: true, + description: "The list of users", + example: [{ id: 1, username: "john_doe", name: "John Doe", email: "john@example.com" }], + }) + users!: GetUserOutput[]; +} diff --git a/apps/api/v2/src/modules/users/services/users.service.ts b/apps/api/v2/src/modules/users/services/users.service.ts new file mode 100644 index 00000000000000..e10845e20b5371 --- /dev/null +++ b/apps/api/v2/src/modules/users/services/users.service.ts @@ -0,0 +1,40 @@ +import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +import { User } from "@calcom/prisma/client"; + +@Injectable() +export class UsersService { + constructor(private readonly usersRepository: UsersRepository) {} + + async getByUsernames(usernames: string[], orgSlug?: string, orgId?: number) { + const users = await Promise.all( + usernames.map((username) => this.usersRepository.findByUsername(username, orgSlug, orgId)) + ); + const usersFiltered: User[] = []; + + for (const user of users) { + if (user) { + usersFiltered.push(user); + } + } + + return users; + } + + getUserMainProfile(user: UserWithProfile) { + return ( + user?.movedToProfile || + user.profiles?.find((p) => p.organizationId === user.organizationId) || + user.profiles?.[0] + ); + } + + getUserMainOrgId(user: UserWithProfile) { + return this.getUserMainProfile(user)?.organizationId ?? user.organizationId; + } + + getUserProfileByOrgId(user: UserWithProfile, organizationId: number) { + return user.profiles?.find((p) => p.organizationId === organizationId); + } +} diff --git a/apps/api/v2/src/modules/users/users.module.ts b/apps/api/v2/src/modules/users/users.module.ts new file mode 100644 index 00000000000000..a7eed6c99de5cc --- /dev/null +++ b/apps/api/v2/src/modules/users/users.module.ts @@ -0,0 +1,13 @@ +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, EventTypesModule_2024_06_14, TokensModule], + providers: [UsersRepository, UsersService], + exports: [UsersRepository, UsersService], +}) +export class UsersModule {} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts new file mode 100644 index 00000000000000..365bbf39e3e03b --- /dev/null +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -0,0 +1,293 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import type { Profile, User, Team, Prisma } from "@prisma/client"; +import { CreationSource } from "@prisma/client"; + +export type UserWithProfile = User & { + movedToProfile?: (Profile & { organization: Pick }) | null; + profiles?: (Profile & { organization: Pick })[]; +}; + +@Injectable() +export class UsersRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async create( + user: CreateManagedUserInput, + username: string, + oAuthClientId: string, + isPlatformManaged: boolean + ) { + this.formatInput(user); + + return this.dbWrite.prisma.user.create({ + data: { + ...user, + username, + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + isPlatformManaged, + creationSource: CreationSource.API_V2, + }, + }); + } + + async addToOAuthClient(userId: number, oAuthClientId: string) { + return this.dbWrite.prisma.user.update({ + data: { + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + where: { id: userId }, + }); + } + + async findById(userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + }); + } + + async findByIdWithinPlatformScope(userId: number, clientId: string) { + return this.dbRead.prisma.user.findFirst({ + where: { + id: userId, + isPlatformManaged: true, + platformOAuthClients: { + some: { + id: clientId, + }, + }, + }, + }); + } + + async findByIdWithProfile(userId: number): Promise { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + movedToProfile: { + include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } }, + }, + profiles: { + include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } }, + }, + }, + }); + } + + async findByIdsWithEventTypes(userIds: number[]) { + return this.dbRead.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + include: { + eventTypes: true, + }, + }); + } + + async findByIds(userIds: number[]) { + return this.dbRead.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + }); + } + + async findByIdWithCalendars(userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + selectedCalendars: true, + destinationCalendar: true, + }, + }); + } + + async findByEmail(email: string) { + return this.dbRead.prisma.user.findUnique({ + where: { + email, + }, + }); + } + + async findByEmailWithProfile(email: string) { + return this.dbRead.prisma.user.findUnique({ + where: { + email, + }, + include: { + movedToProfile: { + include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } }, + }, + profiles: { + include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } }, + }, + }, + }); + } + + async findByUsername(username: string, orgSlug?: string, orgId?: number) { + return this.dbRead.prisma.user.findFirst({ + where: + orgId || orgSlug + ? { + profiles: { + some: { + organization: orgSlug ? { slug: orgSlug } : { id: orgId }, + username: username, + }, + }, + } + : { + username, + }, + }); + } + + async findManagedUsersByOAuthClientId(oauthClientId: string, cursor: number, limit: number) { + return this.dbRead.prisma.user.findMany({ + where: { + platformOAuthClients: { + some: { + id: oauthClientId, + }, + }, + isPlatformManaged: true, + }, + take: limit, + skip: cursor, + }); + } + + async update(userId: number, updateData: UpdateManagedUserInput) { + this.formatInput(updateData); + + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + } + + async updateByEmail(email: string, updateData: Prisma.UserUpdateInput) { + return this.dbWrite.prisma.user.update({ + where: { email }, + data: updateData, + }); + } + + async updateUsername(userId: number, newUsername: string) { + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + username: newUsername, + }, + }); + } + + async delete(userId: number): Promise { + return this.dbWrite.prisma.user.delete({ + where: { id: userId }, + }); + } + + formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) { + if (userInput.weekStart) { + userInput.weekStart = userInput.weekStart; + } + } + + setDefaultSchedule(userId: number, scheduleId: number) { + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + defaultScheduleId: scheduleId, + }, + }); + } + + async getUserScheduleDefaultId(userId: number) { + const user = await this.findById(userId); + + if (!user?.defaultScheduleId) return null; + + return user?.defaultScheduleId; + } + + async getOrganizationUsers(organizationId: number) { + const profiles = await this.dbRead.prisma.profile.findMany({ + where: { + organizationId, + }, + include: { + user: true, + }, + }); + return profiles.map((profile) => profile.user); + } + + async setDefaultConferencingApp(userId: number, appSlug?: string, appLink?: string) { + const user = await this.findById(userId); + + if (!user) { + throw new NotFoundException("user not found"); + } + + return await this.dbWrite.prisma.user.update({ + data: { + metadata: + typeof user.metadata === "object" + ? { + ...user.metadata, + defaultConferencingApp: { + appSlug: appSlug, + appLink: appLink, + }, + } + : {}, + }, + + where: { id: userId }, + }); + } + + async findUserOOORedirectEligible(userId: number, toTeamUserId: number) { + return await this.dbRead.prisma.user.findUnique({ + where: { + id: toTeamUserId, + teams: { + some: { + team: { + members: { + some: { + userId: userId, + accepted: true, + }, + }, + }, + }, + }, + }, + select: { + id: true, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/users/validators/avatarValidator.ts b/apps/api/v2/src/modules/users/validators/avatarValidator.ts new file mode 100644 index 00000000000000..d6b01e91671ca8 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/avatarValidator.ts @@ -0,0 +1,11 @@ +import { ValidatorConstraint } from "class-validator"; +import type { ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "avatarValidator", async: false }) +export class AvatarValidator implements ValidatorConstraintInterface { + validate(avatarString: string) { + // Checks if avatar string is a valid base 64 image + const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + return regex.test(avatarString); + } +} diff --git a/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts b/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts new file mode 100644 index 00000000000000..b4156be4b642ef --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts @@ -0,0 +1,24 @@ +import { ValidatorConstraint } from "class-validator"; +import type { ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "IsEmailStringOrArray", async: false }) +export class IsEmailStringOrArray implements ValidatorConstraintInterface { + validate(value: any): boolean { + if (typeof value === "string") { + return this.validateEmail(value); + } else if (Array.isArray(value)) { + return value.every((item) => this.validateEmail(item)); + } + return false; + } + + validateEmail(email: string): boolean { + const regex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return regex.test(email); + } + + defaultMessage() { + return "Please submit only valid email addresses"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/localeValidator.ts b/apps/api/v2/src/modules/users/validators/localeValidator.ts new file mode 100644 index 00000000000000..18c25d0eab23d7 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/localeValidator.ts @@ -0,0 +1,39 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "localeValidator", async: false }) +export class LocaleValidator implements ValidatorConstraintInterface { + validate(locale: string) { + const localeValues = [ + "en", + "fr", + "it", + "ru", + "es", + "de", + "pt", + "ro", + "nl", + "pt-BR", + "ko", + "ja", + "pl", + "ar", + "iw", + "zh-CN", + "zh-TW", + "cs", + "sr", + "sv", + "vi", + ]; + + if (localeValues.includes(locale)) return true; + + return false; + } + + defaultMessage() { + return "Please include a valid locale"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/themeValidator.ts b/apps/api/v2/src/modules/users/validators/themeValidator.ts new file mode 100644 index 00000000000000..7b6ce944b80733 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/themeValidator.ts @@ -0,0 +1,17 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "themeValidator", async: false }) +export class ThemeValidator implements ValidatorConstraintInterface { + validate(theme: string) { + const themeValues = ["dark", "light"]; + + if (themeValues.includes(theme)) return true; + + return false; + } + + defaultMessage() { + return "Please include either 'dark' or 'light"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts b/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts new file mode 100644 index 00000000000000..c5b82ef111d253 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts @@ -0,0 +1,17 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "timeFormatValidator", async: false }) +export class TimeFormatValidator implements ValidatorConstraintInterface { + validate(timeFormat: number) { + const timeFormatValues = [12, 24]; + + if (timeFormatValues.includes(timeFormat)) return true; + + return false; + } + + defaultMessage() { + return "Please include either 12 or 24"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts b/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts new file mode 100644 index 00000000000000..448d79f0d0aa3c --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts @@ -0,0 +1,18 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; +import tzdata from "tzdata"; + +@ValidatorConstraint({ name: "timezoneValidator", async: false }) +export class TimeZoneValidator implements ValidatorConstraintInterface { + validate(timeZone: string) { + const timeZoneList = Object.keys(tzdata.zones); + + if (timeZoneList.includes(timeZone)) return true; + + return false; + } + + defaultMessage() { + return "Please include a valid time zone"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/weekdayValidator.ts b/apps/api/v2/src/modules/users/validators/weekdayValidator.ts new file mode 100644 index 00000000000000..8a53a649e7b202 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/weekdayValidator.ts @@ -0,0 +1,16 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "weekdayValidator", async: false }) +export class WeekdayValidator implements ValidatorConstraintInterface { + validate(weekday: string) { + const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + + if (weekdays.includes(weekday)) return true; + return false; + } + + defaultMessage() { + return "Please include a valid weekday"; + } +} diff --git a/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts new file mode 100644 index 00000000000000..bb8e1b9e618233 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts @@ -0,0 +1,190 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + UserWebhookOutputResponseDto, + UserWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/user-webhook.output"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { Webhook } from "@calcom/prisma/client"; + +describe("WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = `webhooks-controller-user-${randomString()}@api.com`; + let user: UserWithProfile; + let otherUser: UserWithProfile; + + let userRepositoryFixture: UserRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + + let webhook: UserWebhookOutputResponseDto["data"]; + let otherWebhook: Webhook; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + otherUser = await userRepositoryFixture.create({ + email: `webhooks-controller-other-user-${randomString()}@api.com`, + username: `webhooks-controller-other-user-${randomString()}@api.com`, + }); + + otherWebhook = await webhookRepositoryFixture.create({ + id: "2mdfnn2", + subscriberUrl: "https://example.com", + eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + userRepositoryFixture.deleteByEmail(user.email); + userRepositoryFixture.deleteByEmail(otherUser.email); + webhookRepositoryFixture.delete(otherWebhook.id); + await app.close(); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post("/v2/webhooks") + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(201) + .then(async (res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + userId: user.id, + }, + } satisfies UserWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/webhooks (POST) should fail to create a webhook that already has same userId / subcriberUrl combo", () => { + return request(app.getHttpServer()) + .post("/v2/webhooks") + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(409); + }); + + it("/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + userId: user.id, + }, + } satisfies UserWebhookOutputResponseDto); + }); + }); + + it("/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/webhooks/90284`).expect(404); + }); + + it("/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to user", () => { + return request(app.getHttpServer()).get(`/v2/webhooks/${otherWebhook.id}`).expect(403); + }); + + it("/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get("/v2/webhooks") + .expect(200) + .then((res) => { + const responseBody = res.body as UserWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.userId).toBe(user.id); + }); + }); + }); + + it("/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/webhooks/${webhook.id}`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: { + id: expect.any(String), + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: false, + payloadTemplate: "string", + userId: user.id, + }, + } satisfies UserWebhookOutputResponseDto); + }); + }); + + it("/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()).delete(`/v2/webhooks/12993`).expect(404); + }); + + it("/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => { + return request(app.getHttpServer()).delete(`/v2/webhooks/${otherWebhook.id}`).expect(403); + }); +}); diff --git a/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts new file mode 100644 index 00000000000000..5025699ddd46ec --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts @@ -0,0 +1,120 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator"; +import { IsUserWebhookGuard } from "@/modules/webhooks/guards/is-user-webhook-guard"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + UserWebhookOutputDto, + UserWebhookOutputResponseDto, + UserWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/user-webhook.output"; +import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe"; +import { UserWebhooksService } from "@/modules/webhooks/services/user-webhooks.service"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { Webhook } from "@prisma/client"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard) +@DocsTags("Webhooks") +export class WebhooksController { + constructor( + private readonly webhooksService: WebhooksService, + private readonly userWebhooksService: UserWebhooksService + ) {} + + @Post("/") + @ApiOperation({ summary: "Create a webhook" }) + async createWebhook( + @Body() body: CreateWebhookInputDto, + @GetUser() user: UserWithProfile + ): Promise { + const webhook = await this.userWebhooksService.createUserWebhook( + user.id, + new WebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Patch("/:webhookId") + @ApiOperation({ summary: "Update a webhook" }) + @UseGuards(IsUserWebhookGuard) + async updateWebhook( + @Param("webhookId") webhookId: string, + @Body() body: UpdateWebhookInputDto + ): Promise { + const webhook = await this.webhooksService.updateWebhook( + webhookId, + new PartialWebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/:webhookId") + @ApiOperation({ summary: "Get a webhook" }) + @UseGuards(IsUserWebhookGuard) + async getWebhook(@GetWebhook() webhook: Webhook): Promise { + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook)), + }; + } + + @Get("/") + @ApiOperation({ + summary: "Get all webooks", + description: "Gets a paginated list of webhooks for the authenticated user.", + }) + async getWebhooks( + @GetUser() user: UserWithProfile, + @Query() query: SkipTakePagination + ): Promise { + const webhooks = await this.userWebhooksService.getUserWebhooksPaginated( + user.id, + query.skip ?? 0, + query.take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: webhooks.map((webhook) => + plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }) + ), + }; + } + + @Delete("/:webhookId") + @ApiOperation({ summary: "Delete a webhook" }) + @UseGuards(IsUserWebhookGuard) + async deleteWebhook(@Param("webhookId") webhookId: string): Promise { + const webhook = await this.webhooksService.deleteWebhook(webhookId); + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } +} diff --git a/apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts b/apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts new file mode 100644 index 00000000000000..df98ac107c30ab --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts @@ -0,0 +1,33 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; + +import { Webhook } from "@calcom/prisma/client"; + +export type GetWebhookReturnType = Webhook; + +export const GetWebhook = createParamDecorator< + keyof GetWebhookReturnType | (keyof GetWebhookReturnType)[], + ExecutionContext +>((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const webhook = request.webhook as GetWebhookReturnType; + + if (!webhook) { + throw new Error("GetWebhook decorator : Webhook not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: webhook[curr], + }; + }, {}); + } + + if (data) { + return webhook[data]; + } + + return webhook; +}); diff --git a/apps/api/v2/src/modules/webhooks/guards/is-oauth-client-webhook-guard.ts b/apps/api/v2/src/modules/webhooks/guards/is-oauth-client-webhook-guard.ts new file mode 100644 index 00000000000000..ae4cd803e0f954 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/guards/is-oauth-client-webhook-guard.ts @@ -0,0 +1,67 @@ +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { Request } from "express"; + +import { PlatformOAuthClient, Webhook } from "@calcom/prisma/client"; + +@Injectable() +export class IsOAuthClientWebhookGuard implements CanActivate { + constructor( + private readonly webhooksService: WebhooksService, + private readonly oAuthClientRepository: OAuthClientRepository, + private usersService: UsersService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest(); + const user = request.user as ApiAuthGuardUser; + const webhookId = request.params.webhookId; + const oAuthClientId = request.params.clientId; + const organizationId = this.usersService.getUserMainOrgId(user); + + if (!user) { + throw new ForbiddenException("User not authenticated"); + } + + if (!webhookId) { + throw new BadRequestException("webhookId parameter not specified in the request"); + } + + if (!oAuthClientId) { + throw new BadRequestException("oAuthClientId parameter not specified in the request"); + } + + const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId); + + if (!oAuthClient) { + throw new NotFoundException(`OAuthClient (${oAuthClientId}) not found`); + } + + const webhook = await this.webhooksService.getWebhookById(webhookId); + + if (oAuthClient?.organizationId !== organizationId) { + return user.isSystemAdmin; + } + + if (webhook.platformOAuthClientId !== oAuthClientId) { + throw new ForbiddenException("Webhook does not belong to this oAuthClient"); + } + + request.webhook = webhook; + request.oAuthClient = oAuthClient; + + return true; + } +} diff --git a/apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts b/apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts new file mode 100644 index 00000000000000..2ff90ea6ac6cc9 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts @@ -0,0 +1,60 @@ +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { EventType, Webhook } from "@prisma/client"; +import { Request } from "express"; + +@Injectable() +export class IsUserEventTypeWebhookGuard implements CanActivate { + constructor( + private readonly webhooksService: WebhooksService, + private readonly eventtypesRepository: EventTypesRepository_2024_06_14 + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest(); + const user = request.user as ApiAuthGuardUser; + const webhookId = request.params.webhookId; + const eventTypeId = request.params.eventTypeId; + + if (!user) { + return false; + } + + if (eventTypeId) { + const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId)); + if (!eventType) { + throw new NotFoundException(`Event type (${eventTypeId}) not found`); + } + if (eventType.userId !== user.id) { + throw new ForbiddenException(`User (${user.id}) is not the owner of event type (${eventTypeId})`); + } + request.eventType = eventType; + } + + if (webhookId) { + const webhook = await this.webhooksService.getWebhookById(webhookId); + if (!webhook.eventTypeId) { + throw new BadRequestException(`Webhook (${webhookId}) is not associated with an event type`); + } + if (webhook.eventTypeId !== parseInt(eventTypeId)) { + throw new ForbiddenException( + `Webhook (${webhookId}) is not associated with event type (${eventTypeId})` + ); + } + request.webhook = webhook; + } + + return true; + } +} diff --git a/apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts b/apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts new file mode 100644 index 00000000000000..95cc4ea6eeccad --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts @@ -0,0 +1,31 @@ +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Request } from "express"; + +import { Webhook } from "@calcom/prisma/client"; + +@Injectable() +export class IsUserWebhookGuard implements CanActivate { + constructor(private readonly webhooksService: WebhooksService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user as ApiAuthGuardUser; + const webhookId = request.params.webhookId; + + if (!user || !webhookId) { + return false; + } + + const webhook = await this.webhooksService.getWebhookById(webhookId); + + if (webhook.userId !== user.id) { + return user.isSystemAdmin; + } + + request.webhook = webhook; + + return true; + } +} diff --git a/apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts b/apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts new file mode 100644 index 00000000000000..cf495f0dcadb70 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { WebhookTriggerEvents } from "@prisma/client"; +import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator"; + +export class CreateWebhookInputDto { + @IsString() + @IsOptional() + @ApiProperty({ + description: + "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + example: JSON.stringify({ + content: "A new event has been scheduled", + type: "{{type}}", + name: "{{title}}", + organizer: "{{organizer.name}}", + booker: "{{attendees.0.name}}", + }), + }) + payloadTemplate?: string; + + @IsBoolean() + @ApiProperty() + active!: boolean; + + @IsString() + @ApiProperty() + subscriberUrl!: string; + + @IsArray() + @ApiProperty({ + example: [ + "BOOKING_CREATED", + "BOOKING_RESCHEDULED", + "BOOKING_CANCELLED", + "BOOKING_CONFIRMED", + "BOOKING_REJECTED", + "BOOKING_COMPLETED", + "BOOKING_NO_SHOW", + "BOOKING_REOPENED", + ], + enum: WebhookTriggerEvents, + }) + @IsEnum(WebhookTriggerEvents, { each: true }) + triggers!: WebhookTriggerEvents[]; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + secret?: string; +} + +export class UpdateWebhookInputDto extends PartialType(CreateWebhookInputDto) {} diff --git a/apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts new file mode 100644 index 00000000000000..5c69783e5d2762 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +import { WebhookOutputDto } from "./webhook.output"; + +export class EventTypeWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly eventTypeId!: number; +} + +export class EventTypeWebhookOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: EventTypeWebhookOutputDto; +} + +export class EventTypeWebhooksOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: EventTypeWebhookOutputDto[]; +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/oauth-client-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/oauth-client-webhook.output.ts new file mode 100644 index 00000000000000..e5405d4dc6b17e --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/oauth-client-webhook.output.ts @@ -0,0 +1,26 @@ +import { Expose, Type } from "class-transformer"; +import { IsInt, ValidateNested } from "class-validator"; + +import { ApiResponseWithoutData } from "@calcom/platform-types"; + +import { WebhookOutputDto } from "./webhook.output"; + +export class OAuthClientWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly oAuthClientId!: string; +} + +export class OAuthClientWebhookOutputResponseDto extends ApiResponseWithoutData { + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: OAuthClientWebhookOutputDto; +} + +export class OAuthClientWebhooksOutputResponseDto extends ApiResponseWithoutData { + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: OAuthClientWebhookOutputDto[]; +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/team-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/team-webhook.output.ts new file mode 100644 index 00000000000000..7e67d99ab6aae7 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/team-webhook.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +import { WebhookOutputDto } from "./webhook.output"; + +export class TeamWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly teamId!: number; +} + +export class TeamWebhookOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: TeamWebhookOutputDto; +} + +export class TeamWebhooksOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: TeamWebhookOutputDto[]; +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts new file mode 100644 index 00000000000000..66b3038d23e4cd --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +import { WebhookOutputDto } from "./webhook.output"; + +export class UserWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly userId!: number; +} + +export class UserWebhookOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: UserWebhookOutputDto; +} + +export class UserWebhooksOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: UserWebhookOutputDto[]; +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts new file mode 100644 index 00000000000000..0a9af838149817 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts @@ -0,0 +1,56 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { WebhookTriggerEvents } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsBoolean, IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class WebhookOutputDto { + @IsInt() + @Expose() + readonly id!: number; + + @IsString() + @Expose() + @ApiProperty({ + description: + "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + example: JSON.stringify({ + content: "A new event has been scheduled", + type: "{{type}}", + name: "{{title}}", + organizer: "{{organizer.name}}", + booker: "{{attendees.0.name}}", + }), + }) + readonly payloadTemplate!: string; + + @IsArray() + @IsEnum(WebhookTriggerEvents, { each: true }) + @Expose() + readonly triggers!: WebhookTriggerEvents[]; + + @IsString() + @Expose() + readonly subscriberUrl!: string; + + @IsBoolean() + @Expose() + readonly active!: boolean; + + @IsString() + @Expose() + readonly secret?: string; +} + +export class DeleteManyWebhooksOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + @Expose() + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: string; +} diff --git a/apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts b/apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts new file mode 100644 index 00000000000000..efaef7395dad67 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts @@ -0,0 +1,27 @@ +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { PipeTransform, Injectable } from "@nestjs/common"; + +@Injectable() +export class WebhookInputPipe implements PipeTransform { + transform(value: CreateWebhookInputDto) { + const { triggers, ...rest } = value; + const eventTriggers = triggers; + const parsedData = { ...rest, eventTriggers }; + return parsedData; + } +} + +@Injectable() +export class PartialWebhookInputPipe implements PipeTransform { + transform(value: UpdateWebhookInputDto) { + if (value.triggers) { + const { triggers, ...rest } = value; + const eventTriggers = triggers; + const parsedData = { ...rest, eventTriggers }; + return parsedData; + } + return { ...value, eventTriggers: undefined }; + } +} + +export type PipedInputWebhookType = ReturnType; diff --git a/apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts b/apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts new file mode 100644 index 00000000000000..b3272cd470f0bc --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts @@ -0,0 +1,12 @@ +import { PipeTransform, Injectable } from "@nestjs/common"; +import { Webhook } from "@prisma/client"; + +@Injectable() +export class WebhookOutputPipe implements PipeTransform { + transform(value: Webhook) { + const { eventTriggers, platformOAuthClientId, ...rest } = value; + return { ...rest, triggers: eventTriggers, oAuthClientId: platformOAuthClientId }; + } +} + +export type PipedOutputWebhookType = ReturnType; diff --git a/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts new file mode 100644 index 00000000000000..89014abf55957c --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts @@ -0,0 +1,31 @@ +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable } from "@nestjs/common"; + +@Injectable() +export class EventTypeWebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) { + const existingWebhook = await this.webhooksRepository.getEventTypeWebhookByUrl( + eventTypeId, + body.subscriberUrl + ); + if (existingWebhook) { + throw new ConflictException("Webhook with this subscriber url already exists for this event type"); + } + return this.webhooksRepository.createEventTypeWebhook(eventTypeId, { + ...body, + payloadTemplate: body.payloadTemplate ?? null, + secret: body.secret ?? null, + }); + } + + getEventTypeWebhooksPaginated(eventTypeId: number, skip: number, take: number) { + return this.webhooksRepository.getEventTypeWebhooksPaginated(eventTypeId, skip, take); + } + + async deleteAllEventTypeWebhooks(eventTypeId: number): Promise<{ count: number }> { + return this.webhooksRepository.deleteAllEventTypeWebhooks(eventTypeId); + } +} diff --git a/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts new file mode 100644 index 00000000000000..1e995d867762f8 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts @@ -0,0 +1,32 @@ +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable } from "@nestjs/common"; + +@Injectable() +export class OAuthClientWebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async createOAuthClientWebhook(platformOAuthClientId: string, body: PipedInputWebhookType) { + const existingWebhook = await this.webhooksRepository.getOAuthClientWebhookByUrl( + platformOAuthClientId, + body.subscriberUrl + ); + if (existingWebhook) { + throw new ConflictException("Webhook with this subscriber url already exists for this oAuth client"); + } + + return this.webhooksRepository.createOAuthClientWebhook(platformOAuthClientId, { + ...body, + payloadTemplate: body.payloadTemplate ?? null, + secret: body.secret ?? null, + }); + } + + async getOAuthClientWebhooksPaginated(platformOAuthClientId: string, skip: number, take: number) { + return this.webhooksRepository.getOAuthClientWebhooksPaginated(platformOAuthClientId, skip, take); + } + + async deleteAllOAuthClientWebhooks(platformOAuthClientId: string): Promise<{ count: number }> { + return this.webhooksRepository.deleteAllOAuthClientWebhooks(platformOAuthClientId); + } +} diff --git a/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts new file mode 100644 index 00000000000000..cd091ce7b71dbc --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts @@ -0,0 +1,25 @@ +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable } from "@nestjs/common"; + +@Injectable() +export class UserWebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async createUserWebhook(userId: number, body: PipedInputWebhookType) { + const existingWebhook = await this.webhooksRepository.getUserWebhookByUrl(userId, body.subscriberUrl); + if (existingWebhook) { + throw new ConflictException("Webhook with this subscriber url already exists for this user"); + } + + return this.webhooksRepository.createUserWebhook(userId, { + ...body, + payloadTemplate: body.payloadTemplate ?? null, + secret: body.secret ?? null, + }); + } + + async getUserWebhooksPaginated(userId: number, skip: number, take: number) { + return this.webhooksRepository.getUserWebhooksPaginated(userId, skip, take); + } +} diff --git a/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts new file mode 100644 index 00000000000000..087fe31221f63d --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts @@ -0,0 +1,24 @@ +import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class WebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) { + return this.webhooksRepository.updateWebhook(webhookId, body); + } + + async getWebhookById(webhookId: string) { + const webhook = await this.webhooksRepository.getWebhookById(webhookId); + if (!webhook) { + throw new NotFoundException(`Webhook (${webhookId}) not found`); + } + return webhook; + } + + async deleteWebhook(webhookId: string) { + return this.webhooksRepository.deleteWebhook(webhookId); + } +} diff --git a/apps/api/v2/src/modules/webhooks/webhooks.module.ts b/apps/api/v2/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 00000000000000..03ccd8d0c2ed5a --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,44 @@ +import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; +import { EventTypeWebhooksController } from "@/modules/event-types/controllers/event-types-webhooks.controller"; +import { OAuthClientWebhooksController } from "@/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { Module } from "@nestjs/common"; + +import { MembershipsModule } from "../memberships/memberships.module"; +import { OrganizationsModule } from "../organizations/organizations.module"; +import { PrismaModule } from "../prisma/prisma.module"; +import { UsersModule } from "../users/users.module"; +import { WebhooksController } from "./controllers/webhooks.controller"; +import { EventTypeWebhooksService } from "./services/event-type-webhooks.service"; +import { OAuthClientWebhooksService } from "./services/oauth-clients-webhooks.service"; +import { UserWebhooksService } from "./services/user-webhooks.service"; +import { WebhooksService } from "./services/webhooks.service"; +import { WebhooksRepository } from "./webhooks.repository"; + +@Module({ + imports: [ + PrismaModule, + UsersModule, + EventTypesModule_2024_06_14, + OAuthClientModule, + OrganizationsModule, + MembershipsModule, + OAuthClientModule, + ], + controllers: [WebhooksController, EventTypeWebhooksController, OAuthClientWebhooksController], + providers: [ + WebhooksService, + WebhooksRepository, + UserWebhooksService, + EventTypeWebhooksService, + OAuthClientWebhooksService, + ], + exports: [ + WebhooksService, + WebhooksRepository, + UserWebhooksService, + EventTypeWebhooksService, + OAuthClientWebhooksService, + ], +}) +export class WebhooksModule {} diff --git a/apps/api/v2/src/modules/webhooks/webhooks.repository.ts b/apps/api/v2/src/modules/webhooks/webhooks.repository.ts new file mode 100644 index 00000000000000..bac197ae6ebea8 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/webhooks.repository.ts @@ -0,0 +1,111 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { Webhook } from "@calcom/prisma/client"; + +import { PrismaWriteService } from "../prisma/prisma-write.service"; + +type WebhookInputData = Pick< + Webhook, + "payloadTemplate" | "eventTriggers" | "subscriberUrl" | "secret" | "active" +>; + +@Injectable() +export class WebhooksRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createUserWebhook(userId: number, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, userId }, + }); + } + + async createEventTypeWebhook(eventTypeId: number, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, eventTypeId }, + }); + } + + async createOAuthClientWebhook(platformOAuthClientId: string, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, platformOAuthClientId }, + }); + } + + async updateWebhook(webhookId: string, data: Partial) { + return this.dbWrite.prisma.webhook.update({ + where: { id: webhookId }, + data, + }); + } + + async getWebhookById(webhookId: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { id: webhookId }, + }); + } + + async getUserWebhooksPaginated(userId: number, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { userId }, + skip, + take, + }); + } + + async getEventTypeWebhooksPaginated(eventTypeId: number, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { eventTypeId }, + skip, + take, + }); + } + + async getOAuthClientWebhooksPaginated(platformOAuthClientId: string, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { platformOAuthClientId }, + skip, + take, + }); + } + + async getUserWebhookByUrl(userId: number, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { userId, subscriberUrl }, + }); + } + + async getOAuthClientWebhookByUrl(platformOAuthClientId: string, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { platformOAuthClientId, subscriberUrl }, + }); + } + + async getEventTypeWebhookByUrl(eventTypeId: number, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { eventTypeId, subscriberUrl }, + }); + } + + async deleteWebhook(webhookId: string) { + return this.dbWrite.prisma.webhook.delete({ + where: { id: webhookId }, + }); + } + + async deleteAllEventTypeWebhooks(eventTypeId: number) { + return this.dbWrite.prisma.webhook.deleteMany({ + where: { eventTypeId }, + }); + } + + async deleteAllOAuthClientWebhooks(oAuthClientId: string) { + return this.dbWrite.prisma.webhook.deleteMany({ + where: { platformOAuthClientId: oAuthClientId }, + }); + } +} diff --git a/apps/api/v2/swagger/copy-swagger-module.ts b/apps/api/v2/swagger/copy-swagger-module.ts new file mode 100644 index 00000000000000..8f6f23d3f382ff --- /dev/null +++ b/apps/api/v2/swagger/copy-swagger-module.ts @@ -0,0 +1,28 @@ +import * as fs from "fs-extra"; +import * as path from "path"; + +// First, copyNestSwagger is required to enable "@nestjs/swagger" in the "nest-cli.json", +// because nest-cli.json is resolving "@nestjs/swagger" plugin from +// project's node_modules, but due to dependency hoisting, the "@nestjs/swagger" is located in the root node_modules. +// Second, we need to run this before starting the application using "nest start", because "nest start" is ran by +// "nest-cli" with the "nest-cli.json" file, and for nest cli to be loaded with plugins correctly the "@nestjs/swagger" +// should reside in the project's node_modules already before the "nest start" command is executed. +async function copyNestSwagger() { + const monorepoRoot = path.resolve(__dirname, "../../../../"); + const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs"); + const swaggerModulePath = "@nestjs/swagger"; + + const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath); + const targetDir = path.join(nodeModulesNestjs, "swagger"); + + if (!(await fs.pathExists(targetDir))) { + try { + await fs.ensureDir(nodeModulesNestjs); + await fs.copy(sourceDir, targetDir); + } catch (error) { + console.error("Failed to copy @nestjs/swagger:", error); + } + } +} + +copyNestSwagger(); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json new file mode 100644 index 00000000000000..1d8781c5e93c57 --- /dev/null +++ b/apps/api/v2/swagger/documentation.json @@ -0,0 +1,17217 @@ +{ + "openapi": "3.0.0", + "paths": { + "/v2/provider/{clientId}": { + "get": { + "operationId": "CalProviderController_verifyClientId", + "summary": "Get a provider", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderVerifyClientOutput" + } + } + } + } + }, + "tags": [ + "Platform / Cal Provider" + ] + } + }, + "/v2/provider/{clientId}/access-token": { + "get": { + "operationId": "CalProviderController_verifyAccessToken", + "summary": "Verify an access token", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderVerifyAccessTokenOutput" + } + } + } + } + }, + "tags": [ + "Platform / Cal Provider" + ] + } + }, + "/v2/gcal/oauth/auth-url": { + "get": { + "operationId": "GcalController_redirect", + "summary": "Get auth URL", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalAuthUrlOutput" + } + } + } + } + }, + "tags": [ + "Platform / Google Calendar" + ] + } + }, + "/v2/gcal/oauth/save": { + "get": { + "operationId": "GcalController_save", + "summary": "Connect a calendar", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalSaveRedirectOutput" + } + } + } + } + }, + "tags": [ + "Platform / Google Calendar" + ] + } + }, + "/v2/gcal/check": { + "get": { + "operationId": "GcalController_check", + "summary": "Check a calendar connection status", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalCheckOutput" + } + } + } + } + }, + "tags": [ + "Platform / Google Calendar" + ] + } + }, + "/v2/oauth-clients/{clientId}/users": { + "get": { + "operationId": "OAuthClientUsersController_getManagedUsers", + "summary": "Get all managed users", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUsersOutput" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + }, + "post": { + "operationId": "OAuthClientUsersController_createUser", + "summary": "Create a managed user", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateManagedUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + } + }, + "/v2/oauth-clients/{clientId}/users/{userId}": { + "get": { + "operationId": "OAuthClientUsersController_getUserById", + "summary": "Get a managed user", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + }, + "patch": { + "operationId": "OAuthClientUsersController_updateUser", + "summary": "Update a managed user", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateManagedUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + }, + "delete": { + "operationId": "OAuthClientUsersController_deleteUser", + "summary": "Delete a managed user", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + } + }, + "/v2/oauth-clients/{clientId}/users/{userId}/force-refresh": { + "post": { + "operationId": "OAuthClientUsersController_forceRefresh", + "summary": "Force refresh tokens", + "description": "If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.\n Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + } + }, + "/v2/oauth/{clientId}/refresh": { + "post": { + "operationId": "OAuthFlowController_refreshTokens", + "summary": "Refresh managed user tokens", + "description": "If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and \n each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have `calAccessToken` and `calRefreshToken` columns.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "required": true, + "in": "header", + "description": "OAuth client secret key.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshTokenInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Managed Users" + ] + } + }, + "/v2/oauth-clients/{clientId}/webhooks": { + "post": { + "operationId": "OAuthClientWebhooksController_createOAuthClientWebhook", + "summary": "Create a webhook", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClientWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Webhooks" + ] + }, + "get": { + "operationId": "OAuthClientWebhooksController_getOAuthClientWebhooks", + "summary": "Get all webhooks", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClientWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Webhooks" + ] + }, + "delete": { + "operationId": "OAuthClientWebhooksController_deleteAllOAuthClientWebhooks", + "summary": "Delete all webhooks", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteManyWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Webhooks" + ] + } + }, + "/v2/oauth-clients/{clientId}/webhooks/{webhookId}": { + "patch": { + "operationId": "OAuthClientWebhooksController_updateOAuthClientWebhook", + "summary": "Update a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookInputDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClientWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Webhooks" + ] + }, + "get": { + "operationId": "OAuthClientWebhooksController_getOAuthClientWebhook", + "summary": "Get a webhook", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClientWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Webhooks" + ] + }, + "delete": { + "operationId": "OAuthClientWebhooksController_deleteOAuthClientWebhook", + "summary": "Delete a webhook", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClientWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Platform / Webhooks" + ] + } + }, + "/v2/organizations/{orgId}/attributes": { + "get": { + "operationId": "OrganizationsAttributesController_getOrganizationAttributes", + "summary": "Get all attributes", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationAttributesOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes" + ] + }, + "post": { + "operationId": "OrganizationsAttributesController_createOrganizationAttribute", + "summary": "Create an attribute", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationAttributeInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationAttributesOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes" + ] + } + }, + "/v2/organizations/{orgId}/attributes/{attributeId}": { + "get": { + "operationId": "OrganizationsAttributesController_getOrganizationAttribute", + "summary": "Get an attribute", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSingleAttributeOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes" + ] + }, + "patch": { + "operationId": "OrganizationsAttributesController_updateOrganizationAttribute", + "summary": "Update an attribute", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationAttributeInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationAttributesOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes" + ] + }, + "delete": { + "operationId": "OrganizationsAttributesController_deleteOrganizationAttribute", + "summary": "Delete an attribute", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteOrganizationAttributesOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes" + ] + } + }, + "/v2/organizations/{orgId}/attributes/{attributeId}/options": { + "post": { + "operationId": "OrganizationsOptionsAttributesController_createOrganizationAttributeOption", + "summary": "Create an attribute option", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationAttributeOptionInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAttributeOptionOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + }, + "get": { + "operationId": "OrganizationsOptionsAttributesController_getOrganizationAttributeOptions", + "summary": "Get all attribute options", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllAttributeOptionOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + } + }, + "/v2/organizations/{orgId}/attributes/{attributeId}/options/{optionId}": { + "delete": { + "operationId": "OrganizationsOptionsAttributesController_deleteOrganizationAttributeOption", + "summary": "Delete an attribute option", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "optionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAttributeOptionOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + }, + "patch": { + "operationId": "OrganizationsOptionsAttributesController_updateOrganizationAttributeOption", + "summary": "Update an attribute option", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "optionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationAttributeOptionInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAttributeOptionOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + } + }, + "/v2/organizations/{orgId}/attributes/options/{userId}": { + "post": { + "operationId": "OrganizationsOptionsAttributesController_assignOrganizationAttributeOptionToUser", + "summary": "Assign an attribute to a user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignOrganizationAttributeOptionToUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignOptionUserOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + }, + "get": { + "operationId": "OrganizationsOptionsAttributesController_getOrganizationAttributeOptionsForUser", + "summary": "Get all attribute options for a user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOptionUserOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + } + }, + "/v2/organizations/{orgId}/attributes/options/{userId}/{attributeOptionId}": { + "delete": { + "operationId": "OrganizationsOptionsAttributesController_unassignOrganizationAttributeOptionFromUser", + "summary": "Unassign an attribute from a user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeOptionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnassignOptionUserOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/event-types": { + "post": { + "operationId": "OrganizationsEventTypesController_createTeamEventType", + "summary": "Create an event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamEventTypeInput_2024_06_14" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + }, + "get": { + "operationId": "OrganizationsEventTypesController_getTeamEventTypes", + "summary": "Get a team event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventSlug", + "required": false, + "in": "query", + "description": "Slug of team event type to return.", + "schema": { + "type": "string" + } + }, + { + "name": "hostsLimit", + "required": false, + "in": "query", + "description": "Specifies the maximum number of hosts to include in the response. This limit helps optimize performance. If not provided, all Hosts will be fetched.", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamEventTypesOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}": { + "get": { + "operationId": "OrganizationsEventTypesController_getTeamEventType", + "summary": "Get an event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + }, + "patch": { + "operationId": "OrganizationsEventTypesController_updateTeamEventType", + "summary": "Update a team event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamEventTypeInput_2024_06_14" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + }, + "delete": { + "operationId": "OrganizationsEventTypesController_deleteTeamEventType", + "summary": "Delete a team event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/create-phone-call": { + "post": { + "operationId": "OrganizationsEventTypesController_createPhoneCall", + "summary": "Create a phone call", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePhoneCallInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePhoneCallOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + } + }, + "/v2/organizations/{orgId}/teams/event-types": { + "get": { + "operationId": "OrganizationsEventTypesController_getTeamsEventTypes", + "summary": "Get all team event types", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamEventTypesOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Event Types" + ] + } + }, + "/v2/organizations/{orgId}/memberships": { + "get": { + "operationId": "OrganizationsMembershipsController_getAllMemberships", + "summary": "Get all memberships", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllOrgMemberships" + } + } + } + } + }, + "tags": [ + "Orgs / Memberships" + ] + }, + "post": { + "operationId": "OrganizationsMembershipsController_createMembership", + "summary": "Create a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgMembershipDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgMembershipOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Memberships" + ] + } + }, + "/v2/organizations/{orgId}/memberships/{membershipId}": { + "get": { + "operationId": "OrganizationsMembershipsController_getOrgMembership", + "summary": "Get a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrgMembership" + } + } + } + } + }, + "tags": [ + "Orgs / Memberships" + ] + }, + "delete": { + "operationId": "OrganizationsMembershipsController_deleteMembership", + "summary": "Delete a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteOrgMembership" + } + } + } + } + }, + "tags": [ + "Orgs / Memberships" + ] + }, + "patch": { + "operationId": "OrganizationsMembershipsController_updateMembership", + "summary": "Update a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgMembershipDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgMembership" + } + } + } + } + }, + "tags": [ + "Orgs / Memberships" + ] + } + }, + "/v2/organizations/{orgId}/schedules": { + "get": { + "operationId": "OrganizationsSchedulesController_getOrganizationSchedules", + "summary": "Get all schedules", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Schedules" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}/schedules": { + "post": { + "operationId": "OrganizationsSchedulesController_createUserSchedule", + "summary": "Create a schedule", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Schedules", + "Orgs / Users / Schedules" + ] + }, + "get": { + "operationId": "OrganizationsSchedulesController_getUserSchedules", + "summary": "Get all schedules", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Schedules", + "Orgs / Users / Schedules" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}/schedules/{scheduleId}": { + "get": { + "operationId": "OrganizationsSchedulesController_getUserSchedule", + "summary": "Get a schedule", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Schedules", + "Orgs / Users / Schedules" + ] + }, + "patch": { + "operationId": "OrganizationsSchedulesController_updateUserSchedule", + "summary": "Update a schedule", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Schedules", + "Orgs / Users / Schedules" + ] + }, + "delete": { + "operationId": "OrganizationsSchedulesController_deleteUserSchedule", + "summary": "Delete a schedule", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Schedules", + "Orgs / Users / Schedules" + ] + } + }, + "/v2/organizations/{orgId}/teams": { + "get": { + "operationId": "OrganizationsTeamsController_getAllTeams", + "summary": "Get all teams", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams", + "Teams" + ] + }, + "post": { + "operationId": "OrganizationsTeamsController_createTeam", + "summary": "Create a team", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "x-cal-client-id", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgTeamDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams" + ] + } + }, + "/v2/organizations/{orgId}/teams/me": { + "get": { + "operationId": "OrganizationsTeamsController_getMyTeams", + "summary": "Get teams membership for user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgMeTeamsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}": { + "get": { + "operationId": "OrganizationsTeamsController_getTeam", + "summary": "Get a team", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams" + ] + }, + "delete": { + "operationId": "OrganizationsTeamsController_deleteTeam", + "summary": "Delete a team", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams" + ] + }, + "patch": { + "operationId": "OrganizationsTeamsController_updateTeam", + "summary": "Update a team", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgTeamDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/bookings": { + "get": { + "operationId": "OrganizationsTeamsBookingsController_getAllOrgTeamBookings", + "summary": "Get organization team bookings", + "parameters": [ + { + "name": "status", + "required": false, + "in": "query", + "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + "example": "?status=upcoming,past", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "upcoming", + "recurring", + "past", + "cancelled", + "unconfirmed" + ] + } + } + }, + { + "name": "attendeeEmail", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's email address.", + "example": "example@domain.com", + "schema": { + "type": "string" + } + }, + { + "name": "attendeeName", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's name.", + "example": "John Doe", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeIds", + "required": false, + "in": "query", + "description": "Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.", + "example": "?eventTypeIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": false, + "in": "query", + "description": "Filter bookings by event type id belonging to the team.", + "example": "?eventTypeId=100", + "schema": { + "type": "string" + } + }, + { + "name": "afterStart", + "required": false, + "in": "query", + "description": "Filter bookings with start after this date string.", + "example": "?afterStart=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeEnd", + "required": false, + "in": "query", + "description": "Filter bookings with end before this date string.", + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortCreated", + "required": false, + "in": "query", + "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", + "example": "?sortCreated=asc OR ?sortCreated=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "minimum": 1, + "maximum": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Bookings" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/memberships": { + "get": { + "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships", + "summary": "Get all memberships", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Memberships" + ] + }, + "post": { + "operationId": "OrganizationsTeamsMembershipsController_createOrgTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgTeamMembershipDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Memberships" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/memberships/{membershipId}": { + "get": { + "operationId": "OrganizationsTeamsMembershipsController_getOrgTeamMembership", + "summary": "Get a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Memberships" + ] + }, + "delete": { + "operationId": "OrganizationsTeamsMembershipsController_deleteOrgTeamMembership", + "summary": "Delete a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Memberships" + ] + }, + "patch": { + "operationId": "OrganizationsTeamsMembershipsController_updateOrgTeamMembership", + "summary": "Update a membership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgTeamMembershipDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Memberships" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/users/{userId}/schedules": { + "get": { + "operationId": "OrganizationsTeamsSchedulesController_getUserSchedules", + "summary": "Get schedules of a team member", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Schedules", + "Orgs / Teams / Users / Schedules" + ] + } + }, + "/v2/organizations/{orgId}/users": { + "get": { + "operationId": "OrganizationsUsersController_getOrganizationsUsers", + "summary": "Get all users", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "minimum": 1, + "maximum": 1000, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "type": "number" + } + }, + { + "name": "emails", + "required": false, + "in": "query", + "description": "The email address or an array of email addresses to filter by", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUsersResponseDTO" + } + } + } + } + }, + "tags": [ + "Orgs / Users" + ] + }, + "post": { + "operationId": "OrganizationsUsersController_createOrganizationUser", + "summary": "Create a user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUserOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Users" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}": { + "patch": { + "operationId": "OrganizationsUsersController_updateOrganizationUser", + "summary": "Update a user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUserOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Users" + ] + }, + "delete": { + "operationId": "OrganizationsUsersController_deleteOrganizationUser", + "summary": "Delete a user", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUserOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Users" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}/ooo": { + "get": { + "operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO", + "summary": "Get all ooo entries of a user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + }, + "post": { + "operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO", + "summary": "Create an ooo entry for user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOutOfOfficeEntryDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": { + "patch": { + "operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO", + "summary": "Update ooo entry of a user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "oooId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOutOfOfficeEntryDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + }, + "delete": { + "operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO", + "summary": "Delete ooo entry of a user", + "parameters": [ + { + "name": "oooId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + } + }, + "/v2/organizations/{orgId}/ooo": { + "get": { + "operationId": "OrganizationsUsersOOOController_getOrganizationUsersOOO", + "summary": "Get all OOO entries of org users", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "email", + "required": false, + "in": "query", + "description": "Filter ooo entries by the user email address. user must be within your organization.", + "example": "example@domain.com", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + } + }, + "/v2/organizations/{orgId}/webhooks": { + "get": { + "operationId": "OrganizationsWebhooksController_getAllOrganizationWebhooks", + "summary": "Get all webhooks", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Webhooks" + ] + }, + "post": { + "operationId": "OrganizationsWebhooksController_createOrganizationWebhook", + "summary": "Create a webhook", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Webhooks" + ] + } + }, + "/v2/organizations/{orgId}/webhooks/{webhookId}": { + "get": { + "operationId": "OrganizationsWebhooksController_getOrganizationWebhook", + "summary": "Get a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Webhooks" + ] + }, + "delete": { + "operationId": "OrganizationsWebhooksController_deleteWebhook", + "summary": "Delete a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Webhooks" + ] + }, + "patch": { + "operationId": "OrganizationsWebhooksController_updateOrgWebhook", + "summary": "Update a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookInputDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Orgs / Webhooks" + ] + } + }, + "/v2/bookings": { + "post": { + "operationId": "BookingsController_2024_08_13_createBooking", + "summary": "Create a booking", + "description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n ", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "description": "Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateBookingInput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateInstantBookingInput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateRecurringBookingInput_2024_08_13" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + }, + "get": { + "operationId": "BookingsController_2024_08_13_getBookings", + "summary": "Get all bookings", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + "example": "?status=upcoming,past", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "upcoming", + "recurring", + "past", + "cancelled", + "unconfirmed" + ] + } + } + }, + { + "name": "attendeeEmail", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's email address.", + "example": "example@domain.com", + "schema": { + "type": "string" + } + }, + { + "name": "attendeeName", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's name.", + "example": "John Doe", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeIds", + "required": false, + "in": "query", + "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", + "example": "?eventTypeIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": false, + "in": "query", + "description": "Filter bookings by event type id belonging to the user.", + "example": "?eventTypeId=100", + "schema": { + "type": "string" + } + }, + { + "name": "teamsIds", + "required": false, + "in": "query", + "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", + "example": "?teamIds=50,60", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": false, + "in": "query", + "description": "Filter bookings by team id that user is part of", + "example": "?teamId=50", + "schema": { + "type": "string" + } + }, + { + "name": "afterStart", + "required": false, + "in": "query", + "description": "Filter bookings with start after this date string.", + "example": "?afterStart=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeEnd", + "required": false, + "in": "query", + "description": "Filter bookings with end before this date string.", + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortCreated", + "required": false, + "in": "query", + "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", + "example": "?sortCreated=asc OR ?sortCreated=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}": { + "get": { + "operationId": "BookingsController_2024_08_13_getBooking", + "summary": "Get a booking", + "description": "`:bookingUid` can be\n\n 1. uid of a normal booking\n\n 2. uid of one of the recurring booking recurrences\n\n 3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/reschedule": { + "post": { + "operationId": "BookingsController_2024_08_13_rescheduleBooking", + "summary": "Reschedule a booking", + "description": "Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RescheduleBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/cancel": { + "post": { + "operationId": "BookingsController_2024_08_13_cancelBooking", + "summary": "Cancel a booking", + "description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/mark-absent": { + "post": { + "operationId": "BookingsController_2024_08_13_markNoShow", + "summary": "Mark a booking absence", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkAbsentBookingInput_2024_08_13" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkAbsentBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/reassign": { + "post": { + "operationId": "BookingsController_2024_08_13_reassignBooking", + "summary": "Automatically reassign booking to a new host", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/reassign/{userId}": { + "post": { + "operationId": "BookingsController_2024_08_13_reassignBookingToUser", + "summary": "Reassign a booking to a specific user", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReassignToUserBookingInput_2024_08_13" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/confirm": { + "post": { + "operationId": "BookingsController_2024_08_13_confirmBooking", + "summary": "Confirm booking that requires a confirmation", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/bookings/{bookingUid}/decline": { + "post": { + "operationId": "BookingsController_2024_08_13_declineBooking", + "summary": "Decline booking that requires a confirmation", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeclineBookingInput_2024_08_13" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/v2/calendars/ics-feed/save": { + "post": { + "operationId": "CalendarsController_createIcsFeed", + "summary": "Save an ICS feed", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIcsFeedInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIcsFeedOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/ics-feed/check": { + "get": { + "operationId": "CalendarsController_checkIcsFeed", + "summary": "Check an ICS feed", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/busy-times": { + "get": { + "operationId": "CalendarsController_getBusyTimes", + "summary": "Get busy times", + "description": "Get busy times from a calendar. Example request URL is `https://api.cal.com/v2/calendars/busy-times?loggedInUsersTz=Europe%2FMadrid&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=skrauciz%40gmail.com`", + "parameters": [ + { + "name": "loggedInUsersTz", + "required": true, + "in": "query", + "description": "The timezone of the logged in user represented as a string", + "example": "America/New_York", + "schema": { + "type": "string" + } + }, + { + "name": "dateFrom", + "required": false, + "in": "query", + "description": "The starting date for the busy times query", + "example": "2023-10-01", + "schema": { + "type": "string" + } + }, + { + "name": "dateTo", + "required": false, + "in": "query", + "description": "The ending date for the busy times query", + "example": "2023-10-31", + "schema": { + "type": "string" + } + }, + { + "name": "credentialId", + "in": "query", + "required": true, + "schema": { + "type": "number" + } + }, + { + "name": "externalId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBusyTimesOutput" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars": { + "get": { + "operationId": "CalendarsController_getCalendars", + "summary": "Get all calendars", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectedCalendarsOutput" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/connect": { + "get": { + "operationId": "CalendarsController_redirect", + "summary": "Get connect URL", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/save": { + "get": { + "operationId": "CalendarsController_save", + "summary": "Save a calendar", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/credentials": { + "post": { + "operationId": "CalendarsController_syncCredentials", + "summary": "Sync credentials", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/check": { + "get": { + "operationId": "CalendarsController_check", + "summary": "Check a calendar connection", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/disconnect": { + "post": { + "operationId": "CalendarsController_deleteCalendarCredentials", + "summary": "Disconnect a calendar", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteCalendarCredentialsInputBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedCalendarCredentialsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/conferencing/{app}/connect": { + "post": { + "operationId": "ConferencingController_connect", + "summary": "Connect your conferencing application", + "parameters": [ + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConferencingAppOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing/{app}/oauth/auth-url": { + "get": { + "operationId": "ConferencingController_redirect", + "summary": "Get OAuth conferencing app auth url", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "returnTo", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "onErrorReturnTo", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetConferencingAppsOauthUrlResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing/{app}/oauth/callback": { + "get": { + "operationId": "ConferencingController_save", + "summary": "conferencing apps oauths callback", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing": { + "get": { + "operationId": "ConferencingController_listInstalledConferencingApps", + "summary": "List your conferencing applications", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConferencingAppsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing/{app}/default": { + "post": { + "operationId": "ConferencingController_default", + "summary": "Set your default conferencing application", + "parameters": [ + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDefaultConferencingAppOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing/default": { + "get": { + "operationId": "ConferencingController_getDefault", + "summary": "Get your default conferencing application", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetDefaultConferencingAppOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing/{app}/disconnect": { + "delete": { + "operationId": "ConferencingController_disconnect", + "summary": "Disconnect your conferencing application", + "parameters": [ + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisconnectConferencingAppOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/destination-calendars": { + "put": { + "operationId": "DestinationCalendarsController_updateDestinationCalendars", + "summary": "Update destination calendars", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DestinationCalendarsInputBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DestinationCalendarsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Destination Calendars" + ] + } + }, + "/v2/event-types": { + "post": { + "operationId": "EventTypesController_2024_06_14_createEventType", + "summary": "Create an event type", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-14`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventTypeInput_2024_06_14" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventTypeOutput_2024_06_14" + } + } + } + } + }, + "tags": [ + "Event Types" + ] + }, + "get": { + "operationId": "EventTypesController_2024_06_14_getEventTypes", + "summary": "Get all event types", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-14`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "required": false, + "in": "query", + "description": "The username of the user to get event types for. If only username provided will get all event types.", + "schema": { + "type": "string" + } + }, + { + "name": "eventSlug", + "required": false, + "in": "query", + "description": "Slug of event type to return. Notably, if eventSlug is provided then username must be provided too, because multiple users can have event with same slug.", + "schema": { + "type": "string" + } + }, + { + "name": "usernames", + "required": false, + "in": "query", + "description": "Get dynamic event type for multiple usernames separated by comma. e.g `usernames=alice,bob`", + "schema": { + "type": "string" + } + }, + { + "name": "orgSlug", + "required": false, + "in": "query", + "description": "slug of the user's organization if he is in one, orgId is not required if using this parameter", + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": false, + "in": "query", + "description": "ID of the organization of the user you want the get the event-types of, orgSlug is not needed when using this parameter", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypesOutput_2024_06_14" + } + } + } + } + }, + "tags": [ + "Event Types" + ] + } + }, + "/v2/event-types/{eventTypeId}": { + "get": { + "operationId": "EventTypesController_2024_06_14_getEventTypeById", + "summary": "Get an event type", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-14`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypeOutput_2024_06_14" + } + } + } + } + }, + "tags": [ + "Event Types" + ] + }, + "patch": { + "operationId": "EventTypesController_2024_06_14_updateEventType", + "summary": "Update an event type", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-14`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeInput_2024_06_14" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeOutput_2024_06_14" + } + } + } + } + }, + "tags": [ + "Event Types" + ] + }, + "delete": { + "operationId": "EventTypesController_2024_06_14_deleteEventType", + "summary": "Delete an event type", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-14`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEventTypeOutput_2024_06_14" + } + } + } + } + }, + "tags": [ + "Event Types" + ] + } + }, + "/v2/event-types/{eventTypeId}/webhooks": { + "post": { + "operationId": "EventTypeWebhooksController_createEventTypeWebhook", + "summary": "Create a webhook", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Event Types / Webhooks" + ] + }, + "get": { + "operationId": "EventTypeWebhooksController_getEventTypeWebhooks", + "summary": "Get all webhooks", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Event Types / Webhooks" + ] + }, + "delete": { + "operationId": "EventTypeWebhooksController_deleteAllEventTypeWebhooks", + "summary": "Delete all webhooks", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteManyWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Event Types / Webhooks" + ] + } + }, + "/v2/event-types/{eventTypeId}/webhooks/{webhookId}": { + "patch": { + "operationId": "EventTypeWebhooksController_updateEventTypeWebhook", + "summary": "Update a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookInputDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Event Types / Webhooks" + ] + }, + "get": { + "operationId": "EventTypeWebhooksController_getEventTypeWebhook", + "summary": "Get a webhook", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Event Types / Webhooks" + ] + }, + "delete": { + "operationId": "EventTypeWebhooksController_deleteEventTypeWebhook", + "summary": "Delete a webhook", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Event Types / Webhooks" + ] + } + }, + "/v2/me": { + "get": { + "operationId": "MeController_getMe", + "summary": "Get my profile", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMeOutput" + } + } + } + } + }, + "tags": [ + "Me" + ] + }, + "patch": { + "operationId": "MeController_updateMe", + "summary": "Update my profile", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateManagedUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMeOutput" + } + } + } + } + }, + "tags": [ + "Me" + ] + } + }, + "/v2/schedules": { + "post": { + "operationId": "SchedulesController_2024_06_11_createSchedule", + "summary": "Create a schedule", + "description": "\n Create a schedule for the authenticated user.\n\n The point of creating schedules is for event types to be available at specific times.\n\n The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.\n 1. If you passed `timeZone` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the `AvailabilitySettings` atom.\n 2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying\n `\"isDefault\": true` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.\n\n The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.\n After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH `event-types/{eventTypeId}` endpoint.\n\n When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.\n ", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-11`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + }, + "get": { + "operationId": "SchedulesController_2024_06_11_getSchedules", + "summary": "Get all schedules", + "description": "Get all schedules of the authenticated user.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-11`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + } + }, + "/v2/schedules/default": { + "get": { + "operationId": "SchedulesController_2024_06_11_getDefaultSchedule", + "summary": "Get default schedule", + "description": "Get the default schedule of the authenticated user.", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-11`", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetDefaultScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + } + }, + "/v2/schedules/{scheduleId}": { + "get": { + "operationId": "SchedulesController_2024_06_11_getSchedule", + "summary": "Get a schedule", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-11`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + }, + "patch": { + "operationId": "SchedulesController_2024_06_11_updateSchedule", + "summary": "Update a schedule", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-11`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + }, + "delete": { + "operationId": "SchedulesController_2024_06_11_deleteSchedule", + "summary": "Delete a schedule", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-06-11`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteScheduleOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + } + }, + "/v2/selected-calendars": { + "post": { + "operationId": "SelectedCalendarsController_addSelectedCalendar", + "summary": "Add a selected calendar", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectedCalendarsInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectedCalendarOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Selected Calendars" + ] + }, + "delete": { + "operationId": "SelectedCalendarsController_removeSelectedCalendar", + "summary": "Delete a selected calendar", + "parameters": [ + { + "name": "integration", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "externalId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "credentialId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectedCalendarOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Selected Calendars" + ] + } + }, + "/v2/slots/reserve": { + "post": { + "operationId": "SlotsController_reserveSlot", + "summary": "Reserve a slot", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReserveSlotInput" + } + } + } + }, + "responses": { + "201": { + "description": "Successful response returning uid of reserved slot.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "data": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "example": "e2a7bcf9-cc7b-40a0-80d3-657d391775a6" + } + } + } + } + } + } + } + } + }, + "tags": [ + "Slots" + ] + } + }, + "/v2/slots/selected-slot": { + "delete": { + "operationId": "SlotsController_deleteSelectedSlot", + "summary": "Delete a selected slot", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "query", + "description": "Unique identifier for the slot to be removed.", + "example": "e2a7bcf9-cc7b-40a0-80d3-657d391775a6", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response deleting reserved slot by uid.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success" + } + } + } + } + } + } + }, + "tags": [ + "Slots" + ] + } + }, + "/v2/slots/available": { + "get": { + "operationId": "SlotsController_getAvailableSlots", + "summary": "Get available slots", + "parameters": [ + { + "name": "startTime", + "required": true, + "in": "query", + "description": "Start date string starting from which to fetch slots in UTC timezone.", + "example": "2022-06-14T00:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "endTime", + "required": true, + "in": "query", + "description": "End date string until which to fetch slots in UTC timezone.", + "example": "2022-06-14T23:59:59.999Z", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": false, + "in": "query", + "description": "Event Type ID for which slots are being fetched.", + "example": 100, + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeSlug", + "required": false, + "in": "query", + "description": "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`", + "schema": { + "type": "string" + } + }, + { + "name": "usernameList", + "required": false, + "in": "query", + "description": "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.", + "example": "usernameList[]=bob", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "debug", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "duration", + "required": false, + "in": "query", + "description": "Only for dynamic events - length of returned slots.", + "schema": { + "type": "number" + } + }, + { + "name": "rescheduleUid", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "timeZone", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "orgSlug", + "required": false, + "in": "query", + "description": "Organization slug.", + "schema": { + "type": "string" + } + }, + { + "name": "slotFormat", + "required": false, + "in": "query", + "description": "Format of slot times in response. Use 'range' to get start and end times.", + "example": "range", + "schema": { + "enum": [ + "range", + "time" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Available time slots retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success" + }, + "data": { + "type": "object", + "properties": { + "slots": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "properties": { + "time": { + "type": "string", + "format": "date-time", + "example": "2024-09-25T08:00:00.000Z" + } + } + }, + { + "properties": { + "startTime": { + "type": "string", + "format": "date-time", + "example": "2024-09-25T08:00:00.000Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "example": "2024-09-25T08:30:00.000Z" + } + } + } + ] + } + } + } + } + } + }, + "example": { + "status": "success", + "data": { + "slots": { + "2024-09-25": [ + { + "time": "2024-09-25T08:00:00.000Z" + }, + { + "time": "2024-09-25T08:15:00.000Z" + } + ], + "2024-09-26": [ + { + "startTime": "2024-09-26T08:00:00.000Z", + "endTime": "2024-09-26T08:30:00.000Z" + }, + { + "startTime": "2024-09-26T08:15:00.000Z", + "endTime": "2024-09-26T08:45:00.000Z" + } + ] + } + } + } + } + } + } + } + }, + "tags": [ + "Slots" + ] + } + }, + "/v2/stripe/connect": { + "get": { + "operationId": "StripeController_redirect", + "summary": "Get stripe connect URL", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripConnectOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Stripe" + ] + } + }, + "/v2/stripe/save": { + "get": { + "operationId": "StripeController_save", + "summary": "Save stripe credentials", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Stripe" + ] + } + }, + "/v2/stripe/check": { + "get": { + "operationId": "StripeController_check", + "summary": "Check stripe connection", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Stripe" + ] + } + }, + "/v2/stripe/check/{teamId}": { + "get": { + "operationId": "StripeController_checkTeamStripeConnection", + "summary": "Check team stripe connection", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Stripe" + ] + } + }, + "/v2/teams": { + "post": { + "operationId": "TeamsController_createTeam", + "summary": "Create a team", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + }, + "get": { + "operationId": "TeamsController_getTeams", + "summary": "Get teams", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamsOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + } + }, + "/v2/teams/{teamId}": { + "get": { + "operationId": "TeamsController_getTeam", + "summary": "Get a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + }, + "patch": { + "operationId": "TeamsController_updateTeam", + "summary": "Update a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgTeamDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + }, + "delete": { + "operationId": "TeamsController_deleteTeam", + "summary": "Delete a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Teams" + ] + } + }, + "/v2/teams/{teamId}/event-types": { + "post": { + "operationId": "TeamsEventTypesController_createTeamEventType", + "summary": "Create an event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamEventTypeInput_2024_06_14" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Teams / Event Types" + ] + }, + "get": { + "operationId": "TeamsEventTypesController_getTeamEventTypes", + "summary": "Get a team event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventSlug", + "required": false, + "in": "query", + "description": "Slug of team event type to return.", + "schema": { + "type": "string" + } + }, + { + "name": "hostsLimit", + "required": false, + "in": "query", + "description": "Specifies the maximum number of hosts to include in the response. This limit helps optimize performance. If not provided, all Hosts will be fetched.", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamEventTypesOutput" + } + } + } + } + }, + "tags": [ + "Teams / Event Types" + ] + } + }, + "/v2/teams/{teamId}/event-types/{eventTypeId}": { + "get": { + "operationId": "TeamsEventTypesController_getTeamEventType", + "summary": "Get an event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Teams / Event Types" + ] + }, + "patch": { + "operationId": "TeamsEventTypesController_updateTeamEventType", + "summary": "Update a team event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamEventTypeInput_2024_06_14" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Teams / Event Types" + ] + }, + "delete": { + "operationId": "TeamsEventTypesController_deleteTeamEventType", + "summary": "Delete a team event type", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTeamEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Teams / Event Types" + ] + } + }, + "/v2/teams/{teamId}/event-types/{eventTypeId}/create-phone-call": { + "post": { + "operationId": "TeamsEventTypesController_createPhoneCall", + "summary": "Create a phone call", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePhoneCallInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePhoneCallOutput" + } + } + } + } + }, + "tags": [ + "Teams / Event Types" + ] + } + }, + "/v2/teams/{teamId}/memberships": { + "post": { + "operationId": "TeamsMembershipsController_createTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamMembershipInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + }, + "get": { + "operationId": "TeamsMembershipsController_getTeamMemberships", + "summary": "Get all memberships", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamMembershipsOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + } + }, + "/v2/teams/{teamId}/memberships/{membershipId}": { + "get": { + "operationId": "TeamsMembershipsController_getTeamMembership", + "summary": "Get a membership", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + }, + "patch": { + "operationId": "TeamsMembershipsController_updateTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamMembershipInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + }, + "delete": { + "operationId": "TeamsMembershipsController_deleteTeamMembership", + "summary": "Delete a membership", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + } + }, + "/v2/timezones": { + "get": { + "operationId": "TimezonesController_getTimeZones", + "summary": "Get all timezones", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Timezones" + ] + } + }, + "/v2/webhooks": { + "post": { + "operationId": "WebhooksController_createWebhook", + "summary": "Create a webhook", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Webhooks" + ] + }, + "get": { + "operationId": "WebhooksController_getWebhooks", + "summary": "Get all webooks", + "description": "Gets a paginated list of webhooks for the authenticated user.", + "parameters": [ + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Webhooks" + ] + } + }, + "/v2/webhooks/{webhookId}": { + "patch": { + "operationId": "WebhooksController_updateWebhook", + "summary": "Update a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookInputDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Webhooks" + ] + }, + "get": { + "operationId": "WebhooksController_getWebhook", + "summary": "Get a webhook", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Webhooks" + ] + }, + "delete": { + "operationId": "WebhooksController_deleteWebhook", + "summary": "Delete a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Webhooks" + ] + } + } + }, + "info": { + "title": "Cal.com API v2", + "description": "", + "version": "1.0.0", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "schemas": { + "ManagedUserOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "email": { + "type": "string", + "example": "alice+cluo37fwd0001khkzqqynkpj3@example.com" + }, + "username": { + "type": "string", + "nullable": true, + "example": "alice" + }, + "name": { + "type": "string", + "nullable": true, + "example": "alice" + }, + "timeZone": { + "type": "string", + "example": "America/New_York" + }, + "weekStart": { + "type": "string", + "example": "Sunday" + }, + "createdDate": { + "type": "string", + "example": "2024-04-01T00:00:00.000Z" + }, + "timeFormat": { + "type": "number", + "nullable": true, + "example": 12 + }, + "defaultScheduleId": { + "type": "number", + "nullable": true, + "example": null + }, + "locale": { + "enum": [ + "ar", + "ca", + "de", + "es", + "eu", + "he", + "id", + "ja", + "lv", + "pl", + "ro", + "sr", + "th", + "vi", + "az", + "cs", + "el", + "es-419", + "fi", + "hr", + "it", + "km", + "nl", + "pt", + "ru", + "sv", + "tr", + "zh-CN", + "bg", + "da", + "en", + "et", + "fr", + "hu", + "iw", + "ko", + "no", + "pt-BR", + "sk", + "ta", + "uk", + "zh-TW" + ], + "type": "string", + "example": "en" + }, + "avatarUrl": { + "type": "string", + "nullable": true, + "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + "description": "URL of the user's avatar image" + } + }, + "required": [ + "id", + "email", + "username", + "name", + "timeZone", + "weekStart", + "createdDate", + "timeFormat", + "defaultScheduleId" + ] + }, + "GetManagedUsersOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedUserOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateManagedUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "alice@example.com" + }, + "name": { + "type": "string", + "example": "Alice Smith", + "description": "Managed user's name is used in emails" + }, + "timeFormat": { + "type": "number", + "enum": [ + 12, + 24 + ], + "example": 12, + "description": "Must be a number 12 or 24" + }, + "weekStart": { + "type": "string", + "example": "Monday", + "enum": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ] + }, + "timeZone": { + "type": "string", + "example": "America/New_York", + "description": "Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have\n a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.\n It will default to Europe/London if not passed." + }, + "locale": { + "enum": [ + "ar", + "ca", + "de", + "es", + "eu", + "he", + "id", + "ja", + "lv", + "pl", + "ro", + "sr", + "th", + "vi", + "az", + "cs", + "el", + "es-419", + "fi", + "hr", + "it", + "km", + "nl", + "pt", + "ru", + "sv", + "tr", + "zh-CN", + "bg", + "da", + "en", + "et", + "fr", + "hu", + "iw", + "ko", + "no", + "pt-BR", + "sk", + "ta", + "uk", + "zh-TW" + ], + "type": "string", + "example": "en" + }, + "avatarUrl": { + "type": "string", + "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + "description": "URL of the user's avatar image" + } + }, + "required": [ + "email", + "name" + ] + }, + "CreateManagedUserData": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/ManagedUserOutput" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "accessTokenExpiresAt": { + "type": "number" + } + }, + "required": [ + "user", + "accessToken", + "refreshToken", + "accessTokenExpiresAt" + ] + }, + "CreateManagedUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/CreateManagedUserData" + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetManagedUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ManagedUserOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateManagedUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "timeFormat": { + "type": "number", + "enum": [ + 12, + 24 + ], + "example": 12, + "description": "Must be 12 or 24" + }, + "defaultScheduleId": { + "type": "number" + }, + "weekStart": { + "type": "string", + "enum": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "example": "Monday" + }, + "timeZone": { + "type": "string" + }, + "locale": { + "enum": [ + "ar", + "ca", + "de", + "es", + "eu", + "he", + "id", + "ja", + "lv", + "pl", + "ro", + "sr", + "th", + "vi", + "az", + "cs", + "el", + "es-419", + "fi", + "hr", + "it", + "km", + "nl", + "pt", + "ru", + "sv", + "tr", + "zh-CN", + "bg", + "da", + "en", + "et", + "fr", + "hu", + "iw", + "ko", + "no", + "pt-BR", + "sk", + "ta", + "uk", + "zh-TW" + ], + "type": "string", + "example": "en" + }, + "avatarUrl": { + "type": "string", + "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + "description": "URL of the user's avatar image" + } + } + }, + "KeysDto": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "refreshToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "accessTokenExpiresAt": { + "type": "number" + } + }, + "required": [ + "accessToken", + "refreshToken", + "accessTokenExpiresAt" + ] + }, + "KeysResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/KeysDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateOAuthClientInput": { + "type": "object", + "properties": { + "logo": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "type": "number" + }, + "bookingRedirectUri": { + "type": "string" + }, + "bookingCancelRedirectUri": { + "type": "string" + }, + "bookingRescheduleRedirectUri": { + "type": "string" + }, + "areEmailsEnabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "redirectUris", + "permissions" + ] + }, + "DataDto": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "example": "clsx38nbl0001vkhlwin9fmt0" + }, + "clientSecret": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi" + } + }, + "required": [ + "clientId", + "clientSecret" + ] + }, + "CreateOAuthClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "example": { + "clientId": "clsx38nbl0001vkhlwin9fmt0", + "clientSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi" + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataDto" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "PlatformOAuthClientDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clsx38nbl0001vkhlwin9fmt0" + }, + "name": { + "type": "string", + "example": "MyClient" + }, + "secret": { + "type": "string", + "example": "secretValue" + }, + "permissions": { + "type": "number", + "example": 3 + }, + "logo": { + "type": "string", + "nullable": true, + "example": "https://example.com/logo.png" + }, + "redirectUris": { + "example": [ + "https://example.com/callback" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "organizationId": { + "type": "number", + "example": 1 + }, + "createdAt": { + "format": "date-time", + "type": "string", + "example": "2024-03-23T08:33:21.851Z" + } + }, + "required": [ + "id", + "name", + "secret", + "permissions", + "redirectUris", + "organizationId", + "createdAt" + ] + }, + "GetOAuthClientsResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformOAuthClientDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "GetOAuthClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/PlatformOAuthClientDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOAuthClientInput": { + "type": "object", + "properties": { + "logo": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirectUris": { + "type": "array", + "items": { + "type": "string" + } + }, + "bookingRedirectUri": { + "type": "string" + }, + "bookingCancelRedirectUri": { + "type": "string" + }, + "bookingRescheduleRedirectUri": { + "type": "string" + }, + "areEmailsEnabled": { + "type": "boolean" + } + } + }, + "RefreshTokenInput": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string", + "description": "Managed user's refresh token." + } + }, + "required": [ + "refreshToken" + ] + }, + "InputAddressLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "address", + "description": "only allowed value for type is `address`" + }, + "address": { + "type": "string", + "example": "123 Example St, City, Country" + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "type", + "address", + "public" + ] + }, + "InputLinkLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "link", + "description": "only allowed value for type is `link`" + }, + "link": { + "type": "string", + "example": "https://customvideo.com/join/123456" + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "type", + "link", + "public" + ] + }, + "InputIntegrationLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integration", + "description": "only allowed value for type is `integration`" + }, + "integration": { + "type": "string", + "example": "cal-video", + "enum": [ + "cal-video", + "google-meet" + ] + } + }, + "required": [ + "type", + "integration" + ] + }, + "InputPhoneLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "phone", + "description": "only allowed value for type is `phone`" + }, + "phone": { + "type": "string", + "example": "+37120993151" + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "type", + "phone", + "public" + ] + }, + "PhoneFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "phone", + "description": "only allowed value for type is `phone`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `phone` and the URL contains query parameter `&phone=1234567890`, the phone field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "hidden" + ] + }, + "AddressFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "address", + "description": "only allowed value for type is `address`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter your address" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., 1234 Main St" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `address` and the URL contains query parameter `&address=1234 Main St, London`, the address field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "hidden" + ] + }, + "TextFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "text", + "description": "only allowed value for type is `text`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter your text" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., Enter text here" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `who-referred-you` and the URL contains query parameter `&who-referred-you=bob`, the text field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "hidden" + ] + }, + "NumberFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number", + "description": "only allowed value for type is `number`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter a number" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., 100" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `calories-per-day` and the URL contains query parameter `&calories-per-day=3000`, the number field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "hidden" + ] + }, + "TextAreaFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "textarea", + "description": "only allowed value for type is `textarea`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter detailed information" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., Detailed description here..." + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `dear-diary` and the URL contains query parameter `&dear-diary=Today I shipped a feature`, the text area will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "hidden" + ] + }, + "SelectFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "select", + "description": "only allowed value for type is `select`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please select an option" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "Select..." + }, + "options": { + "example": [ + "Option 1", + "Option 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' will be selected and the select field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "options", + "hidden" + ] + }, + "MultiSelectFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "multiselect", + "description": "only allowed value for type is `multiselect`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please select multiple options" + }, + "required": { + "type": "boolean" + }, + "options": { + "example": [ + "Option 1", + "Option 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `consultants` and the URL contains query parameter `&consultants=en&language=it`, the 'en' and 'it' will be selected and the select field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden" + ] + }, + "MultiEmailFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "multiemail", + "description": "only allowed value for type is `multiemail`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter multiple emails" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., example@example.com" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `consultants` and the URL contains query parameter `&consultants=alice@gmail.com&consultants=bob@gmail.com`, the these emails will be added and none more can be added." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "placeholder", + "hidden" + ] + }, + "CheckboxGroupFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "checkbox", + "description": "only allowed value for type is `checkbox`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Select all that apply" + }, + "required": { + "type": "boolean" + }, + "options": { + "example": [ + "Checkbox 1", + "Checkbox 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `notify-me` and the URL contains query parameter `¬ify-me=true`, the checkbox will be selected and the checkbox field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden" + ] + }, + "RadioGroupFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "radio", + "description": "only allowed value for type is `radio`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Select one option" + }, + "required": { + "type": "boolean" + }, + "options": { + "example": [ + "Radio 1", + "Radio 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio buttom will be selected and the select field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden" + ] + }, + "BooleanFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean", + "description": "only allowed value for type is `boolean`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Agree to terms?" + }, + "required": { + "type": "boolean" + }, + "disableOnPrefill": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden" + ] + }, + "BusinessDaysWindow_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "businessDays", + "calendarDays", + "range" + ], + "description": "Whether the window should be business days, calendar days or a range of dates" + }, + "value": { + "type": "number", + "example": 5, + "description": "How many business day into the future can this event be booked" + }, + "rolling": { + "type": "boolean", + "example": true, + "description": "\n Determines the behavior of the booking window:\n - If **true**, the window is rolling. This means the number of available days will always be equal the specified 'value' \n and adjust dynamically as bookings are made. For example, if 'value' is 3 and availability is only on Mondays, \n a booker attempting to schedule on November 10 will see slots on November 11, 18, and 25. As one of these days \n becomes fully booked, a new day (e.g., December 2) will open up to ensure 3 available days are always visible.\n - If **false**, the window is fixed. This means the booking window only considers the next 'value' days from the\n moment someone is trying to book. For example, if 'value' is 3, availability is only on Mondays, and the current \n date is November 10, the booker will only see slots on November 11 because the window is restricted to the next \n 3 calendar days (November 10–12).\n " + } + }, + "required": [ + "type", + "value" + ] + }, + "CalendarDaysWindow_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "businessDays", + "calendarDays", + "range" + ], + "description": "Whether the window should be business days, calendar days or a range of dates" + }, + "value": { + "type": "number", + "example": 5, + "description": "How many calendar days into the future can this event be booked" + }, + "rolling": { + "type": "boolean", + "example": true, + "description": "\n Determines the behavior of the booking window:\n - If **true**, the window is rolling. This means the number of available days will always be equal the specified 'value' \n and adjust dynamically as bookings are made. For example, if 'value' is 3 and availability is only on Mondays, \n a booker attempting to schedule on November 10 will see slots on November 11, 18, and 25. As one of these days \n becomes fully booked, a new day (e.g., December 2) will open up to ensure 3 available days are always visible.\n - If **false**, the window is fixed. This means the booking window only considers the next 'value' days from the\n moment someone is trying to book. For example, if 'value' is 3, availability is only on Mondays, and the current \n date is November 10, the booker will only see slots on November 11 because the window is restricted to the next \n 3 calendar days (November 10–12).\n " + } + }, + "required": [ + "type", + "value" + ] + }, + "RangeWindow_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "businessDays", + "calendarDays", + "range" + ], + "description": "Whether the window should be business days, calendar days or a range of dates" + }, + "value": { + "example": [ + "2030-09-05", + "2030-09-09" + ], + "description": "Date range for when this event can be booked.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "value" + ] + }, + "BaseBookingLimitsCount_2024_06_14": { + "type": "object", + "properties": { + "day": { + "type": "number", + "description": "The number of bookings per day", + "example": 1 + }, + "week": { + "type": "number", + "description": "The number of bookings per week", + "example": 2 + }, + "month": { + "type": "number", + "description": "The number of bookings per month", + "example": 3 + }, + "year": { + "type": "number", + "description": "The number of bookings per year", + "example": 4 + }, + "disabled": { + "type": "boolean", + "default": false + } + } + }, + "Disabled_2024_06_14": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "description": "Indicates if the option is disabled", + "example": true, + "default": false + } + }, + "required": [ + "disabled" + ] + }, + "BaseBookingLimitsDuration_2024_06_14": { + "type": "object", + "properties": { + "day": { + "type": "number", + "description": "The duration of bookings per day (must be a multiple of 15)", + "example": 60 + }, + "week": { + "type": "number", + "description": "The duration of bookings per week (must be a multiple of 15)", + "example": 120 + }, + "month": { + "type": "number", + "description": "The duration of bookings per month (must be a multiple of 15)", + "example": 180 + }, + "year": { + "type": "number", + "description": "The duration of bookings per year (must be a multiple of 15)", + "example": 240 + } + } + }, + "Recurrence_2024_06_14": { + "type": "object", + "properties": { + "interval": { + "type": "number", + "example": 10, + "description": "Repeats every {count} week | month | year" + }, + "occurrences": { + "type": "number", + "example": 10, + "description": "Repeats for a maximum of {count} events" + }, + "frequency": { + "type": "string", + "enum": [ + "yearly", + "monthly", + "weekly" + ] + } + }, + "required": [ + "interval", + "occurrences", + "frequency" + ] + }, + "NoticeThreshold_2024_06_14": { + "type": "object", + "properties": { + "unit": { + "type": "string", + "description": "The unit of time for the notice threshold (e.g., minutes, hours)", + "example": "minutes" + }, + "count": { + "type": "number", + "description": "The time value for the notice threshold", + "example": 30 + } + }, + "required": [ + "unit", + "count" + ] + }, + "BaseConfirmationPolicy_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The policy that determines when confirmation is required", + "example": "always" + }, + "noticeThreshold": { + "description": "The notice threshold required before confirmation is needed. Required when type is 'time'.", + "allOf": [ + { + "$ref": "#/components/schemas/NoticeThreshold_2024_06_14" + } + ] + } + }, + "required": [ + "type" + ] + }, + "Seats_2024_06_14": { + "type": "object", + "properties": { + "seatsPerTimeSlot": { + "type": "number", + "description": "Number of seats available per time slot", + "example": 4 + }, + "showAttendeeInfo": { + "type": "boolean", + "description": "Show attendee information to other guests", + "example": true + }, + "showAvailabilityCount": { + "type": "boolean", + "description": "Display the count of available seats", + "example": true + } + }, + "required": [ + "seatsPerTimeSlot", + "showAttendeeInfo", + "showAvailabilityCount" + ] + }, + "InputAttendeeAddressLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "attendeeAddress", + "description": "only allowed value for type is `attendeeAddress`" + } + }, + "required": [ + "type" + ] + }, + "InputAttendeePhoneLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "attendeePhone", + "description": "only allowed value for type is `attendeePhone`" + } + }, + "required": [ + "type" + ] + }, + "InputAttendeeDefinedLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "attendeeDefined", + "description": "only allowed value for type is `attendeeDefined`" + } + }, + "required": [ + "type" + ] + }, + "NameDefaultFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "name", + "description": "only allowed value for type is `name`. Used for having 1 booking field for both first name and last name." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&name=bob`, the name field will be prefilled with this value and disabled." + } + }, + "required": [ + "type", + "label", + "placeholder" + ] + }, + "EmailDefaultFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "email", + "description": "only allowed value for type is `email`" + }, + "label": { + "type": "string" + }, + "required": { + "type": "object" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&email=bob@gmail.com`, the email field will be prefilled with this value and disabled." + } + }, + "required": [ + "type", + "label", + "required", + "placeholder" + ] + }, + "TitleDefaultFieldInput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "title", + "description": "only allowed value for type is `title`" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&title=journey`, the title field will be prefilled with this value and disabled." + } + }, + "required": [ + "slug" + ] + }, + "NotesDefaultFieldInput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "notes", + "description": "only allowed value for type is `notes`" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `¬es=journey`, the notes field will be prefilled with this value and disabled." + } + }, + "required": [ + "slug" + ] + }, + "GuestsDefaultFieldInput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "guests", + "description": "only allowed value for type is `guests`" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&guests=bob@cal.com`, the guests field will be prefilled with this value and disabled." + } + }, + "required": [ + "slug" + ] + }, + "RescheduleReasonDefaultFieldInput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "rescheduleReason", + "description": "only allowed value for type is `rescheduleReason`" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&rescheduleReason=travel`, the rescheduleReason field will be prefilled with this value and disabled." + } + }, + "required": [ + "slug" + ] + }, + "BookerLayouts_2024_06_14": { + "type": "object", + "properties": { + "defaultLayout": { + "type": "string", + "enum": [ + "month", + "week", + "column" + ] + }, + "enabledLayouts": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "month", + "week", + "column" + ] + } + } + }, + "required": [ + "defaultLayout", + "enabledLayouts" + ] + }, + "EventTypeColor_2024_06_14": { + "type": "object", + "properties": { + "lightThemeHex": { + "type": "string", + "description": "Color used for event types in light theme", + "example": "#292929" + }, + "darkThemeHex": { + "type": "string", + "description": "Color used for event types in dark theme", + "example": "#fafafa" + } + }, + "required": [ + "lightThemeHex", + "darkThemeHex" + ] + }, + "DestinationCalendar_2024_06_14": { + "type": "object", + "properties": { + "integration": { + "type": "string", + "description": "The integration type of the destination calendar. Refer to the /api/v2/calendars endpoint to retrieve the integration type of your connected calendars." + }, + "externalId": { + "type": "string", + "description": "The external ID of the destination calendar. Refer to the /api/v2/calendars endpoint to retrieve the external IDs of your connected calendars." + } + }, + "required": [ + "integration", + "externalId" + ] + }, + "CreateEventTypeInput_2024_06_14": { + "type": "object", + "properties": { + "lengthInMinutes": { + "type": "number", + "example": 60 + }, + "lengthInMinutesOptions": { + "example": [ + 15, + 30, + 60 + ], + "description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string", + "example": "learn-the-secrets-of-masterchief" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of the Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/InputAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputLinkLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputPhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14" + } + ] + } + }, + "bookingFields": { + "type": "array", + "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NameDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/GuestsDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RescheduleReasonDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/AddressFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NumberFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextAreaFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/SelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiSelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiEmailFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/CheckboxGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RadioGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/BooleanFieldInput_2024_06_14" + } + ] + } + }, + "disableGuests": { + "type": "boolean", + "description": "If true, person booking this event't cant add guests via their emails." + }, + "slotInterval": { + "type": "number", + "description": "Number representing length of each slot when event is booked. By default it equal length of the event type.\n If event length is 60 minutes then we would have slots 9AM, 10AM, 11AM etc. but if it was changed to 30 minutes then\n we would have slots 9AM, 9:30AM, 10AM, 10:30AM etc. as the available times to book the 60 minute event." + }, + "minimumBookingNotice": { + "type": "number", + "description": "Minimum number of minutes before the event that a booking can be made." + }, + "beforeEventBuffer": { + "type": "number", + "description": "Time spaces that can be pre-pended before an event to give more time before it." + }, + "afterEventBuffer": { + "type": "number", + "description": "Time spaces that can be appended after an event to give more time after it." + }, + "scheduleId": { + "type": "number", + "description": "If you want that this event has different schedule than user's default one you can specify it here." + }, + "bookingLimitsCount": { + "description": "Limit how many times this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsCount_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "onlyShowFirstAvailableSlot": { + "type": "boolean", + "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." + }, + "bookingLimitsDuration": { + "description": "Limit total amount of time that this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsDuration_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "bookingWindow": { + "description": "Limit how far in the future this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/RangeWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "offsetStart": { + "type": "number", + "description": "Offset timeslots shown to bookers by a specified number of minutes" + }, + "bookerLayouts": { + "description": "Should booker have week, month or column view. Specify default layout and enabled layouts user can pick.", + "allOf": [ + { + "$ref": "#/components/schemas/BookerLayouts_2024_06_14" + } + ] + }, + "confirmationPolicy": { + "description": "Specify how the booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseConfirmationPolicy_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "recurrence": { + "description": "Create a recurring event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/Recurrence_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "hideCalendarNotes": { + "type": "boolean" + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/EventTypeColor_2024_06_14" + }, + "seats": { + "description": "Create an event type with multiple seats.", + "oneOf": [ + { + "$ref": "#/components/schemas/Seats_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "customName": { + "type": "string", + "description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}", + "example": "{Event type title} between {Organiser} and {Scheduler}" + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar_2024_06_14" + }, + "useDestinationCalendarEmail": { + "type": "boolean" + }, + "hideCalendarEventDetails": { + "type": "boolean" + }, + "successRedirectUrl": { + "type": "string", + "description": "A valid URL where the booker will redirect to, once the booking is completed successfully", + "example": "https://masterchief.com/argentina/flan/video/9129412" + } + }, + "required": [ + "lengthInMinutes", + "lengthInMinutesOptions", + "title", + "slug" + ] + }, + "OutputAddressLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "link", + "integration", + "phone", + "attendeeAddress", + "attendeePhone", + "attendeeDefined" + ], + "example": "address", + "description": "only allowed value for type is `address`" + }, + "address": { + "type": "string", + "example": "123 Example St, City, Country" + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "type", + "address", + "public" + ] + }, + "OutputLinkLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "link", + "integration", + "phone", + "attendeeAddress", + "attendeePhone", + "attendeeDefined" + ], + "example": "link", + "description": "only allowed value for type is `link`" + }, + "link": { + "type": "string", + "example": "https://customvideo.com/join/123456" + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "type", + "link", + "public" + ] + }, + "OutputIntegrationLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "link", + "integration", + "phone", + "attendeeAddress", + "attendeePhone", + "attendeeDefined", + "conferencing", + "unknown" + ], + "example": "integration", + "description": "Only allowed value for type is `integration`" + }, + "integration": { + "type": "string", + "enum": [ + "cal-video", + "google-meet", + "zoom", + "whereby-video", + "whatsapp-video", + "webex-video", + "telegram-video", + "tandem", + "sylaps-video", + "skype-video", + "sirius-video", + "signal-video", + "shimmer-video", + "salesroom-video", + "roam-video", + "riverside-video", + "ping-video", + "office365-video", + "mirotalk-video", + "jitsi", + "jelly-video", + "jelly-conferencing", + "huddle", + "facetime-video", + "element-call-video", + "eightxeight-video", + "discord-video", + "demodesk-video", + "campsite-conferencing", + "campfire-video", + "around-video" + ], + "example": "cal-video" + }, + "link": { + "type": "string", + "example": "https://example.com" + }, + "credentialId": { + "type": "number", + "description": "Credential ID associated with the integration" + } + }, + "required": [ + "type", + "integration" + ] + }, + "OutputPhoneLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "link", + "integration", + "phone", + "attendeeAddress", + "attendeePhone", + "attendeeDefined" + ], + "example": "phone", + "description": "only allowed value for type is `phone`" + }, + "phone": { + "type": "string", + "example": "+37120993151" + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "type", + "phone", + "public" + ] + }, + "OutputConferencingLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "link", + "integration", + "phone", + "attendeeAddress", + "attendeePhone", + "attendeeDefined", + "conferencing", + "unknown" + ], + "example": "conferencing", + "description": "only allowed value for type is `conferencing`" + } + }, + "required": [ + "type" + ] + }, + "OutputUnknownLocation_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "address", + "link", + "integration", + "phone", + "attendeeAddress", + "attendeePhone", + "attendeeDefined", + "conferencing", + "unknown" + ], + "example": "unknown", + "description": "only allowed value for type is `unknown`" + }, + "location": { + "type": "string" + } + }, + "required": [ + "type", + "location" + ] + }, + "EmailDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "email", + "description": "only allowed value for type is `email`", + "default": "email" + }, + "label": { + "type": "string" + }, + "required": { + "type": "object", + "default": true + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&email=bob@gmail.com`, the email field will be prefilled with this value and disabled." + }, + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "slug": { + "type": "string", + "default": "email" + } + }, + "required": [ + "type", + "required", + "isDefault", + "slug" + ] + }, + "NameDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "name", + "description": "only allowed value for type is `name`. Used for having 1 booking field for both first name and last name.", + "default": "name" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&name=bob`, the name field will be prefilled with this value and disabled." + }, + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "slug": { + "type": "string", + "default": "name" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "type", + "isDefault", + "slug", + "required" + ] + }, + "LocationDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "slug": { + "type": "string", + "default": "location", + "description": "This booking field is returned only if the event type has more than one location. The purpose of this field is to allow the user to select the location where the event will take place." + }, + "type": { + "type": "string", + "default": "radioInput" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + } + }, + "required": [ + "isDefault", + "slug", + "type", + "required", + "hidden" + ] + }, + "RescheduleReasonDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "enum": [ + "name", + "email", + "title", + "notes", + "guests" + ], + "example": "rescheduleReason", + "description": "only allowed value for type is `rescheduleReason`", + "default": "rescheduleReason" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&rescheduleReason=busy`, the reschedule reason field will be prefilled with this value and disabled." + }, + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "type": { + "type": "string", + "default": "textarea" + } + }, + "required": [ + "slug", + "isDefault", + "type" + ] + }, + "TitleDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "enum": [ + "name", + "email", + "title", + "notes", + "guests" + ], + "example": "title", + "description": "only allowed value for type is `title`", + "default": "title" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&title=masterclass`, the title field will be prefilled with this value and disabled." + }, + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "type": { + "type": "string", + "default": "text" + } + }, + "required": [ + "slug", + "isDefault", + "type" + ] + }, + "NotesDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "enum": [ + "name", + "email", + "title", + "notes", + "guests" + ], + "example": "notes", + "description": "only allowed value for type is `notes`", + "default": "notes" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `¬es=hello`, the notes field will be prefilled with this value and disabled." + }, + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "type": { + "type": "string", + "default": "textarea" + } + }, + "required": [ + "slug", + "isDefault", + "type" + ] + }, + "GuestsDefaultFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "enum": [ + "name", + "email", + "title", + "notes", + "guests" + ], + "example": "guests", + "description": "only allowed value for type is `guests`", + "default": "guests" + }, + "required": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&guests=lauris@cal.com`, the guests field will be prefilled with this value and disabled." + }, + "isDefault": { + "type": "object", + "default": true, + "description": "This property is always true because it's a default field", + "example": true + }, + "type": { + "type": "string", + "default": "multiemail" + } + }, + "required": [ + "slug", + "isDefault", + "type" + ] + }, + "AddressFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "address", + "description": "only allowed value for type is `address`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter your address" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., 1234 Main St" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `address` and the URL contains query parameter `&address=1234 Main St, London`, the address field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "BooleanFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "boolean", + "description": "only allowed value for type is `boolean`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Agree to terms?" + }, + "required": { + "type": "boolean" + }, + "disableOnPrefill": { + "type": "boolean" + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "CheckboxGroupFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "checkbox", + "description": "only allowed value for type is `checkbox`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Select all that apply" + }, + "required": { + "type": "boolean" + }, + "options": { + "example": [ + "Checkbox 1", + "Checkbox 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `notify-me` and the URL contains query parameter `¬ify-me=true`, the checkbox will be selected and the checkbox field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden", + "isDefault" + ] + }, + "MultiEmailFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "multiemail", + "description": "only allowed value for type is `multiemail`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter multiple emails" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., example@example.com" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `consultants` and the URL contains query parameter `&consultants=alice@gmail.com&consultants=bob@gmail.com`, the these emails will be added and none more can be added." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "MultiSelectFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "multiselect", + "description": "only allowed value for type is `multiselect`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please select multiple options" + }, + "required": { + "type": "boolean" + }, + "options": { + "example": [ + "Option 1", + "Option 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `consultants` and the URL contains query parameter `&consultants=en&language=it`, the 'en' and 'it' will be selected and the select field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden", + "isDefault" + ] + }, + "NumberFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "number", + "description": "only allowed value for type is `number`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter a number" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., 100" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `calories-per-day` and the URL contains query parameter `&calories-per-day=3000`, the number field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "PhoneFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "phone", + "description": "only allowed value for type is `phone`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `phone` and the URL contains query parameter `&phone=1234567890`, the phone field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "RadioGroupFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "radio", + "description": "only allowed value for type is `radio`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Select one option" + }, + "required": { + "type": "boolean" + }, + "options": { + "example": [ + "Radio 1", + "Radio 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio buttom will be selected and the select field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden", + "isDefault" + ] + }, + "SelectFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "select", + "description": "only allowed value for type is `select`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please select an option" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "Select..." + }, + "options": { + "example": [ + "Option 1", + "Option 2" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' will be selected and the select field will be disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "options", + "hidden", + "isDefault" + ] + }, + "TextAreaFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "textarea", + "description": "only allowed value for type is `textarea`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter detailed information" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., Detailed description here..." + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `dear-diary` and the URL contains query parameter `&dear-diary=Today I shipped a feature`, the text area will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "TextFieldOutput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "name", + "email", + "phone", + "address", + "text", + "number", + "textarea", + "select", + "multiselect", + "multiemail", + "checkbox", + "radio", + "boolean" + ], + "example": "text", + "description": "only allowed value for type is `text`" + }, + "slug": { + "type": "string", + "description": "Unique identifier for the field in format `some-slug`. It is used to access response to this booking field during the booking", + "example": "some-slug" + }, + "label": { + "type": "string", + "example": "Please enter your text" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": "string", + "example": "e.g., Enter text here" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `who-referred-you` and the URL contains query parameter `&who-referred-you=bob`, the text field will be prefilled with this value and disabled." + }, + "hidden": { + "type": "boolean", + "description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both." + }, + "isDefault": { + "type": "object", + "default": false, + "description": "This property is always false because it's not default field but custom field", + "example": false + } + }, + "required": [ + "type", + "slug", + "label", + "required", + "hidden", + "isDefault" + ] + }, + "EventTypeOutput_2024_06_14": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "lengthInMinutes": { + "type": "number", + "example": 60 + }, + "lengthInMinutesOptions": { + "example": [ + 15, + 30, + 60 + ], + "description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.", + "type": "array", + "items": { + "type": "number" + } + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string", + "example": "learn-the-secrets-of-masterchief" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/OutputAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputLinkLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputIntegrationLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputPhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputConferencingLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputUnknownLocation_2024_06_14" + } + ] + } + }, + "bookingFields": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NameDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/LocationDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RescheduleReasonDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TitleDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NotesDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/GuestsDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/PhoneFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/AddressFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NumberFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextAreaFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/SelectFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiSelectFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiEmailFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/CheckboxGroupFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RadioGroupFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/BooleanFieldOutput_2024_06_14" + } + ] + } + }, + "disableGuests": { + "type": "boolean" + }, + "slotInterval": { + "type": "object", + "example": 60, + "nullable": true + }, + "minimumBookingNotice": { + "type": "number", + "example": 0 + }, + "beforeEventBuffer": { + "type": "number", + "example": 0 + }, + "afterEventBuffer": { + "type": "number", + "example": 0 + }, + "recurrence": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Recurrence_2024_06_14" + } + ] + }, + "metadata": { + "type": "object" + }, + "price": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "seatsPerTimeSlot": { + "type": "object", + "nullable": true + }, + "forwardParamsSuccessRedirect": { + "type": "object", + "nullable": true + }, + "successRedirectUrl": { + "type": "object", + "nullable": true + }, + "isInstantEvent": { + "type": "boolean" + }, + "seatsShowAvailabilityCount": { + "type": "boolean", + "nullable": true + }, + "scheduleId": { + "type": "number", + "nullable": true + }, + "bookingLimitsCount": { + "type": "object" + }, + "onlyShowFirstAvailableSlot": { + "type": "boolean" + }, + "bookingLimitsDuration": { + "type": "object" + }, + "bookingWindow": { + "type": "array", + "description": "Limit how far in the future this event can be booked", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/RangeWindow_2024_06_14" + } + ] + } + }, + "bookerLayouts": { + "$ref": "#/components/schemas/BookerLayouts_2024_06_14" + }, + "confirmationPolicy": { + "type": "object" + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "hideCalendarNotes": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/EventTypeColor_2024_06_14" + }, + "seats": { + "$ref": "#/components/schemas/Seats_2024_06_14" + }, + "offsetStart": { + "type": "number" + }, + "customName": { + "type": "string" + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar_2024_06_14" + }, + "useDestinationCalendarEmail": { + "type": "boolean" + }, + "hideCalendarEventDetails": { + "type": "boolean" + }, + "ownerId": { + "type": "number", + "example": 10 + }, + "users": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "lengthInMinutes", + "title", + "slug", + "description", + "locations", + "bookingFields", + "disableGuests", + "recurrence", + "metadata", + "price", + "currency", + "lockTimeZoneToggleOnBookingPage", + "forwardParamsSuccessRedirect", + "successRedirectUrl", + "isInstantEvent", + "scheduleId", + "ownerId", + "users" + ] + }, + "CreateEventTypeOutput_2024_06_14": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "$ref": "#/components/schemas/EventTypeOutput_2024_06_14" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetEventTypeOutput_2024_06_14": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/EventTypeOutput_2024_06_14" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "GetEventTypesOutput_2024_06_14": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeOutput_2024_06_14" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateEventTypeInput_2024_06_14": { + "type": "object", + "properties": { + "lengthInMinutes": { + "type": "number", + "example": 60 + }, + "lengthInMinutesOptions": { + "example": [ + 15, + 30, + 60 + ], + "description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string", + "example": "learn-the-secrets-of-masterchief" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of the Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/InputAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputLinkLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputPhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14" + } + ] + } + }, + "bookingFields": { + "type": "array", + "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NameDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/GuestsDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RescheduleReasonDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/AddressFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NumberFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextAreaFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/SelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiSelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiEmailFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/CheckboxGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RadioGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/BooleanFieldInput_2024_06_14" + } + ] + } + }, + "disableGuests": { + "type": "boolean", + "description": "If true, person booking this event't cant add guests via their emails." + }, + "slotInterval": { + "type": "number", + "description": "Number representing length of each slot when event is booked. By default it equal length of the event type.\n If event length is 60 minutes then we would have slots 9AM, 10AM, 11AM etc. but if it was changed to 30 minutes then\n we would have slots 9AM, 9:30AM, 10AM, 10:30AM etc. as the available times to book the 60 minute event." + }, + "minimumBookingNotice": { + "type": "number", + "description": "Minimum number of minutes before the event that a booking can be made." + }, + "beforeEventBuffer": { + "type": "number", + "description": "Time spaces that can be pre-pended before an event to give more time before it." + }, + "afterEventBuffer": { + "type": "number", + "description": "Time spaces that can be appended after an event to give more time after it." + }, + "scheduleId": { + "type": "number", + "description": "If you want that this event has different schedule than user's default one you can specify it here." + }, + "bookingLimitsCount": { + "description": "Limit how many times this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsCount_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "onlyShowFirstAvailableSlot": { + "type": "boolean", + "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." + }, + "bookingLimitsDuration": { + "description": "Limit total amount of time that this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsDuration_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "bookingWindow": { + "description": "Limit how far in the future this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/RangeWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "offsetStart": { + "type": "number", + "description": "Offset timeslots shown to bookers by a specified number of minutes" + }, + "bookerLayouts": { + "description": "Should booker have week, month or column view. Specify default layout and enabled layouts user can pick.", + "allOf": [ + { + "$ref": "#/components/schemas/BookerLayouts_2024_06_14" + } + ] + }, + "confirmationPolicy": { + "description": "Specify how the booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseConfirmationPolicy_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "recurrence": { + "description": "Create a recurring event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/Recurrence_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "hideCalendarNotes": { + "type": "boolean" + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/EventTypeColor_2024_06_14" + }, + "seats": { + "description": "Create an event type with multiple seats.", + "oneOf": [ + { + "$ref": "#/components/schemas/Seats_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "customName": { + "type": "string", + "description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}", + "example": "{Event type title} between {Organiser} and {Scheduler}" + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar_2024_06_14" + }, + "useDestinationCalendarEmail": { + "type": "boolean" + }, + "hideCalendarEventDetails": { + "type": "boolean" + }, + "successRedirectUrl": { + "type": "string", + "description": "A valid URL where the booker will redirect to, once the booking is completed successfully", + "example": "https://masterchief.com/argentina/flan/video/9129412" + } + }, + "required": [ + "lengthInMinutesOptions" + ] + }, + "UpdateEventTypeOutput_2024_06_14": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "$ref": "#/components/schemas/EventTypeOutput_2024_06_14" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteData_2024_06_14": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "lengthInMinutes": { + "type": "number", + "example": 60 + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "id", + "lengthInMinutes", + "title", + "slug" + ] + }, + "DeleteEventTypeOutput_2024_06_14": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "$ref": "#/components/schemas/DeleteData_2024_06_14" + } + }, + "required": [ + "status", + "data" + ] + }, + "SelectedCalendarsInputDto": { + "type": "object", + "properties": { + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "credentialId": { + "type": "number" + } + }, + "required": [ + "integration", + "externalId", + "credentialId" + ] + }, + "SelectedCalendarOutputDto": { + "type": "object", + "properties": { + "userId": { + "type": "number" + }, + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "credentialId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "userId", + "integration", + "externalId", + "credentialId" + ] + }, + "SelectedCalendarOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/SelectedCalendarOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "OrgTeamOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "parentId": { + "type": "number" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "isOrganization": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean", + "default": false + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London" + }, + "weekStart": { + "type": "string", + "default": "Sunday" + } + }, + "required": [ + "id", + "name", + "isOrganization" + ] + }, + "OrgTeamsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgTeamOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "OrgMeTeamsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgTeamOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "OrgTeamOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgTeamOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrgTeamDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the team", + "example": "CalTeam" + }, + "slug": { + "type": "string", + "description": "Team slug", + "example": "caltel" + }, + "logoUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + "description": "URL of the teams logo image" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean" + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + "description": "URL of the teams banner image which is shown on booker" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "example": "America/New_York", + "description": "Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed." + }, + "weekStart": { + "type": "string", + "example": "Monday" + }, + "bookingLimits": { + "type": "string" + }, + "includeManagedEventsInLimits": { + "type": "boolean" + } + } + }, + "CreateOrgTeamDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the team", + "example": "CalTeam" + }, + "slug": { + "type": "string", + "description": "Team slug", + "example": "caltel" + }, + "logoUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + "description": "URL of the teams logo image" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean", + "default": false + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean" + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + "description": "URL of the teams banner image which is shown on booker" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London", + "example": "America/New_York", + "description": "Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed." + }, + "weekStart": { + "type": "string", + "default": "Sunday", + "example": "Monday" + }, + "autoAcceptCreator": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name" + ] + }, + "ScheduleAvailabilityInput_2024_06_11": { + "type": "object", + "properties": { + "days": { + "type": "array", + "enum": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "example": [ + "Monday", + "Tuesday" + ], + "description": "Array of days when schedule is active.", + "items": { + "type": "string", + "enum": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ] + } + }, + "startTime": { + "type": "string", + "pattern": "TIME_FORMAT_HH_MM", + "example": "08:00", + "description": "startTime must be a valid time in format HH:MM e.g. 08:00" + }, + "endTime": { + "type": "string", + "pattern": "TIME_FORMAT_HH_MM", + "example": "15:00", + "description": "endTime must be a valid time in format HH:MM e.g. 15:00" + } + }, + "required": [ + "days", + "startTime", + "endTime" + ] + }, + "ScheduleOverrideInput_2024_06_11": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2024-05-20" + }, + "startTime": { + "type": "string", + "pattern": "TIME_FORMAT_HH_MM", + "example": "12:00", + "description": "startTime must be a valid time in format HH:MM e.g. 12:00" + }, + "endTime": { + "type": "string", + "pattern": "TIME_FORMAT_HH_MM", + "example": "13:00", + "description": "endTime must be a valid time in format HH:MM e.g. 13:00" + } + }, + "required": [ + "date", + "startTime", + "endTime" + ] + }, + "ScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 254 + }, + "ownerId": { + "type": "number", + "example": 478 + }, + "name": { + "type": "string", + "example": "Catch up hours" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome" + }, + "availability": { + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "17:00", + "endTime": "19:00" + }, + { + "days": [ + "Wednesday", + "Thursday" + ], + "startTime": "16:00", + "endTime": "20:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true + }, + "overrides": { + "example": [ + { + "date": "2024-05-20", + "startTime": "18:00", + "endTime": "21:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + }, + "required": [ + "id", + "ownerId", + "name", + "timeZone", + "availability", + "isDefault", + "overrides" + ] + }, + "GetSchedulesOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Catch up hours" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome", + "description": "Timezone is used to calculate available times when an event using the schedule is booked." + }, + "availability": { + "description": "Each object contains days and times when the user is available. If not passed, the default availability is Monday to Friday from 09:00 to 17:00.", + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "17:00", + "endTime": "19:00" + }, + { + "days": [ + "Wednesday", + "Thursday" + ], + "startTime": "16:00", + "endTime": "20:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true, + "description": "Each user should have 1 default schedule. If you specified `timeZone` when creating managed user, then the default schedule will be created with that timezone.\n Default schedule means that if an event type is not tied to a specific schedule then the default schedule is used." + }, + "overrides": { + "description": "Need to change availability for a specific date? Add an override.", + "example": [ + { + "date": "2024-05-20", + "startTime": "18:00", + "endTime": "21:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + }, + "required": [ + "name", + "timeZone", + "isDefault" + ] + }, + "CreateScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + ] + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "One-on-one coaching" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome" + }, + "availability": { + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "09:00", + "endTime": "10:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true + }, + "overrides": { + "example": [ + { + "date": "2024-05-20", + "startTime": "12:00", + "endTime": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + } + }, + "UpdateScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "ProfileOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the profile of user", + "example": 1 + }, + "organizationId": { + "type": "number", + "description": "The ID of the organization of user", + "example": 1 + }, + "userId": { + "type": "number", + "description": "The IDof the user", + "example": 1 + }, + "username": { + "type": "string", + "nullable": true, + "description": "The username of the user within the organization context", + "example": "john_doe" + } + }, + "required": [ + "id", + "organizationId", + "userId" + ] + }, + "GetOrgUsersWithProfileOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the user", + "example": 1 + }, + "username": { + "type": "string", + "nullable": true, + "description": "The username of the user", + "example": "john_doe" + }, + "name": { + "type": "string", + "nullable": true, + "description": "The name of the user", + "example": "John Doe" + }, + "email": { + "type": "string", + "description": "The email of the user", + "example": "john@example.com" + }, + "emailVerified": { + "format": "date-time", + "type": "string", + "nullable": true, + "description": "The date when the email was verified", + "example": "2022-01-01T00:00:00Z" + }, + "bio": { + "type": "string", + "nullable": true, + "description": "The bio of the user", + "example": "I am a software developer" + }, + "avatarUrl": { + "type": "string", + "nullable": true, + "description": "The URL of the user's avatar", + "example": "https://example.com/avatar.jpg" + }, + "timeZone": { + "type": "string", + "description": "The time zone of the user", + "example": "America/New_York" + }, + "weekStart": { + "type": "string", + "description": "The week start day of the user", + "example": "Monday" + }, + "appTheme": { + "type": "string", + "nullable": true, + "description": "The app theme of the user", + "example": "light" + }, + "theme": { + "type": "string", + "nullable": true, + "description": "The theme of the user", + "example": "default" + }, + "defaultScheduleId": { + "type": "number", + "nullable": true, + "description": "The ID of the default schedule for the user", + "example": 1 + }, + "locale": { + "type": "string", + "nullable": true, + "description": "The locale of the user", + "example": "en-US" + }, + "timeFormat": { + "type": "number", + "nullable": true, + "description": "The time format of the user", + "example": 12 + }, + "hideBranding": { + "type": "boolean", + "description": "Whether to hide branding for the user", + "example": false + }, + "brandColor": { + "type": "string", + "nullable": true, + "description": "The brand color of the user", + "example": "#ffffff" + }, + "darkBrandColor": { + "type": "string", + "nullable": true, + "description": "The dark brand color of the user", + "example": "#000000" + }, + "allowDynamicBooking": { + "type": "boolean", + "nullable": true, + "description": "Whether dynamic booking is allowed for the user", + "example": true + }, + "createdDate": { + "format": "date-time", + "type": "string", + "description": "The date when the user was created", + "example": "2022-01-01T00:00:00Z" + }, + "verified": { + "type": "boolean", + "nullable": true, + "description": "Whether the user is verified", + "example": true + }, + "invitedTo": { + "type": "number", + "nullable": true, + "description": "The ID of the user who invited this user", + "example": 1 + }, + "profile": { + "description": "organization user profile, contains user data within the organizaton context", + "allOf": [ + { + "$ref": "#/components/schemas/ProfileOutput" + } + ] + } + }, + "required": [ + "id", + "email", + "timeZone", + "weekStart", + "hideBranding", + "createdDate", + "profile" + ] + }, + "GetOrganizationUsersResponseDTO": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetOrgUsersWithProfileOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateOrganizationUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "User email address", + "example": "user@example.com" + }, + "username": { + "type": "string", + "description": "Username", + "example": "user123" + }, + "weekday": { + "type": "string", + "description": "Preferred weekday", + "example": "Monday" + }, + "brandColor": { + "type": "string", + "description": "Brand color in HEX format", + "example": "#FFFFFF" + }, + "darkBrandColor": { + "type": "string", + "description": "Dark brand color in HEX format", + "example": "#000000" + }, + "hideBranding": { + "type": "boolean", + "description": "Hide branding", + "example": false + }, + "timeZone": { + "type": "string", + "description": "Time zone", + "example": "America/New_York" + }, + "theme": { + "type": "string", + "nullable": true, + "description": "Theme", + "example": "dark" + }, + "appTheme": { + "type": "string", + "nullable": true, + "description": "Application theme", + "example": "light" + }, + "timeFormat": { + "type": "number", + "description": "Time format", + "example": 24 + }, + "defaultScheduleId": { + "type": "number", + "minimum": 0, + "description": "Default schedule ID", + "example": 1 + }, + "locale": { + "type": "string", + "nullable": true, + "default": "en", + "description": "Locale", + "example": "en" + }, + "avatarUrl": { + "type": "string", + "description": "Avatar URL", + "example": "https://example.com/avatar.jpg" + }, + "organizationRole": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "ADMIN", + "OWNER" + ] + }, + "autoAccept": { + "type": "boolean", + "default": true + } + }, + "required": [ + "email" + ] + }, + "GetOrganizationUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/GetOrgUsersWithProfileOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrganizationUserInput": { + "type": "object", + "properties": {} + }, + "OrgMembershipOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "teamId": { + "type": "number" + }, + "accepted": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean" + } + }, + "required": [ + "id", + "userId", + "teamId", + "accepted", + "role" + ] + }, + "GetAllOrgMemberships": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateOrgMembershipDto": { + "type": "object", + "properties": { + "userId": { + "type": "number" + }, + "accepted": { + "type": "boolean", + "default": false + }, + "role": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + }, + "required": [ + "userId", + "role" + ] + }, + "CreateOrgMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetOrgMembership": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteOrgMembership": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrgMembershipDto": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean" + } + } + }, + "UpdateOrgMembership": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "Host": { + "type": "object", + "properties": { + "userId": { + "type": "number", + "description": "Which user is the host of this event" + }, + "mandatory": { + "type": "boolean", + "description": "Only relevant for round robin event types. If true then the user must attend round robin event always." + }, + "priority": { + "type": "string", + "enum": [ + "lowest", + "low", + "medium", + "high", + "highest" + ] + } + }, + "required": [ + "userId" + ] + }, + "CreateTeamEventTypeInput_2024_06_14": { + "type": "object", + "properties": { + "lengthInMinutes": { + "type": "number", + "example": 60 + }, + "lengthInMinutesOptions": { + "example": [ + 15, + 30, + 60 + ], + "description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string", + "example": "learn-the-secrets-of-masterchief" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of the Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/InputAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputLinkLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputPhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14" + } + ] + } + }, + "bookingFields": { + "type": "array", + "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NameDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/GuestsDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RescheduleReasonDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/AddressFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NumberFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextAreaFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/SelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiSelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiEmailFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/CheckboxGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RadioGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/BooleanFieldInput_2024_06_14" + } + ] + } + }, + "disableGuests": { + "type": "boolean", + "description": "If true, person booking this event't cant add guests via their emails." + }, + "slotInterval": { + "type": "number", + "description": "Number representing length of each slot when event is booked. By default it equal length of the event type.\n If event length is 60 minutes then we would have slots 9AM, 10AM, 11AM etc. but if it was changed to 30 minutes then\n we would have slots 9AM, 9:30AM, 10AM, 10:30AM etc. as the available times to book the 60 minute event." + }, + "minimumBookingNotice": { + "type": "number", + "description": "Minimum number of minutes before the event that a booking can be made." + }, + "beforeEventBuffer": { + "type": "number", + "description": "Time spaces that can be pre-pended before an event to give more time before it." + }, + "afterEventBuffer": { + "type": "number", + "description": "Time spaces that can be appended after an event to give more time after it." + }, + "scheduleId": { + "type": "number", + "description": "If you want that this event has different schedule than user's default one you can specify it here." + }, + "bookingLimitsCount": { + "description": "Limit how many times this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsCount_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "onlyShowFirstAvailableSlot": { + "type": "boolean", + "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." + }, + "bookingLimitsDuration": { + "description": "Limit total amount of time that this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsDuration_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "bookingWindow": { + "description": "Limit how far in the future this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/RangeWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "offsetStart": { + "type": "number", + "description": "Offset timeslots shown to bookers by a specified number of minutes" + }, + "bookerLayouts": { + "description": "Should booker have week, month or column view. Specify default layout and enabled layouts user can pick.", + "allOf": [ + { + "$ref": "#/components/schemas/BookerLayouts_2024_06_14" + } + ] + }, + "confirmationPolicy": { + "description": "Specify how the booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseConfirmationPolicy_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "recurrence": { + "description": "Create a recurring event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/Recurrence_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "hideCalendarNotes": { + "type": "boolean" + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/EventTypeColor_2024_06_14" + }, + "seats": { + "description": "Create an event type with multiple seats.", + "oneOf": [ + { + "$ref": "#/components/schemas/Seats_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "customName": { + "type": "string", + "description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}", + "example": "{Event type title} between {Organiser} and {Scheduler}" + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar_2024_06_14" + }, + "useDestinationCalendarEmail": { + "type": "boolean" + }, + "hideCalendarEventDetails": { + "type": "boolean" + }, + "successRedirectUrl": { + "type": "string", + "description": "A valid URL where the booker will redirect to, once the booking is completed successfully", + "example": "https://masterchief.com/argentina/flan/video/9129412" + }, + "schedulingType": { + "type": "object" + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } + }, + "assignAllTeamMembers": { + "type": "boolean", + "description": "If true, all current and future team members will be assigned to this event type" + } + }, + "required": [ + "lengthInMinutes", + "lengthInMinutesOptions", + "title", + "slug", + "schedulingType", + "hosts" + ] + }, + "CreateTeamEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14" + } + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "TeamEventTypeResponseHost": { + "type": "object", + "properties": { + "userId": { + "type": "number", + "description": "Which user is the host of this event" + }, + "mandatory": { + "type": "boolean", + "default": false, + "description": "Only relevant for round robin event types. If true then the user must attend round robin event always." + }, + "priority": { + "type": "string", + "default": "medium", + "enum": [ + "lowest", + "low", + "medium", + "high", + "highest" + ] + }, + "name": { + "type": "string", + "example": "John Doe" + }, + "avatarUrl": { + "type": "string", + "nullable": true, + "example": "https://cal.com/api/avatar/d95949bc-ccb1-400f-acf6-045c51a16856.png" + } + }, + "required": [ + "userId", + "name" + ] + }, + "EventTypeTeam": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "slug": { + "type": "string" + }, + "bannerUrl": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "weekStart": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "theme": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "TeamEventTypeOutput_2024_06_14": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "lengthInMinutes": { + "type": "number", + "minimum": 1, + "example": 60 + }, + "lengthInMinutesOptions": { + "example": [ + 15, + 30, + 60 + ], + "description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.", + "type": "array", + "items": { + "type": "number", + "minimum": 1 + } + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string", + "example": "learn-the-secrets-of-masterchief" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/OutputAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputLinkLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputIntegrationLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputPhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputConferencingLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/OutputUnknownLocation_2024_06_14" + } + ] + } + }, + "bookingFields": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NameDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/LocationDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RescheduleReasonDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TitleDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NotesDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/GuestsDefaultFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/PhoneFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/AddressFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NumberFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextAreaFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/SelectFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiSelectFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiEmailFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/CheckboxGroupFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RadioGroupFieldOutput_2024_06_14" + }, + { + "$ref": "#/components/schemas/BooleanFieldOutput_2024_06_14" + } + ] + } + }, + "disableGuests": { + "type": "boolean" + }, + "slotInterval": { + "type": "number", + "nullable": true, + "example": 60 + }, + "minimumBookingNotice": { + "type": "number", + "minimum": 0, + "example": 0 + }, + "beforeEventBuffer": { + "type": "number", + "minimum": 0, + "example": 0 + }, + "afterEventBuffer": { + "type": "number", + "minimum": 0, + "example": 0 + }, + "recurrence": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Recurrence_2024_06_14" + } + ] + }, + "metadata": { + "type": "object" + }, + "price": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "seatsPerTimeSlot": { + "type": "number", + "nullable": true + }, + "forwardParamsSuccessRedirect": { + "type": "boolean", + "nullable": true + }, + "successRedirectUrl": { + "type": "string", + "nullable": true + }, + "isInstantEvent": { + "type": "boolean" + }, + "seatsShowAvailabilityCount": { + "type": "boolean", + "nullable": true + }, + "scheduleId": { + "type": "number", + "nullable": true + }, + "bookingLimitsCount": { + "type": "object" + }, + "onlyShowFirstAvailableSlot": { + "type": "boolean" + }, + "bookingLimitsDuration": { + "type": "object" + }, + "bookingWindow": { + "type": "array", + "description": "Limit how far in the future this event can be booked", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/RangeWindow_2024_06_14" + } + ] + } + }, + "bookerLayouts": { + "$ref": "#/components/schemas/BookerLayouts_2024_06_14" + }, + "confirmationPolicy": { + "type": "object" + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "hideCalendarNotes": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/EventTypeColor_2024_06_14" + }, + "seats": { + "$ref": "#/components/schemas/Seats_2024_06_14" + }, + "offsetStart": { + "type": "number", + "minimum": 1 + }, + "customName": { + "type": "string" + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar_2024_06_14" + }, + "useDestinationCalendarEmail": { + "type": "boolean" + }, + "hideCalendarEventDetails": { + "type": "boolean" + }, + "teamId": { + "type": "number" + }, + "ownerId": { + "type": "number", + "nullable": true + }, + "parentEventTypeId": { + "type": "number", + "nullable": true, + "description": "For managed event types, parent event type is the event type that this event type is based on" + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamEventTypeResponseHost" + } + }, + "assignAllTeamMembers": { + "type": "boolean" + }, + "schedulingType": { + "type": "string", + "nullable": true, + "enum": [ + "ROUND_ROBIN", + "COLLECTIVE", + "MANAGED" + ] + }, + "team": { + "$ref": "#/components/schemas/EventTypeTeam" + } + }, + "required": [ + "id", + "lengthInMinutes", + "title", + "slug", + "description", + "locations", + "bookingFields", + "disableGuests", + "recurrence", + "metadata", + "price", + "currency", + "lockTimeZoneToggleOnBookingPage", + "forwardParamsSuccessRedirect", + "successRedirectUrl", + "isInstantEvent", + "scheduleId", + "teamId", + "hosts", + "schedulingType", + "team" + ] + }, + "GetTeamEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreatePhoneCallInput": { + "type": "object", + "properties": { + "yourPhoneNumber": { + "type": "string", + "pattern": "/^\\+[1-9]\\d{1,14}$/", + "description": "Your phone number" + }, + "numberToCall": { + "type": "string", + "pattern": "/^\\+[1-9]\\d{1,14}$/", + "description": "Number to call" + }, + "calApiKey": { + "type": "string", + "description": "CAL API Key" + }, + "enabled": { + "type": "object", + "default": true, + "description": "Enabled status" + }, + "templateType": { + "default": "CUSTOM_TEMPLATE", + "enum": [ + "CHECK_IN_APPOINTMENT", + "CUSTOM_TEMPLATE" + ], + "type": "string", + "description": "Template type" + }, + "schedulerName": { + "type": "string", + "description": "Scheduler name" + }, + "guestName": { + "type": "string", + "description": "Guest name" + }, + "guestEmail": { + "type": "string", + "description": "Guest email" + }, + "guestCompany": { + "type": "string", + "description": "Guest company" + }, + "beginMessage": { + "type": "string", + "description": "Begin message" + }, + "generalPrompt": { + "type": "string", + "description": "General prompt" + } + }, + "required": [ + "yourPhoneNumber", + "numberToCall", + "calApiKey", + "enabled", + "templateType" + ] + }, + "Data": { + "type": "object", + "properties": { + "callId": { + "type": "string" + }, + "agentId": { + "type": "string" + } + }, + "required": [ + "callId", + "agentId" + ] + }, + "CreatePhoneCallOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/Data" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamEventTypesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateTeamEventTypeInput_2024_06_14": { + "type": "object", + "properties": { + "lengthInMinutes": { + "type": "number", + "example": 60 + }, + "lengthInMinutesOptions": { + "example": [ + 15, + 30, + 60 + ], + "description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "slug": { + "type": "string", + "example": "learn-the-secrets-of-masterchief" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of the Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/InputAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputLinkLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputIntegrationLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputPhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeAddressLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeePhoneLocation_2024_06_14" + }, + { + "$ref": "#/components/schemas/InputAttendeeDefinedLocation_2024_06_14" + } + ] + } + }, + "bookingFields": { + "type": "array", + "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NameDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/GuestsDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RescheduleReasonDefaultFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/AddressFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/NumberFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/TextAreaFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/SelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiSelectFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/MultiEmailFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/CheckboxGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/RadioGroupFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/BooleanFieldInput_2024_06_14" + } + ] + } + }, + "disableGuests": { + "type": "boolean", + "description": "If true, person booking this event't cant add guests via their emails." + }, + "slotInterval": { + "type": "number", + "description": "Number representing length of each slot when event is booked. By default it equal length of the event type.\n If event length is 60 minutes then we would have slots 9AM, 10AM, 11AM etc. but if it was changed to 30 minutes then\n we would have slots 9AM, 9:30AM, 10AM, 10:30AM etc. as the available times to book the 60 minute event." + }, + "minimumBookingNotice": { + "type": "number", + "description": "Minimum number of minutes before the event that a booking can be made." + }, + "beforeEventBuffer": { + "type": "number", + "description": "Time spaces that can be pre-pended before an event to give more time before it." + }, + "afterEventBuffer": { + "type": "number", + "description": "Time spaces that can be appended after an event to give more time after it." + }, + "scheduleId": { + "type": "number", + "description": "If you want that this event has different schedule than user's default one you can specify it here." + }, + "bookingLimitsCount": { + "description": "Limit how many times this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsCount_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "onlyShowFirstAvailableSlot": { + "type": "boolean", + "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." + }, + "bookingLimitsDuration": { + "description": "Limit total amount of time that this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseBookingLimitsDuration_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "bookingWindow": { + "description": "Limit how far in the future this event can be booked", + "oneOf": [ + { + "$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/RangeWindow_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "offsetStart": { + "type": "number", + "description": "Offset timeslots shown to bookers by a specified number of minutes" + }, + "bookerLayouts": { + "description": "Should booker have week, month or column view. Specify default layout and enabled layouts user can pick.", + "allOf": [ + { + "$ref": "#/components/schemas/BookerLayouts_2024_06_14" + } + ] + }, + "confirmationPolicy": { + "description": "Specify how the booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.", + "oneOf": [ + { + "$ref": "#/components/schemas/BaseConfirmationPolicy_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "recurrence": { + "description": "Create a recurring event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/Recurrence_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "hideCalendarNotes": { + "type": "boolean" + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/EventTypeColor_2024_06_14" + }, + "seats": { + "description": "Create an event type with multiple seats.", + "oneOf": [ + { + "$ref": "#/components/schemas/Seats_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, + "customName": { + "type": "string", + "description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}", + "example": "{Event type title} between {Organiser} and {Scheduler}" + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar_2024_06_14" + }, + "useDestinationCalendarEmail": { + "type": "boolean" + }, + "hideCalendarEventDetails": { + "type": "boolean" + }, + "successRedirectUrl": { + "type": "string", + "description": "A valid URL where the booker will redirect to, once the booking is completed successfully", + "example": "https://masterchief.com/argentina/flan/video/9129412" + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } + }, + "assignAllTeamMembers": { + "type": "boolean", + "description": "If true, all current and future team members will be assigned to this event type" + } + }, + "required": [ + "lengthInMinutesOptions" + ] + }, + "UpdateTeamEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14" + } + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteTeamEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "MembershipUserOutputDto": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "username": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "email" + ] + }, + "OrgTeamMembershipOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "teamId": { + "type": "number" + }, + "accepted": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/MembershipUserOutputDto" + } + }, + "required": [ + "id", + "userId", + "teamId", + "accepted", + "role", + "user" + ] + }, + "OrgTeamMembershipsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "OrgTeamMembershipOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrgTeamMembershipDto": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean" + } + } + }, + "CreateOrgTeamMembershipDto": { + "type": "object", + "properties": { + "userId": { + "type": "number" + }, + "accepted": { + "type": "boolean", + "default": false + }, + "role": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + }, + "required": [ + "userId", + "role" + ] + }, + "Attribute": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the attribute", + "example": "attr_123" + }, + "teamId": { + "type": "number", + "description": "The team ID associated with the attribute", + "example": 1 + }, + "type": { + "type": "string", + "description": "The type of the attribute", + "enum": [ + "TEXT", + "NUMBER", + "SINGLE_SELECT", + "MULTI_SELECT" + ] + }, + "name": { + "type": "string", + "description": "The name of the attribute", + "example": "Attribute Name" + }, + "slug": { + "type": "string", + "description": "The slug of the attribute", + "example": "attribute-name" + }, + "enabled": { + "type": "boolean", + "description": "Whether the attribute is enabled and displayed on their profile", + "example": true + }, + "usersCanEditRelation": { + "type": "boolean", + "description": "Whether users can edit the relation", + "example": true + } + }, + "required": [ + "id", + "teamId", + "type", + "name", + "slug", + "enabled" + ] + }, + "GetOrganizationAttributesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attribute" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "GetSingleAttributeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Attribute" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateOrganizationAttributeOptionInput": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "value", + "slug" + ] + }, + "CreateOrganizationAttributeInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "TEXT", + "NUMBER", + "SINGLE_SELECT", + "MULTI_SELECT" + ] + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateOrganizationAttributeOptionInput" + } + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "slug", + "type", + "options" + ] + }, + "CreateOrganizationAttributesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/Attribute" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrganizationAttributeInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "TEXT", + "NUMBER", + "SINGLE_SELECT", + "MULTI_SELECT" + ] + }, + "enabled": { + "type": "boolean" + } + } + }, + "UpdateOrganizationAttributesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/Attribute" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteOrganizationAttributesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/Attribute" + } + }, + "required": [ + "status", + "data" + ] + }, + "OptionOutput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the option", + "example": "attr_option_id" + }, + "attributeId": { + "type": "string", + "description": "The ID of the attribute", + "example": "attr_id" + }, + "value": { + "type": "string", + "description": "The value of the option", + "example": "option_value" + }, + "slug": { + "type": "string", + "description": "The slug of the option", + "example": "option-slug" + } + }, + "required": [ + "id", + "attributeId", + "value", + "slug" + ] + }, + "CreateAttributeOptionOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OptionOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteAttributeOptionOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OptionOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrganizationAttributeOptionInput": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, + "UpdateAttributeOptionOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OptionOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetAllAttributeOptionOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OptionOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "AssignOrganizationAttributeOptionToUserInput": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "attributeOptionId": { + "type": "string" + }, + "attributeId": { + "type": "string" + } + }, + "required": [ + "attributeId" + ] + }, + "AssignOptionUserOutputData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the option assigned to the user" + }, + "memberId": { + "type": "number", + "description": "The ID form the org membership for the user" + }, + "attributeOptionId": { + "type": "string", + "description": "The value of the option" + } + }, + "required": [ + "id", + "memberId", + "attributeOptionId" + ] + }, + "AssignOptionUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/AssignOptionUserOutputData" + } + }, + "required": [ + "status", + "data" + ] + }, + "UnassignOptionUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/AssignOptionUserOutputData" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetOptionUserOutputData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the option assigned to the user" + }, + "attributeId": { + "type": "string", + "description": "The ID of the attribute" + }, + "value": { + "type": "string", + "description": "The value of the option" + }, + "slug": { + "type": "string", + "description": "The slug of the option" + } + }, + "required": [ + "id", + "attributeId", + "value", + "slug" + ] + }, + "GetOptionUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetOptionUserOutputData" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "TeamWebhookOutputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "teamId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "payloadTemplate", + "teamId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "TeamWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamWebhookOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateWebhookInputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "active": { + "type": "boolean" + }, + "subscriberUrl": { + "type": "string" + }, + "triggers": { + "type": "string", + "example": [ + "BOOKING_CREATED", + "BOOKING_RESCHEDULED", + "BOOKING_CANCELLED", + "BOOKING_CONFIRMED", + "BOOKING_REJECTED", + "BOOKING_COMPLETED", + "BOOKING_NO_SHOW", + "BOOKING_REOPENED" + ], + "enum": [ + "BOOKING_CREATED", + "BOOKING_PAYMENT_INITIATED", + "BOOKING_PAID", + "BOOKING_RESCHEDULED", + "BOOKING_REQUESTED", + "BOOKING_CANCELLED", + "BOOKING_REJECTED", + "BOOKING_NO_SHOW_UPDATED", + "FORM_SUBMITTED", + "MEETING_ENDED", + "MEETING_STARTED", + "RECORDING_READY", + "INSTANT_MEETING", + "RECORDING_TRANSCRIPTION_GENERATED", + "OOO_CREATED", + "AFTER_HOSTS_CAL_VIDEO_NO_SHOW", + "AFTER_GUESTS_CAL_VIDEO_NO_SHOW", + "FORM_SUBMITTED_NO_EVENT" + ] + }, + "secret": { + "type": "string" + } + }, + "required": [ + "active", + "subscriberUrl", + "triggers" + ] + }, + "TeamWebhookOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamWebhookOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateWebhookInputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "active": { + "type": "boolean" + }, + "subscriberUrl": { + "type": "string" + }, + "triggers": { + "type": "string", + "example": [ + "BOOKING_CREATED", + "BOOKING_RESCHEDULED", + "BOOKING_CANCELLED", + "BOOKING_CONFIRMED", + "BOOKING_REJECTED", + "BOOKING_COMPLETED", + "BOOKING_NO_SHOW", + "BOOKING_REOPENED" + ], + "enum": [ + "BOOKING_CREATED", + "BOOKING_PAYMENT_INITIATED", + "BOOKING_PAID", + "BOOKING_RESCHEDULED", + "BOOKING_REQUESTED", + "BOOKING_CANCELLED", + "BOOKING_REJECTED", + "BOOKING_NO_SHOW_UPDATED", + "FORM_SUBMITTED", + "MEETING_ENDED", + "MEETING_STARTED", + "RECORDING_READY", + "INSTANT_MEETING", + "RECORDING_TRANSCRIPTION_GENERATED", + "OOO_CREATED", + "AFTER_HOSTS_CAL_VIDEO_NO_SHOW", + "AFTER_GUESTS_CAL_VIDEO_NO_SHOW", + "FORM_SUBMITTED_NO_EVENT" + ] + }, + "secret": { + "type": "string" + } + } + }, + "CreateOutOfOfficeEntryDto": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string", + "description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-01T00:00:00.000Z" + }, + "end": { + "format": "date-time", + "type": "string", + "description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-10T23:59:59.999Z" + }, + "notes": { + "type": "string", + "description": "Optional notes for the out of office entry.", + "example": "Vacation in Hawaii" + }, + "toUserId": { + "type": "number", + "description": "The ID of the user covering for the out of office period, if applicable.", + "example": 2 + }, + "reason": { + "type": "string", + "description": "the reason for the out of office entry, if applicable", + "example": "vacation", + "enum": [ + "unspecified", + "vacation", + "travel", + "sick", + "public_holiday" + ] + } + }, + "required": [ + "start", + "end" + ] + }, + "UpdateOutOfOfficeEntryDto": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string", + "description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-01T00:00:00.000Z" + }, + "end": { + "format": "date-time", + "type": "string", + "description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-10T23:59:59.999Z" + }, + "notes": { + "type": "string", + "description": "Optional notes for the out of office entry.", + "example": "Vacation in Hawaii" + }, + "toUserId": { + "type": "number", + "description": "The ID of the user covering for the out of office period, if applicable.", + "example": 2 + }, + "reason": { + "type": "string", + "description": "the reason for the out of office entry, if applicable", + "example": "vacation", + "enum": [ + "unspecified", + "vacation", + "travel", + "sick", + "public_holiday" + ] + } + } + }, + "StripConnectOutputDto": { + "type": "object", + "properties": { + "authUrl": { + "type": "string" + } + }, + "required": [ + "authUrl" + ] + }, + "StripConnectOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/StripConnectOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "StripCredentialsSaveOutputResponseDto": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "StripCredentialsCheckOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success" + } + }, + "required": [ + "status" + ] + }, + "GetDefaultScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateTeamInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the team", + "example": "CalTeam" + }, + "slug": { + "type": "string", + "description": "Team slug", + "example": "caltel" + }, + "logoUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + "description": "URL of the teams logo image" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean", + "default": false + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean" + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + "description": "URL of the teams banner image which is shown on booker" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London", + "example": "America/New_York", + "description": "Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed." + }, + "weekStart": { + "type": "string", + "default": "Sunday", + "example": "Monday" + }, + "autoAcceptCreator": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name" + ] + }, + "CreateTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/Output" + }, + { + "$ref": "#/components/schemas/TeamOutputDto" + } + ], + "description": "Either an Output object or a TeamOutputDto." + } + }, + "required": [ + "status", + "data" + ] + }, + "TeamOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "parentId": { + "type": "number" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "isOrganization": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean", + "default": false + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London" + }, + "weekStart": { + "type": "string", + "default": "Sunday" + } + }, + "required": [ + "id", + "name", + "isOrganization" + ] + }, + "GetTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "AuthUrlData": { + "type": "object", + "properties": { + "authUrl": { + "type": "string" + } + }, + "required": [ + "authUrl" + ] + }, + "GcalAuthUrlOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/AuthUrlData" + } + }, + "required": [ + "status", + "data" + ] + }, + "GcalSaveRedirectOutput": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "GcalCheckOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "ProviderVerifyClientData": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "organizationId": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": [ + "clientId", + "organizationId", + "name" + ] + }, + "ProviderVerifyClientOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ProviderVerifyClientData" + } + }, + "required": [ + "status", + "data" + ] + }, + "ProviderVerifyAccessTokenOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "MeOrgOutput": { + "type": "object", + "properties": { + "isPlatform": { + "type": "boolean" + }, + "id": { + "type": "number" + } + }, + "required": [ + "isPlatform", + "id" + ] + }, + "MeOutput": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "defaultScheduleId": { + "type": "number", + "nullable": true + }, + "weekStart": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "organizationId": { + "type": "number", + "nullable": true + }, + "organization": { + "$ref": "#/components/schemas/MeOrgOutput" + } + }, + "required": [ + "id", + "username", + "email", + "timeFormat", + "defaultScheduleId", + "weekStart", + "timeZone", + "organizationId" + ] + }, + "GetMeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/MeOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateMeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/MeOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateIcsFeedInputDto": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "example": [ + "https://cal.com/ics/feed.ics", + "http://cal.com/ics/feed.ics" + ], + "description": "An array of ICS URLs", + "items": { + "type": "string", + "example": "https://cal.com/ics/feed.ics" + } + }, + "readOnly": { + "type": "boolean", + "default": true, + "example": false, + "description": "Whether to allowing writing to the calendar or not" + } + }, + "required": [ + "urls" + ] + }, + "CreateIcsFeedOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1234567890, + "description": "The id of the calendar credential" + }, + "type": { + "type": "string", + "example": "ics-feed_calendar", + "description": "The type of the calendar" + }, + "userId": { + "type": "integer", + "nullable": true, + "example": 1234567890, + "description": "The user id of the user that created the calendar" + }, + "teamId": { + "type": "integer", + "nullable": true, + "example": 1234567890, + "description": "The team id of the user that created the calendar" + }, + "appId": { + "type": "string", + "nullable": true, + "example": "ics-feed", + "description": "The slug of the calendar" + }, + "invalid": { + "type": "boolean", + "nullable": true, + "example": false, + "description": "Whether the calendar credentials are valid or not" + } + }, + "required": [ + "id", + "type", + "userId", + "teamId", + "appId", + "invalid" + ] + }, + "CreateIcsFeedOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/CreateIcsFeedOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "BusyTimesOutput": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string" + }, + "end": { + "format": "date-time", + "type": "string" + }, + "source": { + "type": "string", + "nullable": true + } + }, + "required": [ + "start", + "end" + ] + }, + "GetBusyTimesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BusyTimesOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "Integration": { + "type": "object", + "properties": { + "appData": { + "type": "object", + "nullable": true + }, + "dirName": { + "type": "string" + }, + "__template": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "logo": { + "type": "string" + }, + "publisher": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "email": { + "type": "string" + }, + "locationOption": { + "type": "object", + "nullable": true + } + }, + "required": [ + "name", + "description", + "type", + "variant", + "categories", + "logo", + "publisher", + "slug", + "url", + "email", + "locationOption" + ] + }, + "Primary": { + "type": "object", + "properties": { + "externalId": { + "type": "string" + }, + "integration": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primary": { + "type": "boolean", + "nullable": true + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "isSelected": { + "type": "boolean" + }, + "credentialId": { + "type": "number" + } + }, + "required": [ + "externalId", + "primary", + "readOnly", + "isSelected", + "credentialId" + ] + }, + "Calendar": { + "type": "object", + "properties": { + "externalId": { + "type": "string" + }, + "integration": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primary": { + "type": "boolean", + "nullable": true + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "isSelected": { + "type": "boolean" + }, + "credentialId": { + "type": "number" + } + }, + "required": [ + "externalId", + "readOnly", + "isSelected", + "credentialId" + ] + }, + "ConnectedCalendar": { + "type": "object", + "properties": { + "integration": { + "$ref": "#/components/schemas/Integration" + }, + "credentialId": { + "type": "number" + }, + "primary": { + "$ref": "#/components/schemas/Primary" + }, + "calendars": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Calendar" + } + } + }, + "required": [ + "integration", + "credentialId" + ] + }, + "DestinationCalendar": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "primaryEmail": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "number", + "nullable": true + }, + "eventTypeId": { + "type": "number", + "nullable": true + }, + "credentialId": { + "type": "number", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "primary": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "integrationTitle": { + "type": "string" + } + }, + "required": [ + "id", + "integration", + "externalId", + "primaryEmail", + "userId", + "eventTypeId", + "credentialId" + ] + }, + "ConnectedCalendarsData": { + "type": "object", + "properties": { + "connectedCalendars": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConnectedCalendar" + } + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar" + } + }, + "required": [ + "connectedCalendars", + "destinationCalendar" + ] + }, + "ConnectedCalendarsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ConnectedCalendarsData" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteCalendarCredentialsInputBodyDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 10, + "description": "Credential ID of the calendar to delete, as returned by the /calendars endpoint" + } + }, + "required": [ + "id" + ] + }, + "DeletedCalendarCredentialsOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "type": { + "type": "string" + }, + "userId": { + "type": "number", + "nullable": true + }, + "teamId": { + "type": "number", + "nullable": true + }, + "appId": { + "type": "string", + "nullable": true + }, + "invalid": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "id", + "type", + "userId", + "teamId", + "appId", + "invalid" + ] + }, + "DeletedCalendarCredentialsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/DeletedCalendarCredentialsOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "Attendee": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the attendee.", + "example": "John Doe" + }, + "email": { + "type": "string", + "description": "The email of the attendee.", + "example": "john.doe@example.com" + }, + "timeZone": { + "type": "string", + "description": "The time zone of the attendee.", + "example": "America/New_York" + }, + "phoneNumber": { + "type": "string", + "description": "The phone number of the attendee in international format.", + "example": "+919876543210" + }, + "language": { + "type": "string", + "enum": [ + "ar", + "ca", + "de", + "es", + "eu", + "he", + "id", + "ja", + "lv", + "pl", + "ro", + "sr", + "th", + "vi", + "az", + "cs", + "el", + "es-419", + "fi", + "hr", + "it", + "km", + "nl", + "pt", + "ru", + "sv", + "tr", + "zh-CN", + "bg", + "da", + "en", + "et", + "fr", + "hu", + "iw", + "ko", + "no", + "pt-BR", + "sk", + "ta", + "uk", + "zh-TW" + ], + "description": "The preferred language of the attendee. Used for booking confirmation.", + "example": "it", + "default": "en" + } + }, + "required": [ + "name", + "timeZone" + ] + }, + "CreateBookingInput_2024_08_13": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "The start time of the booking in ISO 8601 format in UTC timezone.", + "example": "2024-08-13T09:00:00Z" + }, + "lengthInMinutes": { + "type": "number", + "example": 30, + "description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking." + }, + "eventTypeId": { + "type": "number", + "description": "The ID of the event type that is booked.", + "example": 123 + }, + "attendee": { + "description": "The attendee's details.", + "allOf": [ + { + "$ref": "#/components/schemas/Attendee" + } + ] + }, + "guests": { + "description": "An optional list of guest emails attending the event.", + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - use 'location' instead. Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + "example": "https://example.com/meeting", + "deprecated": true + }, + "location": { + "type": "string", + "description": "Location for this booking. Displayed in email and calendar event.", + "example": "https://example.com/meeting" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.", + "example": { + "key": "value" + } + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + "example": { + "customField": "customValue" + } + } + }, + "required": [ + "start", + "eventTypeId", + "attendee" + ] + }, + "CreateInstantBookingInput_2024_08_13": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "The start time of the booking in ISO 8601 format in UTC timezone.", + "example": "2024-08-13T09:00:00Z" + }, + "lengthInMinutes": { + "type": "number", + "example": 30, + "description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking." + }, + "eventTypeId": { + "type": "number", + "description": "The ID of the event type that is booked.", + "example": 123 + }, + "attendee": { + "description": "The attendee's details.", + "allOf": [ + { + "$ref": "#/components/schemas/Attendee" + } + ] + }, + "guests": { + "description": "An optional list of guest emails attending the event.", + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - use 'location' instead. Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + "example": "https://example.com/meeting", + "deprecated": true + }, + "location": { + "type": "string", + "description": "Location for this booking. Displayed in email and calendar event.", + "example": "https://example.com/meeting" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.", + "example": { + "key": "value" + } + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + "example": { + "customField": "customValue" + } + }, + "instant": { + "type": "boolean", + "description": "Flag indicating if the booking is an instant booking. Only available for team events.", + "example": true + } + }, + "required": [ + "start", + "eventTypeId", + "attendee", + "instant" + ] + }, + "CreateRecurringBookingInput_2024_08_13": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "The start time of the booking in ISO 8601 format in UTC timezone.", + "example": "2024-08-13T09:00:00Z" + }, + "lengthInMinutes": { + "type": "number", + "example": 30, + "description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking." + }, + "eventTypeId": { + "type": "number", + "description": "The ID of the event type that is booked.", + "example": 123 + }, + "attendee": { + "description": "The attendee's details.", + "allOf": [ + { + "$ref": "#/components/schemas/Attendee" + } + ] + }, + "guests": { + "description": "An optional list of guest emails attending the event.", + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - use 'location' instead. Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + "example": "https://example.com/meeting", + "deprecated": true + }, + "location": { + "type": "string", + "description": "Location for this booking. Displayed in email and calendar event.", + "example": "https://example.com/meeting" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.", + "example": { + "key": "value" + } + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + "example": { + "customField": "customValue" + } + }, + "recurrenceCount": { + "type": "number", + "description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count", + "example": 5 + } + }, + "required": [ + "start", + "eventTypeId", + "attendee" + ] + }, + "BookingHost": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "email": { + "type": "string", + "example": "jane100@example.com" + }, + "username": { + "type": "string", + "example": "jane100" + }, + "timeZone": { + "type": "string", + "example": "America/Los_Angeles" + } + }, + "required": [ + "id", + "name", + "email", + "username", + "timeZone" + ] + }, + "EventType": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "slug": { + "type": "string", + "example": "some-event" + } + }, + "required": [ + "id", + "slug" + ] + }, + "BookingOutput_2024_08_13": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "uid": { + "type": "string", + "example": "booking_uid_123" + }, + "title": { + "type": "string", + "example": "Consultation" + }, + "description": { + "type": "string", + "example": "Learn how to integrate scheduling into marketplace." + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingHost" + } + }, + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "accepted" + }, + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" + }, + "reschedulingReason": { + "type": "string", + "example": "User rescheduled the event" + }, + "rescheduledFromUid": { + "type": "string", + "example": "previous_uid_123" + }, + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" + }, + "duration": { + "type": "number", + "example": 60 + }, + "eventTypeId": { + "type": "number", + "example": 50, + "deprecated": true, + "description": "Deprecated - rely on 'eventType' object containing the id instead." + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attendee" + } + }, + "guests": { + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + "example": { + "customField": "customValue" + } + } + }, + "required": [ + "id", + "uid", + "title", + "description", + "hosts", + "status", + "start", + "end", + "duration", + "eventTypeId", + "eventType", + "location", + "absentHost", + "createdAt", + "attendees", + "bookingFieldsResponses" + ] + }, + "RecurringBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "uid": { + "type": "string", + "example": "booking_uid_123" + }, + "title": { + "type": "string", + "example": "Consultation" + }, + "description": { + "type": "string", + "example": "Learn how to integrate scheduling into marketplace." + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingHost" + } + }, + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "accepted" + }, + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" + }, + "reschedulingReason": { + "type": "string", + "example": "User rescheduled the event" + }, + "rescheduledFromUid": { + "type": "string", + "example": "previous_uid_123" + }, + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" + }, + "duration": { + "type": "number", + "example": 60 + }, + "eventTypeId": { + "type": "number", + "example": 50, + "deprecated": true, + "description": "Deprecated - rely on 'eventType' object containing the id instead." + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attendee" + } + }, + "guests": { + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + "example": { + "customField": "customValue" + } + }, + "recurringBookingUid": { + "type": "string", + "example": "recurring_uid_987" + } + }, + "required": [ + "id", + "uid", + "title", + "description", + "hosts", + "status", + "start", + "end", + "duration", + "eventTypeId", + "eventType", + "location", + "absentHost", + "createdAt", + "attendees", + "bookingFieldsResponses", + "recurringBookingUid" + ] + }, + "SeatedAttendee": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "John Doe" + }, + "email": { + "type": "string", + "example": "john@example.com" + }, + "timeZone": { + "type": "string", + "example": "America/New_York" + }, + "language": { + "type": "string", + "enum": [ + "ar", + "ca", + "de", + "es", + "eu", + "he", + "id", + "ja", + "lv", + "pl", + "ro", + "sr", + "th", + "vi", + "az", + "cs", + "el", + "es-419", + "fi", + "hr", + "it", + "km", + "nl", + "pt", + "ru", + "sv", + "tr", + "zh-CN", + "bg", + "da", + "en", + "et", + "fr", + "hu", + "iw", + "ko", + "no", + "pt-BR", + "sk", + "ta", + "uk", + "zh-TW" + ], + "example": "en" + }, + "absent": { + "type": "boolean", + "example": false + }, + "phoneNumber": { + "type": "string", + "example": "+1234567890" + }, + "seatUid": { + "type": "string", + "example": "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.", + "example": { + "customField": "customValue" + } + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + } + }, + "required": [ + "name", + "email", + "timeZone", + "absent", + "seatUid", + "bookingFieldsResponses" + ] + }, + "CreateSeatedBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "uid": { + "type": "string", + "example": "booking_uid_123" + }, + "title": { + "type": "string", + "example": "Consultation" + }, + "description": { + "type": "string", + "example": "Learn how to integrate scheduling into marketplace." + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingHost" + } + }, + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "accepted" + }, + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" + }, + "reschedulingReason": { + "type": "string", + "example": "User rescheduled the event" + }, + "rescheduledFromUid": { + "type": "string", + "example": "previous_uid_123" + }, + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" + }, + "duration": { + "type": "number", + "example": 60 + }, + "eventTypeId": { + "type": "number", + "example": 50, + "deprecated": true, + "description": "Deprecated - rely on 'eventType' object containing the id instead." + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, + "seatUid": { + "type": "string", + "example": "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeatedAttendee" + } + } + }, + "required": [ + "id", + "uid", + "title", + "description", + "hosts", + "status", + "start", + "end", + "duration", + "eventTypeId", + "eventType", + "location", + "absentHost", + "createdAt", + "seatUid", + "attendees" + ] + }, + "CreateRecurringSeatedBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "uid": { + "type": "string", + "example": "booking_uid_123" + }, + "title": { + "type": "string", + "example": "Consultation" + }, + "description": { + "type": "string", + "example": "Learn how to integrate scheduling into marketplace." + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingHost" + } + }, + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "accepted" + }, + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" + }, + "reschedulingReason": { + "type": "string", + "example": "User rescheduled the event" + }, + "rescheduledFromUid": { + "type": "string", + "example": "previous_uid_123" + }, + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" + }, + "duration": { + "type": "number", + "example": 60 + }, + "eventTypeId": { + "type": "number", + "example": 50, + "deprecated": true, + "description": "Deprecated - rely on 'eventType' object containing the id instead." + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, + "seatUid": { + "type": "string", + "example": "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeatedAttendee" + } + }, + "recurringBookingUid": { + "type": "string", + "example": "recurring_uid_987" + } + }, + "required": [ + "id", + "uid", + "title", + "description", + "hosts", + "status", + "start", + "end", + "duration", + "eventTypeId", + "eventType", + "location", + "absentHost", + "createdAt", + "seatUid", + "attendees", + "recurringBookingUid" + ] + }, + "CreateBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + }, + { + "$ref": "#/components/schemas/CreateSeatedBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateRecurringSeatedBookingOutput_2024_08_13" + } + } + ], + "description": "Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetSeatedBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "uid": { + "type": "string", + "example": "booking_uid_123" + }, + "title": { + "type": "string", + "example": "Consultation" + }, + "description": { + "type": "string", + "example": "Learn how to integrate scheduling into marketplace." + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingHost" + } + }, + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "accepted" + }, + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" + }, + "reschedulingReason": { + "type": "string", + "example": "User rescheduled the event" + }, + "rescheduledFromUid": { + "type": "string", + "example": "previous_uid_123" + }, + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" + }, + "duration": { + "type": "number", + "example": 60 + }, + "eventTypeId": { + "type": "number", + "example": 50, + "deprecated": true, + "description": "Deprecated - rely on 'eventType' object containing the id instead." + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeatedAttendee" + } + } + }, + "required": [ + "id", + "uid", + "title", + "description", + "hosts", + "status", + "start", + "end", + "duration", + "eventTypeId", + "eventType", + "location", + "absentHost", + "createdAt", + "attendees" + ] + }, + "GetRecurringSeatedBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "uid": { + "type": "string", + "example": "booking_uid_123" + }, + "title": { + "type": "string", + "example": "Consultation" + }, + "description": { + "type": "string", + "example": "Learn how to integrate scheduling into marketplace." + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingHost" + } + }, + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "accepted" + }, + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" + }, + "reschedulingReason": { + "type": "string", + "example": "User rescheduled the event" + }, + "rescheduledFromUid": { + "type": "string", + "example": "previous_uid_123" + }, + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" + }, + "duration": { + "type": "number", + "example": 60 + }, + "eventTypeId": { + "type": "number", + "example": 50, + "deprecated": true, + "description": "Deprecated - rely on 'eventType' object containing the id instead." + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "meetingUrl": { + "type": "string", + "description": "Deprecated - rely on 'location' field instead.", + "example": "https://example.com/recurring-meeting", + "deprecated": true + }, + "location": { + "type": "string", + "example": "https://example.com/meeting" + }, + "absentHost": { + "type": "boolean", + "example": true + }, + "createdAt": { + "type": "string", + "example": "2024-08-13T15:30:00Z" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeatedAttendee" + } + }, + "recurringBookingUid": { + "type": "string", + "example": "recurring_uid_987" + } + }, + "required": [ + "id", + "uid", + "title", + "description", + "hosts", + "status", + "start", + "end", + "duration", + "eventTypeId", + "eventType", + "location", + "absentHost", + "createdAt", + "attendees", + "recurringBookingUid" + ] + }, + "GetBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + }, + { + "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + } + } + ], + "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects" + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetBookingsOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + } + ] + }, + "description": "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects" + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "RescheduleBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateRecurringSeatedBookingOutput_2024_08_13" + } + ], + "description": "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object" + } + }, + "required": [ + "status", + "data" + ] + }, + "CancelBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + }, + { + "$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13" + } + } + ], + "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects" + } + }, + "required": [ + "status", + "data" + ] + }, + "MarkAbsentBookingInput_2024_08_13": { + "type": "object", + "properties": { + "host": { + "type": "boolean", + "example": false, + "description": "Whether the host was absent" + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attendee" + } + } + } + }, + "MarkAbsentBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + ], + "description": "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object" + } + }, + "required": [ + "status", + "data" + ] + }, + "ReassignedToDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "name": { + "type": "string", + "example": "John Doe" + }, + "email": { + "type": "string", + "example": "john.doe@example.com" + } + }, + "required": [ + "id", + "name", + "email" + ] + }, + "ReassignBookingOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13" + } + ], + "description": "Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object", + "allOf": [ + { + "$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "ReassignToUserBookingInput_2024_08_13": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "example": "Host has to take another call", + "description": "Reason for reassigning the booking" + } + } + }, + "DeclineBookingInput_2024_08_13": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "example": "Host has to take another call", + "description": "Reason for declining a booking that requires a confirmation" + } + } + }, + "CreateTeamMembershipInput": { + "type": "object", + "properties": { + "userId": { + "type": "number" + }, + "accepted": { + "type": "boolean", + "default": false + }, + "role": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + }, + "required": [ + "userId" + ] + }, + "TeamMembershipOutput": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "teamId": { + "type": "number" + }, + "accepted": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean" + } + }, + "required": [ + "id", + "userId", + "teamId", + "accepted", + "role" + ] + }, + "CreateTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamMembershipsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateTeamMembershipInput": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "disableImpersonation": { + "type": "boolean" + } + } + }, + "UpdateTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "ReserveSlotInput": { + "type": "object", + "properties": { + "eventTypeId": { + "type": "number", + "description": "Event Type ID for which timeslot is being reserved.", + "example": 100 + }, + "slotUtcStartDate": { + "type": "string", + "description": "Start date of the slot in UTC timezone.", + "example": "2022-06-14T00:00:00.000Z" + }, + "slotUtcEndDate": { + "type": "string", + "description": "End date of the slot in UTC timezone.", + "example": "2022-06-14T00:30:00.000Z" + }, + "bookingUid": { + "type": "string", + "description": "Optional but only for events with seats. Used to retrieve booking of a seated event." + } + }, + "required": [ + "eventTypeId", + "slotUtcStartDate", + "slotUtcEndDate" + ] + }, + "UserWebhookOutputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "userId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "payloadTemplate", + "userId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "UserWebhookOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/UserWebhookOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UserWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserWebhookOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "EventTypeWebhookOutputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "eventTypeId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "payloadTemplate", + "eventTypeId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "EventTypeWebhookOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/EventTypeWebhookOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "EventTypeWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeWebhookOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteManyWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "string" + } + }, + "required": [ + "status", + "data" + ] + }, + "OAuthClientWebhookOutputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "oAuthClientId": { + "type": "string" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "payloadTemplate", + "oAuthClientId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "OAuthClientWebhookOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OAuthClientWebhookOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "OAuthClientWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OAuthClientWebhookOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "DestinationCalendarsInputBodyDto": { + "type": "object", + "properties": { + "integration": { + "type": "string", + "example": "apple_calendar", + "description": "The calendar service you want to integrate, as returned by the /calendars endpoint", + "enum": [ + "apple_calendar", + "google_calendar", + "office365_calendar" + ] + }, + "externalId": { + "type": "string", + "example": "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/", + "description": "Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint" + } + }, + "required": [ + "integration", + "externalId" + ] + }, + "DestinationCalendarsOutputDto": { + "type": "object", + "properties": { + "userId": { + "type": "number" + }, + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "credentialId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "userId", + "integration", + "externalId", + "credentialId" + ] + }, + "DestinationCalendarsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/DestinationCalendarsOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "ConferencingAppsOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Id of the conferencing app credentials" + }, + "type": { + "type": "string", + "example": "google_video", + "description": "Type of conferencing app" + }, + "userId": { + "type": "number", + "description": "Id of the user associated to the conferencing app" + }, + "invalid": { + "type": "boolean", + "nullable": true, + "example": true, + "description": "Whether if the connection is working or not." + } + }, + "required": [ + "id", + "type", + "userId" + ] + }, + "ConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ConferencingAppsOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetConferencingAppsOauthUrlResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "ConferencingAppsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConferencingAppsOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "SetDefaultConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "DefaultConferencingAppsOutputDto": { + "type": "object", + "properties": { + "appSlug": { + "type": "string" + }, + "appLink": { + "type": "string" + } + } + }, + "GetDefaultConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/DefaultConferencingAppsOutputDto" + } + }, + "required": [ + "status" + ] + }, + "DisconnectConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + } + } + } +} \ No newline at end of file diff --git a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts new file mode 100644 index 00000000000000..ddf113290550c2 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts @@ -0,0 +1,28 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { randomBytes, createHash } from "crypto"; + +export class ApiKeysRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) { + const keyString = randomBytes(16).toString("hex"); + const apiKey = await this.prismaWriteClient.apiKey.create({ + data: { + userId, + teamId, + hashedKey: createHash("sha256").update(keyString).digest("hex"), + expiresAt: expiresAt, + }, + }); + + return { apiKey, keyString }; + } +} diff --git a/apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts new file mode 100644 index 00000000000000..c33d247a6aee31 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts @@ -0,0 +1,30 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, User } from "@prisma/client"; + +export class AttributeRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.AttributeCreateInput) { + return this.prismaWriteClient.attribute.create({ data }); + } + + async createOption(data: Prisma.AttributeOptionCreateInput) { + return this.prismaWriteClient.attributeOption.create({ data }); + } + + async delete(id: string) { + return this.prismaWriteClient.attribute.delete({ where: { id } }); + } + + async deleteOption(id: string) { + return this.prismaWriteClient.attributeOption.delete({ where: { id } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts new file mode 100644 index 00000000000000..40565399218711 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts @@ -0,0 +1,46 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; + +export class PlatformBillingRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(orgId: number) { + const randomString = Date.now().toString(36); + return this.prismaWriteClient.platformBilling.create({ + data: { + id: orgId, + customerId: `cus_123_${randomString}`, + subscriptionId: `sub_123_${randomString}`, + plan: "STARTER", + }, + }); + } + + async get(orgId: number) { + return this.prismaWriteClient.platformBilling.findFirst({ + where: { + id: orgId, + }, + }); + } + + async deleteSubscriptionForTeam(teamId: number) { + // silently try to delete the subscription + try { + await this.prismaWriteClient.platformBilling.delete({ + where: { + id: teamId, + }, + }); + } catch (err) { + console.error(err); + } + } +} diff --git a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts new file mode 100644 index 00000000000000..9bfbd7abd24f8d --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -0,0 +1,44 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Booking, User } from "@prisma/client"; + +import { Prisma } from "@calcom/prisma/client"; + +export class BookingsRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getById(bookingId: Booking["id"]) { + return this.prismaReadClient.booking.findFirst({ where: { id: bookingId } }); + } + + async getByUid(bookingUid: Booking["uid"]) { + return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } }); + } + + async getByRecurringBookingUid(recurringBookingUid: string) { + return this.prismaReadClient.booking.findMany({ + where: { + recurringEventId: recurringBookingUid, + }, + }); + } + + async create(booking: Prisma.BookingCreateInput) { + return this.prismaWriteClient.booking.create({ data: booking }); + } + + async deleteById(bookingId: Booking["id"]) { + return this.prismaWriteClient.booking.delete({ where: { id: bookingId } }); + } + + async deleteAllBookings(userId: User["id"], userEmail: User["email"]) { + return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts new file mode 100644 index 00000000000000..6bfad9c671a741 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts @@ -0,0 +1,33 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma } from "@prisma/client"; + +export class CredentialsRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + create(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) { + return this.prismaWriteClient.credential.create({ + data: { + type, + key, + userId, + appId, + }, + }); + } + + delete(id: number) { + return this.prismaWriteClient.credential.delete({ + where: { + id, + }, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts new file mode 100644 index 00000000000000..e27ff90711c5fa --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts @@ -0,0 +1,62 @@ +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { EventType } from "@prisma/client"; + +import { Prisma } from "@calcom/prisma/client"; + +export class EventTypesRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getAllUserEventTypes(userId: number) { + return this.prismaWriteClient.eventType.findMany({ + where: { + userId, + }, + }); + } + + async getAllTeamEventTypes(teamId: number) { + return this.prismaWriteClient.eventType.findMany({ + where: { + teamId, + }, + include: { + hosts: true, + }, + }); + } + + async create(data: Prisma.EventTypeCreateInput, userId: number) { + return this.prismaWriteClient.eventType.create({ + data: { + ...data, + users: { + connect: { + id: userId, + }, + }, + owner: { + connect: { + id: userId, + }, + }, + }, + }); + } + + async createTeamEventType(data: Prisma.EventTypeCreateInput) { + return this.prismaWriteClient.eventType.create({ data }); + } + + async delete(eventTypeId: EventType["id"]) { + return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts new file mode 100644 index 00000000000000..304fdf1415a690 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts @@ -0,0 +1,25 @@ +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { EventType } from "@prisma/client"; + +import { Prisma } from "@calcom/prisma/client"; + +export class HostsRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.HostCreateInput) { + return this.prismaWriteClient.host.create({ + data: { + ...data, + }, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts new file mode 100644 index 00000000000000..0943c4001fe608 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts @@ -0,0 +1,38 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Membership, MembershipRole, Prisma, Team, User } from "@prisma/client"; + +export class MembershipRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.MembershipCreateInput) { + return this.prismaWriteClient.membership.create({ data }); + } + + async delete(membershipId: Membership["id"]) { + return this.prismaWriteClient.membership.delete({ where: { id: membershipId } }); + } + + async get(membershipId: Membership["id"]) { + return this.prismaReadClient.membership.findFirst({ where: { id: membershipId } }); + } + + async getUserMembershipByTeamId(userId: User["id"], teamId: Team["id"]) { + return this.prismaReadClient.membership.findFirst({ where: { teamId, userId } }); + } + + async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) { + const membership = await this.prismaWriteClient.membership.create({ + data: { teamId: org.id, userId: user.id, role, accepted }, + }); + await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } }); + return membership; + } +} diff --git a/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts new file mode 100644 index 00000000000000..4aeb2868b041dc --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts @@ -0,0 +1,49 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient } from "@prisma/client"; + +import { CreateOAuthClientInput } from "@calcom/platform-types"; + +export class OAuthClientRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(clientId: PlatformOAuthClient["id"]) { + return this.prismaReadClient.platformOAuthClient.findFirst({ where: { id: clientId } }); + } + + async getUsers(clientId: PlatformOAuthClient["id"]) { + const response = await this.prismaReadClient.platformOAuthClient.findFirst({ + where: { id: clientId }, + include: { + users: true, + }, + }); + + return response?.users; + } + + async create(organizationId: number, data: CreateOAuthClientInput, secret: string) { + return this.prismaWriteClient.platformOAuthClient.create({ + data: { + ...data, + secret, + organizationId, + }, + }); + } + + async delete(clientId: PlatformOAuthClient["id"]) { + return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } }); + } + + async deleteByClientId(clientId: PlatformOAuthClient["id"]) { + return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts new file mode 100644 index 00000000000000..dbf746918cdb0c --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts @@ -0,0 +1,47 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, Team } from "@prisma/client"; + +export class OrganizationRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(teamId: Team["id"]) { + return this.primaReadClient.team.findFirst({ where: { id: teamId } }); + } + + async create(data: Prisma.TeamCreateInput) { + return await this.prismaWriteClient.$transaction(async (prisma) => { + const team = await prisma.team.create({ + data: { + ...data, + isOrganization: true, + }, + }); + + await prisma.organizationSettings.create({ + data: { + organizationId: team.id, + isAdminAPIEnabled: true, + orgAutoAcceptEmail: "cal.com", + }, + }); + return team; + }); + } + + async delete(teamId: Team["id"]) { + return await this.prismaWriteClient.$transaction(async (prisma) => { + await prisma.organizationSettings.delete({ + where: { organizationId: teamId }, + }); + return prisma.team.delete({ where: { id: teamId } }); + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/profiles.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/profiles.repository.fixture.ts new file mode 100644 index 00000000000000..fbaf918b13eb48 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/profiles.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma } from "@prisma/client"; + +export class ProfileRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(profileId: number) { + return this.prismaReadClient.profile.findFirst({ where: { id: profileId } }); + } + + async create(data: Prisma.ProfileCreateInput) { + return this.prismaWriteClient.profile.create({ data }); + } + + async delete(profileId: number) { + return this.prismaWriteClient.profile.delete({ where: { id: profileId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/rate-limit.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/rate-limit.repository.fixture.ts new file mode 100644 index 00000000000000..2c0672b5d8e87e --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/rate-limit.repository.fixture.ts @@ -0,0 +1,22 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; + +export class RateLimitRepositoryFixture { + private dbWrite: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.dbWrite = module.get(PrismaWriteService).prisma; + } + + async createRateLimit(name: string, apiKeyId: string, ttl: number, limit: number, blockDuration: number) { + return await this.dbWrite.rateLimit.create({ + data: { + name, + apiKeyId, + ttl, + limit, + blockDuration, + }, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts new file mode 100644 index 00000000000000..7ece0dd3bc52ff --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts @@ -0,0 +1,36 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Schedule } from "@prisma/client"; + +import { Prisma } from "@calcom/prisma/client"; + +export class SchedulesRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(schedule: Prisma.ScheduleCreateArgs["data"]) { + return this.prismaWriteClient.schedule.create({ data: schedule }); + } + + async getById(scheduleId: Schedule["id"]) { + return this.prismaReadClient.schedule.findFirst({ where: { id: scheduleId } }); + } + + async deleteById(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.schedule.delete({ where: { id: scheduleId } }); + } + + async deleteAvailabilities(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.availability.deleteMany({ where: { scheduleId } }); + } + + async getByUserId(userId: Schedule["userId"]) { + return this.prismaReadClient.schedule.findMany({ where: { userId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/selected-slots.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/selected-slots.repository.fixture.ts new file mode 100644 index 00000000000000..b9b5150eff3c3e --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/selected-slots.repository.fixture.ts @@ -0,0 +1,18 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { SelectedSlots } from "@prisma/client"; + +export class SelectedSlotsRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async deleteByUId(uid: SelectedSlots["uid"]) { + return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts new file mode 100644 index 00000000000000..0e917e3e124d89 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts @@ -0,0 +1,35 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, Team } from "@prisma/client"; + +export class TeamRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(teamId: Team["id"]) { + return this.prismaReadClient.team.findFirst({ where: { id: teamId } }); + } + + async create(data: Prisma.TeamCreateInput) { + return this.prismaWriteClient.team.create({ data }); + } + + async delete(teamId: Team["id"]) { + return this.prismaWriteClient.team.deleteMany({ where: { id: teamId } }); + } + + async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) { + return this.prismaReadClient.team.findMany({ + where: { + parentId: organizationId, + createdByOAuthClientId: oAuthClientId, + }, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts new file mode 100644 index 00000000000000..da0316ddc58a84 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts @@ -0,0 +1,47 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import * as crypto from "crypto"; +import { DateTime } from "luxon"; + +export class TokensRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async createTokens(userId: number, clientId: string) { + const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + const accessTokenBuffer = crypto.randomBytes(48); + const accessTokenSecret = accessTokenBuffer.toString("hex"); + const refreshTokenBuffer = crypto.randomBytes(48); + const refreshTokenSecret = refreshTokenBuffer.toString("hex"); + const [accessToken, refreshToken] = await this.prismaWriteClient.$transaction([ + this.prismaWriteClient.accessToken.create({ + data: { + secret: accessTokenSecret, + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: userId } }, + }, + }), + this.prismaWriteClient.refreshToken.create({ + data: { + secret: refreshTokenSecret, + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: userId } }, + }, + }), + ]); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } +} diff --git a/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts new file mode 100644 index 00000000000000..92449ddb783db1 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts @@ -0,0 +1,51 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, User } from "@prisma/client"; + +export class UserRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(userId: User["id"]) { + return this.prismaReadClient.user.findFirst({ where: { id: userId } }); + } + + async create(data: Prisma.UserCreateInput) { + try { + // avoid uniq constraint in tests + await this.deleteByEmail(data.email); + } catch {} + + return this.prismaWriteClient.user.create({ data }); + } + + async createOAuthManagedUser(email: Prisma.UserCreateInput["email"], oAuthClientId: string) { + try { + // avoid uniq constraint in tests + await this.deleteByEmail(email); + } catch {} + + return this.prismaWriteClient.user.create({ + data: { + email, + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + }); + } + + async delete(userId: User["id"]) { + return this.prismaWriteClient.user.delete({ where: { id: userId } }); + } + + async deleteByEmail(email: User["email"]) { + return this.prismaWriteClient.user.delete({ where: { email } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts new file mode 100644 index 00000000000000..0ab53e6e4244ef --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts @@ -0,0 +1,22 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma } from "@prisma/client"; + +export class WebhookRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.WebhookCreateInput) { + return this.prismaWriteClient.webhook.create({ data }); + } + + async delete(webhookId: string) { + return this.prismaWriteClient.webhook.delete({ where: { id: webhookId } }); + } +} diff --git a/apps/api/v2/test/mocks/api-auth-mock.strategy.ts b/apps/api/v2/test/mocks/api-auth-mock.strategy.ts new file mode 100644 index 00000000000000..9a7e760740b136 --- /dev/null +++ b/apps/api/v2/test/mocks/api-auth-mock.strategy.ts @@ -0,0 +1,25 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class ApiAuthMockStrategy extends PassportStrategy(BaseStrategy, "api-auth") { + constructor(private readonly email: string, private readonly usersRepository: UsersRepository) { + super(); + } + + async authenticate() { + try { + const user = await this.usersRepository.findByEmailWithProfile(this.email); + if (!user) { + throw new Error("User with the provided ID not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/mocks/calendars-service-mock.ts b/apps/api/v2/test/mocks/calendars-service-mock.ts new file mode 100644 index 00000000000000..85dd5a0f81a668 --- /dev/null +++ b/apps/api/v2/test/mocks/calendars-service-mock.ts @@ -0,0 +1,79 @@ +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; + +import { ICS_CALENDAR_ID, ICS_CALENDAR_TYPE } from "@calcom/platform-constants"; + +export class CalendarsServiceMock { + async getCalendars() { + return { + connectedCalendars: [ + { + integration: { + installed: false, + type: "google_calendar", + title: "", + name: "", + description: "", + variant: "calendar", + slug: "", + locationOption: null, + categories: ["calendar"], + logo: "", + publisher: "", + url: "", + email: "", + }, + credentialId: 1, + error: { message: "" }, + }, + { + integration: { + installed: false, + type: "office365_calendar", + title: "", + name: "", + description: "", + variant: "calendar", + slug: "", + locationOption: null, + categories: ["calendar"], + logo: "", + publisher: "", + url: "", + email: "", + }, + credentialId: 2, + error: { message: "" }, + }, + { + integration: { + installed: false, + type: ICS_CALENDAR_TYPE, + title: "ics-feed_calendar", + name: "ics-feed_calendar", + description: "", + variant: "calendar", + slug: ICS_CALENDAR_ID, + locationOption: null, + categories: ["calendar"], + logo: "", + publisher: "", + url: "", + email: "", + }, + credentialId: 2, + error: { message: "" }, + }, + ], + destinationCalendar: { + name: "destinationCalendar", + eventTypeId: 1, + credentialId: 1, + primaryEmail: "primaryEmail", + integration: "google_calendar", + externalId: "externalId", + userId: null, + id: 0, + }, + } satisfies Awaited>; + } +} diff --git a/apps/api/v2/test/mocks/ics-calendar-service-mock.ts b/apps/api/v2/test/mocks/ics-calendar-service-mock.ts new file mode 100644 index 00000000000000..6c9ddcfdd15326 --- /dev/null +++ b/apps/api/v2/test/mocks/ics-calendar-service-mock.ts @@ -0,0 +1,27 @@ +export class IcsCalendarServiceMock { + async listCalendars() { + return [ + { + name: "name", + readOnly: true, + externalId: "externalId", + integrationName: "ics-feed_calendar", + primary: true, + email: "email", + primaryEmail: "primaryEmail", + credentialId: 1, + integrationTitle: "integrationTitle", + }, + ] satisfies { + primary?: boolean; + name?: string; + readOnly?: boolean; + email?: string; + primaryEmail?: string; + credentialId?: number | null; + integrationTitle?: string; + externalId?: string; + integrationName?: string; + }[]; + } +} diff --git a/apps/api/v2/test/mocks/mock-redis-service.ts b/apps/api/v2/test/mocks/mock-redis-service.ts new file mode 100644 index 00000000000000..1239c3c5403890 --- /dev/null +++ b/apps/api/v2/test/mocks/mock-redis-service.ts @@ -0,0 +1,15 @@ +import { RedisService } from "@/modules/redis/redis.service"; +import { Provider } from "@nestjs/common"; + +export const MockedRedisService = { + provide: RedisService, + useValue: { + redis: { + get: jest.fn(), + hgetall: jest.fn(), + set: jest.fn(), + hmset: jest.fn(), + expireat: jest.fn(), + }, + }, +} as Provider; diff --git a/apps/api/v2/test/mocks/next-auth-mock.strategy.ts b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts new file mode 100644 index 00000000000000..236748220f0e24 --- /dev/null +++ b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts @@ -0,0 +1,24 @@ +import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class NextAuthMockStrategy extends PassportStrategy(NextAuthPassportStrategy, "next-auth") { + constructor(private readonly email: string, private readonly userRepository: UsersRepository) { + super(); + } + async authenticate() { + try { + const user = await this.userRepository.findByEmailWithProfile(this.email); + if (!user) { + throw new Error("User with the provided email not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts new file mode 100644 index 00000000000000..76aa4dea239735 --- /dev/null +++ b/apps/api/v2/test/setEnvVars.ts @@ -0,0 +1,35 @@ +import type { Environment } from "@/env"; + +const env: Partial> = { + API_URL: "http://localhost", + API_PORT: "5555", + DATABASE_URL: "postgresql://postgres:@localhost:5450/calendso", + DATABASE_READ_URL: "postgresql://postgres:@localhost:5450/calendso", + DATABASE_WRITE_URL: "postgresql://postgres:@localhost:5450/calendso", + NEXTAUTH_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", + JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", + LOG_LEVEL: "trace", + REDIS_URL: "redis://localhost:6379", + STRIPE_API_KEY: "sk_test_51J4", + STRIPE_WEBHOOK_SECRET: "whsec_51J4", + IS_E2E: true, + API_KEY_PREFIX: "cal_test_", + GET_LICENSE_KEY_URL: " https://console.cal.com/api/license", + CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7", + RATE_LIMIT_DEFAULT_TTL_MS: 60000, + // note(Lauris): setting high limit so that e2e tests themselves are not rate limited + RATE_LIMIT_DEFAULT_LIMIT: 10000, + RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000, + IS_TEAM_BILLING_ENABLED: false, +}; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +process.env = { + ...env, + ...process.env, + // fake keys for testing + NEXT_PUBLIC_VAPID_PUBLIC_KEY: + "BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw", + VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk", + CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX", +}; diff --git a/apps/api/v2/test/utils/randomString.ts b/apps/api/v2/test/utils/randomString.ts new file mode 100644 index 00000000000000..4ecd6ec28e4fd4 --- /dev/null +++ b/apps/api/v2/test/utils/randomString.ts @@ -0,0 +1,5 @@ +import { v4 as uuidv4 } from "uuid"; + +export function randomString() { + return uuidv4().replace(/-/g, ""); +} diff --git a/apps/api/v2/test/utils/withApiAuth.ts b/apps/api/v2/test/utils/withApiAuth.ts new file mode 100644 index 00000000000000..bc5a4904b8f53f --- /dev/null +++ b/apps/api/v2/test/utils/withApiAuth.ts @@ -0,0 +1,10 @@ +import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { ApiAuthMockStrategy } from "test/mocks/api-auth-mock.strategy"; + +export const withApiAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(ApiAuthStrategy).useFactory({ + factory: (usersRepository: UsersRepository) => new ApiAuthMockStrategy(email, usersRepository), + inject: [UsersRepository], + }); diff --git a/apps/api/v2/test/utils/withNextAuth.ts b/apps/api/v2/test/utils/withNextAuth.ts new file mode 100644 index 00000000000000..4a96bf7edfd93a --- /dev/null +++ b/apps/api/v2/test/utils/withNextAuth.ts @@ -0,0 +1,10 @@ +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy"; + +export const withNextAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(NextAuthStrategy).useFactory({ + factory: (userRepository: UsersRepository) => new NextAuthMockStrategy(email, userRepository), + inject: [UsersRepository], + }); diff --git a/apps/api/v2/tsconfig.build.json b/apps/api/v2/tsconfig.build.json new file mode 100644 index 00000000000000..64f86c6bd2bb30 --- /dev/null +++ b/apps/api/v2/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json new file mode 100644 index 00000000000000..f0d797462d2574 --- /dev/null +++ b/apps/api/v2/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "resolveJsonModule": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": ".", + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + "@prisma/client/*": ["@calcom/prisma/client/*"], + "@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"], + "@calcom/platform-types": ["../../../packages/platform/types/index.ts"], + "@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"], + "@calcom/platform-enums": ["../../../packages/platform/enums/index.ts"] + }, + "incremental": true, + "skipLibCheck": true, + "strict": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + }, + "watchOptions": { + "watchFile": "fixedPollingInterval", + "watchDirectory": "useFsEvents", + "fallbackPolling": "dynamicPriority", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "dist"] + }, + "exclude": ["./dist", "./node_modules", "next-i18next.config.js"], + "include": ["./**/*.ts", "../../../packages/types/*.d.ts"] +} diff --git a/apps/storybook/.gitignore b/apps/storybook/.gitignore index 8c444bd2f45a0a..5689902ae18544 100644 --- a/apps/storybook/.gitignore +++ b/apps/storybook/.gitignore @@ -8,7 +8,9 @@ pnpm-debug.log* lerna-debug.log* node_modules -storybook-static +storybook-static/* +!storybook-static/favicon.ico +!storybook-static/sb-cover.jpg dist dist-ssr *.local diff --git a/apps/storybook/.storybook/i18next.js b/apps/storybook/.storybook/i18next.js index ca49d8044eb086..d3f384b3233605 100644 --- a/apps/storybook/.storybook/i18next.js +++ b/apps/storybook/.storybook/i18next.js @@ -1,9 +1,29 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; +const ns = ["common"]; +const supportedLngs = ["en", "fr"]; +const resources = ns.reduce((acc, n) => { + supportedLngs.forEach((lng) => { + if (!acc[lng]) acc[lng] = {}; + acc[lng] = { + ...acc[lng], + [n]: require(`../../web/public/static/locales/${lng}/${n}.json`), + }; + }); + return acc; +}, {}); + i18n.use(initReactI18next).init({ - resources: [], debug: true, + fallbackLng: "en", + defaultNS: "common", + ns, + interpolation: { + escapeValue: false, + }, + react: { useSuspense: true }, + resources, }); export default i18n; diff --git a/apps/storybook/.storybook/main.js b/apps/storybook/.storybook/main.js deleted file mode 100644 index ae653ae06eb30a..00000000000000 --- a/apps/storybook/.storybook/main.js +++ /dev/null @@ -1,75 +0,0 @@ -const path = require("path"); - -module.exports = { - stories: [ - "../intro.stories.mdx", - "../../../packages/ui/components/**/*.stories.mdx", - "../../../packages/atoms/**/*.stories.mdx", - "../../../packages/features/**/*.stories.mdx", - "../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)", - ], - addons: [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-interactions", - "storybook-addon-rtl-direction", - "storybook-react-i18next", - "storybook-addon-next", - /*{ - name: "storybook-addon-next", - options: { - nextConfigPath: path.resolve(__dirname, "../../web/next.config.js"), - }, - },*/ - ], - framework: "@storybook/react", - core: { - builder: "webpack5", - }, - staticDirs: ["../public"], - webpackFinal: async (config, { configType }) => { - config.resolve.fallback = { - fs: false, - assert: false, - buffer: false, - console: false, - constants: false, - crypto: false, - domain: false, - events: false, - http: false, - https: false, - os: false, - path: false, - punycode: false, - process: false, - querystring: false, - stream: false, - string_decoder: false, - sys: false, - timers: false, - tty: false, - url: false, - util: false, - vm: false, - zlib: false, - }; - - config.module.rules.push({ - test: /\.css$/, - use: [ - "style-loader", - { - loader: "css-loader", - options: { - modules: true, // Enable modules to help you using className - }, - }, - ], - include: path.resolve(__dirname, "../src"), - }); - - return config; - }, - typescript: { reactDocgen: "react-docgen" }, -}; diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts new file mode 100644 index 00000000000000..bc68cb15c028cf --- /dev/null +++ b/apps/storybook/.storybook/main.ts @@ -0,0 +1,96 @@ +import type { StorybookConfig } from "@storybook/nextjs"; +import path, { dirname, join } from "path"; + +const config: StorybookConfig = { + stories: [ + "../intro.stories.mdx", + "../../../packages/ui/components/**/*.stories.mdx", // legacy SB6 stories + "../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)", + "../../../packages/ui/components/**/*.docs.mdx", + "../../../packages/features/**/*.stories.@(js|jsx|ts|tsx)", + "../../../packages/features/**/*.docs.mdx", + "../../../packages/atoms/**/*.stories.@(js|jsx|ts|tsx)", + "../../../packages/atoms/**/*.docs.mdx", + ], + + addons: [ + getAbsolutePath("@storybook/addon-links"), + getAbsolutePath("@storybook/addon-essentials"), + getAbsolutePath("@storybook/addon-interactions"), + getAbsolutePath("storybook-addon-rtl-direction"), + getAbsolutePath("storybook-react-i18next"), + ], + + framework: { + name: getAbsolutePath("@storybook/nextjs") as "@storybook/nextjs", + + options: { + // builder: { + // fsCache: true, + // lazyCompilation: true, + // }, + }, + }, + + staticDirs: ["../public"], + + webpackFinal: async (config, { configType }) => { + config.resolve = config.resolve || {}; + config.resolve.fallback = { + fs: false, + assert: false, + buffer: false, + console: false, + constants: false, + crypto: false, + domain: false, + events: false, + http: false, + https: false, + os: false, + path: false, + punycode: false, + process: false, + querystring: false, + stream: false, + string_decoder: false, + sys: false, + timers: false, + tty: false, + url: false, + util: false, + vm: false, + zlib: false, + }; + + config.module = config.module || {}; + config.module.rules = config.module.rules || []; + config.module.rules.push({ + test: /\.css$/, + use: [ + "style-loader", + { + loader: "css-loader", + options: { + modules: true, // Enable modules to help you using className + }, + }, + ], + include: path.resolve(__dirname, "../src"), + }); + + return config; + }, + + typescript: { reactDocgen: "react-docgen" }, + + docs: { + autodocs: true, + }, +}; + +export default config; + +function getAbsolutePath(value) { + return dirname(require.resolve(join(value, "package.json"))); +} diff --git a/apps/storybook/.storybook/preview-head.html b/apps/storybook/.storybook/preview-head.html index ad4b6e89966612..01db3306076430 100644 --- a/apps/storybook/.storybook/preview-head.html +++ b/apps/storybook/.storybook/preview-head.html @@ -1,9 +1,9 @@ diff --git a/apps/storybook/.storybook/preview.jsx b/apps/storybook/.storybook/preview.jsx deleted file mode 100644 index 5d8d9bdd0b0b23..00000000000000 --- a/apps/storybook/.storybook/preview.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { addDecorator } from "@storybook/react"; -import { I18nextProvider } from "react-i18next"; - -import "../styles/globals.css"; -import "../styles/storybook-styles.css"; -import i18n from "./i18next"; - -export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -}; - -addDecorator((storyFn) => ( - -
{storyFn()}
-
-)); - -window.getEmbedNamespace = () => { - const url = new URL(document.URL); - const namespace = url.searchParams.get("embed"); - return namespace; -}; - -window.getEmbedTheme = () => { - return "auto"; -}; diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx new file mode 100644 index 00000000000000..620f7a9ff6dd0d --- /dev/null +++ b/apps/storybook/.storybook/preview.tsx @@ -0,0 +1,73 @@ +// adds tooltip context to all stories +import { TooltipProvider } from "@radix-ui/react-tooltip"; +import type { Preview } from "@storybook/react"; +import React from "react"; +import { I18nextProvider } from "react-i18next"; + +import type { EmbedThemeConfig } from "@calcom/embed-core/src/types"; +// adds trpc context to all stories (esp. booker) +import { StorybookTrpcProvider } from "@calcom/ui"; + +import "../styles/globals.css"; +import "../styles/storybook-styles.css"; +import i18n from "./i18next"; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + + globals: { + locale: "en", + locales: { + en: "English", + fr: "Français", + }, + }, + + i18n, + + nextjs: { + appDirectory: true, + }, + }, + + decorators: [ + (Story) => ( + + + +
+ +
+
+
+
+ ), + ], +}; + +export default preview; + +declare global { + interface Window { + getEmbedNamespace: () => string | null; + getEmbedTheme: () => EmbedThemeConfig | null; + } +} + +window.getEmbedNamespace = () => { + const url = new URL(document.URL); + const namespace = url.searchParams.get("embed"); + return namespace; +}; + +window.getEmbedTheme = () => { + return "auto"; +}; diff --git a/apps/storybook/components/CustomArgsTable.tsx b/apps/storybook/components/CustomArgsTable.tsx index e8feabf577e04d..1bb51bbdd94349 100644 --- a/apps/storybook/components/CustomArgsTable.tsx +++ b/apps/storybook/components/CustomArgsTable.tsx @@ -1,6 +1,6 @@ import { ArgsTable } from "@storybook/addon-docs"; -import { SortType } from "@storybook/components"; -import { PropDescriptor } from "@storybook/store"; +import type { SortType } from "@storybook/blocks"; +import type { PropDescriptor } from "@storybook/preview-api"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ignore storybook addon types component as any so we have to do type Component = any; diff --git a/apps/storybook/components/VariantsTable.tsx b/apps/storybook/components/VariantsTable.tsx index 30b136777e152e..1120d4b8ba95bb 100644 --- a/apps/storybook/components/VariantsTable.tsx +++ b/apps/storybook/components/VariantsTable.tsx @@ -18,6 +18,7 @@ export function VariantsTable({ const columns = React.Children.toArray(children) as ReactElement[]; return (
{columns.map((column) => ( - + {column.props.variant} {React.Children.count(column.props.children) && React.Children.map(column.props.children, (cell) => ( - + {cell} ))} @@ -43,7 +44,7 @@ export function VariantsTable({
{!isDark && ( -
+
{children} @@ -70,9 +71,9 @@ export function VariantRow({ children }: RowProps) { export function RowTitles({ titles }: { titles: string[] }) { return ( - + {titles.map((title) => ( - + {title} ))} diff --git a/apps/storybook/intro.stories.mdx b/apps/storybook/intro.stories.mdx index dde9150fae9917..f6858dc892a1a1 100644 --- a/apps/storybook/intro.stories.mdx +++ b/apps/storybook/intro.stories.mdx @@ -1,11 +1,18 @@ -import { Meta } from '@storybook/addon-docs'; +import { Meta } from "@storybook/addon-docs"; -
-

Welcome to Cal.com UI

-

This is the beginning of our storybook impovments to match Figma as close as possible. Like our Figma library, we will be adding more components as we go along.

-

Our Figma library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to the design team.

+
+

Welcome to Cal.com UI

+

+ This is the beginning of our storybook improvements to match Figma as close as possible. Like our Figma + library, we will be adding more components as we go along. +

+

+ Our Figma + library is available for anyone to view and use. If you have any questions or concerns, please reach out to + the design team. +

- \ No newline at end of file + diff --git a/apps/storybook/package.json b/apps/storybook/package.json index fb93a78171ad74..13c9914f7f0d3b 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -3,53 +3,55 @@ "private": true, "version": "0.0.0", "scripts": { - "dev": "start-storybook -p 6006", - "build": "build-storybook" + "dev": "storybook dev -p 6006", + "build": "storybook build" }, "dependencies": { "@calcom/config": "*", "@calcom/dayjs": "*", "@calcom/ui": "*", - "@radix-ui/react-avatar": "^1.0.0", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.0", - "@radix-ui/react-dialog": "^1.0.0", - "@radix-ui/react-dropdown-menu": "^1.0.0", + "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-id": "^1.0.0", "@radix-ui/react-popover": "^1.0.2", "@radix-ui/react-radio-group": "^1.0.0", "@radix-ui/react-slider": "^1.0.0", "@radix-ui/react-switch": "^1.0.0", "@radix-ui/react-tooltip": "^1.0.0", - "next": "^13.2.1", + "@storybook/addon-docs": "^7.6.3", + "@storybook/blocks": "^7.6.3", + "@storybook/nextjs": "^7.6.3", + "@storybook/preview-api": "^7.6.3", + "next": "^13.5.6", "react": "^18.2.0", "react-dom": "^18.2.0", "storybook-addon-rtl-direction": "^0.0.19" }, "devDependencies": { "@babel/core": "^7.19.6", - "@storybook/addon-actions": "^6.5.13", - "@storybook/addon-essentials": "^6.5.13", - "@storybook/addon-interactions": "^6.5.13", - "@storybook/addon-links": "^6.5.13", - "@storybook/builder-vite": "^0.2.4", - "@storybook/builder-webpack5": "^6.5.13", - "@storybook/manager-webpack5": "^6.5.13", - "@storybook/react": "^6.5.13", - "@storybook/testing-library": "^0.0.13", + "@storybook/addon-actions": "^7.6.3", + "@storybook/addon-designs": "^7.0.7", + "@storybook/addon-essentials": "^7.6.3", + "@storybook/addon-interactions": "^7.6.3", + "@storybook/addon-links": "^7.6.3", + "@storybook/nextjs": "^7.6.3", + "@storybook/react": "^7.6.3", + "@storybook/testing-library": "^0.2.2", "@types/react": "18.0.26", - "@types/react-dom": "18.0.9", - "@vitejs/plugin-react": "^2.1.0", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^2.2.0", "autoprefixer": "^10.4.12", "babel-loader": "^8.2.5", "fs": "^0.0.1-security", "postcss": "^8.4.18", "postcss-pseudo-companion-classes": "^0.1.1", "rollup-plugin-polyfill-node": "^0.10.2", - "storybook-addon-designs": "^6.3.1", - "storybook-addon-next": "^1.6.9", - "storybook-react-i18next": "^1.1.2", - "tailwindcss": "^3.2.1", + "storybook": "^7.6.3", + "storybook-react-i18next": "^2.0.9", + "tailwindcss": "^3.3.3", "typescript": "^4.9.4", - "vite": "^2.9.15" + "vite": "^4.1.2" } } diff --git a/apps/storybook/styles/storybook-styles.css b/apps/storybook/styles/storybook-styles.css index a5cc2aa8d1b9bd..78d8ce13b7dbe2 100644 --- a/apps/storybook/styles/storybook-styles.css +++ b/apps/storybook/styles/storybook-styles.css @@ -1,8 +1,8 @@ @import url("../../../packages/features/calendars/weeklyview/styles/styles.css"); .sbdocs { - font-family: 'Inter var' !important; - padding: 0!important; + font-family: "Inter var" !important; + padding: 0 !important; } #docs-root { @@ -21,8 +21,8 @@ font-weight: 500; border: none; border-top: 2px solid rgba(0, 0, 0, 0.12); - padding-top: 12px!important; - padding-bottom: 12px!important; + padding-top: 12px !important; + padding-bottom: 12px !important; margin: 82px 0 0 0; } @@ -37,7 +37,7 @@ } /** Docs table **/ -.custom-args-wrapper{ +.custom-args-wrapper { max-height: 400px; overflow-y: scroll; overflow-x: hidden; @@ -45,7 +45,7 @@ } .docblock-argstable-body { - box-shadow: none!important; + box-shadow: none !important; font-size: 14px; } @@ -64,7 +64,7 @@ .docblock-argstable-body div p, .docblock-argstable-body div span { - color: #8F8F8F !important; + color: #8f8f8f !important; } /** Custom components **/ @@ -83,8 +83,8 @@ } .story-title h1 span { - color: #9CA3AF; - font-family: 'Inter var'; + color: #9ca3af; + font-family: "Inter var"; font-weight: normal; display: inline-block; margin-left: 8px; @@ -96,7 +96,7 @@ } .examples { - background-color: #F9FAFB; + background-color: #f9fafb; padding: 20px; display: flex; flex-direction: column; @@ -111,14 +111,14 @@ } .examples-title { - color: #8F8F8F; + color: #8f8f8f; font-size: 14px; margin-bottom: 30px; } .examples-item-title { font-size: 12px; - color: #8F8F8F; + color: #8f8f8f; margin-bottom: 12px; display: block; } @@ -130,7 +130,7 @@ .examples-footnote p, .examples-footnote ul, .examples-footnote li { - color: #8F8F8F; + color: #8f8f8f; font-size: 14px; } @@ -139,11 +139,11 @@ } .examples-footnote li { - margin: 0!important; + margin: 0 !important; } .story-note { - background-color: #F9FAFB; + background-color: #f9fafb; font-size: 14px; padding: 20px; margin-bottom: 12px; @@ -161,12 +161,12 @@ box-shadow: none !important; margin: 0 !important; border: none !important; - border-radius: none!important; + border-radius: none !important; } .docs-story > div:first-child { - padding: 0!important; - margin: 0!important; + padding: 0 !important; + margin: 0 !important; } .docs-story .innerZoomElementWrapper > div { @@ -174,7 +174,7 @@ } .sb-main-padded { - padding: 0!important; + padding: 0 !important; } @media screen and (max-width: 1200px) { @@ -197,84 +197,91 @@ @layer { :root { /* background */ - - --cal-bg-emphasis: #E5E7EB; - --cal-bg: white; - --cal-bg-subtle: #F3F4F6; - --cal-bg-muted: #F9FAFB; - --cal-bg-inverted: #111827; - + + --cal-bg-emphasis: hsla(220,13%,91%,1); + --cal-bg: hsla(0,0%,100%,1); + --cal-bg-subtle: hsla(220, 14%, 96%,1); + --cal-bg-muted: hsla(210,20%,98%,1); + --cal-bg-inverted: hsla(0,0%,6%,1); + /* background -> components*/ - --cal-bg-info: #DEE9FC; - --cal-bg-success: #E2FBE8; - --cal-bg-attention: #FCEED8; - --cal-bg-error: #F9E3E2; - - + --cal-bg-info: hsla(218,83%,98%,1); + --cal-bg-success: hsla(134,76%,94%,1); + --cal-bg-attention: hsla(37, 86%, 92%, 1); + --cal-bg-error: hsla(3,66,93,1); + --cal-bg-dark-error: hsla(2, 55%, 30%, 1); + /* Borders */ - --cal-border-emphasis:#9CA3AF; - --cal-border: #D1D5DB; - --cal-border-subtle:#E5E7EB; - --cal-border-muted:#F3F4F6; - + --cal-border-emphasis: hsla(218, 11%, 65%, 1); + --cal-border: hsla(216, 12%, 84%, 1); + --cal-border-subtle: hsla(220, 13%, 91%, 1); + --cal-border-booker: #e5e7eb; + --cal-border-muted: hsla(220, 14%, 96%, 1); + --cal-border-error: hsla(4, 63%, 41%, 1); + /* Content/Text */ - --cal-text-emphasis: #111827; - --cal-text:#374151; - --cal-text-subtle:#6B7280; - --cal-text-muted:#9CA3AF; - --cal-text-inverted:white; - + --cal-text-emphasis: hsla(217, 19%, 27%, 1); + --cal-text: hsla(217, 19%, 27%, 1); + --cal-text-subtle: hsla(220, 9%, 46%, 1); + --cal-text-muted: hsla(218, 11%, 65%, 1); + --cal-text-inverted: hsla(0, 0%, 100%, 1); + /* Content/Text -> components */ - --cal-text-info:#253985; - --cal-text-success:#285231; - --cal-text-attention:#73321B; - --cal-text-error:#752522; - + --cal-text-info: hsla(228, 56%, 33%, 1); + --cal-text-success: hsla(133, 34%, 24%, 1); + --cal-text-attention: hsla(16, 62%, 28%, 1); + --cal-text-error: hsla(2, 55%, 30%, 1); + /* Brand shinanigans - -> These will be computed for the users theme at runtime. - */ - --cal-brand:#111827; - --cal-brand-emphasis:#101010; + -> These will be computed for the users theme at runtime. + */ + --cal-brand: hsla(221, 39%, 11%, 1); + --cal-brand-emphasis: hsla(0, 0%, 6%, 1); + --cal-brand-text: hsla(0, 0%, 100%, 1); } .dark { /* background */ - - --cal-bg-emphasis: #2B2B2B; - --cal-bg: #101010; - --cal-bg-subtle: #2B2B2B; - --cal-bg-muted: #1C1C1C; - --cal-bg-inverted: #F3F4F6; - + + --cal-bg-emphasis: hsla(0, 0%, 32%, 1); + --cal-bg: hsla(0, 0%, 10%, 1); + --cal-bg-subtle: hsla(0, 0%, 18%, 1); + --cal-bg-muted: hsla(0, 0%, 12%, 1); + --cal-bg-inverted: hsla(220, 14%, 96%, 1); + /* background -> components*/ - --cal-bg-info: #DEE9FC; - --cal-bg-success: #E2FBE8; - --cal-bg-attention: #FCEED8; - --cal-bg-error: #F9E3E2; - - + --cal-bg-info: hsla(228, 56%, 33%, 1); + --cal-bg-success: hsla(133, 34%, 24%, 1); + --cal-bg-attention: hsla(16, 62%, 28%, 1); + --cal-bg-error: hsla(2, 55%, 30%, 1); + --cal-bg-dark-error: hsla(2, 55%, 30%, 1); + /* Borders */ - --cal-border-emphasis: #575757; - --cal-border: #444444; - --cal-border-subtle: #2B2B2B; - --cal-border-muted: #1C1C1C; - + --cal-border-emphasis: hsla(0, 0%, 46%, 1); + --cal-border: hsla(0, 0%, 34%, 1); + --cal-border-subtle: hsla(0, 0%, 22%, 1); + --cal-border-booker: hsla(0, 0%, 22%, 1); + --cal-border-muted: hsla(0, 0%, 18%, 1); + --cal-border-error: hsla(4, 63%, 41%, 1); + /* Content/Text */ - --cal-text-emphasis: #F3F4F6; - --cal-text: #D6D6D6; - --cal-text-subtle: #757575; - --cal-text-muted: #575757; - --cal-text-inverted: #101010; - + --cal-text-emphasis: hsla(240, 20%, 99%, 1); + --cal-text: hsla(0, 0%, 84%, 1); + --cal-text-subtle: hsla(0, 0%, 65%, 1); + --cal-text-muted: hsla(0, 0%, 34%, 1); + --cal-text-inverted: hsla(0, 0%, 10%, 1); + /* Content/Text -> components */ - --cal-text-info: #253985; - --cal-text-success: #285231; - --cal-text-attention: #73321B; - --cal-text-error: #752522; - + --cal-text-info: hsla(218, 83%, 93%, 1); + --cal-text-success: hsla(134, 76%, 94%, 1); + --cal-text-attention: hsla(37, 86%, 92%, 1); + --cal-text-error: hsla(3, 66%, 93%, 1); + /* Brand shenanigans - -> These will be computed for the users theme at runtime. - */ - --cal-brand: #111827; - --cal-brand-emphasis: #101010; + -> These will be computed for the users theme at runtime. + */ + --cal-brand: hsla(0, 0%, 100%, 1); + --cal-brand-emphasis: hsla(218, 11%, 65%, 1); + --cal-brand-text: hsla(0, 0%, 0%,1); } + } diff --git a/apps/swagger/lib/snippets.ts b/apps/swagger/lib/snippets.js similarity index 100% rename from apps/swagger/lib/snippets.ts rename to apps/swagger/lib/snippets.js diff --git a/apps/swagger/package.json b/apps/swagger/package.json index bb815c333bdbd2..da1d0342a9b8ed 100644 --- a/apps/swagger/package.json +++ b/apps/swagger/package.json @@ -14,7 +14,7 @@ "dependencies": { "highlight.js": "^11.6.0", "isarray": "2.0.5", - "next": "^13.2.1", + "next": "^13.5.6", "openapi-snippet": "^0.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -23,7 +23,8 @@ "devDependencies": { "@types/node": "16.9.1", "@types/react": "18.0.26", - "@types/react-dom": "18.0.9", + "@types/react-dom": "^18.0.9", + "@types/swagger-ui-react": "^4.18.3", "typescript": "^4.9.4" } } diff --git a/apps/swagger/pages/_app.tsx b/apps/swagger/pages/_app.tsx index 02fc2c3d26a78c..7669f61bbb5173 100644 --- a/apps/swagger/pages/_app.tsx +++ b/apps/swagger/pages/_app.tsx @@ -1,9 +1,10 @@ import "highlight.js/styles/default.css"; +import { type AppProps } from "next/app"; import "swagger-ui-react/swagger-ui.css"; import "../styles/globals.css"; -function MyApp({ Component, pageProps }) { +function MyApp({ Component, pageProps }: AppProps) { return ; } diff --git a/apps/swagger/pages/index.tsx b/apps/swagger/pages/index.tsx index 10dbee9c13cfa5..568f2f2b5de1b5 100644 --- a/apps/swagger/pages/index.tsx +++ b/apps/swagger/pages/index.tsx @@ -1,9 +1,8 @@ import dynamic from "next/dynamic"; -import type { SwaggerUI } from "swagger-ui-react"; import { SnippedGenerator, requestSnippets } from "@lib/snippets"; -const SwaggerUIDynamic: SwaggerUI & { url: string } = dynamic(() => import("swagger-ui-react"), { +const SwaggerUIDynamic = dynamic(() => import("swagger-ui-react"), { ssr: false, }); @@ -17,8 +16,6 @@ export default function APIDocs() { requestSnippets={requestSnippets} plugins={[SnippedGenerator]} tryItOutEnabled={true} - syntaxHighlight={true} - enableCORS={false} // Doesn't seem to work either docExpansion="list" filter={true} /> diff --git a/apps/swagger/styles/globals.css b/apps/swagger/styles/globals.css index 52d33738c5ee8c..f89757a3f91371 100644 --- a/apps/swagger/styles/globals.css +++ b/apps/swagger/styles/globals.css @@ -2,8 +2,8 @@ html, body { padding: 0; margin: 0; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, - Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, + Droid Sans, Helvetica Neue, sans-serif; } a { @@ -21,7 +21,7 @@ a { } .swagger-ui .opblock .opblock-summary { display: grid; - flex-direction: column; + flex-direction: column; } .opblock-summary-path { flex-shrink: 0; @@ -43,22 +43,22 @@ a { font-size: 22px; } .swagger-ui .scheme-container { - padding: 14px 0; + padding: 14px 0; } .swagger-ui .info { - margin: 10px 0; + margin: 10px 0; } .swagger-ui .auth-wrapper { margin: 10px 0; } .swagger-ui .authorization__btn { - display: none; + display: none; } .swagger-ui .opblock { margin: 0 0 5px; } button.opblock-summary-control > svg { - display: none; + display: none; } .swagger-ui .filter .operation-filter-input { border: 2px solid #d8dde7; @@ -73,15 +73,16 @@ a { .swagger-ui .info .title small { top: 5px; } - .swagger-ui a.nostyle, .swagger-ui a.nostyle:visited { + .swagger-ui a.nostyle, + .swagger-ui a.nostyle:visited { width: 100%; } div.request-snippets > div.curl-command > div:nth-child(1) { overscroll-behavior: contain; overflow-x: scroll; } - .swagger-ui .opblock-body pre.microlight { - font-size: 9px; + .swagger-ui .opblock-body pre.microlight { + font-size: 9px; } .swagger-ui table tbody tr td { padding: 0px 0 0; @@ -91,7 +92,7 @@ a { font-size: 12px; } div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr { - display: flex; + display: flex; width: 100vw; flex-direction: column; font-size: 60%; @@ -99,4 +100,4 @@ a { div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr { display: none; } -} \ No newline at end of file +} diff --git a/apps/ui-playground/.gitignore b/apps/ui-playground/.gitignore new file mode 100644 index 00000000000000..5ef6a520780202 --- /dev/null +++ b/apps/ui-playground/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/ui-playground/README.md b/apps/ui-playground/README.md new file mode 100644 index 00000000000000..e215bc4ccf138b --- /dev/null +++ b/apps/ui-playground/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/ui-playground/app/favicon.ico b/apps/ui-playground/app/favicon.ico new file mode 100644 index 00000000000000..718d6fea4835ec Binary files /dev/null and b/apps/ui-playground/app/favicon.ico differ diff --git a/apps/ui-playground/app/globals.css b/apps/ui-playground/app/globals.css new file mode 100644 index 00000000000000..4c25942b20f5f0 --- /dev/null +++ b/apps/ui-playground/app/globals.css @@ -0,0 +1,485 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +/** +* Add css variables here as well for light mode in addition to tailwind.config to avoid FOUC +*/ +:root { + /* background */ + + --cal-bg-emphasis: hsla(220,13%,91%,1); + --cal-bg: hsla(0,0%,100%,1); + --cal-bg-subtle: hsla(220, 14%, 96%,1); + --cal-bg-muted: hsla(210,20%,98%,1); + --cal-bg-inverted: hsla(0,0%,6%,1); + + /* background -> components*/ + --cal-bg-info: hsla(218,83%,98%,1); + --cal-bg-success: hsla(134,76%,94%,1); + --cal-bg-attention: hsla(37, 86%, 92%, 1); + --cal-bg-error: hsla(3,66%,93%,1); + --cal-bg-dark-error: hsla(2, 55%, 30%, 1); + + /* Borders */ + --cal-border-emphasis: hsla(218, 11%, 65%, 1); + --cal-border: hsla(216, 12%, 84%, 1); + --cal-border-subtle: hsla(220, 13%, 91%, 1); + --cal-border-booker: #e5e7eb; + --cal-border-muted: hsla(220, 14%, 96%, 1); + --cal-border-error: hsla(4, 63%, 41%, 1); + --cal-border-focus: hsla(0, 0%, 10%, 1); + + /* Content/Text */ + --cal-text-emphasis: hsla(217, 19%, 27%, 1); + --cal-text: hsla(217, 19%, 27%, 1); + --cal-text-subtle: hsla(220, 9%, 46%, 1); + --cal-text-muted: hsla(218, 11%, 65%, 1); + --cal-text-inverted: hsla(0, 0%, 100%, 1); + + /* Content/Text -> components */ + --cal-text-info: hsla(228, 56%, 33%, 1); + --cal-text-success: hsla(133, 34%, 24%, 1); + --cal-text-attention: hsla(16, 62%, 28%, 1); + --cal-text-error: hsla(2, 55%, 30%, 1); + + /* Brand shinanigans + -> These will be computed for the users theme at runtime. + */ + --cal-brand: hsla(221, 39%, 11%, 1); + --cal-brand-emphasis: hsla(0, 0%, 6%, 1); + --cal-brand-text: hsla(0, 0%, 100%, 1); +} +.dark { + /* background */ + + --cal-bg-emphasis: hsla(0, 0%, 25%, 1); + --cal-bg: hsla(0, 0%, 10%, 1); + --cal-bg-subtle: hsla(0, 0%, 18%, 1); + --cal-bg-muted: hsla(0, 0%, 12%, 1); + --cal-bg-inverted: hsla(220, 14%, 96%, 1); + + /* background -> components*/ + --cal-bg-info: hsla(228, 56%, 33%, 1); + --cal-bg-success: hsla(133, 34%, 24%, 1); + --cal-bg-attention: hsla(16, 62%, 28%, 1); + --cal-bg-error: hsla(2, 55%, 30%, 1); + --cal-bg-dark-error: hsla(2, 55%, 30%, 1); + + /* Borders */ + --cal-border-emphasis: hsla(0, 0%, 46%, 1); + --cal-border: hsla(0, 0%, 34%, 1); + --cal-border-subtle: hsla(0, 0%, 22%, 1); + --cal-border-booker: hsla(0, 0%, 22%, 1); + --cal-border-muted: hsla(0, 0%, 18%, 1); + --cal-border-error: hsla(4, 63%, 41%, 1); + --cal-border-focus: hsla(0, 0%, 100%, 1); + + /* Content/Text */ + --cal-text-emphasis: hsla(240, 20%, 99%, 1); + --cal-text: hsla(0, 0%, 84%, 1); + --cal-text-subtle: hsla(0, 0%, 65%, 1); + --cal-text-muted: hsla(0, 0%, 34%, 1); + --cal-text-inverted: hsla(0, 0%, 10%, 1); + + /* Content/Text -> components */ + --cal-text-info: hsla(218, 83%, 93%, 1); + --cal-text-success: hsla(134, 76%, 94%, 1); + --cal-text-attention: hsla(37, 86%, 92%, 1); + --cal-text-error: hsla(3, 66%, 93%, 1); + + /* Brand shenanigans + -> These will be computed for the users theme at runtime. + */ + --cal-brand: hsla(0, 0%, 100%, 1); + --cal-brand-emphasis: hsla(218, 11%, 65%, 1); + --cal-brand-text: hsla(0, 0%, 0%,1); +} + +@layer base { + * { + @apply border-default + } +} + +::-moz-selection { + color: var(--cal-brand-text); + background: var(--cal-brand); +} + +::selection { + color: var(--cal-brand-text); + background: var(--cal-brand); +} + +body  { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +/* + Desktop App specific CSS + https://docs.todesktop.com/ +*/ + +html.todesktop-platform-win32 .todesktop\:\!bg-transparent{ + background: inherit !important; +} + +/* disable user selection on buttons, links and images on desktop app */ +html.todesktop button, +html.todesktop a, +html.todesktop img, +html.todesktop header { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default !important; +} + +html.todesktop, +html.todesktop div { + cursor: default !important; +} + +/* make header draggable on desktop app */ +html.todesktop header { + -webkit-app-region: drag; +} + +html.todesktop header button, +html.todesktop header a { + -webkit-app-region: no-drag; +} + +html.todesktop-platform-darwin body, html.todesktop-platform-darwin aside { + background: transparent !important; +} + +html.todesktop-platform-darwin.dark main.bg-default { + background: rgba(0, 0, 0, 0.6) !important; +} + +html.todesktop-platform-darwin.light main.bg-default { + background: rgba(255, 255, 255, 0.8) !important; +} + +html.todesktop.light { + --cal-bg-emphasis: hsla(0, 0%, 11%, 0.1); +} + +html.todesktop.dark { + --cal-bg-emphasis: hsla(220, 2%, 26%, 0.3); +} + +/* + Adds Utility to hide scrollbar to tailwind + https://github.com/tailwindlabs/tailwindcss/discussions/2394 + https://github.com/tailwindlabs/tailwindcss/pull/5732 +*/ +@layer utilities { + /* Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} + +/* + * Override the default tailwindcss-forms styling (default is: 'colors.blue.600') + * @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006 + */ +[type="text"]:focus, +[type="email"]:focus, +[type="url"]:focus, +[type="password"]:focus, +[type="number"]:focus, +[type="date"]:focus, +[type="datetime-local"]:focus, +[type="month"]:focus, +[type="search"]:focus, +[type="tel"]:focus, +[type="checkbox"]:focus, +[type="radio"]:focus, +[type="time"]:focus, +[type="week"]:focus, +[multiple]:focus, +textarea:focus, +select:focus { + --tw-ring-color: var(--brand-color); + border-color: var(--brand-color); +} + +@layer components { + .scroll-bar { + @apply scrollbar-thin scrollbar-thumb-rounded-md dark:scrollbar-thumb-darkgray-300 scrollbar-thumb-gray-300 scrollbar-track-transparent; + } +} + +/* TODO: avoid global specific css */ +/* button[role="switch"][data-state="checked"] span { + transform: translateX(16px); +} */ + +@layer components { + /* slider */ + .slider { + @apply relative flex h-4 w-40 select-none items-center; + } + + .slider > .slider-track { + @apply relative h-1 flex-grow rounded-md bg-gray-400; + } + + .slider .slider-range { + @apply absolute h-full rounded-full bg-gray-700; + } + + .slider .slider-thumb { + @apply block h-3 w-3 cursor-pointer rounded-full bg-gray-700 transition-all; + } + + .slider .slider-thumb:hover { + @apply bg-gray-600; + } + + .slider .slider-thumb:focus { + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.2); + } +} + +/* hide chat bubble on mobile */ +@media only screen and (max-width: 768px) { + /* Intercom FAB*/ + #launcher { + display: none !important; + } + + /* Zendesk FAB*/ + div[role="presentation"] > iframe { + display: none !important; + } + + /* Helpscout FAB*/ + .BeaconFabButtonFrame { + margin-left: -30px; + left: 50%; + bottom: 28px !important; + z-index: 1058 !important; + } +} + +/* TODO: implement styling for react-multi-email */ + +/* !important to override react-dates */ +.DateRangePickerInput__withBorder { + border: 0 !important; +} +.DateInput_input { + border: 1px solid #d1d5db !important; + border-radius: 2px !important; + font-size: inherit !important; + font-weight: inherit !important; + color: #000; + padding: 11px ​11px 9px !important; + line-height: 16px !important; +} + +.DateInput_input__focused { + border: 2px solid #000 !important; + border-radius: 2px !important; + box-shadow: none !important; + padding: 10px ​10px 9px !important; +} + +.DateRangePickerInput_arrow { + padding: 0px 10px; +} + +.loader { + display: block; + width: 30px; + height: 30px; + margin: 60px auto; + position: relative; + border-width: 4px; + border-style: solid; + animation: loader 2s infinite ease; +} + +.loader-inner { + vertical-align: top; + display: inline-block; + width: 100%; + animation: loader-inner 2s infinite ease-in; +} + +.no-ring-inset { + --tw-ring-inset: unset; +} + +@keyframes loader { + 0% { + transform: rotate(0deg); + } + + 25% { + transform: rotate(180deg); + } + + 50% { + transform: rotate(180deg); + } + + 75% { + transform: rotate(360deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes loader-inner { + 0% { + height: 0%; + } + + 25% { + height: 0%; + } + + 50% { + height: 100%; + } + + 75% { + height: 100%; + } + + 100% { + height: 0%; + } +} + +.text-inverted-important { + color: white !important; +} + +@layer utilities { + .transition-max-width { + -webkit-transition-property: max-width; + transition-property: max-width; + } +} + +#timeZone input:focus { + box-shadow: none; +} + +/* react-date-picker forces a border upon us, cast it away */ +.react-date-picker__wrapper { + border: none !important; +} + +.react-date-picker__inputGroup__input { + padding-top: 0; + padding-bottom: 0; +} + +/* animations */ +.slideInBottom { + animation-duration: 0.3s; + animation-fill-mode: both; + animation-name: slideInBottom; +} + +@keyframes slideInBottom { + from { + opacity: 0; + transform: translateY(30%); + pointer-events: none; + } + to { + opacity: 1; + pointer-events: auto; + } +} + +/* animations */ +.slideInTop { + animation-duration: 0.3s; + animation-fill-mode: both; + animation-name: slideInTop; +} + +@keyframes slideInTop { + from { + opacity: 0; + transform: translateY(-20%); + pointer-events: none; + } + to { + opacity: 1; + pointer-events: auto; + transform: translateY(0%); + } +} + +.fadeIn { + animation-duration: 0.3s; + animation-fill-mode: both; + animation-name: fadeIn; + animation-timing-function: ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/** + * Makes sure h-screen works on mobile Safari. By default h-screen + * does not take into account the height of the address bar, causing + * weird behaviour when scrolling — sometimes the height will be correct + * and sometimes it won't, depending on whether the address bar is + * in 'collapsed' state or not. + * @see: https://benborgers.com/posts/tailwind-h-screen + */ +@supports (-webkit-touch-callout: none) { + .h-screen { + height: -webkit-fill-available; + } +} + +::-webkit-search-cancel-button { + -webkit-appearance: none; +} + +.react-tel-input .country-list .country:hover, +.react-tel-input .country-list .country.highlight { + @apply !bg-emphasis; +} + +.react-tel-input .flag-dropdown .selected-flag, +.react-tel-input .flag-dropdown.open .selected-flag { + @apply !bg-default; +} + +.react-tel-input .flag-dropdown { + @apply !border-r-default left-0.5 !border-y-0 !border-l-0; +} + +.intercom-lightweight-app { + @apply z-40 !important; +} diff --git a/apps/ui-playground/app/layout.tsx b/apps/ui-playground/app/layout.tsx new file mode 100644 index 00000000000000..a521089ba04653 --- /dev/null +++ b/apps/ui-playground/app/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; + +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/ui-playground/app/page.tsx b/apps/ui-playground/app/page.tsx new file mode 100644 index 00000000000000..18bb6f0c614c3c --- /dev/null +++ b/apps/ui-playground/app/page.tsx @@ -0,0 +1,5 @@ +// import AvatarDemo from "./_components/AvatarDemo"; + +export default function Home() { + return
{/* */}
; +} diff --git a/apps/ui-playground/eslint.config.mjs b/apps/ui-playground/eslint.config.mjs new file mode 100644 index 00000000000000..c85fb67c463f20 --- /dev/null +++ b/apps/ui-playground/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/apps/ui-playground/next.config.ts b/apps/ui-playground/next.config.ts new file mode 100644 index 00000000000000..e9ffa3083ad279 --- /dev/null +++ b/apps/ui-playground/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/apps/ui-playground/package.json b/apps/ui-playground/package.json new file mode 100644 index 00000000000000..79f7abbe38155b --- /dev/null +++ b/apps/ui-playground/package.json @@ -0,0 +1,30 @@ +{ + "name": "ui-playground", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack --port=1337", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@calcom/config": "*", + "@calcom/lib": "*", + "@calcom/ui": "*", + "next": "15.1.6", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^9", + "eslint-config-next": "15.1.6", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/apps/ui-playground/postcss.config.mjs b/apps/ui-playground/postcss.config.mjs new file mode 100644 index 00000000000000..1a69fd2a450afc --- /dev/null +++ b/apps/ui-playground/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/ui-playground/tailwind.config.ts b/apps/ui-playground/tailwind.config.ts new file mode 100644 index 00000000000000..f21d47baefefee --- /dev/null +++ b/apps/ui-playground/tailwind.config.ts @@ -0,0 +1,8 @@ +import base from "@calcom/config/tailwind-preset"; + +/** @type {import('tailwindcss').Config} */ +module.exports = { + ...base, + content: [...base.content], + plugins: [...base.plugins, require("tailwindcss-animate")], +}; diff --git a/apps/ui-playground/tsconfig.json b/apps/ui-playground/tsconfig.json new file mode 100644 index 00000000000000..d8b93235f205ef --- /dev/null +++ b/apps/ui-playground/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 520a11d78c396a..3f657d09b55ab9 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -68,3 +68,5 @@ public/embed # Copied app-store images public/app-store + +certificates \ No newline at end of file diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore index e0f842003c6be3..a31370892c9b8d 100644 --- a/apps/web/.prettierignore +++ b/apps/web/.prettierignore @@ -1,2 +1 @@ -public/embed -*.test.ts +public/embed \ No newline at end of file diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index 795250d0dffe2f..1209b448d4dbf2 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -1,5 +1,75 @@ # @calcom/web +## 4.8.7 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-core@1.5.2 + - @calcom/embed-react@1.5.2 + - @calcom/embed-snippet@1.3.2 + +## 4.5.2 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-core@1.5.1 + - @calcom/embed-react@1.5.1 + - @calcom/embed-snippet@1.3.1 + +## 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 + +- Updated dependencies + - @calcom/embed-core@1.4.0 + - @calcom/embed-react@1.4.0 + - @calcom/embed-snippet@1.2.0 + +## 3.1.3 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-react@1.3.0 + +## 3.0.10 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-snippet@1.1.2 + - @calcom/embed-react@1.2.2 + - @calcom/embed-core@1.3.2 + +## 3.0.9 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-snippet@1.1.1 + - @calcom/embed-react@1.2.1 + - @calcom/embed-core@1.3.1 + +## 3.0.8 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-core@1.3.0 + - @calcom/embed-react@1.2.0 + - @calcom/embed-snippet@1.1.0 + ## 2.9.4 ### Patch Changes diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts new file mode 100644 index 00000000000000..dc06d17ad58e81 --- /dev/null +++ b/apps/web/abTest/middlewareFactory.ts @@ -0,0 +1,62 @@ +import { getBucket } from "abTest/utils"; +import type { NextMiddleware, NextRequest } from "next/server"; +import { NextResponse, URLPattern } from "next/server"; + +import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants"; + +const ROUTES: [URLPattern, boolean][] = [ + ["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const, + ["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const, +].map(([pathname, enabled]) => [ + new URLPattern({ + pathname, + }), + enabled, +]); + +export const abTestMiddlewareFactory = + (next: (req: NextRequest) => Promise>): NextMiddleware => + async (req: NextRequest) => { + const response = await next(req); + + const { pathname } = req.nextUrl; + + const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); + + const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null; + const enabled = route !== null ? route[1] || override : false; + + if (pathname.includes("future") || !enabled) { + return response; + } + + const bucketValue = override ? "future" : req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value; + + if (!bucketValue || !["future", "legacy"].includes(bucketValue)) { + // cookie does not exist or it has incorrect value + const bucket = getBucket(); + + response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, { + expires: Date.now() + 1000 * 60 * 30, + httpOnly: true, + }); // 30 min in ms + + if (bucket === "legacy") { + return response; + } + + const url = req.nextUrl.clone(); + url.pathname = `future${pathname}/`; + + return NextResponse.rewrite(url, response); + } + + if (bucketValue === "legacy") { + return response; + } + + const url = req.nextUrl.clone(); + url.pathname = `future${pathname}/`; + + return NextResponse.rewrite(url, response); + }; diff --git a/apps/web/abTest/utils.ts b/apps/web/abTest/utils.ts new file mode 100644 index 00000000000000..ed40c9fca96f65 --- /dev/null +++ b/apps/web/abTest/utils.ts @@ -0,0 +1,9 @@ +import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants"; + +const cryptoRandom = () => { + return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff; +}; + +export const getBucket = () => { + return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy"; +}; diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/ShellMainAppDir.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/ShellMainAppDir.tsx new file mode 100644 index 00000000000000..8aedfe510be9f0 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/ShellMainAppDir.tsx @@ -0,0 +1,65 @@ +import { ShellMainAppDirBackButton } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDirBackButton"; +import classNames from "classnames"; + +import type { LayoutProps } from "@calcom/features/shell/Shell"; + +// Copied from `ShellMain` but with a different `ShellMainAppDirBackButton` import +// for client/server component separation +export function ShellMainAppDir(props: LayoutProps) { + return ( + <> + {(props.heading || !!props.backPath) && ( +
+ {!!props.backPath && } + {props.heading && ( +
+ {props.HeadingLeftIcon &&
{props.HeadingLeftIcon}
} +
+ {props.heading && ( +

+ {props.heading} +

+ )} + {props.subtitle && ( +

+ {props.subtitle} +

+ )} +
+ {props.beforeCTAactions} + {props.CTA && ( +
+ {props.CTA} +
+ )} + {props.actions && props.actions} +
+ )} +
+ )} + {props.afterHeading && <>{props.afterHeading}} + +
+ {props.children} +
+ + ); +} diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/ShellMainAppDirBackButton.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/ShellMainAppDirBackButton.tsx new file mode 100644 index 00000000000000..2be728fbd93376 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/ShellMainAppDirBackButton.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { LayoutProps } from "@calcom/features/shell/Shell"; +import { Button } from "@calcom/ui"; + +export const ShellMainAppDirBackButton = ({ backPath }: { backPath: LayoutProps["backPath"] }) => { + const router = useRouter(); + return ( + +
+
+ ); +} + +export default Error403; diff --git a/apps/web/app/(use-page-wrapper)/500/copy-button.tsx b/apps/web/app/(use-page-wrapper)/500/copy-button.tsx new file mode 100644 index 00000000000000..b99d30bd941c5d --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/500/copy-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, showToast } from "@calcom/ui"; + +export default function CopyButton({ error }: { error: string }) { + const { t } = useLocale(); + + return ( + + ); +} diff --git a/apps/web/app/(use-page-wrapper)/500/page.tsx b/apps/web/app/(use-page-wrapper)/500/page.tsx new file mode 100644 index 00000000000000..32239cd1663374 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/500/page.tsx @@ -0,0 +1,44 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { Button } from "@calcom/ui"; + +import CopyButton from "./copy-button"; + +export const generateMetadata = () => + _generateMetadata( + (t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`, + () => "" + ); + +async function Error500({ searchParams }: { searchParams: { error?: string } }) { + const t = await getTranslate(); + + return ( +
+
+

500

+

{t("500_error_message")}

+

{t("something_went_wrong_on_our_end")}

+ {searchParams?.error && ( +
+

+ {t("please_provide_following_text_to_suppport")}: +

+
+              {searchParams.error}
+              
+ +
+
+ )} + + +
+
+ ); +} + +export default Error500; diff --git a/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx new file mode 100644 index 00000000000000..94b15fd4983a25 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx @@ -0,0 +1,51 @@ +import type { PageProps as _PageProps } from "app/_types"; +import { generateAppMetadata } from "app/_utils"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import { getStaticProps } from "@lib/apps/[slug]/getStaticProps"; + +import AppView from "~/apps/[slug]/slug-view"; + +const paramsSchema = z.object({ + slug: z.string(), +}); + +export const generateMetadata = async ({ params }: _PageProps) => { + const p = paramsSchema.safeParse(params); + + if (!p.success) { + return notFound(); + } + + const props = await getStaticProps(p.data.slug); + + if (!props) { + notFound(); + } + const { name, logo, description } = props.data; + + return await generateAppMetadata( + { slug: logo, name, description }, + () => name, + () => description + ); +}; + +async function Page({ params }: _PageProps) { + const p = paramsSchema.safeParse(params); + + if (!p.success) { + return notFound(); + } + + const props = await getStaticProps(p.data.slug); + + if (!props) { + notFound(); + } + + return ; +} + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/apps/[slug]/setup/page.tsx b/apps/web/app/(use-page-wrapper)/apps/[slug]/setup/page.tsx new file mode 100644 index 00000000000000..6ac89e645510ba --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/[slug]/setup/page.tsx @@ -0,0 +1,35 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import SetupView, { type PageProps as ClientPageProps } from "~/apps/[slug]/setup/setup-view"; + +export const generateMetadata = async ({ params }: ServerPageProps) => { + const metadata = await _generateMetadata( + () => `${params.slug}`, + () => "" + ); + return { + ...metadata, + robots: { + index: false, + follow: false, + }, + }; +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const { dehydratedState, ...props } = await getData(context); + return ; +}; + +export default ServerPage; \ No newline at end of file diff --git a/apps/web/app/(use-page-wrapper)/apps/categories/[category]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/categories/[category]/page.tsx new file mode 100644 index 00000000000000..43b6b74bd7eac2 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/categories/[category]/page.tsx @@ -0,0 +1,26 @@ +import type { PageProps } from "app/_types"; +import { redirect } from "next/navigation"; +import { z } from "zod"; + +import { AppCategories } from "@calcom/prisma/enums"; + +import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps"; + +import CategoryPage from "~/apps/categories/[category]/category-view"; + +const querySchema = z.object({ + category: z.enum(Object.values(AppCategories) as [AppCategories, ...AppCategories[]]), +}); + +async function Page({ params, searchParams }: PageProps) { + const parsed = querySchema.safeParse({ ...params, ...searchParams }); + if (!parsed.success) { + redirect("/apps/categories/calendar"); + } + + const props = await getStaticProps(parsed.data.category); + + return ; +} + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/apps/categories/layout.tsx b/apps/web/app/(use-page-wrapper)/apps/categories/layout.tsx new file mode 100644 index 00000000000000..46785b22c556f8 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/categories/layout.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("app_store"), + (t) => t("app_store_description") + ); +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(use-page-wrapper)/apps/categories/page.tsx b/apps/web/app/(use-page-wrapper)/apps/categories/page.tsx new file mode 100644 index 00000000000000..0f07252b777490 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/categories/page.tsx @@ -0,0 +1,18 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { cookies, headers } from "next/headers"; + +import { getServerSideProps } from "@lib/apps/categories/getServerSideProps"; +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import Page from "~/apps/categories/categories-view"; + +const getData = withAppDirSsr(getServerSideProps); + +async function ServerPage({ params, searchParams }: PageProps) { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + + return ; +} + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/apps/installation/[[...step]]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/installation/[[...step]]/page.tsx new file mode 100644 index 00000000000000..591b0d52d7a179 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/installation/[[...step]]/page.tsx @@ -0,0 +1,28 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps"; +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view"; +import Page from "~/apps/installation/[[...step]]/step-view"; + +export const generateMetadata = async ({ params, searchParams }: PageProps) => { + const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const { appMetadata } = await getData(legacyCtx); + return await _generateMetadata( + (t) => `${t("install")} ${appMetadata?.name ?? ""}`, + () => "" + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: PageProps) => { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + return ; +}; +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx new file mode 100644 index 00000000000000..ff6d563bc92e52 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx @@ -0,0 +1,31 @@ +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { redirect } from "next/navigation"; +import { z } from "zod"; + +import { AppCategories } from "@calcom/prisma/enums"; + +import InstalledApps from "~/apps/installed/[category]/installed-category-view"; + +const querySchema = z.object({ + category: z.nativeEnum(AppCategories), +}); + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("installed_apps"), + (t) => t("manage_your_connected_apps") + ); +}; + +const InstalledAppsWrapper = async ({ params }: PageProps) => { + const parsedParams = querySchema.safeParse(params); + + if (!parsedParams.success) { + redirect("/apps/installed/calendar"); + } + + return ; +}; + +export default InstalledAppsWrapper; diff --git a/apps/web/app/(use-page-wrapper)/apps/installed/page.tsx b/apps/web/app/(use-page-wrapper)/apps/installed/page.tsx new file mode 100644 index 00000000000000..c602a66bd77c0f --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/installed/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +const Page = () => { + redirect("/apps/installed/calendar"); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/apps/page.tsx b/apps/web/app/(use-page-wrapper)/apps/page.tsx new file mode 100644 index 00000000000000..0117c797c9e9ba --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/apps/page.tsx @@ -0,0 +1,27 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { getServerSideProps } from "@lib/apps/getServerSideProps"; +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import AppsPage from "~/apps/apps-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("app_store"), + (t) => t("app_store_description") + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: PageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const props = await getData(context); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/error/page.tsx b/apps/web/app/(use-page-wrapper)/auth/error/page.tsx new file mode 100644 index 00000000000000..cd550ded4b8016 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/error/page.tsx @@ -0,0 +1,46 @@ +import type { PageProps } from "app/_types"; +import { _generateMetadata, getTranslate } from "app/_utils"; +import Link from "next/link"; +import { z } from "zod"; + +import { Button, Icon } from "@calcom/ui"; + +import AuthContainer from "@components/ui/AuthContainer"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("error"), + () => "" + ); +}; + +const querySchema = z.object({ + error: z.string().optional(), +}); + +const ServerPage = async ({ searchParams }: PageProps) => { + const t = await getTranslate(); + const { error } = querySchema.parse({ error: searchParams?.error || undefined }); + const errorMsg = error || t("error_during_login"); + return ( + +
+
+ +
+
+ +
+
+
+ + + +
+
+ ); +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/forgot-password/[id]/page.tsx b/apps/web/app/(use-page-wrapper)/auth/forgot-password/[id]/page.tsx new file mode 100644 index 00000000000000..4810f917a44e6c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/forgot-password/[id]/page.tsx @@ -0,0 +1,28 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view"; +import SetNewUserPassword from "~/auth/forgot-password/[id]/forgot-password-single-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("reset_password"), + (t) => t("change_your_password") + ); +}; + +const getData = withAppDirSsr(getServerSideProps); +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + const props = await getData(context); + + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/forgot-password/page.tsx b/apps/web/app/(use-page-wrapper)/auth/forgot-password/page.tsx new file mode 100644 index 00000000000000..0b656a734f0b1f --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/forgot-password/page.tsx @@ -0,0 +1,33 @@ +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { getCsrfToken } from "next-auth/react"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import ForgotPassword from "~/auth/forgot-password/forgot-password-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("forgot_password"), + (t) => t("request_password_reset") + ); +}; + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + const session = await getServerSession({ req: context.req }); + + if (session) { + redirect("/"); + } + + const csrfToken = await getCsrfToken(context); + + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/login/page.tsx b/apps/web/app/(use-page-wrapper)/auth/login/page.tsx new file mode 100644 index 00000000000000..9ccefad02c9f7b --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/login/page.tsx @@ -0,0 +1,27 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/auth/login/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/auth/login-view"; +import Login from "~/auth/login-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("login"), + (t) => t("login") + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/logout/page.tsx b/apps/web/app/(use-page-wrapper)/auth/logout/page.tsx new file mode 100644 index 00000000000000..99639e5e10300d --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/logout/page.tsx @@ -0,0 +1,27 @@ +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { ssrInit } from "@server/lib/ssr"; + +import Logout from "~/auth/logout-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("logged_out"), + (t) => t("youve_been_logged_out") + ); +}; + +const Page = async ({ params, searchParams }: PageProps) => { + // cookie will be cleared in `/apps/web/middleware.ts` + const h = headers(); + const context = buildLegacyCtx(h, cookies(), params, searchParams); + await ssrInit(context); + + return ; +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/auth/oauth2/authorize/page.tsx b/apps/web/app/(use-page-wrapper)/auth/oauth2/authorize/page.tsx new file mode 100644 index 00000000000000..be9c0eb3c80fbc --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/oauth2/authorize/page.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +import Page from "~/auth/oauth2/authorize-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("authorize"), + () => "" + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/auth/platform/authorize/page.tsx b/apps/web/app/(use-page-wrapper)/auth/platform/authorize/page.tsx new file mode 100644 index 00000000000000..d42fdace8d0fa1 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/platform/authorize/page.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +import Page from "~/auth/platform/authorize-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("authorize"), + () => "" + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/auth/saml-idp/page.tsx b/apps/web/app/(use-page-wrapper)/auth/saml-idp/page.tsx new file mode 100644 index 00000000000000..c8887bc3dc9e79 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/saml-idp/page.tsx @@ -0,0 +1,18 @@ +import type { PageProps } from "app/_types"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import SamlIdpClient from "~/auth/saml-idp/saml-idp-view"; + +const querySchema = z.object({ + code: z.string(), +}); + +export default async function SamlIdpPage({ searchParams }: PageProps) { + const parsed = querySchema.safeParse(searchParams); + if (!parsed.success) { + notFound(); + } + + return ; +} diff --git a/apps/web/app/(use-page-wrapper)/auth/setup/page.tsx b/apps/web/app/(use-page-wrapper)/auth/setup/page.tsx new file mode 100644 index 00000000000000..ff6768e7cc3d95 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/setup/page.tsx @@ -0,0 +1,27 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/setup/getServerSideProps"; + +import Setup from "~/auth/setup-view"; +import type { PageProps as ClientPageProps } from "~/auth/setup-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("setup"), + (t) => t("setup_description") + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/signin/page.tsx b/apps/web/app/(use-page-wrapper)/auth/signin/page.tsx new file mode 100644 index 00000000000000..aac25c869bf786 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/signin/page.tsx @@ -0,0 +1,26 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/auth/signin/getServerSideProps"; + +import SignIn from "~/auth/signin-view"; +import type { PageProps as ClientPageProps } from "~/auth/signin-view"; + +const getData = withAppDirSsr(getServerSideProps); +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + const session = await getServerSession({ req: context.req }); + if (session) { + redirect("/"); + } + + const props = await getData(context); + return ; +}; +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/sso/[provider]/page.tsx b/apps/web/app/(use-page-wrapper)/auth/sso/[provider]/page.tsx new file mode 100644 index 00000000000000..cb3572e78f1e49 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/sso/[provider]/page.tsx @@ -0,0 +1,20 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/auth/sso/[provider]/getServerSideProps"; + +import type { SSOProviderPageProps } from "~/auth/sso/provider-view"; +import SSOProviderView from "~/auth/sso/provider-view"; + +const getData = withAppDirSsr(getServerSideProps); +const ServerPage = async ({ params, searchParams }: PageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + const props = await getData(context); + + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/sso/direct/page.tsx b/apps/web/app/(use-page-wrapper)/auth/sso/direct/page.tsx new file mode 100644 index 00000000000000..a80dac7d7416cf --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/sso/direct/page.tsx @@ -0,0 +1,20 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/auth/sso/direct/getServerSideProps"; + +import type { SSODirectPageProps } from "~/auth/sso/direct-view"; +import SSODirectView from "~/auth/sso/direct-view"; + +const getData = withAppDirSsr(getServerSideProps); +const ServerPage = async ({ params, searchParams }: PageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + const props = await getData(context); + + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/verify-email-change/page.tsx b/apps/web/app/(use-page-wrapper)/auth/verify-email-change/page.tsx new file mode 100644 index 00000000000000..f49468649ff45c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/verify-email-change/page.tsx @@ -0,0 +1,25 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import { getServerSideProps } from "@server/lib/auth/verify-email-change/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/auth/verify-email-change-view"; +import VerifyEmailChange from "~/auth/verify-email-change-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("verify_email_change"), + () => "" + ); +}; + +const getData = withAppDirSsr(getServerSideProps); +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + return ; +}; +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/verify-email/page.tsx b/apps/web/app/(use-page-wrapper)/auth/verify-email/page.tsx new file mode 100644 index 00000000000000..a738827284edc9 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/verify-email/page.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +import VerifyEmailPage from "~/auth/verify-email-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("verify_email"), + () => "" + ); +}; + +export default VerifyEmailPage; diff --git a/apps/web/app/(use-page-wrapper)/auth/verify/page.tsx b/apps/web/app/(use-page-wrapper)/auth/verify/page.tsx new file mode 100644 index 00000000000000..b9862497c8b460 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/verify/page.tsx @@ -0,0 +1,37 @@ +import type { PageProps as _PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { z } from "zod"; + +import { StripeService } from "@calcom/lib/server/service/stripe"; + +import VerifyPage from "~/auth/verify-view"; + +const querySchema = z.object({ + stripeCustomerId: z.string().optional(), + sessionId: z.string().optional(), + t: z.string().optional(), +}); + +export const generateMetadata = async ({ params, searchParams }: _PageProps) => { + const p = { ...params, ...searchParams }; + const { sessionId, stripeCustomerId } = querySchema.parse(p); + + const data = await StripeService.getCheckoutSession({ + stripeCustomerId, + checkoutSessionId: sessionId, + }); + + const { hasPaymentFailed } = data; + + return await _generateMetadata( + () => + hasPaymentFailed ? "Your payment failed" : sessionId ? "Payment successful!" : `Verify your email`, + () => "" + ); +}; + +const ServerPage = () => { + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/availability/[schedule]/page.tsx b/apps/web/app/(use-page-wrapper)/availability/[schedule]/page.tsx new file mode 100644 index 00000000000000..d3d443ec185f55 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/availability/[schedule]/page.tsx @@ -0,0 +1,93 @@ +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { notFound } from "next/navigation"; +import { cache } from "react"; +import { z } from "zod"; + +// import { cookies, headers } from "next/headers"; +// import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +// import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { ScheduleRepository } from "@calcom/lib/server/repository/schedule"; + +// import { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule"; +// import { UserRepository } from "@calcom/lib/server/repository/user"; +import { AvailabilitySettingsWebWrapper } from "~/availability/[schedule]/schedule-view"; + +const querySchema = z.object({ + schedule: z + .string() + .refine((val) => !isNaN(Number(val)), { + message: "schedule must be a string that can be cast to a number", + }) + .transform((val) => Number(val)), +}); + +const getSchedule = cache((id: number) => ScheduleRepository.findScheduleById({ id })); + +export const generateMetadata = async ({ params }: PageProps) => { + const parsed = querySchema.safeParse(params); + if (!parsed.success) { + notFound(); + } + + const schedule = await getSchedule(parsed.data.schedule); + + if (!schedule) { + notFound(); + } + + return await _generateMetadata( + (t) => (schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")), + () => "" + ); +}; + +const Page = async ({ params }: PageProps) => { + const parsed = querySchema.safeParse(params); + if (!parsed.success) { + notFound(); + } + // const scheduleId = Number(params.schedule); + + // const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) }); + // const userId = session?.user?.id; + // if (!userId) { + // notFound(); + // } + + // let userData, schedule, travelSchedules; + + // try { + // userData = await UserRepository.getTimeZoneAndDefaultScheduleId({ + // userId, + // }); + // if (!userData?.timeZone || !userData?.defaultScheduleId) { + // throw new Error("timeZone and defaultScheduleId not found"); + // } + // } catch (e) { + // notFound(); + // } + + // try { + // schedule = await ScheduleRepository.findDetailedScheduleById({ + // scheduleId, + // isManagedEventType: false, + // userId, + // timeZone: userData.timeZone, + // defaultScheduleId: userData.defaultScheduleId, + // }); + // } catch (e) {} + + // try { + // travelSchedules = await TravelScheduleRepository.findTravelSchedulesByUserId(userId); + // } catch (e) {} + + return ( + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/availability/troubleshoot/layout.tsx b/apps/web/app/(use-page-wrapper)/availability/troubleshoot/layout.tsx new file mode 100644 index 00000000000000..1d9fe958788b56 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/availability/troubleshoot/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React, { Suspense } from "react"; + +import { ErrorBoundary, Icon } from "@calcom/ui"; + +export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + }>{children} + +
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/availability/troubleshoot/page.tsx b/apps/web/app/(use-page-wrapper)/availability/troubleshoot/page.tsx new file mode 100644 index 00000000000000..245f6855242a04 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/availability/troubleshoot/page.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +import Troubleshoot from "~/availability/troubleshoot/troubleshoot-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("troubleshoot"), + (t) => t("troubleshoot_availability") + ); +}; + +export default Troubleshoot; diff --git a/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx new file mode 100644 index 00000000000000..f110db95579ec6 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/booking/[uid]/page.tsx @@ -0,0 +1,40 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as _PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import OldPage from "~/bookings/views/bookings-single-view"; +import { + getServerSideProps, + type PageProps as ClientPageProps, +} from "~/bookings/views/bookings-single-view.getServerSideProps"; + +export const generateMetadata = async ({ params, searchParams }: _PageProps) => { + const { bookingInfo, eventType, recurringBookings, orgSlug } = await getData( + buildLegacyCtx(headers(), cookies(), params, searchParams) + ); + const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation; + + return await _generateMetadata( + (t) => + t(`booking_${needsConfirmation ? "submitted" : "confirmed"}${recurringBookings ? "_recurring" : ""}`), + (t) => + t(`booking_${needsConfirmation ? "submitted" : "confirmed"}${recurringBookings ? "_recurring" : ""}`), + false, + getOrgFullOrigin(orgSlug) + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: _PageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + const props = await getData(context); + return ; +}; +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/booking/dry-run-successful/page.tsx b/apps/web/app/(use-page-wrapper)/booking/dry-run-successful/page.tsx new file mode 100644 index 00000000000000..a3196f3b678cc9 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/booking/dry-run-successful/page.tsx @@ -0,0 +1,16 @@ +import { _generateMetadata } from "app/_utils"; + +import BookingDryRunSuccessView from "~/bookings/views/booking-dry-run-success-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("booking_dry_run_successful"), + (t) => t("booking_dry_run_successful_description") + ); +}; + +const Page = async () => { + return ; +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/connect-and-join/page.tsx b/apps/web/app/(use-page-wrapper)/connect-and-join/page.tsx new file mode 100644 index 00000000000000..a271ffc7ec3f8c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/connect-and-join/page.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +import LegacyPage from "~/connect-and-join/connect-and-join-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("connect_and_join"), + () => "" + ); +}; + +export default LegacyPage; diff --git a/apps/web/app/(use-page-wrapper)/enterprise/page.tsx b/apps/web/app/(use-page-wrapper)/enterprise/page.tsx new file mode 100644 index 00000000000000..3be9790f06a2d0 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/enterprise/page.tsx @@ -0,0 +1,11 @@ +import { _generateMetadata } from "app/_utils"; + +import EnterprisePage from "@components/EnterprisePage"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("create_your_org"), + (t) => t("create_your_org_description") + ); + +export default EnterprisePage; diff --git a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx new file mode 100644 index 00000000000000..44d7b43f87e460 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx @@ -0,0 +1,31 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as _PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import type { PageProps as EventTypePageProps } from "@lib/event-types/[type]/getServerSideProps"; +import { getServerSideProps } from "@lib/event-types/[type]/getServerSideProps"; + +import EventTypePageWrapper from "~/event-types/views/event-types-single-view"; + +export const generateMetadata = async ({ params, searchParams }: _PageProps) => { + const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams); + const { eventType } = await getData(legacyCtx); + + return await _generateMetadata( + (t) => `${eventType.title} | ${t("event_type")}`, + () => "" + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: _PageProps) => { + const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams); + const props = await getData(legacyCtx); + + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx b/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx new file mode 100644 index 00000000000000..0a347eae7ca57a --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/getting-started/[[...step]]/page.tsx @@ -0,0 +1,31 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { APP_NAME } from "@calcom/lib/constants"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/getting-started/[[...step]]/onboarding-view"; +import Page from "~/getting-started/[[...step]]/onboarding-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => `${APP_NAME} - ${t("getting_started")}`, + () => "", + true + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const props = await getData(context); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/insights/layout.tsx b/apps/web/app/(use-page-wrapper)/insights/layout.tsx new file mode 100644 index 00000000000000..df6bd32be650d7 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/layout.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useSession } from "next-auth/react"; + +import Shell from "@calcom/features/shell/Shell"; +import { UpgradeTip } from "@calcom/features/tips"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, ButtonGroup } from "@calcom/ui"; +import { Icon } from "@calcom/ui"; + +export default function InsightsLayout({ children }: { children: React.ReactNode }) { + const { t } = useLocale(); + const session = useSession(); + + const features = [ + { + icon: , + title: t("view_bookings_across"), + description: t("view_bookings_across_description"), + }, + { + icon: , + title: t("identify_booking_trends"), + description: t("identify_booking_trends_description"), + }, + { + icon: , + title: t("spot_popular_event_types"), + description: t("spot_popular_event_types_description"), + }, + ]; + + return ( +
+ + + + + + +
+ }> + {!session.data?.user ? null : children} + + +
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/insights/page.tsx b/apps/web/app/(use-page-wrapper)/insights/page.tsx new file mode 100644 index 00000000000000..ccdaa1b1d56dca --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/page.tsx @@ -0,0 +1,23 @@ +import { _generateMetadata } from "app/_utils"; +import { notFound } from "next/navigation"; + +import { getFeatureFlag } from "@calcom/features/flags/server/utils"; + +import InsightsPage from "~/insights/insights-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("insights"), + (t) => t("insights_subtitle") + ); + +export default async function Page() { + const prisma = await import("@calcom/prisma").then((mod) => mod.default); + const insightsEnabled = await getFeatureFlag(prisma, "insights"); + + if (!insightsEnabled) { + return notFound(); + } + + return ; +} diff --git a/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx b/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx new file mode 100644 index 00000000000000..39a30efbf3588c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx @@ -0,0 +1,23 @@ +import { _generateMetadata } from "app/_utils"; +import { notFound } from "next/navigation"; + +import { getFeatureFlag } from "@calcom/features/flags/server/utils"; + +import InsightsRoutingPage from "~/insights/insights-routing-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("insights"), + (t) => t("insights_subtitle") + ); + +export default async function Page() { + const prisma = await import("@calcom/prisma").then((mod) => mod.default); + const insightsEnabled = await getFeatureFlag(prisma, "insights"); + + if (!insightsEnabled) { + return notFound(); + } + + return ; +} diff --git a/apps/web/app/(use-page-wrapper)/insights/virtual-queues/page.tsx b/apps/web/app/(use-page-wrapper)/insights/virtual-queues/page.tsx new file mode 100644 index 00000000000000..81e8fe6d8e7d51 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/virtual-queues/page.tsx @@ -0,0 +1,23 @@ +import { _generateMetadata } from "app/_utils"; +import { notFound } from "next/navigation"; + +import { getFeatureFlag } from "@calcom/features/flags/server/utils"; + +import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("insights"), + (t) => t("insights_subtitle") + ); + +export default async function Page() { + const prisma = await import("@calcom/prisma").then((mod) => mod.default); + const insightsEnabled = await getFeatureFlag(prisma, "insights"); + + if (!insightsEnabled) { + return notFound(); + } + + return ; +} diff --git a/apps/web/app/(use-page-wrapper)/layout.tsx b/apps/web/app/(use-page-wrapper)/layout.tsx new file mode 100644 index 00000000000000..2e75568305a3c4 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/layout.tsx @@ -0,0 +1,20 @@ +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import PageWrapper from "@components/PageWrapperAppDir"; + +import { ssrInit } from "@server/lib/ssr"; + +export default async function PageWrapperLayout({ children }: { children: React.ReactNode }) { + const h = headers(); + const nonce = h.get("x-nonce") ?? undefined; + const context = buildLegacyCtx(headers(), cookies(), {}, {}); + const ssr = await ssrInit(context); + + return ( + + {children} + + ); +} diff --git a/apps/web/app/(use-page-wrapper)/maintenance/page.tsx b/apps/web/app/(use-page-wrapper)/maintenance/page.tsx new file mode 100644 index 00000000000000..804f49b40d717c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/maintenance/page.tsx @@ -0,0 +1,14 @@ +import { _generateMetadata } from "app/_utils"; + +import { APP_NAME } from "@calcom/lib/constants"; + +import LegacyPage from "~/maintenance/maintenance-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("under_maintenance"), + (t) => t("under_maintenance_description", { appName: APP_NAME }) + ); +}; + +export default LegacyPage; diff --git a/apps/web/app/(use-page-wrapper)/more/page.tsx b/apps/web/app/(use-page-wrapper)/more/page.tsx new file mode 100644 index 00000000000000..0fbc354ec09a46 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/more/page.tsx @@ -0,0 +1,12 @@ +import { _generateMetadata } from "app/_utils"; + +import Page from "~/more/more-page-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("more"), + () => "" + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/payment/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/payment/[uid]/page.tsx new file mode 100644 index 00000000000000..008602773b1874 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/payment/[uid]/page.tsx @@ -0,0 +1,28 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import PaymentPage from "@calcom/features/ee/payments/components/PaymentPage"; +import { getServerSideProps, type PaymentPageProps } from "@calcom/features/ee/payments/pages/payment"; +import { APP_NAME } from "@calcom/lib/constants"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +export const generateMetadata = async ({ params, searchParams }: PageProps) => { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + const eventName = props.booking.title; + return await _generateMetadata( + (t) => `${t("payment")} | ${eventName} | ${APP_NAME}`, + () => "" + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: PageProps) => { + const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams)); + + return ; +}; +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/signup/page.tsx b/apps/web/app/(use-page-wrapper)/signup/page.tsx new file mode 100644 index 00000000000000..afbb38cc8062e4 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/signup/page.tsx @@ -0,0 +1,27 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { getServerSideProps } from "@lib/signup/getServerSideProps"; + +import type { SignupProps } from "~/signup-view"; +import Signup from "~/signup-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("sign_up"), + (t) => t("sign_up") + ); + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: PageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const props = await getData(context); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/upgrade/page.tsx b/apps/web/app/(use-page-wrapper)/upgrade/page.tsx new file mode 100644 index 00000000000000..26c5c6842ab074 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/upgrade/page.tsx @@ -0,0 +1,11 @@ +import { _generateMetadata } from "app/_utils"; + +import LegacyPage from "~/upgrade/upgrade-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("upgrade"), + () => "" + ); + +export default LegacyPage; diff --git a/apps/web/app/(use-page-wrapper)/video/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/video/[uid]/page.tsx new file mode 100644 index 00000000000000..4e034265dc9057 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/video/[uid]/page.tsx @@ -0,0 +1,48 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { getServerSideProps } from "@lib/video/[uid]/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/videos/views/videos-single-view"; +import VideosSingleView from "~/videos/views/videos-single-view"; + +export const generateMetadata = async () => { + const t = await getTranslate(); + return { + title: `${APP_NAME} Video`, + description: t("quick_video_meeting"), + openGraph: { + title: `${APP_NAME} Video`, + description: t("quick_video_meeting"), + url: `${WEBSITE_URL}/video`, + images: [ + { + url: SEO_IMG_OGIMG_VIDEO, + }, + ], + type: "website", + }, + twitter: { + card: "summary_large_image", + title: `${APP_NAME} Video`, + description: t("quick_video_meeting"), + images: [SEO_IMG_OGIMG_VIDEO], + }, + }; +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const props = await getData(context); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/video/meeting-ended/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/video/meeting-ended/[uid]/page.tsx new file mode 100644 index 00000000000000..89d44c95ff52d2 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/video/meeting-ended/[uid]/page.tsx @@ -0,0 +1,27 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { getServerSideProps } from "@lib/video/meeting-ended/[uid]/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/videos/views/videos-meeting-ended-single-view"; +import MeetingEnded from "~/videos/views/videos-meeting-ended-single-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("meeting_unavailable"), + (t) => t("meeting_unavailable") + ); + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const props = await getData(context); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx b/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx new file mode 100644 index 00000000000000..c31aec62644597 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/video/meeting-not-started/[uid]/page.tsx @@ -0,0 +1,44 @@ +import { withAppDirSsr } from "app/WithAppDirSsr"; +import type { PageProps as ServerPageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import { BookingRepository } from "@calcom/lib/server/repository/booking"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { getServerSideProps } from "@lib/video/meeting-not-started/[uid]/getServerSideProps"; + +import type { PageProps as ClientPageProps } from "~/videos/views/videos-meeting-not-started-single-view"; +import MeetingNotStarted from "~/videos/views/videos-meeting-not-started-single-view"; + +const querySchema = z.object({ + uid: z.string(), +}); + +export const generateMetadata = async ({ params }: ServerPageProps) => { + const parsed = querySchema.safeParse(params); + if (!parsed.success) { + notFound(); + } + const booking = await BookingRepository.findBookingByUid({ + bookingUid: parsed.data.uid, + }); + + return await _generateMetadata( + (t) => t("this_meeting_has_not_started_yet"), + () => booking?.title ?? "" + ); +}; + +const getData = withAppDirSsr(getServerSideProps); + +const ServerPage = async ({ params, searchParams }: ServerPageProps) => { + const context = buildLegacyCtx(headers(), cookies(), params, searchParams); + + const props = await getData(context); + return ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/video/no-meeting-found/page.tsx b/apps/web/app/(use-page-wrapper)/video/no-meeting-found/page.tsx new file mode 100644 index 00000000000000..abb6e246c7982f --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/video/no-meeting-found/page.tsx @@ -0,0 +1,11 @@ +import { _generateMetadata } from "app/_utils"; + +import NoMeetingFound from "~/videos/views/videos-no-meeting-found-single-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("no_meeting_found"), + (t) => t("no_meeting_found") + ); + +export default NoMeetingFound; diff --git a/apps/web/app/(use-page-wrapper)/workflows/[workflow]/page.tsx b/apps/web/app/(use-page-wrapper)/workflows/[workflow]/page.tsx new file mode 100644 index 00000000000000..eafa40c5281ab5 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/workflows/[workflow]/page.tsx @@ -0,0 +1,72 @@ +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { cache } from "react"; +import { z } from "zod"; + +// import { cookies, headers } from "next/headers"; +// import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +// import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import LegacyPage from "@calcom/features/ee/workflows/pages/workflow"; +import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; + +const querySchema = z.object({ + workflow: z + .string() + .refine((val) => !isNaN(Number(val)), { + message: "workflow must be a string that can be cast to a number", + }) + .transform((val) => Number(val)), +}); + +const getWorkflow = cache((id: number) => WorkflowRepository.getById({ id })); + +export const generateMetadata = async ({ params }: PageProps): Promise => { + const parsed = querySchema.safeParse(params); + if (!parsed.success) { + notFound(); + } + const workflow = await getWorkflow(parsed.data.workflow); + if (!workflow) { + notFound(); + } + return await _generateMetadata( + (t) => (workflow && workflow.name ? workflow.name : t("untitled")), + () => "" + ); +}; + +const Page = async ({ params }: PageProps) => { + // const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) }); + // const user = session?.user; + const parsed = querySchema.safeParse(params); + if (!parsed.success) { + notFound(); + } + + // const workflow = await WorkflowRepository.getById({ id: +parsed.data.workflow }); + // let verifiedEmails, verifiedNumbers; + // try { + // verifiedEmails = await WorkflowRepository.getVerifiedEmails({ + // userEmail: user?.email ?? null, + // userId: user?.id ?? null, + // teamId: workflow?.team?.id, + // }); + // } catch (err) {} + // try { + // verifiedNumbers = await WorkflowRepository.getVerifiedNumbers({ + // userId: user?.id ?? null, + // teamId: workflow?.team?.id, + // }); + // } catch (err) {} + + return ( + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/workflows/page.tsx b/apps/web/app/(use-page-wrapper)/workflows/page.tsx new file mode 100644 index 00000000000000..cdf5b195f711c1 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/workflows/page.tsx @@ -0,0 +1,40 @@ +import type { PageProps } from "app/_types"; +import { _generateMetadata } from "app/_utils"; + +// import { cookies, headers } from "next/headers"; +// import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +// import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +// import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery"; +// import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; +import LegacyPage from "@calcom/features/ee/workflows/pages/index"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("workflows"), + (t) => t("workflows_to_automate_notifications") + ); + +const Page = async ({ params, searchParams }: PageProps) => { + // const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) }); + // const user = session?.user; + + // const filters = getTeamsFiltersFromQuery({ ...searchParams, ...params }); + + // let filteredList; + // try { + // filteredList = await WorkflowRepository.getFilteredList({ + // userId: user?.id, + // input: { + // filters, + // }, + // }); + // } catch (err) {} + + return ( + + ); +}; + +export default Page; diff --git a/apps/web/app/SpeculationRules.tsx b/apps/web/app/SpeculationRules.tsx new file mode 100644 index 00000000000000..0a14e62d695a31 --- /dev/null +++ b/apps/web/app/SpeculationRules.tsx @@ -0,0 +1,34 @@ +import Script from "next/script"; + +export function SpeculationRules({ + prefetchPathsOnHover = [], + prerenderPathsOnHover = [], +}: { + prefetchPathsOnHover?: string[]; + prerenderPathsOnHover?: string[]; +}) { + const speculationRules = { + prefetch: [ + { + urls: prefetchPathsOnHover, + eagerness: "moderate", + }, + ], + prerender: [ + { + urls: prerenderPathsOnHover, + eagerness: "moderate", + }, + ], + }; + + return ( + -` - } - /> -

{t("need_help_embedding")}

- - ); - }), - }, - { - name: "React", - href: "embedTabName=embed-react", - icon: Code, - type: "code", - Component: forwardRef< - HTMLTextAreaElement | HTMLIFrameElement | null, - { embedType: EmbedType; calLink: string; previewState: PreviewState } - >(function EmbedReact({ embedType, calLink, previewState }, ref) { - const { t } = useLocale(); - if (ref instanceof Function || !ref) { - return null; - } - if (ref.current && !(ref.current instanceof HTMLTextAreaElement)) { - return null; - } - return ( - <> - {t("create_update_react_component")} -