Compare commits

..

2 Commits

Author SHA1 Message Date
Peer Richelsen 081ff70ec0 removed inverteed logos 2023-09-15 00:17:43 +02:00
Peer Richelsen 996342ced1 added alby metadata app store entry 2023-09-13 23:45:47 +02:00
949 changed files with 10255 additions and 40616 deletions

View File

@ -125,5 +125,4 @@ SALESFORCE_CONSUMER_SECRET=""
ZOHOCRM_CLIENT_ID=""
ZOHOCRM_CLIENT_SECRET=""
# *********************************************************************************************************

View File

@ -87,7 +87,7 @@ 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 32` to generate one
# You can use: `openssl rand -base64 24` to generate one
CALENDSO_ENCRYPTION_KEY=
# Intercom Config
@ -229,31 +229,3 @@ AUTH_BEARER_TOKEN_VERCEL=
# Used for E2E tests on Apple Calendar
E2E_TEST_APPLE_CALENDAR_EMAIL=""
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
# - 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_WEBHOOK_SECRET=""
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
# Key should match on Cal.com and your application
# must be 32 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=
# ***********************************************************************************************************

View File

@ -1,33 +0,0 @@
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 "::set-output name=is_last_day::true"
else
echo "::set-output name=is_last_day::false"
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

View File

@ -19,20 +19,12 @@ jobs:
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: crowdin action
uses: crowdin/github-action@v1.13.0
uses: crowdin/github-action@1.5.1
with:
# upload sources
upload_sources: true
# upload translations (& auto-approve 'em)
upload_translations_args: '--auto-approve-imported'
upload_translations: true
push_translations: true
# download translations
download_translations: true
# GH config
push_translations: true
commit_message: "New Crowdin translations by Github Action"
localization_branch_name: main
create_pull_request: false

View File

@ -13,4 +13,4 @@ jobs:
with:
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
organization-name: calcom
ignore-labels: "app-store, ai, authentication, automated-testing, 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"
ignore-labels: "app-store, authentication, automated-testing, 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"

View File

@ -44,5 +44,4 @@ jobs:
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
Thank you for following the naming conventions! 🙏

View File

@ -15,5 +15,3 @@ jobs:
- 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
# We could add different timezones here that we need to run our tests in
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly

View File

@ -22,6 +22,6 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
Feel free to join the conversation at [discord](https://go.cal.com/discord)
issue-message: |
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉

View File

@ -5,9 +5,7 @@ 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|" \
-e "s|http://localhost:3000|https://localhost:3000|" \
-e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
command: yarn dx
ports:

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

View File

@ -7,6 +7,7 @@ public
*.lock
*.log
*.test.ts
.gitignore
.npmignore

View File

@ -1,15 +0,0 @@
diff --git a/index.cjs b/index.cjs
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 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.

View File

@ -1,26 +0,0 @@
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

View File

@ -92,22 +92,7 @@ To develop locally:
- 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.
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
```
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).
7. Start developing and watch for code changes:
6. Start developing and watch for code changes:
```sh
yarn dev
@ -135,16 +120,6 @@ 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:
@ -160,49 +135,3 @@ If you get errors, be sure to fix them before committing.
- 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"
```
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 <your-branch-name> --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 <commit_hash> 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 <your-branch-name>
```
- Then push the updated branch:
```bash
git push origin <your-branch-name>
```

View File

@ -131,39 +131,23 @@ Here is what you need to be able to run Cal.com.
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
> 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
1. Go to the project folder
```sh
cd cal.com
```
3. Install packages with yarn
1. Install packages with yarn
```sh
yarn
```
4. Set up your `.env` file
1. 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.
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`
> - **Requires Docker and Docker Compose to be installed**
@ -237,7 +221,6 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
```
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
```sh
@ -253,8 +236,6 @@ 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
@ -266,17 +247,6 @@ 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`.
@ -289,16 +259,6 @@ yarn test-e2e
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:
@ -381,10 +341,6 @@ 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://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png)](https://elest.io/open-source/cal.com)
<!-- ROADMAP -->
## Roadmap
@ -514,8 +470,9 @@ following
4. Select Basecamp 4 as the product to integrate with.
5. Set the Redirect URL for OAuth `<Cal.com URL>/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)`.
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
@ -547,10 +504,6 @@ 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/)

View File

@ -1,4 +0,0 @@
# Checkly Tests
Run as `yarn checkly test`
Deploy the tests as `yarn checkly deploy`

View File

@ -1,53 +0,0 @@
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 expectPageToBeServerSideRendered(page);
});
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
await expectPageToBeServerSideRendered(page);
});
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/meet/embed");
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
await expectPageToBeServerSideRendered(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 expectPageToBeServerSideRendered(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 expectPageToBeServerSideRendered(page);
});
});
});
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
async function expectPageToBeServerSideRendered(page: Page) {
expect(
await page.evaluate(() => {
return window.__NEXT_DATA__.props.pageProps.isEmbed;
})
).toBe(true);
}

View File

@ -1,14 +1,8 @@
BACKEND_URL=http://localhost:3002/api
# BACKEND_URL=https://api.cal.com/v1
FRONTEND_URL=http://localhost:3000
# FRONTEND_URL=https://cal.com
APP_ID=cal-ai
APP_URL=http://localhost:3000/apps/cal-ai
# This is for the onboard route. Which domain should we send emails from?
SENDER_DOMAIN=cal.ai
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
PARSE_KEY=

View File

@ -1,12 +1,12 @@
# Cal.ai
# Cal.com Email Assistant
Welcome to [Cal.ai](https://cal.ai)!
Welcome to the first stage of Cal AI!
This app lets you chat with your calendar via email:
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
- List and rearrange your bookings eg. "clear my afternoon"
- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
- List and rearrange your bookings eg. "Cancel my next meeting"
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
@ -14,11 +14,7 @@ _The AI agent can only choose from a set of tools, without ever seeing your API
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
## Recognition
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
## Getting Started
@ -26,39 +22,27 @@ Incoming emails are routed by email address. Addresses are verified by [DKIM rec
If you haven't yet, please run the [root setup](/README.md) steps.
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need:
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
- A default sender email (for example, `me@dev.example.com`)
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
- A default sender email (for example, `ai@cal.dev`)
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
### Agent Architecture
The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
Here is the full architecture:
![Cal.ai architecture](/apps/ai/src/public/architecture.png)
### Email Router
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted.
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
6. Activate "POST the raw, full MIME message".
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server.
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
1. [Sign up for an account](https://signup.sendgrid.com/)
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
4. Use the nGrok URL from above as the **Destination URL**.
5. Activate "POST the raw, full MIME message".
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
Please feel free to improve any part of this architecture!
Please feel free to improve any part of this architecture.

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/ai",
"version": "1.2.1",
"version": "1.0.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
@ -8,7 +8,7 @@
"@t3-oss/env-nextjs": "^0.6.1",
"langchain": "^0.0.131",
"mailparser": "^3.6.5",
"next": "^13.5.4",
"next": "^13.4.6",
"supports-color": "8.1.1",
"zod": "^3.22.2"
},

View File

@ -5,9 +5,6 @@ import agent from "../../../utils/agent";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
// Allow agent loop to run for up to 5 minutes
export const maxDuration = 300;
/**
* Launches a LangChain agent to process an incoming email,
* then sends the response to the user.
@ -21,32 +18,25 @@ export const POST = async (request: NextRequest) => {
const json = await request.json();
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
const { apiKey, userId, message, subject, user, replyTo } = json;
if ((!message && !subject) || !user) {
return new NextResponse("Missing fields", { status: 400 });
}
try {
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
// Send response to user
await sendEmail({
subject: `Re: ${subject}`,
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
to: user.email,
from: agentEmail,
from: replyTo,
});
return new NextResponse("ok");
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
to: user.email,
from: agentEmail,
});
return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }

View File

@ -1,44 +0,0 @@
import type { NextRequest } from "next/server";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import sendEmail from "../../../utils/sendEmail";
export const POST = async (request: NextRequest) => {
const { userId } = await request.json();
const user = await prisma.user.findUnique({
select: {
email: true,
name: true,
username: true,
},
where: {
id: userId,
},
});
if (!user) {
return new Response("User not found", { status: 404 });
}
await sendEmail({
subject: "Welcome to Cal AI",
to: user.email,
from: `${user.username}@${env.SENDER_DOMAIN}`,
text: `Hi ${
user.name || `@${user.username}`
},\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
user.username
}@${
env.SENDER_DOMAIN
}.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
html: `Hi ${
user.name || `@${user.username}`
},<br><br>I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.<br><br>Here are some things you can ask me:<br><br>- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)<br>- "What meetings do I have today?" (I'll show you your schedule)<br>- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)<br><br>I'm still learning, so if you have any feedback, please send it to <a href="https://twitter.com/calcom">@calcom</a> on X!<br><br>Remember, you can always reach me here, at ${
user.username
}@${env.SENDER_DOMAIN}.<br><br>Looking forward to working together (:<br><br>- Cal AI`,
});
return new Response("OK", { status: 200 });
};

View File

@ -3,22 +3,16 @@ import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import { fetchAvailability } from "../../../tools/getAvailability";
import { fetchEventTypes } from "../../../tools/getEventTypes";
import { extractUsers } from "../../../utils/extractUsers";
import getHostFromHeaders from "../../../utils/host";
import now from "../../../utils/now";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
// Allow receive loop to run for up to 30 seconds
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
export const maxDuration = 30;
/**
* Verifies email signature and app authorization,
* then hands off to booking agent.
@ -32,37 +26,18 @@ export const POST = async (request: NextRequest) => {
const formData = await request.formData();
const body = Object.fromEntries(formData);
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
const envelope = JSON.parse(body.envelope as string);
const aiEmail = envelope.to[0];
const subject = body.subject || "";
try {
await checkRateLimitAndThrowError({
identifier: `ai:email:${envelope.from}`,
rateLimitingType: "ai",
});
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
}
// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);
if (!parsed.text && !parsed.subject) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Email missing text and subject", { status: 400 });
}
@ -70,7 +45,6 @@ export const POST = async (request: NextRequest) => {
select: {
email: true,
id: true,
username: true,
timeZone: true,
credentials: {
select: {
@ -82,15 +56,12 @@ export const POST = async (request: NextRequest) => {
where: { email: envelope.from },
});
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
subject: `Re: ${subject}`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
subject: `Re: ${body.subject}`,
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
});
@ -105,9 +76,9 @@ export const POST = async (request: NextRequest) => {
const url = env.APP_URL;
await sendEmail({
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${subject}`,
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${body.subject}`,
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
to: envelope.from,
from: aiEmail,
});
@ -118,7 +89,7 @@ export const POST = async (request: NextRequest) => {
const { apiKey } = credential as { apiKey: string };
// Pre-fetch data relevant to most bookings.
const [eventTypes, availability, users] = await Promise.all([
const [eventTypes, availability] = await Promise.all([
fetchEventTypes({
apiKey,
}),
@ -128,12 +99,11 @@ export const POST = async (request: NextRequest) => {
dateFrom: now(user.timeZone),
dateTo: now(user.timeZone),
}),
extractUsers(`${parsed.text} ${parsed.subject}`),
]);
if ("error" in availability) {
await sendEmail({
subject: `Re: ${subject}`,
subject: `Re: ${body.subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
@ -144,7 +114,7 @@ export const POST = async (request: NextRequest) => {
if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${subject}`,
subject: `Re: ${body.subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
@ -162,17 +132,15 @@ export const POST = async (request: NextRequest) => {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text || "",
subject: parsed.subject || "",
message: parsed.text,
subject: parsed.subject,
replyTo: aiEmail,
user: {
email: user.email,
eventTypes,
username: user.username,
timeZone: user.timeZone,
workingHours,
},
users,
}),
headers: {
"Content-Type": "application/json",

View File

@ -17,10 +17,8 @@ export const env = createEnv({
*/
runtimeEnv: {
BACKEND_URL: process.env.BACKEND_URL,
FRONTEND_URL: process.env.FRONTEND_URL,
APP_ID: process.env.APP_ID,
APP_URL: process.env.APP_URL,
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
PARSE_KEY: process.env.PARSE_KEY,
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
@ -34,10 +32,8 @@ export const env = createEnv({
*/
server: {
BACKEND_URL: z.string().url(),
FRONTEND_URL: z.string().url(),
APP_ID: z.string().min(1),
APP_URL: z.string().url(),
SENDER_DOMAIN: z.string().min(1),
PARSE_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "test", "production"]),
OPENAI_API_KEY: z.string().min(1),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

View File

@ -1,8 +1,6 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import type { UserList } from "~/src/types/user";
import { env } from "../env.mjs";
/**
@ -11,23 +9,21 @@ import { env } from "../env.mjs";
const createBooking = async ({
apiKey,
userId,
users,
eventTypeId,
start,
end,
timeZone,
language,
invite,
responses,
}: {
apiKey: string;
userId: number;
users: UserList;
eventTypeId: number;
start: string;
end: string;
timeZone: string;
language: string;
invite: number;
responses: { name?: string; email?: string; location?: string };
title?: string;
status?: string;
}): Promise<string | Error | { error: string }> => {
@ -40,18 +36,6 @@ const createBooking = async ({
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const user = users.find((u) => u.id === invite);
if (!user) {
return { error: `User with id ${invite} not found to invite` };
}
const responses = {
id: invite.toString(),
name: user.username,
email: user.email,
};
const response = await fetch(url, {
body: JSON.stringify({
end,
@ -82,19 +66,19 @@ const createBooking = async ({
return "Booking created";
};
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
const createBookingTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Creates a booking on the primary user's calendar.",
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
description:
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
return JSON.stringify(
await createBooking({
apiKey,
userId,
users,
end,
eventTypeId,
language,
invite,
responses,
start,
status,
timeZone,
@ -102,14 +86,19 @@ const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
})
);
},
name: "createBooking",
name: "createBookingIfAvailable",
schema: z.object({
end: z
.string()
.describe("This should correspond to the event type's length, unless otherwise specified."),
eventTypeId: z.number(),
language: z.string(),
invite: z.number().describe("External user id to invite."),
responses: z
.object({
email: z.string().optional(),
name: z.string().optional(),
})
.describe("External invited user. Not the user making the request."),
start: z.string(),
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
timeZone: z.string(),

View File

@ -12,11 +12,13 @@ export const fetchAvailability = async ({
userId,
dateFrom,
dateTo,
eventTypeId,
}: {
apiKey: string;
userId: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
}): Promise<Partial<Availability> | { error: string }> => {
const params: { [k: string]: string } = {
apiKey,
@ -25,6 +27,8 @@ export const fetchAvailability = async ({
dateTo,
};
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
@ -47,29 +51,30 @@ export const fetchAvailability = async ({
};
};
const getAvailabilityTool = (apiKey: string) => {
const getAvailabilityTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get availability of users within range.",
func: async ({ userIds, dateFrom, dateTo }) => {
description: "Get availability within range.",
func: async ({ dateFrom, dateTo, eventTypeId }) => {
return JSON.stringify(
await Promise.all(
userIds.map(
async (userId) =>
await fetchAvailability({
userId: userId,
apiKey,
dateFrom,
dateTo,
})
)
)
await fetchAvailability({
apiKey,
userId,
dateFrom,
dateTo,
eventTypeId,
})
);
},
name: "getAvailability",
schema: z.object({
userIds: z.array(z.number()).describe("The users to fetch availability for."),
dateFrom: z.string(),
dateTo: z.string(),
eventTypeId: z
.number()
.optional()
.describe(
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
),
}),
});
};

View File

@ -60,7 +60,7 @@ const fetchBookings = async ({
const getBookingsTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get bookings for the primary user between two dates.",
description: "Get bookings for a user between two dates.",
func: async ({ from, to }) => {
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
},

View File

@ -7,15 +7,11 @@ import type { EventType } from "../types/eventType";
/**
* Fetches event types by user ID.
*/
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
const params: Record<string, string> = {
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
const params = {
apiKey,
};
if (userId) {
params["userId"] = userId.toString();
}
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
@ -32,7 +28,6 @@ export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; user
return data.event_types.map((eventType: EventType) => ({
id: eventType.id,
slug: eventType.slug,
length: eventType.length,
title: eventType.title,
}));
@ -40,19 +35,16 @@ export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; user
const getEventTypesTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
func: async ({ userId }) => {
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
func: async () => {
return JSON.stringify(
await fetchEventTypes({
apiKey,
userId,
})
);
},
name: "getEventTypes",
schema: z.object({
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
}),
schema: z.object({}),
});
};

View File

@ -1,124 +0,0 @@
import { DynamicStructuredTool } from "langchain/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
import type { User, UserList } from "~/src/types/user";
import sendEmail from "~/src/utils/sendEmail";
export const sendBookingEmail = async ({
user,
agentEmail,
subject,
to,
message,
eventTypeSlug,
slots,
date,
}: {
apiKey: string;
user: User;
users: UserList;
agentEmail: string;
subject: string;
to: string;
message: string;
eventTypeSlug: string;
slots?: {
time: string;
text: string;
}[];
date: {
date: string;
text: string;
};
}) => {
// const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
const timeUrls = slots?.map(({ time, text }) => {
return {
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
text,
};
});
const dateUrl = {
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
text: date.text,
};
await sendEmail({
subject,
to,
cc: user.email,
from: agentEmail,
text: message
.split("[[[Slots]]]")
.join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
.split("[[[Link]]]")
.join(`${dateUrl.text}: ${dateUrl.url}`),
html: message
.split("\n")
.join("<br>")
.split("[[[Slots]]]")
.join(timeUrls?.map(({ url, text }) => `<a href="${url}">${text}</a>`).join("<br>"))
.split("[[[Link]]]")
.join(`<a href="${dateUrl.url}">${dateUrl.text}</a>`),
});
return "Booking link sent";
};
const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
return new DynamicStructuredTool({
description:
"Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
return JSON.stringify(
await sendBookingEmail({
apiKey,
user,
users,
agentEmail,
subject,
to,
message,
eventTypeSlug,
slots,
date,
})
);
},
name: "sendBookingEmail",
schema: z.object({
message: z
.string()
.describe(
"A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
),
subject: z.string(),
to: z
.string()
.describe("email address to send the booking link to. Primary user is automatically CC'd"),
eventTypeSlug: z.string().describe("the slug of the event type to book"),
slots: z
.array(
z.object({
time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
text: z.string().describe("minimum readable label. Ex. 4pm."),
})
)
.optional()
.describe("Time slots the external user can click"),
date: z
.object({
date: z.string().describe("YYYY-MM-DD"),
text: z.string().describe('"See all times" or similar'),
})
.describe(
"A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
),
}),
});
};
export default sendBookingEmailTool;

View File

@ -2,17 +2,8 @@ import type { EventType } from "./eventType";
import type { WorkingHours } from "./workingHours";
export type User = {
id: number;
email: string;
username: string;
timeZone: string;
eventTypes: EventType[];
workingHours: WorkingHours[];
};
export type UserList = {
id?: number;
email?: string;
username?: string;
type: "fromUsername" | "fromEmail";
}[];

View File

@ -2,40 +2,30 @@ import { initializeAgentExecutorWithOptions } from "langchain/agents";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { env } from "../env.mjs";
import createBookingIfAvailable from "../tools/createBooking";
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
import deleteBooking from "../tools/deleteBooking";
import getAvailability from "../tools/getAvailability";
import getBookings from "../tools/getBookings";
import sendBookingEmail from "../tools/sendBookingEmail";
import updateBooking from "../tools/updateBooking";
import type { EventType } from "../types/eventType";
import type { User, UserList } from "../types/user";
import type { User } from "../types/user";
import type { WorkingHours } from "../types/workingHours";
import now from "./now";
const gptModel = "gpt-4";
/**
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
* Core of the Cal AI booking agent: a LangChain Agent Executor.
* Uses a toolchain to book meetings, list available slots, etc.
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
*/
const agent = async (
input: string,
user: User,
users: UserList,
apiKey: string,
userId: number,
agentEmail: string
) => {
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
const tools = [
// getEventTypes(apiKey),
getAvailability(apiKey),
createBookingIfAvailable(apiKey, userId),
getAvailability(apiKey, userId),
getBookings(apiKey, userId),
createBookingIfAvailable(apiKey, userId, users),
updateBooking(apiKey, userId),
deleteBooking(apiKey),
sendBookingEmail(apiKey, user, users, agentEmail),
];
const model = new ChatOpenAI({
@ -49,50 +39,24 @@ const agent = async (
*/
const executor = await initializeAgentExecutorWithOptions(tools, model, {
agentArgs: {
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
The primary user's id is: ${userId}
The primary user's username is: ${user.username}
The current time in the primary user's timezone is: ${now(user.timeZone)}
The primary user's time zone is: ${user.timeZone}
The primary user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
.join("\n")}
The primary user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime};`
)
.join("\n")}
${
users.length
? `The email references the following @usernames and emails: ${users
.map(
(u) =>
`${
(u.id ? `, id: ${u.id}` : "id: (non user)") +
(u.username
? u.type === "fromUsername"
? `, username: @${u.username}`
: ", username: REDACTED"
: ", (no username)") +
(u.email
? u.type === "fromEmail"
? `, email: ${u.email}`
: ", email: REDACTED"
: ", (no email)")
};`
)
.join("\n")}`
: ""
}
The current time in the user's timezone is: ${now(user.timeZone)}
The user's time zone is: ${user.timeZone}
The user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
.join("\n")}
The user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime}`
)
.join("\n")}
`,
},
agentType: "openai-functions",

View File

@ -1 +0,0 @@
export const context = { apiKey: "", userId: "" };

View File

@ -1,85 +0,0 @@
import prisma from "@calcom/prisma";
import type { UserList } from "../types/user";
/*
* Extracts usernames (@Example) and emails (hi@example.com) from a string
*/
export const extractUsers = async (text: string) => {
const usernames = text
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
?.map((username) => username.slice(1).toLowerCase());
const emails = text
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
?.map((email) => email.toLowerCase());
const dbUsersFromUsernames = usernames
? await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
},
where: {
username: {
in: usernames,
},
},
})
: [];
const usersFromUsernames = usernames
? usernames.map((username) => {
const user = dbUsersFromUsernames.find((u) => u.username === username);
return user
? {
username,
id: user.id,
email: user.email,
type: "fromUsername",
}
: {
username,
id: null,
email: null,
type: "fromUsername",
};
})
: [];
const dbUsersFromEmails = emails
? await prisma.user.findMany({
select: {
id: true,
email: true,
username: true,
},
where: {
email: {
in: emails,
},
},
})
: [];
const usersFromEmails = emails
? emails.map((email) => {
const user = dbUsersFromEmails.find((u) => u.email === email);
return user
? {
email,
id: user.id,
username: user.username,
type: "fromEmail",
}
: {
email,
id: null,
username: null,
type: "fromEmail",
};
})
: [];
return [...usersFromUsernames, ...usersFromEmails] as UserList;
};

View File

@ -8,14 +8,12 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
const send = async ({
subject,
to,
cc,
from,
text,
html,
}: {
subject: string;
to: string | string[];
cc?: string | string[];
to: string;
from: string;
text: string;
html?: string;
@ -24,10 +22,9 @@ const send = async ({
const msg = {
to,
cc,
from: {
email: from,
name: "Cal.ai",
name: "Cal AI",
},
text,
html,

View File

@ -1,7 +1,7 @@
import { PrismaClient } from "@prisma/client";
import type { NextMiddleware } from "next-api-middleware";
import { CONSOLE_URL } from "@calcom/lib/constants";
import { customPrisma } from "@calcom/prisma";
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
@ -12,7 +12,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
} = req;
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
if (!key) {
req.prisma = customPrisma();
req.prisma = new PrismaClient();
await next();
return;
}
@ -26,7 +26,7 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
return;
}
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
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,

View File

@ -1,15 +0,0 @@
import type { NextMiddleware } from "next-api-middleware";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// TODO: Add a way to add trusted api keys
await checkRateLimitAndThrowError({
identifier: req.query.apiKey as string,
rateLimitingType: "api",
});
await next();
};

View File

@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isAdminGuard } from "../utils/isAdmin";
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) {

View File

@ -12,29 +12,24 @@ import {
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
} from "./httpMethods";
import { rateLimitApiKey } from "./rateLimitApiKey";
import { verifyApiKey } from "./verifyApiKey";
import { withPagination } from "./withPagination";
const middleware = {
HTTP_GET_OR_POST,
HTTP_GET_DELETE_PATCH,
HTTP_GET,
HTTP_PATCH,
HTTP_POST,
HTTP_DELETE,
addRequestId,
verifyApiKey,
rateLimitApiKey,
customPrismaClient,
extendRequest,
pagination: withPagination,
captureErrors,
};
type Middleware = keyof typeof middleware;
const middlewareOrder =
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,
captureErrors,
},
// The order here, determines the order of execution
[
"extendRequest",
@ -42,10 +37,8 @@ const middlewareOrder =
// - Put customPrismaClient before verifyApiKey always.
"customPrismaClient",
"verifyApiKey",
"rateLimitApiKey",
"addRequestId",
] as Middleware[]; // <-- Provide a list of middleware to call automatically
] // <-- Provide a list of middleware to call automatically
);
const withMiddleware = label(middleware, middlewareOrder);
export { withMiddleware, middleware, middlewareOrder };
export { withMiddleware };

View File

@ -1,14 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
export function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
/** Guard: Only admins can query other users */
if (!isAdmin) {
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
}
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
}

View File

@ -58,7 +58,6 @@ export const schemaBookingReadPublic = Booking.extend({
})
)
.optional(),
responses: z.record(z.any()).nullable(),
}).pick({
id: true,
userId: true,

View File

@ -14,9 +14,9 @@ const schemaDestinationCalendarCreateParams = z
.object({
integration: z.string(),
externalId: z.string(),
eventTypeId: z.number().optional(),
bookingId: z.number().optional(),
userId: z.number().optional(),
eventTypeId: z.number(),
bookingId: z.number(),
userId: z.number(),
})
.strict();

View File

@ -24,11 +24,6 @@ const hostSchema = _HostModel.pick({
userId: true,
});
export const childrenSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
});
export const schemaEventTypeBaseBodyParams = EventType.pick({
title: true,
description: true,
@ -50,7 +45,6 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
parentId: true,
beforeEventBuffer: true,
afterEventBuffer: true,
teamId: true,
@ -62,12 +56,7 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
bookingLimits: true,
durationLimits: true,
})
.merge(
z.object({
children: z.array(childrenSchema).optional().default([]),
hosts: z.array(hostSchema).optional().default([]),
})
)
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
.partial()
.strict();
@ -84,7 +73,6 @@ const schemaEventTypeCreateParams = z
seatsShowAvailabilityCount: z.boolean().optional(),
bookingFields: eventTypeBookingFields.optional(),
scheduleId: z.number().optional(),
parentId: z.number().optional(),
})
.strict();
@ -137,7 +125,6 @@ export const schemaEventTypeReadPublic = EventType.pick({
price: true,
currency: true,
slotInterval: true,
parentId: true,
successRedirectUrl: true,
description: true,
locations: true,
@ -150,8 +137,6 @@ export const schemaEventTypeReadPublic = EventType.pick({
durationLimits: true,
}).merge(
z.object({
children: z.array(childrenSchema).optional().default([]),
hosts: z.array(hostSchema).optional().default([]),
locations: z
.array(
z.object({

View File

@ -75,7 +75,6 @@ export const schemaUserBaseBodyParams = User.pick({
theme: true,
defaultScheduleId: true,
locale: true,
hideBranding: true,
timeFormat: true,
brandColor: true,
darkBrandColor: true,
@ -96,7 +95,6 @@ const schemaUserEditParams = z.object({
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(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@ -117,7 +115,6 @@ const schemaUserCreateParams = z.object({
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(),
timeFormat: z.nativeEnum(timeFormat).optional(),
@ -160,7 +157,6 @@ export const schemaUserReadPublic = User.pick({
defaultScheduleId: true,
locale: true,
timeFormat: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,

View File

@ -20,7 +20,6 @@ export const schemaWebhookCreateParams = z
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(),
})
@ -32,7 +31,6 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
.merge(
z.object({
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
secret: z.string().optional().nullable(),
})
)
.partial()

2
apps/api/next.d.ts vendored
View File

@ -1,7 +1,7 @@
import type { Session } from "next-auth";
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
import type { PrismaClient } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma/client";
export type * from "next/types";

View File

@ -10,86 +10,9 @@ 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
* summary: Find user or team availability
* parameters:
* - in: query
* name: apiKey
@ -105,6 +28,12 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* 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
@ -130,7 +59,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* type: integer
* example: 123
* description: Event Type ID of the event type to fetch the availability for
* operationId: user-availability
* operationId: availability
* tags:
* - availability
* responses:
@ -167,7 +96,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: User not found
* description: User not found | Team not found | Team has no members
*/
interface MemberRoles {
[userId: number | string]: MembershipRole;

View File

@ -0,0 +1,240 @@
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<DestinationCalendarResponse>
) {
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)
);

View File

@ -1,32 +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 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;

View File

@ -1,42 +0,0 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
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 { prisma, query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
await prisma.destinationCalendar.delete({ where: { id } });
return { message: `OK, Destination Calendar removed successfully` };
}
export default defaultResponder(deleteHandler);

View File

@ -1,47 +0,0 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
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 { prisma, query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const destinationCalendar = await prisma.destinationCalendar.findUnique({
where: { id },
});
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse({ ...destinationCalendar }) };
}
export default defaultResponder(getHandler);

View File

@ -1,312 +0,0 @@
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 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, isAdmin, prisma, query, body } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
const assignedUserId = isAdmin ? 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<typeof schemaDestinationCalendarEditBodyParams>;
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<typeof schemaDestinationCalendarEditBodyParams>;
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<typeof schemaDestinationCalendarEditBodyParams>) {
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);

View File

@ -1,18 +0,0 @@
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"),
PATCH: import("./_patch"),
DELETE: import("./_delete"),
})(req, res);
})
);

View File

@ -1,58 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
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, prisma } = 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);

View File

@ -1,141 +0,0 @@
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 { 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, isAdmin, prisma, body } = req;
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
await checkPermissions(req, userId);
const assignedUserId = isAdmin && 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 { isAdmin } = req;
const body = schemaDestinationCalendarCreateBodyParams.parse(req.body);
/* Non-admin users can only create destination calendars 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" });
/* User should only be able to create for their own destination calendars*/
if (!isAdmin && body.eventTypeId) {
const ownsEventType = await req.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);

View File

@ -1,10 +1,114 @@
import { defaultHandler } from "@calcom/lib/server";
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";
export default withMiddleware()(
defaultHandler({
GET: import("./_get"),
POST: import("./_post"),
})
);
async function createOrlistAllDestinationCalendars(
{ method, body, userId, prisma }: NextApiRequest,
res: NextApiResponse<DestinationCalendarsResponse | DestinationCalendarResponse>
) {
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);

View File

@ -52,7 +52,6 @@ export async function getHandler(req: NextApiRequest) {
team: { select: { slug: true } },
users: true,
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
});
await checkPermissions(req, eventType);

View File

@ -209,8 +209,6 @@ export async function patchHandler(req: NextApiRequest) {
hosts = [],
bookingLimits,
durationLimits,
/** FIXME: Updating event-type children from API not supported for now */
children: _,
...parsedBody
} = schemaEventTypeEditBodyParams.parse(body);

View File

@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import type { PrismaClient } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma/client";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
@ -46,7 +46,6 @@ async function getHandler(req: NextApiRequest) {
team: { select: { slug: true } },
users: true,
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
});
// this really should return [], but backwards compatibility..

View File

@ -6,9 +6,7 @@ import { defaultResponder } from "@calcom/lib/server";
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
import checkUserMembership from "./_utils/checkUserMembership";
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
/**
@ -120,13 +118,10 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* schedulingType:
* type: string
* description: The type of scheduling if a Team event. Required for team events only
* enum: [ROUND_ROBIN, COLLECTIVE, MANAGED]
* enum: [ROUND_ROBIN, COLLECTIVE]
* 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.
@ -268,8 +263,6 @@ async function postHandler(req: NextApiRequest) {
hosts = [],
bookingLimits,
durationLimits,
/** FIXME: Adding event-type children from API not supported for now */
children: _,
...parsedBody
} = schemaEventTypeCreateBodyParams.parse(body || {});
@ -283,11 +276,6 @@ async function postHandler(req: NextApiRequest) {
await checkPermissions(req);
if (parsedBody.parentId) {
await checkParentEventOwnership(req);
await checkUserMembership(req);
}
if (isAdmin && parsedBody.userId) {
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
}

View File

@ -1,56 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
/**
* 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, prisma, 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,
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
},
});
if (!teamMember) {
throw new HttpError({
statusCode: 403,
message: "User is not authorized to access the team to which the parent event type belongs.",
});
}
}

View File

@ -1,57 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
/**
* 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 { prisma, 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.",
});
}
}

View File

@ -3,17 +3,18 @@ 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 { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
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 {
const input = getScheduleSchema.parse(req.query);
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
// 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);

View File

@ -53,9 +53,6 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* 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
@ -82,7 +79,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* - users
* responses:
* 200:
* description: OK, user edited successfully
* description: OK, user edited successfuly
* 400:
* description: Bad request. User body is invalid.
* 401:
@ -97,10 +94,9 @@ export async function patchHandler(req: NextApiRequest) {
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const body = await schemaUserEditBodyParams.parseAsync(req.body);
// disable role or branding changes unless admin.
if (!isAdmin) {
if (body.role) body.role = undefined;
if (body.hideBranding) body.hideBranding = undefined;
// disable role changes unless admin.
if (!isAdmin && body.role) {
body.role = undefined;
}
const userSchedules = await prisma.schedule.findMany({

View File

@ -42,9 +42,6 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
* 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

View File

@ -51,9 +51,6 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
* 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:

View File

@ -49,9 +49,6 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
* 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:

View File

@ -1,6 +1,3 @@
// TODO: Fix tests (These test were never running due to the vitest workspace config)
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
@ -11,6 +8,7 @@ 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;
@ -22,7 +20,7 @@ vi.mock("@calcom/lib/server/i18n", () => {
};
});
describe.skipIf(true)("POST /api/bookings", () => {
describe("POST /api/bookings", () => {
describe("Errors", () => {
test("Missing required data", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
@ -32,7 +30,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message:

View File

@ -1,36 +0,0 @@
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<CustomNextApiRequest, CustomNextApiResponse>({
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();
});
});

View File

@ -1,53 +0,0 @@
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<CustomNextApiRequest, CustomNextApiResponse>({
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<CustomNextApiRequest, CustomNextApiResponse>({
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);
});
});

View File

@ -1,76 +0,0 @@
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 checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
return {
default: vi.fn(),
};
});
vi.mock("~/lib/utils/isAdmin", () => {
return {
isAdminGuard: vi.fn(),
};
});
describe("Verify API key", () => {
it("It should throw an error if the api key is not valid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(false);
vi.mocked(isAdminGuard).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("It should thow an error if no api key is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const middleware = {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).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);
});
});

View File

@ -1,17 +0,0 @@
import { describe, vi, it, expect, afterEach } from "vitest";
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
afterEach(() => {
vi.resetAllMocks();
});
// Not sure if there is much point testing this order is actually applied via an integration test:
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
describe("API - withMiddleware test", () => {
it("Custom prisma should be before verifyApiKey", async () => {
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
});
});

View File

@ -1 +0,0 @@
Hello World

View File

@ -1 +1,2 @@
public/embed
public/embed
*.test.ts

View File

@ -1,109 +0,0 @@
import type { Metadata } from "next";
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
import Script from "next/script";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import "../styles/globals.css";
export const metadata: Metadata = {
icons: {
icon: [
{
sizes: "32x32",
url: "/api/logo?type=favicon-32",
},
{
sizes: "16x16",
url: "/api/logo?type=favicon-16",
},
],
apple: {
sizes: "180x180",
url: "/api/logo?type=apple-touch-icon",
},
other: [
{
url: "/safari-pinned-tab.svg",
rel: "mask-icon",
},
],
},
manifest: "/site.webmanifest",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
],
other: {
"msapplication-TileColor": "#000000",
},
};
const getInitialProps = async (
url: string,
headers: ReturnType<typeof nextHeaders>,
cookies: ReturnType<typeof nextCookies>
) => {
const { pathname, searchParams } = new URL(url);
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
const embedColorScheme = searchParams?.get("ui.color-scheme");
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
const newLocale = await getLocale({ headers, cookies });
let direction = "ltr";
try {
const intlLocale = new Intl.Locale(newLocale);
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
direction = intlLocale.textInfo?.direction;
} catch (e) {
console.error(e);
}
return { isEmbed, embedColorScheme, locale: newLocale, direction };
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headers = nextHeaders();
const cookies = nextCookies();
const fullUrl = headers.get("x-url") ?? "";
const nonce = headers.get("x-csp") ?? "";
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
return (
<html
lang={locale}
dir={direction}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<head nonce={nonce}>
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
// eslint-disable-next-line @next/next/no-sync-scripts
<Script
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
/>
)}
</head>
<body
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"
style={
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
}
: {}
}>
{children}
</body>
</html>
);
}

View File

@ -20,7 +20,7 @@ export default function AddToHomescreen() {
<div className="flex w-0 flex-1 items-center">
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
<svg
className="h-7 w-7 fill-current text-[#5B93F9]"
className="h-7 w-7 fill-current text-indigo-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
enableBackground="new 0 0 50 50">
@ -29,7 +29,7 @@ export default function AddToHomescreen() {
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
</svg>
</span>
<p className="text-inverted ms-3 text-xs font-medium dark:text-white">
<p className="text-inverted ms-3 text-xs font-medium">
<span className="inline">{t("add_to_homescreen")}</span>
</p>
</div>
@ -40,7 +40,7 @@ export default function AddToHomescreen() {
type="button"
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
<span className="sr-only">{t("dismiss")}</span>
<X className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
<X className="text-inverted h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>

View File

@ -60,18 +60,14 @@ export default function AppListCard(props: AppListCardProps) {
const pathname = usePathname();
useEffect(() => {
if (shouldHighlight && highlight && searchParams !== null && pathname !== null) {
timeoutRef.current = setTimeout(() => {
if (shouldHighlight && highlight) {
const timer = setTimeout(() => {
setHighlight(false);
const _searchParams = new URLSearchParams(searchParams);
_searchParams.delete("hl");
_searchParams.delete("category"); // this comes from params, not from search params
setHighlight(false);
const stringifiedSearchParams = _searchParams.toString();
router.replace(`${pathname}${stringifiedSearchParams !== "" ? `?${stringifiedSearchParams}` : ""}`);
router.replace(`${pathname}?${_searchParams.toString()}`);
}, 3000);
timeoutRef.current = timer;
}
return () => {
if (timeoutRef.current) {
@ -79,11 +75,12 @@ export default function AppListCard(props: AppListCardProps) {
timeoutRef.current = null;
}
};
}, [highlight, pathname, router, searchParams, shouldHighlight]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
<div className="flex items-center gap-x-3 px-4 py-4 sm:px-6">
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<div className="flex items-center gap-x-3 px-5 py-4">
{logo ? (
<img
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}

View File

@ -1,7 +1,12 @@
import { lookup } from "bcp-47-match";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react";
export function useViewerI18n(locale: string) {
function useViewerI18n(locale: string) {
return trpc.viewer.public.i18n.useQuery(
{ locale, CalComVersion: CALCOM_VERSION },
{
@ -14,3 +19,46 @@ export function useViewerI18n(locale: string) {
}
);
}
function useClientLocale(locales: string[]) {
const session = useSession();
// If the user is logged in, use their locale
if (session.data?.user.locale) return session.data.user.locale;
// If the user is not logged in, use the browser locale
if (typeof window !== "undefined") {
// This is the only way I found to ensure the prefetched locale is used on first render
// FIXME: Find a better way to pick the best matching locale from the browser
return lookup(locales, window.navigator.language) || window.navigator.language;
}
// If the browser is not available, use English
return "en";
}
export function useClientViewerI18n(locales: string[]) {
const clientLocale = useClientLocale(locales);
return useViewerI18n(clientLocale);
}
/**
* Auto-switches locale client-side to the logged in user's preference
*/
const I18nLanguageHandler = (props: { locales: string[] }) => {
const { locales } = props;
const { i18n } = useTranslation("common");
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
useEffect(() => {
// bail early when i18n = {}
if (Object.keys(i18n).length === 0) return;
// if locale is ready and the i18n.language does != locale - changeLanguage
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
// set dir="rtl|ltr"
document.dir = i18n.dir();
document.documentElement.setAttribute("lang", locale);
}, [locale, i18n]);
return null;
};
export default I18nLanguageHandler;

View File

@ -13,6 +13,8 @@ import type { AppProps } from "@lib/app-providers";
import AppProviders from "@lib/app-providers";
import { seoConfig } from "@lib/config/next-seo.config";
import I18nLanguageHandler from "@components/I18nLanguageHandler";
export interface CalPageWrapper {
(props?: AppProps): JSX.Element;
PageWrapper?: AppProps["Component"]["PageWrapper"];
@ -58,7 +60,7 @@ function PageWrapper(props: AppProps) {
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
</Head>
<DefaultSeo
@ -70,6 +72,7 @@ function PageWrapper(props: AppProps) {
}
{...seoConfig.defaultNextSeo}
/>
<I18nLanguageHandler locales={props.router.locales || []} />
<Script
nonce={nonce}
id="page-status"

View File

@ -1,88 +0,0 @@
"use client";
import type { SSRConfig } from "next-i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
import { usePathname } from "next/navigation";
import Script from "next/script";
import type { ReactNode } from "react";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { trpc } from "@calcom/trpc/react";
import type { AppProps } from "@lib/app-providers-app-dir";
import AppProviders from "@lib/app-providers-app-dir";
export interface CalPageWrapper {
(props?: AppProps): JSX.Element;
PageWrapper?: AppProps["Component"]["PageWrapper"];
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const calFont = localFont({
src: "../fonts/CalSans-SemiBold.woff2",
variable: "--font-cal",
preload: true,
display: "swap",
});
export type PageWrapperProps = Readonly<{
getLayout: (page: React.ReactElement) => ReactNode;
children: React.ReactElement;
requiresLicense: boolean;
isThemeSupported: boolean;
isBookingPage: boolean;
nonce: string | undefined;
themeBasis: string | null;
i18n?: SSRConfig;
}>;
function PageWrapper(props: PageWrapperProps) {
const pathname = usePathname();
let pageStatus = "200";
if (pathname === "/404") {
pageStatus = "404";
} else if (pathname === "/500") {
pageStatus = "500";
}
// On client side don't let nonce creep into DOM
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
// See https://github.com/kentcdodds/nonce-hydration-issues
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
const nonce = typeof window !== "undefined" ? (props.nonce ? "" : undefined) : props.nonce;
const providerProps: PageWrapperProps = {
...props,
nonce,
};
const getLayout: (page: React.ReactElement) => ReactNode = props.getLayout ?? ((page) => page);
return (
<AppProviders {...providerProps}>
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
<>
<Script
nonce={nonce}
id="page-status"
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>
<style jsx global>{`
:root {
--font-inter: ${interFont.style.fontFamily};
--font-cal: ${calFont.style.fontFamily};
}
`}</style>
{getLayout(
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children
)}
</>
</AppProviders>
);
}
export default trpc.withTRPC(PageWrapper);

View File

@ -1,232 +0,0 @@
import { useCallback, useState } from "react";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import type { App } from "@calcom/types/App";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuTrigger,
List,
showToast,
Button,
DropdownMenuItem,
Alert,
} from "@calcom/ui";
import { MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
import AppListCard from "@components/AppListCard";
interface AppListProps {
variant?: AppCategories;
data: RouterOutputs["viewer"]["integrations"];
handleDisconnect: (credentialId: number) => void;
listClassName?: string;
}
export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => {
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const utils = trpc.useContext();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
undefined
);
const onSuccessCallback = useCallback(() => {
setBulkUpdateModal(true);
showToast("Default app updated successfully", "success");
}, []);
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
});
const ChildAppCard = ({
item,
}: {
item: RouterOutputs["viewer"]["integrations"]["items"][number] & {
credentialOwner?: CredentialOwner;
};
}) => {
const appSlug = item?.slug;
const appIsDefault =
appSlug === defaultConferencingApp?.appSlug ||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
return (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
credentialOwner={item?.credentialOwner}
actions={
!item.credentialOwner?.readOnly ? (
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
/>
</DropdownMenuContent>
</Dropdown>
</div>
) : null
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
};
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
const appCards = [];
if (app.userCredentialIds.length) {
appCards.push(<ChildAppCard item={app} />);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<ChildAppCard
item={{
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
},
}}
/>
);
}
}
return appCards;
});
const { t } = useLocale();
return (
<>
<List className={listClassName}>
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
})}
</List>
{locationType && (
<AppSetDefaultLinkDialog
locationType={locationType}
setLocationType={() => setLocationType(undefined)}
onSuccess={onSuccessCallback}
/>
)}
{bulkUpdateModal && (
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
)}
</>
);
};
function ConnectOrDisconnectIntegrationMenuItem(props: {
credentialId: number;
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
invalidCredentialIds?: number[];
teamId?: number;
handleDisconnect: (credentialId: number, teamId?: number) => void;
}) {
const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props;
const { t } = useLocale();
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.viewer.integrations.invalidate();
};
if (credentialId || type === "stripe_payment" || isGlobal) {
return (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal}
StartIcon={Trash}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
);
}
if (!installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
return (
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}

View File

@ -89,7 +89,6 @@ export const AppPage = ({
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
{ appType: type },
{
@ -265,8 +264,8 @@ export const AppPage = ({
{price !== 0 && (
<span className="block text-right">
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`}
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
{feeType === "monthly" && "/" + t("month")}
</span>
)}
@ -286,7 +285,7 @@ export const AppPage = ({
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
{feeType === "monthly" && "/" + t("month")}
</>
)}
</span>
@ -323,7 +322,7 @@ export const AppPage = ({
target="_blank"
rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline"
href={`mailto:${email}`}>
href={"mailto:" + email}>
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
{email}

View File

@ -130,7 +130,7 @@ function ConnectedCalendarsList(props: Props) {
title={t("something_went_wrong")}
message={
<span>
<Link href={`/apps/${item.integration.slug}`}>{item.integration.name}</Link>:{" "}
<Link href={"/apps/" + item.integration.slug}>{item.integration.name}</Link>:{" "}
{t("calendar_error")}
</span>
}

View File

@ -76,7 +76,6 @@ export const InstallAppButtonChild = ({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
className="w-auto"
onInteractOutside={(event) => {
if (mutation.isLoading) event.preventDefault();
}}>
@ -95,7 +94,6 @@ export const InstallAppButtonChild = ({
return (
<DropdownItem
className="flex"
type="button"
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
key={team.id}

View File

@ -1,4 +1,4 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { EventLocationType } from "@calcom/app-store/locations";
@ -26,11 +26,11 @@ import {
DialogFooter,
MeetingTimeInTimezones,
showToast,
Tooltip,
TableActions,
TextAreaField,
Tooltip,
} from "@calcom/ui";
import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon";
import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon";
import useMeQuery from "@lib/hooks/useMeQuery";
@ -58,6 +58,7 @@ function BookingListItem(booking: BookingItemProps) {
i18n: { language },
} = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
@ -141,6 +142,17 @@ function BookingListItem(booking: BookingItemProps) {
: []),
];
const showRecordingActions: ActionType[] = [
{
id: "view_recordings",
label: t("view_recordings"),
onClick: () => {
setViewRecordingsDialogIsOpen(true);
},
disabled: mutation.isLoading,
},
];
let bookedActions: ActionType[] = [
{
id: "cancel",
@ -215,7 +227,6 @@ function BookingListItem(booking: BookingItemProps) {
};
const startTime = dayjs(booking.startTime)
.tz(user?.timeZone)
.locale(language)
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
@ -248,32 +259,20 @@ function BookingListItem(booking: BookingItemProps) {
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
const buildBookingLink = () => {
const onClickTableData = () => {
const urlSearchParams = new URLSearchParams({
allRemainingBookings: isTabRecurring.toString(),
});
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
return `/booking/${booking.uid}?${urlSearchParams.toString()}`;
router.push(`/booking/${booking.uid}?${urlSearchParams.toString()}`);
};
const bookingLink = buildBookingLink();
const title = booking.title;
// To be used after we run query on legacy bookings
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed);
const checkForRecordingsButton =
!showRecordingsButtons && (booking.location === "integrations:daily" || booking?.location?.trim() === "");
const showRecordingActions: ActionType[] = [
{
id: checkForRecordingsButton ? "check_for_recordings" : "view_recordings",
label: checkForRecordingsButton ? t("check_for_recordings") : t("view_recordings"),
onClick: () => {
setViewRecordingsDialogIsOpen(true);
},
disabled: mutation.isLoading,
},
];
const showRecordingsButtons =
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
return (
<>
@ -298,7 +297,7 @@ function BookingListItem(booking: BookingItemProps) {
paymentCurrency={booking.payment[0].currency}
/>
)}
{(showRecordingsButtons || checkForRecordingsButton) && (
{showRecordingsButtons && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
@ -338,11 +337,54 @@ function BookingListItem(booking: BookingItemProps) {
</Dialog>
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<Link href={bookingLink}>
<div className="cursor-pointer py-4">
<td
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
onClick={onClickTableData}>
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
<MeetingTimeInTimezones
timeFormat={user?.timeFormat}
userTimezone={user?.timeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{booking.paid && !booking.payment[0] ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("error_collecting_card")}
</Badge>
) : booking.paid ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
</Badge>
) : null}
{recurringDates !== undefined && (
<div className="text-muted mt-2 text-sm">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
</td>
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
<MeetingTimeInTimezones
@ -353,111 +395,66 @@ function BookingListItem(booking: BookingItemProps) {
attendees={booking.attendees}
/>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{booking.paid && !booking.payment[0] ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("error_collecting_card")}
</Badge>
) : booking.paid ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
</Badge>
) : null}
{recurringDates !== undefined && (
<div className="text-muted mt-2 text-sm">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
</Link>
</td>
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
<MeetingTimeInTimezones
timeFormat={user?.timeFormat}
userTimezone={user?.timeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{!!booking?.eventType?.price && !booking.paid && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{!!booking?.eventType?.price && !booking.paid && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
<div className="cursor-pointer py-4">
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
</div>
)}
</div>
<div className="cursor-pointer py-4">
{booking.description && (
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{paymentAppData.enabled && !booking.paid && booking.payment.length && (
<Badge className="me-2 ms-2 hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
{booking.description && (
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={booking.attendees}
user={booking.user}
currentEmail={user?.email}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
</Link>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={booking.attendees}
user={booking.user}
currentEmail={user?.email}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
</td>
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
{isUpcoming && !isCancelled ? (
@ -468,9 +465,7 @@ function BookingListItem(booking: BookingItemProps) {
</>
) : null}
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{(showRecordingsButtons || checkForRecordingsButton) && (
<TableActions actions={showRecordingActions} />
)}
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
@ -578,7 +573,7 @@ const FirstAttendee = ({
<a
key={user.email}
className=" hover:text-blue-500"
href={`mailto:${user.email}`}
href={"mailto:" + user.email}
onClick={(e) => e.stopPropagation()}>
{user.name}
</a>
@ -592,7 +587,7 @@ type AttendeeProps = {
const Attendee = ({ email, name }: AttendeeProps) => {
return (
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
<a className="hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
{name || email}
</a>
);

View File

@ -1,4 +1,4 @@
import { useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -26,6 +26,9 @@ type Props = {
};
export default function CancelBooking(props: Props) {
const pathname = usePathname();
const searchParams = useSearchParams();
const asPath = `${pathname}?${searchParams.toString()}`;
const [cancellationReason, setCancellationReason] = useState<string>("");
const { t } = useLocale();
const router = useRouter();
@ -41,7 +44,6 @@ export default function CancelBooking(props: Props) {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
{error && (
@ -98,8 +100,7 @@ export default function CancelBooking(props: Props) {
});
if (res.status >= 200 && res.status < 300) {
// tested by apps/web/playwright/booking-pages.e2e.ts
router.refresh();
router.replace(asPath);
} else {
setLoading(false);
setError(

View File

@ -47,7 +47,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<div className="flex flex-row space-x-3">
<div className=" bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<CreditCard className="m-auto h-6 w-6" />
</div>
<div className="pt-1">

View File

@ -121,7 +121,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid URL for ${eventLocationType.label}. ${
sampleUrl ? `Sample URL: ${sampleUrl}` : ""
sampleUrl ? "Sample URL: " + sampleUrl : ""
}`,
});
}

View File

@ -43,7 +43,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<div className="flex flex-row space-x-3">
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Clock className="m-auto h-6 w-6" />
</div>
<div className="pt-1">

View File

@ -34,7 +34,7 @@ import {
TextField,
Tooltip,
} from "@calcom/ui";
import { Copy, Edit, Info } from "@calcom/ui/components/icon";
import { Copy, Edit } from "@calcom/ui/components/icon";
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
import RequiresConfirmationController from "./RequiresConfirmationController";
@ -67,7 +67,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = `${name} input`;
bookingFields[name] = name + " input";
});
const eventNameObject: EventNameObjectType = {
@ -124,81 +124,79 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const setEventName = (value: string) => formMethods.setValue("eventName", value);
return (
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-8">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attendee calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
<div className="border-subtle space-y-6 rounded-lg border p-6">
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
<Label className="font-medium">{t("add_to_calendar")}</Label>
<Link
href="/apps/categories/calendar"
target="_blank"
className="hover:text-emphasis text-default text-sm">
{t("add_another_calendar")}
</Link>
</div>
<div className="-mt-1 w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="flex flex-col">
<div className="flex justify-between">
<Label>{t("add_to_calendar")}</Label>
<Link
href="/apps/categories/calendar"
target="_blank"
className="hover:text-emphasis text-default text-sm">
{t("add_another_calendar")}
</Link>
</div>
)}
<div className="w-full">
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Edit className="h-4 w-4" />
</Button>
}
/>
<div className="-mt-1 w-full">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
destinationCalendar={eventType.destinationCalendar}
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
<p className="text-default text-sm">{t("select_which_cal")}</p>
</div>
</div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
<div className="border-subtle space-y-6 rounded-lg border p-6">
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
},
}}
)}
<div className="w-full">
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Edit className="h-4 w-4" />
</Button>
}
/>
</div>
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
<div>
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
</div>
<hr className="border-subtle" />
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
},
}}
/>
<hr className="border-subtle" />
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
@ -206,16 +204,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
requiresConfirmation={requiresConfirmation}
onRequiresConfirmation={setRequiresConfirmation}
/>
<hr className="border-subtle" />
<Controller
name="requiresBookerEmailVerification"
control={formMethods.control}
defaultValue={eventType.requiresBookerEmailVerification}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("requires_booker_email_verification")}
{...shouldLockDisableProps("requiresBookerEmailVerification")}
description={t("description_requires_booker_email_verification")}
@ -224,16 +219,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="hideCalendarNotes"
control={formMethods.control}
defaultValue={eventType.hideCalendarNotes}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_notes")}
{...shouldLockDisableProps("hideCalendarNotes")}
description={t("disable_notes_description")}
@ -242,20 +234,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
)}
/>
<hr className="border-subtle" />
<Controller
name="successRedirectUrl"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
redirectUrlVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("redirect_success_booking")}
{...successRedirectUrlLocked}
description={t("redirect_url_description")}
@ -264,7 +249,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
setRedirectUrlVisible(e);
onChange(e ? value : "");
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{/* Textfield has some margin by default we remove that so we can keep consistent alignment */}
<div className="lg:-mb-2 lg:-ml-2">
<TextField
className="w-full"
label={t("redirect_success_booking")}
@ -288,25 +274,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</>
)}
/>
<hr className="border-subtle" />
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
hashedLinkVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="hashedLinkCheck"
title={t("enable_private_url")}
Badge={
<a
target="_blank"
rel="noreferrer"
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
<Info className="ml-1.5 h-4 w-4 cursor-pointer" />
</a>
}
title={t("private_link")}
{...shouldLockDisableProps("hashedLinkCheck")}
description={t("private_link_description", { appName: APP_NAME })}
checked={hashedLinkVisible}
@ -314,7 +285,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
setHashedLinkVisible(e);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
<div className="lg:-ml-2">
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
@ -349,7 +321,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
</div>
</SettingsToggle>
<hr className="border-subtle" />
<Controller
name="seatsPerTimeSlotEnabled"
control={formMethods.control}
@ -357,13 +329,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
value && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="offer-seats-toggle"
title={t("offer_seats")}
{...seatsLocked}
@ -384,83 +349,59 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}
onChange(e);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div>
<TextField
required
name="seatsPerTimeSlot"
labelSrOnly
label={t("number_of_seats")}
type="number"
<Controller
name="seatsPerTimeSlot"
control={formMethods.control}
defaultValue={eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<div className="lg:-ml-2">
<TextField
required
name="seatsPerTimeSlot"
labelSrOnly
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
containerClassName="max-w-80"
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
/>
<div className="mt-4">
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
/>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) =>
formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)
}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div>
)}
/>
</div>
<div className="mt-2">
<CheckboxField
description={t("show_available_seats_count")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)}
defaultChecked={!!eventType.seatsShowAvailabilityCount}
/>
</div>
</div>
)}
/>
</SettingsToggle>
{noShowFeeEnabled && <Alert severity="warning" title={t("seats_and_no_show_fee_error")} />}
</>
)}
/>
<Controller
name="lockTimeZoneToggleOnBookingPage"
control={formMethods.control}
defaultValue={eventType.lockTimeZoneToggleOnBookingPage}
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("lock_timezone_toggle_on_booking_page")}
{...shouldLockDisableProps("lockTimeZoneToggleOnBookingPage")}
description={t("description_lock_timezone_toggle_on_booking_page")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
)}
/>
{allowDisablingAttendeeConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.attendee"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value || false}
@ -476,6 +417,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
{allowDisablingHostConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.host"
control={formMethods.control}
@ -483,9 +425,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value || false}

View File

@ -158,7 +158,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</div>
</div>
{!shouldLockDisableProps("apps").disabled && (
<div className="bg-muted mt-6 rounded-md p-8">
<div className="bg-muted rounded-md p-8">
{!isLoading && notInstalledApps?.length ? (
<>
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</h2>
<p className="text-default mb-6 text-sm font-normal">
<Trans i18nKey="available_apps_desc">
View popular apps below and explore more in our &nbsp;
You have no apps installed. View popular apps below and explore more in our &nbsp;
<Link className="cursor-pointer underline" href="/apps">
App Store
</Link>

View File

@ -1,5 +1,5 @@
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import { useState, memo, useEffect } from "react";
import { useState, memo } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
@ -98,43 +98,42 @@ const EventTypeScheduleDetails = memo(
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
return (
<div>
<div className="border-subtle space-y-4 border-x p-6">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="text-default flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
</div>
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
<div className="border-default space-y-4 rounded border px-6 pb-4">
<ol className="table border-collapse text-sm">
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li key={day} className="my-6 flex border-transparent last:mb-2">
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default"
)}>
{day}
</span>
{isLoading ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div key={i} className="text-default flex items-center leading-4">
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ms-4">-</span>
<div className="ml-6 sm:w-28">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
</div>
) : (
<span className="text-subtle ml-6 sm:ml-0">{t("unavailable")}</span>
)}
</li>
);
})}
</ol>
<hr className="border-subtle" />
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
@ -165,8 +164,9 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const { watch, setValue, getValues } = useFormContext<FormValues>();
const { watch } = useFormContext<FormValues>();
const watchSchedule = watch("schedule");
const formMethods = useFormContext<FormValues>();
const [options, setOptions] = useState<AvailabilityOption[]>([]);
const { isLoading } = trpc.viewer.availability.list.useQuery(undefined, {
@ -214,7 +214,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
setOptions(options);
const scheduleId = getValues("schedule");
const scheduleId = formMethods.getValues("schedule");
const value = options.find((option) =>
scheduleId
? option.value === scheduleId
@ -223,20 +223,15 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
);
setValue("availability", value);
formMethods.setValue("availability", value);
},
});
const availabilityValue = watch("availability");
useEffect(() => {
if (!availabilityValue?.value) return;
setValue("schedule", availabilityValue.value);
}, [availabilityValue, setValue]);
const availabilityValue = formMethods.watch("availability");
return (
<div>
<div className="border-subtle rounded-t-md border p-6">
<div className="space-y-4">
<div>
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
{t("availability")}
{shouldLockIndicator("availability")}
@ -253,7 +248,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
isSearchable={false}
onChange={(selected) => {
field.onChange(selected?.value || null);
if (selected?.value) setValue("availability", selected);
if (selected?.value) formMethods.setValue("availability", selected);
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={availabilityValue}
@ -281,7 +276,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
const { resetField, setValue } = useFormContext<FormValues>();
return (
<Controller
name="metadata.config.useHostSchedulesForTeamEvent"
@ -290,7 +285,9 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
checked={!value}
onCheckedChange={(checked) => {
onChange(!checked);
if (!checked) {
if (checked) {
resetField("schedule");
} else {
setValue("schedule", null);
}
}}

View File

@ -7,6 +7,7 @@ import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
@ -16,7 +17,7 @@ import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interval
import type { PeriodType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
import { Plus, Trash2 } from "@calcom/ui/components/icon";
import { Plus, Trash } from "@calcom/ui/components/icon";
const MinimumBookingNoticeInput = React.forwardRef<
HTMLInputElement,
@ -82,14 +83,14 @@ const MinimumBookingNoticeInput = React.forwardRef<
type="number"
placeholder="0"
min={0}
className="mb-0 h-9 rounded-[4px] ltr:mr-2 rtl:ml-2"
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
/>
<input type="hidden" ref={ref} {...passThroughProps} />
</div>
<Select
isSearchable={false}
isDisabled={passThroughProps.disabled}
className="mb-0 ml-2 h-9 w-full capitalize md:min-w-[150px] md:max-w-[200px]"
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={durationTypeOptions.find(
(option) => option.value === minimumBookingNoticeDisplayValues.type
)}
@ -140,6 +141,17 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue: periodType?.type,
});
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
@ -158,11 +170,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000);
return (
<div>
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="space-y-8">
<div className="space-y-4 lg:space-y-8">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
<Label htmlFor="beforeBufferTime">
{t("before_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
@ -174,13 +189,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: `${minutes} ${t("minutes")}`,
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@ -194,7 +210,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Label htmlFor="afterBufferTime">
{t("after_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
@ -206,13 +225,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
label: `${minutes} ${t("minutes")}`,
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@ -228,11 +248,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="w-full">
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
<Label htmlFor="minimumBookingNotice">
{t("minimum_booking_notice")}
{shouldLockIndicator("minimumBookingNotice")}
</Label>
<MinimumBookingNoticeInput
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
{...formMethods.register("minimumBookingNotice")}
/>
</div>
<div className="w-full">
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
<Label htmlFor="slotInterval">
{t("slot_interval")}
{shouldLockIndicator("slotInterval")}
</Label>
<Controller
name="slotInterval"
control={formMethods.control}
@ -243,13 +272,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
value: -1,
},
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
label: `${minutes} ${t("minutes")}`,
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("slotInterval").disabled}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
@ -265,186 +295,162 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
</div>
</div>
<hr className="border-subtle" />
<Controller
name="bookingLimits"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("limit_booking_frequency")}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", {});
}
}}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0">
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
</div>
</SettingsToggle>
);
}}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
} else {
formMethods.setValue("bookingLimits", {});
}
}}>
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
</SettingsToggle>
)}
/>
<hr className="border-subtle" />
<Controller
name="durationLimits"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
step={15}
textFieldSuffix={t("minutes")}
/>
</div>
</SettingsToggle>
);
}}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
}
}}>
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
</SettingsToggle>
)}
/>
<hr className="border-subtle" />
<Controller
name="periodType"
control={formMethods.control}
render={({ field: { value } }) => {
const isChecked = value && value !== "UNLIMITED";
return (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
/>
)}
/>
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={value && value !== "UNLIMITED"}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
);
})}
</RadioGroup.Root>
</div>
</SettingsToggle>
);
}}
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
offsetToggle && "rounded-b-none"
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
defaultValue={periodDates}
render={() => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
endDate,
});
}}
/>
)}
/>
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
</div>
);
})}
</RadioGroup.Root>
</SettingsToggle>
)}
childrenClassName="lg:ml-0"
/>
<hr className="border-subtle" />
<SettingsToggle
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
@ -452,20 +458,18 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("offsetStart", 0);
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<TextField
required
type="number"
containerClassName="max-w-80"
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
</div>
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
</SettingsToggle>
</div>
);
@ -505,19 +509,19 @@ const IntervalLimitItem = ({
onIntervalSelect,
}: IntervalLimitItemProps) => {
return (
<div className="mb-4 flex max-h-9 items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<div className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse" key={limitKey}>
<TextField
required
type="number"
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
className="mb-0"
className="mb-0 !h-auto"
placeholder={`${value}`}
disabled={disabled}
min={step}
step={step}
defaultValue={value}
addOnSuffix={textFieldSuffix}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value || "0", 10))}
onChange={(e) => onLimitChange(limitKey, parseInt(e.target.value))}
/>
<Select
options={selectOptions}
@ -525,16 +529,9 @@ const IntervalLimitItem = ({
isDisabled={disabled}
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
onChange={onIntervalSelect}
className="w-36"
/>
{hasDeleteButton && !disabled && (
<Button
variant="icon"
StartIcon={Trash2}
color="destructive"
className="border-none"
onClick={() => onDelete(limitKey)}
/>
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
)}
</div>
);

View File

@ -1,14 +1,16 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ErrorMessage } from "@hookform/error-message";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import { Controller, useForm, useFormContext } from "react-hook-form";
import type { MultiValue } from "react-select";
import { z } from "zod";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CAL_URL } from "@calcom/lib/constants";
@ -17,6 +19,7 @@ import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import {
Button,
Label,
Select,
SettingsToggle,
@ -25,16 +28,11 @@ import {
Editor,
SkeletonContainer,
SkeletonText,
Input,
PhoneInput,
Button,
showToast,
} from "@calcom/ui";
import { Plus, X, Check } from "@calcom/ui/components/icon";
import { CornerDownRight } from "@calcom/ui/components/icon";
import { Edit2, Check, X, Plus } from "@calcom/ui/components/icon";
import CheckboxField from "@components/ui/form/CheckboxField";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect";
import LocationSelect from "@components/ui/form/LocationSelect";
const getLocationFromType = (
@ -114,6 +112,9 @@ export const EventSetupTab = (
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
const orgBranding = useOrgBranding();
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
@ -130,12 +131,12 @@ export const EventSetupTab = (
};
});
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 480,
].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
}));
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180].map(
(mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
})
);
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
MultiValue<{
@ -147,6 +148,83 @@ export const EventSetupTab = (
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
);
const openLocationModal = (type: EventLocationType["type"], address = "") => {
const option = getLocationFromType(type, locationOptions);
if (option && option.value === LocationType.InPerson) {
const inPersonOption = {
...option,
address,
};
setSelectedLocation(inPersonOption);
} else {
setSelectedLocation(option);
}
setShowLocationModal(true);
};
const removeLocation = (selectedLocation: (typeof eventType.locations)[number]) => {
formMethods.setValue(
"locations",
formMethods.getValues("locations").filter((location) => {
if (location.type === LocationType.InPerson) {
return location.address !== selectedLocation.address;
}
return location.type !== selectedLocation.type;
}),
{ shouldValidate: true }
);
};
const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => {
const locationType = editingLocationType !== "" ? editingLocationType : newLocationType;
const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type);
if (existingIdx !== -1) {
const copy = formMethods.getValues("locations");
if (editingLocationType !== "") {
copy[existingIdx] = {
...details,
type: newLocationType,
};
}
formMethods.setValue("locations", [
...copy,
...(newLocationType === LocationType.InPerson && editingLocationType === ""
? [{ ...details, type: newLocationType }]
: []),
]);
} else {
formMethods.setValue(
"locations",
formMethods.getValues("locations").concat({ type: newLocationType, ...details })
);
}
setEditingLocationType("");
setShowLocationModal(false);
};
const locationFormSchema = z.object({
locationType: z.string(),
locationAddress: z.string().optional(),
displayLocationPublicly: z.boolean().optional(),
locationPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field
});
const locationFormMethods = useForm<{
locationType: EventLocationType["type"];
locationPhoneNumber?: string;
locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid?
locationLink?: string; // Currently this only accepts links that are HTTPS://
displayLocationPublicly?: boolean;
}>({
resolver: zodResolver(locationFormSchema),
});
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
useLockedFieldsManager(
eventType,
@ -156,15 +234,6 @@ export const EventSetupTab = (
const Locations = () => {
const { t } = useLocale();
const {
fields: locationFields,
append,
remove,
update: updateLocationField,
} = useFieldArray({
control: formMethods.control,
name: "locations",
});
const [animationRef] = useAutoAnimate<HTMLUListElement>();
@ -183,266 +252,129 @@ export const EventSetupTab = (
const { locationDetails, locationAvailable } = getLocationInfo(props);
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
}) => {
const { eventLocationType, index, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<>
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
type="text"
required
onChange={onChange}
value={value}
className="my-0"
{...rest}
/>
<ErrorMessage
errors={formMethods.formState.errors.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 text-sm"
as="div"
/>
</>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<>
<PhoneInput
required
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
value={value}
onChange={onChange}
{...rest}
/>
<ErrorMessage
errors={formMethods.formState.errors.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 text-sm"
as="div"
/>
</>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(null);
return (
<div className="w-full">
<ul ref={animationRef} className="space-y-2">
{locationFields.map((field, index) => {
const eventLocationType = getEventLocationType(field.type);
const defaultLocation = formMethods
.getValues("locations")
?.find((location: { type: EventLocationType["type"]; address?: string }) => {
if (location.type === LocationType.InPerson) {
return location.type === eventLocationType?.type && location.address === field?.address;
} else {
return location.type === eventLocationType?.type;
{validLocations.length === 0 && (
<div className="flex">
<LocationSelect
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
locationFormMethods.setValue("locationType", newLocationType);
if (eventLocationType.organizerInputType) {
openLocationModal(newLocationType);
} else {
saveLocation(newLocationType);
}
}
});
}}
/>
</div>
)}
{validLocations.length > 0 && (
<ul ref={animationRef}>
{validLocations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
return null;
}
const option = getLocationFromType(field.type, locationOptions);
const eventLabel =
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
return (
<li key={field.id}>
<div className="flex w-full items-center">
<LocationSelect
name={`locations[${index}].type`}
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={option}
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAddLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAddLocation) {
updateLocationField(index, { type: newLocationType });
} else {
updateLocationField(index, { type: field.type });
showToast(t("location_already_exists"), "warning");
}
}
}}
/>
<button
data-testid={`delete-locations.${index}.type`}
className="min-h-9 block h-9 px-2"
type="button"
onClick={() => remove(index)}
aria-label={t("remove")}>
<div className="h-4 w-4">
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
</div>
</button>
</div>
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
<div className="flex gap-2">
<div className="flex items-center justify-center">
<CornerDownRight className="h-4 w-4" />
</div>
<div className="w-full">
<LocationInput
defaultValue={
defaultLocation
? defaultLocation[eventLocationType.defaultValueVariable]
: undefined
}
eventLocationType={eventLocationType}
index={index}
/>
</div>
</div>
<div className="ml-6">
<CheckboxField
data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
const fieldValues = formMethods.getValues().locations[index];
updateLocationField(index, {
...fieldValues,
displayLocationPublicly: e.target.checked,
});
}}
informationIconText={t("display_location_info_badge")}
return (
<li
key={`${location.type}${index}`}
className="border-default text-default mb-2 h-9 rounded-md border px-2 py-1.5 hover:cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={eventLocationType.iconUrl}
className="h-4 w-4"
alt={`${eventLocationType.label} logo`}
/>
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
location.teamName ? `(${location.teamName})` : ""
}`}</span>
</div>
<div className="flex">
<button
type="button"
onClick={() => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
if (location.type === LocationType.InPerson) {
locationFormMethods.setValue("locationAddress", location.address);
} else {
locationFormMethods.unregister("locationAddress");
}
locationFormMethods.unregister("locationPhoneNumber");
setEditingLocationType(location.type);
openLocationModal(location.type, location.address);
}}
aria-label={t("edit")}
className="hover:text-emphasis text-subtle mr-1 p-1">
<Edit2 className="h-4 w-4" />
</button>
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
<X className="border-l-1 hover:text-emphasis text-subtle h-6 w-6 pl-1 " />
</button>
</div>
</div>
)}
</li>
);
})}
{(validLocations.length === 0 || showEmptyLocationSelect) && (
<div className="flex">
<LocationSelect
defaultMenuIsOpen={showEmptyLocationSelect}
autoFocus
placeholder={t("select")}
options={locationOptions}
value={selectedNewOption}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAppendLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAppendLocation) {
append({ type: newLocationType });
setSelectedNewOption(e);
} else {
showToast(t("location_already_exists"), "warning");
setSelectedNewOption(null);
}
}
}}
/>
</div>
)}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex items-center text-sm">
<div className="mr-1.5 h-3 w-3">
<Check className="h-3 w-3" />
</li>
);
})}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex text-sm">
<Check className="mr-1.5 mt-0.5 h-2 w-2.5" />
<Trans i18nKey="event_type_requres_google_cal">
<p>
The Add to calendar for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
<Trans i18nKey="event_type_requres_google_cal">
<p>
The Add to calendar for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
StartIcon={Plus}
color="minimal"
onClick={() => setShowEmptyLocationSelect(true)}>
{t("add_location")}
</Button>
</li>
)}
</ul>
<p className="text-default mt-2 text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
App Store
</Link>
.
</Trans>
</p>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
StartIcon={Plus}
color="minimal"
onClick={() => setShowLocationModal(true)}>
{t("add_location")}
</Button>
</li>
)}
</ul>
)}
</div>
);
};
@ -455,158 +387,178 @@ export const EventSetupTab = (
return (
<div>
<div className="space-y-4">
<div className="border-subtle space-y-6 rounded-lg border p-6">
<TextField
required
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
<div>
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
setValueAs: (v) => slugify(v),
})}
<div className="space-y-8">
<TextField
required
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
<div>
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
<div className="border-subtle rounded-lg border p-6">
{multipleDuration ? (
<div className="space-y-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("available_durations")}
</Skeleton>
<Select
isMulti
defaultValue={selectedMultipleDuration}
name="metadata.multipleDuration"
isSearchable={false}
className="h-auto !min-h-[36px] text-sm"
options={multipleDurationOptions}
value={selectedMultipleDuration}
onChange={(options) => {
let newOptions = [...options];
newOptions = newOptions.sort((a, b) => {
return a?.value - b?.value;
});
const values = newOptions.map((opt) => opt.value);
setMultipleDuration(values);
setSelectedMultipleDuration(newOptions);
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
} else {
setDefaultDuration(null);
}
}
if (newOptions.length === 1 && defaultDuration === null) {
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
{urlPrefix}/
{!isManagedEventType
? team
? (orgBranding ? "" : "team/") + team.slug
: eventType.users[0].username
: t("username_placeholder")}
/
</>
}
{...formMethods.register("slug", {
setValueAs: (v) => slugify(v),
})}
/>
{multipleDuration ? (
<div className="space-y-4">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("available_durations")}
</Skeleton>
<Select
isMulti
defaultValue={selectedMultipleDuration}
name="metadata.multipleDuration"
isSearchable={false}
className="h-auto !min-h-[36px] text-sm"
options={multipleDurationOptions}
value={selectedMultipleDuration}
onChange={(options) => {
let newOptions = [...options];
newOptions = newOptions.sort((a, b) => {
return a?.value - b?.value;
});
const values = newOptions.map((opt) => opt.value);
setMultipleDuration(values);
setSelectedMultipleDuration(newOptions);
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
} else {
setDefaultDuration(null);
}
formMethods.setValue("metadata.multipleDuration", values);
}}
/>
</div>
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
}}
/>
</div>
</div>
) : (
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
/>
)}
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
if (newOptions.length === 1 && defaultDuration === null) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
}
formMethods.setValue("metadata.multipleDuration", values);
}}
/>
</div>
)}
</div>
<div className="border-subtle rounded-lg border p-6">
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
}}
/>
</div>
</div>
) : (
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
min={1}
/>
)}
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
/>
</div>
)}
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
/>
</div>
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
</div>
);
};

View File

@ -247,7 +247,7 @@ function EventTypeSingleLayout({
return (
<Shell
backPath="/event-types"
title={`${eventType.title} | ${t("event_type")}`}
title={eventType.title + " | " + t("event_type")}
heading={eventType.title}
CTA={
<div className="flex items-center justify-end">
@ -268,11 +268,9 @@ function EventTypeSingleLayout({
</Skeleton>
)}
<Tooltip
sideOffset={4}
content={
formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")
}
side="bottom">
}>
<div className="self-center rounded-md p-2">
<Switch
id="hiddenSwitch"
@ -293,7 +291,7 @@ function EventTypeSingleLayout({
{!isManagedEventType && (
<>
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
<Tooltip content={t("preview")} side="bottom" sideOffset={4}>
<Tooltip content={t("preview")}>
<Button
color="secondary"
data-testid="preview-button"
@ -310,8 +308,6 @@ function EventTypeSingleLayout({
variant="icon"
StartIcon={LinkIcon}
tooltip={t("copy_link")}
tooltipSide="bottom"
tooltipOffset={4}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Link copied!", "success");
@ -323,8 +319,6 @@ function EventTypeSingleLayout({
color="secondary"
variant="icon"
tooltip={t("embed")}
tooltipSide="bottom"
tooltipOffset={4}
eventId={eventType.id}
/>
</>
@ -335,8 +329,6 @@ function EventTypeSingleLayout({
variant="icon"
StartIcon={Trash}
tooltip={t("delete")}
tooltipSide="bottom"
tooltipOffset={4}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}
/>

View File

@ -1,7 +1,5 @@
import type { Webhook } from "@prisma/client";
import { Webhook as TbWebhook } from "lucide-react";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useState } from "react";
@ -10,7 +8,6 @@ import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
@ -118,40 +115,23 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
)}
{webhooks.length ? (
<>
<div className="border-subtle mb-2 rounded-md border p-8">
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
<div className="border-subtle my-8 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
</div>
<p className="text-default text-sm font-normal">
<Trans i18nKey="edit_or_manage_webhooks">
If you wish to edit or manage your web hooks, please head over to &nbsp;
<Link
className="cursor-pointer font-semibold underline"
href="/settings/developer/webhooks">
webhooks settings
</Link>
</Trans>
</p>
<div className="mb-2 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
/>
);
})}
</div>
<NewWebhookButton />
</>
) : (
<EmptyScreen

View File

@ -3,7 +3,6 @@ import { useState } from "react";
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
@ -47,19 +46,7 @@ export default function RecurringEventController({
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
) : (
<>
<Alert
className="mb-4"
severity="warning"
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
recurringEventState !== null && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("recurring_event")}
{...recurringLocked}
description={t("recurring_event_description")}
@ -79,70 +66,68 @@ export default function RecurringEventController({
setRecurringEventState(newVal);
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}
</p>
</div>
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
)}
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
)}
</SettingsToggle>
</>
)}

View File

@ -67,13 +67,6 @@ export default function RequiresConfirmationController({
control={formMethods.control}
render={() => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
requiresConfirmation && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("requires_confirmation")}
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
@ -84,111 +77,107 @@ export default function RequiresConfirmationController({
formMethods.setValue("requiresConfirmation", val);
onRequiresConfirmation(val);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
}
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
}
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
}
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val
);
}}
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
className="ml-2"
onChange={(opt) => {
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
);
}}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
defaultValue={defaultValue}
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
onChange={(opt) => {
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
);
}}
defaultValue={defaultValue}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
)}
</div>
</RadioGroup.Root>
</div>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
)}
</div>
</RadioGroup.Root>
</SettingsToggle>
)}
/>

View File

@ -3,13 +3,12 @@ import type { FormEvent } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
@ -97,19 +96,16 @@ const UserProfile = () => {
},
];
const organization =
user.organization && user.organization.id
? {
...(user.organization as Ensure<typeof user.organization, "id">),
slug: user.organization.slug || null,
requestedSlug: user.organization.metadata?.requestedSlug || null,
}
: null;
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && (
<OrganizationMemberAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />
<OrganizationAvatar
alt={user.username || "user avatar"}
size="lg"
imageSrc={imageSrc}
organizationSlug={user.organization?.slug}
/>
)}
<input
ref={avatarRef}

View File

@ -1,10 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
@ -23,7 +22,7 @@ const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.useSuspenseQuery();
const { t } = useLocale();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
const telemetry = useTelemetry();
const userSettingsSchema = z.object({
name: z

Some files were not shown because too many files have changed in this diff Show More