Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f179c23cc5 | ||
|
|
9f83885e8c | ||
|
|
8ffb5529e6 | ||
|
|
2e686d03fa | ||
|
|
cc3be0a6ac | ||
|
|
ec3a3268c9 | ||
|
|
77eed5611e | ||
|
|
a827d1defb | ||
|
|
b08a62d30b | ||
|
|
d6fb0df64f | ||
|
|
03548d8381 | ||
|
|
36d8554fd5 | ||
|
|
6fc9e9315d | ||
|
|
6eb8119a82 | ||
|
|
f4c613e6d1 | ||
|
|
082507aea5 | ||
|
|
90c1270e3d | ||
|
|
94917dbb1e | ||
|
|
fbbcc401cb | ||
|
|
af7d7ec678 | ||
|
|
3b7d85852a | ||
|
|
5362cd66b4 | ||
|
|
2907405d4e | ||
|
|
3c46c39114 | ||
|
|
c6ab889b98 | ||
|
|
a2e2b0ed34 | ||
|
|
bc38b3bc55 | ||
|
|
7611d5c24e | ||
|
|
5d2fd7aafb | ||
|
|
b9ade55ac1 | ||
|
|
d7051c4a3f | ||
|
|
ab50067194 | ||
|
|
cdba1920fc | ||
|
|
98a3508b8f | ||
|
|
446f384405 | ||
|
|
be79b8ecd6 | ||
|
|
371e7b024a | ||
|
|
76807a7e70 | ||
|
|
d3f77bf292 | ||
|
|
2f175eacf5 | ||
|
|
b45aea3f26 | ||
|
|
c401a58bb1 | ||
|
|
05b42c001f | ||
|
|
20f7a5841e | ||
|
|
dbf6bb991f | ||
|
|
75cd5a05b8 | ||
|
|
ca6afed27c | ||
|
|
45250f6f3e | ||
|
|
bd5af9e821 | ||
|
|
4267c9063b | ||
|
|
2b49d96e4a | ||
|
|
127c96b46c | ||
|
|
3595ba9824 | ||
|
|
56acfa38f1 | ||
|
|
e78fb22451 | ||
|
|
99f736b317 | ||
|
|
ee9f80ae3b | ||
|
|
a3c283da5e | ||
|
|
0ef678b2cf | ||
|
|
4f466fd95d |
@@ -16,11 +16,12 @@
|
||||
# - WEB3
|
||||
# - SALESFORCE
|
||||
# - ZOHOCRM
|
||||
# - ZOHO_BIGIN
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - DAILY.CO VIDEO
|
||||
# Enables Cal Video. to get your key
|
||||
# Enables Cal Video. to get your key
|
||||
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
# 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
# @see https://github.com/calcom/cal.com#obtaining-daily-api-credentials
|
||||
|
||||
+8
-4
@@ -138,12 +138,16 @@ API_KEY_PREFIX=cal_
|
||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||
|
||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||
# Configuration to receive emails locally (mailhog)
|
||||
EMAIL_SERVER_HOST='localhost'
|
||||
EMAIL_SERVER_PORT=1025
|
||||
|
||||
# Note: The below configuration for Office 365 has been verified to work.
|
||||
EMAIL_SERVER_HOST='smtp.office365.com'
|
||||
EMAIL_SERVER_PORT=587
|
||||
EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||
# EMAIL_SERVER_HOST='smtp.office365.com'
|
||||
# EMAIL_SERVER_PORT=587
|
||||
# EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
|
||||
EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
# EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
|
||||
# The following configuration for Gmail has been verified to work.
|
||||
# EMAIL_SERVER_HOST='smtp.gmail.com'
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cal.com/slack"><img src="https://img.shields.io/badge/Slack-calendso.slack.com-%234A154B" alt="Join Cal.com Slack"></a>
|
||||
<a href="https://www.producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
|
||||
@@ -38,13 +38,13 @@
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
<a href="https://contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
||||
</p>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
|
||||
## About The Project
|
||||
## About the Project
|
||||
|
||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/176390354-f1bc7069-0341-437a-9bb8-eb41092b4016.gif">
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
The open source Calendly alternative. You are in charge
|
||||
of your own data, workflow and appearance.
|
||||
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customisations.
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customizations.
|
||||
|
||||
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
||||
|
||||
## Recognition
|
||||
|
||||
#### Hacker News
|
||||
#### [Hacker News](https://news.ycombinator.com/from?site=cal.com)
|
||||
|
||||
<a href="https://news.ycombinator.com/item?id=34507672">
|
||||
<img
|
||||
@@ -77,22 +77,22 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
||||
/>
|
||||
</a>
|
||||
|
||||
#### [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso)
|
||||
#### [Product Hunt](https://producthunt.com/products/cal-com?utm_source=badge-top-post-badge&utm_medium=badge)
|
||||
|
||||
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
### Built With
|
||||
|
||||
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||
- [tRPC](https://trpc.io/?ref=cal.com)
|
||||
- [React](https://reactjs.org/?ref=cal.com)
|
||||
- [Tailwind](https://tailwindcss.com/?ref=cal.com)
|
||||
- [Prisma](https://prisma.io/?ref=cal.com)
|
||||
- [React.js](https://reactjs.org/?ref=cal.com)
|
||||
- [Tailwind CSS](https://tailwindcss.com/?ref=cal.com)
|
||||
- [Prisma.io](https://prisma.io/?ref=cal.com)
|
||||
- [Daily.co](https://go.cal.com/daily)
|
||||
|
||||
## Stay Up-to-Date
|
||||
|
||||
Cal officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates:
|
||||
Cal.com officially launched as v.1.0 on 15th of September, however a lot of new features are coming. Watch **releases** of this repository to be notified for future updates:
|
||||
|
||||

|
||||
|
||||
@@ -104,7 +104,7 @@ To get a local copy up and running, please follow these simple steps.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Here is what you need to be able to run Cal.
|
||||
Here is what you need to be able to run Cal.com.
|
||||
|
||||
- Node.js (Version: >=15.x <17)
|
||||
- PostgreSQL
|
||||
@@ -121,6 +121,9 @@ Here is what you need to be able to run Cal.
|
||||
```sh
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
```
|
||||
> 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.
|
||||
|
||||
1. Go to the project folder
|
||||
|
||||
@@ -134,10 +137,10 @@ Here is what you need to be able to run Cal.
|
||||
yarn
|
||||
```
|
||||
|
||||
1. 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.
|
||||
- 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.
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
@@ -150,7 +153,7 @@ yarn dx
|
||||
|
||||
#### Development tip
|
||||
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **tRPC**.
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
@@ -158,16 +161,16 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
#### Manual setup
|
||||
|
||||
1. Configure environment variables in the `.env` file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||
1. Configure environment variables in the `.env` file. Replace `<user>`, `<pass>`, `<db-host>`, and `<db-port>` with their applicable values
|
||||
|
||||
```
|
||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB using Heroku</summary>
|
||||
<summary>If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB using Heroku.</summary>
|
||||
|
||||
1. Create a free account with [Heroku](https://www.heroku.com/).
|
||||
1. Create a free account with [Heroku](https://heroku.com).
|
||||
|
||||
2. Create a new app.
|
||||
<img width="306" alt="Create an App" src="https://user-images.githubusercontent.com/16905768/115322780-b3d58c00-a17e-11eb-8a52-b758fb0ea942.png">
|
||||
@@ -184,19 +187,25 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
6. Once you completed the above steps, click on your newly created `Heroku Postgres` and go to its `Settings`.
|
||||

|
||||
|
||||
7. In `Settings`, copy your URI to your Cal.com .env file and replace the `postgresql://<user>:<pass>@<db-host>:<db-port>` with it.
|
||||
7. In `Settings`, copy your URI to your Cal.com `.env` file and replace the `postgresql://<user>:<pass>@<db-host>:<db-port>` with it.
|
||||

|
||||

|
||||
|
||||
8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com/).
|
||||
8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com).
|
||||
</details>
|
||||
|
||||
1. Set a 32 character random string in your .env file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||
1. Set a 32 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
||||
|
||||
```sh
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
||||
|
||||
```sh
|
||||
docker pull mailhog/mailhog
|
||||
docker run -d -p 8025:8025 -p 1025:1025 mailhog/mailhog
|
||||
```
|
||||
|
||||
1. Run (in development mode)
|
||||
|
||||
@@ -206,7 +215,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
#### Setting up your first user
|
||||
|
||||
1. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content:
|
||||
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
||||
|
||||
```sh
|
||||
yarn db-studio
|
||||
@@ -251,7 +260,7 @@ yarn playwright show-report test-results/reports/playwright-html-report
|
||||
yarn workspace @calcom/prisma db-migrate
|
||||
```
|
||||
|
||||
(this can clear your development database in some cases)
|
||||
(This can clear your development database in some cases)
|
||||
|
||||
In a production environment, run:
|
||||
|
||||
@@ -285,7 +294,7 @@ yarn playwright show-report test-results/reports/playwright-html-report
|
||||
|
||||
### Docker
|
||||
|
||||
The Docker configuration for Cal is an effort powered by people within the community.
|
||||
The Docker configuration for Cal.com is an effort powered by people within the community.
|
||||
|
||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calcom/docker/discussions/32).
|
||||
|
||||
@@ -298,14 +307,14 @@ Cal.com, Inc. does not provide official support for Docker, but we will accept f
|
||||
### Heroku
|
||||
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/calcom/cal.com">
|
||||
<img width="185px" height="auto" src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy">
|
||||
<img width="185px" height="auto" src="https://herokucdn.com/deploy/button.svg" alt="Deploy">
|
||||
</a>
|
||||
|
||||
### Railway
|
||||
|
||||
[](https://railway.app/new/template/cal)
|
||||
|
||||
You can deploy Cal on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal on their platform.
|
||||
You can deploy Cal.com on [Railway](https://railway.app) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal.com on their platform.
|
||||
|
||||
### Vercel
|
||||
|
||||
@@ -386,7 +395,7 @@ Don't code but still want to contribute? Join our [slack](https://cal.com/slack)
|
||||
8. Select Web Application as the Application Type.
|
||||
9. Under Authorized redirect URI's, select Add URI and then add the URI `<Cal.com URL>/api/integrations/googlecalendar/callback` and `<Cal.com URL>/api/auth/callback/google` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs.
|
||||
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the .env file as the value for GOOGLE_API_CREDENTIALS key.
|
||||
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the `.env` file as the value for `GOOGLE_API_CREDENTIALS` key.
|
||||
|
||||
#### _Adding google calendar to Cal.com App Store_
|
||||
|
||||
@@ -423,7 +432,7 @@ following
|
||||
5. Choose "User-managed app" as the app type.
|
||||
6. De-select the option to publish the app on the Zoom App Marketplace.
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
@@ -435,8 +444,8 @@ following
|
||||
1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have the [Daily Scale Plan](https://www.daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
|
||||
5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
@@ -445,7 +454,7 @@ following
|
||||
3. Click "Create app" button top right.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
@@ -458,31 +467,33 @@ following
|
||||
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
|
||||
4. Fill in any information you want in the "Client Details" tab
|
||||
5. Go to tab "Client Secret" tab.
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
|
||||
6. Now copy the Client ID and Client Secret to your `.env` file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
|
||||
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 Bigin Client ID and Secret
|
||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
||||
1. Create a SendGrid account (https://signup.sendgrid.com/)
|
||||
2. Go to Settings -> API keys and create an API key
|
||||
3. Copy API key to your .env file into the SENDGRID_API_KEY field
|
||||
3. Copy API key to your `.env` file into the `SENDGRID_API_KEY` field
|
||||
4. Go to Settings -> Sender Authentication and verify a single sender
|
||||
5. Copy the verified E-Mail to your .env file into the SENDGRID_EMAIL field
|
||||
6. Add your custom sender name to the .env file into the NEXT_PUBLIC_SENDGRID_SENDER_NAME field (fallback is Cal.com)
|
||||
5. Copy the verified E-Mail to your `.env` file into the `SENDGRID_EMAIL` field
|
||||
6. Add your custom sender name to the `.env` file into the `NEXT_PUBLIC_SENDGRID_SENDER_NAME` field (fallback is Cal.com)
|
||||
|
||||
### Setting up Twilio for SMS reminders
|
||||
|
||||
1. Create a Twilio account (https://www.twilio.com/try-twilio)
|
||||
1. Create a Twilio account (https://twilio.com/try-twilio)
|
||||
2. Click ‘Get a Twilio phone number’
|
||||
3. Copy Account SID to your .env file into the TWILIO_SID field
|
||||
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
||||
5. Copy your Twilio phone number to your .env file into the TWILIO_PHONE_NUMBER field
|
||||
6. Add your own sender id to the .env file into the NEXT_PUBLIC_SENDER_ID field (fallback is Cal)
|
||||
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
||||
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
||||
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
||||
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||
8. Choose any name for the messaging service
|
||||
9. Click 'Add Senders'
|
||||
@@ -490,15 +501,15 @@ following
|
||||
11. Add the listed phone number
|
||||
12. Leave all other fields as they are
|
||||
13. Complete setup and click ‘View my new Messaging Service’
|
||||
14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||
14. Copy Messaging Service SID to your `.env` file into the `TWILIO_MESSAGING_SID` field
|
||||
15. Create a verify service
|
||||
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
|
||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the AGPLv3 License. See `LICENSE` for more information.
|
||||
Distributed under the [AGPLv3 License](https://github.com/calcom/cal.com/blob/main/LICENSE). See `LICENSE` for more information.
|
||||
|
||||
<!-- ACKNOWLEDGEMENTS -->
|
||||
|
||||
|
||||
+264
-273
@@ -1,5 +1,6 @@
|
||||
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
@@ -21,11 +22,10 @@ import {
|
||||
TextArea,
|
||||
TextField,
|
||||
ColorPicker,
|
||||
Select,
|
||||
} from "@calcom/ui";
|
||||
import { Code, Trello, Sun, ArrowLeft } from "@calcom/ui/components/icon";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||
type EmbedFramework = "react" | "HTML";
|
||||
|
||||
@@ -305,190 +305,189 @@ const getEmbedTypeSpecificString = ({
|
||||
return "";
|
||||
};
|
||||
|
||||
const embeds: {
|
||||
illustration: React.ReactElement;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: EmbedType;
|
||||
}[] = [
|
||||
{
|
||||
title: "Inline Embed",
|
||||
subtitle: "Loads your event type directly inline with your other website content.",
|
||||
type: "inline",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="6" fill="#F3F4F6" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="6" fill="#F3F4F6" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="6" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Floating pop-up button",
|
||||
subtitle: "Adds a floating button on your site that launches Cal in a dialog.",
|
||||
type: "floating-popup",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="6" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Pop up via element click",
|
||||
subtitle: "Open your Cal dialog when someone clicks an element.",
|
||||
type: "element-click",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" y="0.50293" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" fill="#F9FAFB" />
|
||||
<g filter="url(#filter0_i_3223_14162)">
|
||||
<rect opacity="0.8" x="40" y="99" width="24" height="24" rx="2" fill="#E5E7EB" />
|
||||
<rect x="40" y="127" width="48" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="139" width="82" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="151" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="159" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
</g>
|
||||
<rect x="152" y="48" width="2" height="169" rx="2" fill="#E5E7EB" />
|
||||
const embeds = (t: TFunction) =>
|
||||
(() => {
|
||||
return [
|
||||
{
|
||||
title: t("inline_embed"),
|
||||
subtitle: t("load_inline_content"),
|
||||
type: "inline",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="6" fill="#F3F4F6" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="6" fill="#F3F4F6" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="6" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("floating_pop_up_button"),
|
||||
subtitle: t("floating_button_trigger_modal"),
|
||||
type: "floating-popup",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="6" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("pop_up_element_click"),
|
||||
subtitle: t("open_dialog_with_element_click"),
|
||||
type: "element-click",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" y="0.50293" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" fill="#F9FAFB" />
|
||||
<g filter="url(#filter0_i_3223_14162)">
|
||||
<rect opacity="0.8" x="40" y="99" width="24" height="24" rx="2" fill="#E5E7EB" />
|
||||
<rect x="40" y="127" width="48" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="139" width="82" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="151" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="159" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
</g>
|
||||
<rect x="152" y="48" width="2" height="169" rx="2" fill="#E5E7EB" />
|
||||
|
||||
<rect opacity="0.8" x="176" y="84" width="80" height="8" rx="2" fill="#E5E7EB" />
|
||||
<rect x="176" y="96" width="48" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="109" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="201" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="135" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="187.3" y="133.4" width="9" height="9" rx="1.5" stroke="#0D121D" />
|
||||
<rect x="201" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="171" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" stroke="#101010" />
|
||||
<rect x="24" y="241.503" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
})();
|
||||
|
||||
<rect opacity="0.8" x="176" y="84" width="80" height="8" rx="2" fill="#E5E7EB" />
|
||||
<rect x="176" y="96" width="48" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="109" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="201" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="135" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="187.3" y="133.4" width="9" height="9" rx="1.5" stroke="#0D121D" />
|
||||
<rect x="201" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="171" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" stroke="#101010" />
|
||||
<rect x="24" y="241.503" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
const tabs = [
|
||||
{
|
||||
name: "HTML",
|
||||
@@ -534,11 +533,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
<!-- Cal ${embedType} embed code ends -->`
|
||||
}
|
||||
/>
|
||||
<p className="text-subtle hidden text-sm">
|
||||
{t(
|
||||
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
|
||||
)}
|
||||
</p>
|
||||
<p className="text-subtle hidden text-sm">{t("need_help_embedding")}</p>
|
||||
</>
|
||||
);
|
||||
}),
|
||||
@@ -622,7 +617,7 @@ Cal("init", {origin:"${WEBAPP_URL}"});
|
||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; label: string }, false>) => {
|
||||
return (
|
||||
<components.Control {...props}>
|
||||
<Sun className="text-subtle ml-2 h-4 w-4" />
|
||||
<Sun className="text-subtle mr-2 h-4 w-4" />
|
||||
{children}
|
||||
</components.Control>
|
||||
);
|
||||
@@ -642,7 +637,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="items-start space-y-2 md:flex md:space-y-0">
|
||||
{embeds.map((embed, index) => (
|
||||
{embeds(t).map((embed, index) => (
|
||||
<button
|
||||
className="hover:bg-subtle bg-muted w-full rounded-md border border-transparent p-6 text-left hover:rounded-md ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0 lg:w-1/3"
|
||||
key={index}
|
||||
@@ -692,7 +687,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
});
|
||||
|
||||
const refOfEmbedCodesRefs = useRef(embedCodeRefs);
|
||||
const embed = embeds.find((embed) => embed.type === embedType);
|
||||
const embed = embeds(t).find((embed) => embed.type === embedType);
|
||||
|
||||
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
|
||||
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
|
||||
@@ -806,11 +801,11 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
const FloatingPopupPositionOptions = [
|
||||
{
|
||||
value: "bottom-right",
|
||||
label: "Bottom Right",
|
||||
label: "Bottom right",
|
||||
},
|
||||
{
|
||||
value: "bottom-left",
|
||||
label: "Bottom Left",
|
||||
label: "Bottom left",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -834,7 +829,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</button>
|
||||
{embed.title}
|
||||
</h3>
|
||||
<h4 className="text-emphasis mb-6 text-sm font-normal">{embed.subtitle}</h4>
|
||||
<h4 className="text-subtle mb-6 text-sm font-normal">{embed.subtitle}</h4>
|
||||
<div className="flex flex-col">
|
||||
<div className={classNames("font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
@@ -844,7 +839,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div className={classNames(embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-default mb-[9px] text-sm">Window sizing</div>
|
||||
<div className="justify-left flex items-center !font-normal">
|
||||
<div className="justify-left mb-6 flex items-center !font-normal ">
|
||||
<div className="mr-[9px]">
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
@@ -892,10 +887,10 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
"items-center justify-between",
|
||||
embedType === "floating-popup" ? "text-emphasis" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
<div className="mb-2 text-sm">Button text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
@@ -910,7 +905,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
defaultValue={t("book_my_cal")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -935,14 +930,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-default text-sm">Display Calendar Icon Button</div>
|
||||
<div className="text-default my-2 text-sm">Display calendar icon</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "text-emphasis" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2">Position of Button</div>
|
||||
<div className="mb-2">Position of button</div>
|
||||
<Select
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
@@ -959,54 +954,50 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4",
|
||||
embedType === "floating-popup" ? "text-emphasis" : "hidden"
|
||||
)}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex flex-col xl:flex-row xl:justify-between">
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div className="whitespace-nowrap">Button color</div>
|
||||
<div className="mt-2 w-40 xl:mt-0 xl:w-full">
|
||||
<ColorPicker
|
||||
className="w-[130px]"
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4",
|
||||
embedType === "floating-popup" ? "text-emphasis" : "hidden"
|
||||
)}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div className="whitespace-nowrap">Text color</div>
|
||||
<div className="mt-2 mb-6 w-40 xl:mt-0 xl:w-full">
|
||||
<ColorPicker
|
||||
className="w-[130px]"
|
||||
popoverAlign="start"
|
||||
container={dialogContentRef?.current ?? undefined}
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
@@ -1017,7 +1008,30 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<div className="text-sm">
|
||||
<Label className="mb-6">
|
||||
<div className="mb-2">Theme</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}
|
||||
/>
|
||||
</Label>
|
||||
<div className="mb-6 flex items-center justify-start space-x-2 rtl:space-x-reverse">
|
||||
<Switch
|
||||
checked={previewState.hideEventTypeDetails}
|
||||
@@ -1056,29 +1070,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
<Label>
|
||||
<div className="mb-2">Theme</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
Button,
|
||||
EmptyScreen,
|
||||
List,
|
||||
showToast,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
Switch,
|
||||
ShellSubHeading,
|
||||
} from "@calcom/ui";
|
||||
import { ArrowLeft, Calendar, Plus } from "@calcom/ui/components/icon";
|
||||
import { Calendar, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -31,83 +29,6 @@ type Props = {
|
||||
destinationCalendarId?: string;
|
||||
};
|
||||
|
||||
function CalendarSwitch(props: {
|
||||
type: string;
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
destination?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
isOn: boolean;
|
||||
}
|
||||
>(
|
||||
async ({ isOn }) => {
|
||||
const body = {
|
||||
integration: props.type,
|
||||
externalId: props.externalId,
|
||||
};
|
||||
if (isOn) {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col py-1 sm:flex-row">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.defaultSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
{!!props.destination && (
|
||||
<span className="bg-subtle text-default ml-8 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-normal sm:ml-4">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("adding_events_to")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||
@@ -185,14 +106,15 @@ function ConnectedCalendarsList(props: Props) {
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 px-5 py-4">
|
||||
<ul className="space-y-4 px-5 py-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
isChecked={cal.isSelected}
|
||||
destination={cal.externalId === props.destinationCalendarId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
|
||||
!eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
|
||||
@@ -72,6 +72,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
slotUtcStartDate: slot.time,
|
||||
eventTypeId,
|
||||
slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(),
|
||||
bookingAttendees: bookingAttendees || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export function TimezoneDropdown({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
|
||||
<Globe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
|
||||
@@ -230,12 +230,14 @@ const BookingPage = ({
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
const reserveSlot = () => {
|
||||
if (queryDuration) {
|
||||
reserveSlotMutation.mutate({
|
||||
eventTypeId: eventType.id,
|
||||
slotUtcStartDate: dayjs(queryDate).utc().format(),
|
||||
slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(),
|
||||
bookingAttendees: currentSlotBooking ? currentSlotBooking.attendees.length : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -550,11 +552,11 @@ const BookingPage = ({
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:border-subtle text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<div className="sm:border-subtle text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
<BookingDescriptionPayment eventType={eventType} t={t} i18n={i18n} />
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="dark:text-inverted text-default items-start text-sm font-medium">
|
||||
<div className="text-default items-start text-sm font-medium">
|
||||
<RefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
|
||||
@@ -11,6 +11,10 @@ import { getEventName } from "@calcom/core/event";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -43,6 +47,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
|
||||
eventType.bookingFields.forEach(({ name }) => {
|
||||
bookingFields[name] = name + " input";
|
||||
});
|
||||
@@ -338,7 +344,51 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.attendee"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
title={t("disable_attendees_confirmation_emails")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("metadata.disableStandardEmails.confirmation.attendee", e);
|
||||
onChange(e);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{allowDisablingHostConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="metadata.disableStandardEmails.confirmation.host"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
title={t("disable_host_confirmation_emails")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("metadata.disableStandardEmails.confirmation.host", e);
|
||||
onChange(e);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showEventNameTip && (
|
||||
<CustomEventTypeModal
|
||||
close={closeEventNameTip}
|
||||
|
||||
@@ -154,12 +154,11 @@ const EventTypeScheduleDetails = ({
|
||||
|
||||
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } =
|
||||
useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { shouldLockIndicator, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
const watchSchedule = watch("schedule");
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
@@ -205,6 +204,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const value = options.find((option) =>
|
||||
scheduleId
|
||||
? option.value === scheduleId
|
||||
: isManagedEventType
|
||||
? option.value === 0
|
||||
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
|
||||
);
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ export const EventSetupTab = (
|
||||
return (
|
||||
<li
|
||||
key={`${location.type}${index}`}
|
||||
className="border-default text-default mb-2 rounded-md border py-1.5 px-2 hover:cursor-pointer">
|
||||
className="border-default text-default mb-2 h-9 rounded-md border py-1.5 px-2 hover:cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
@@ -285,7 +285,7 @@ export const EventSetupTab = (
|
||||
"h-4 w-4",
|
||||
// invert all the icons except app icons
|
||||
eventLocationType.iconUrl &&
|
||||
!eventLocationType.iconUrl.startsWith("/api") &&
|
||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
||||
"dark:invert"
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
@@ -466,6 +466,7 @@ export const EventSetupTab = (
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
{!lengthLockedProps.disabled && (
|
||||
|
||||
@@ -238,7 +238,7 @@ function EventTypeSingleLayout({
|
||||
<div className="flex items-center justify-end">
|
||||
{!eventType.metadata.managedEventConfig && (
|
||||
<>
|
||||
<div className="sm:hover:bg-subtle hidden items-center rounded-md px-2 lg:flex">
|
||||
<div className="sm:hover:bg-muted hidden items-center rounded-md px-2 lg:flex">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarSwitch } from "./CalendarSwitch";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
|
||||
interface IConnectedCalendarItem {
|
||||
name: string;
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, showToast, Switch } from "@calcom/ui";
|
||||
import { ArrowLeft } from "@calcom/ui/components/icon";
|
||||
|
||||
export function CalendarSwitch(props: {
|
||||
type: string;
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
isOn: boolean;
|
||||
}
|
||||
>(
|
||||
async ({ isOn }) => {
|
||||
const body = {
|
||||
integration: props.type,
|
||||
externalId: props.externalId,
|
||||
};
|
||||
if (isOn) {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex space-x-2 py-1 rtl:space-x-reverse">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.isSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
{props.defaultSelected && (
|
||||
<Badge variant="gray">
|
||||
<div className="flex items-center">
|
||||
<ArrowLeft className="text-default mr-1 h-4 w-4" /> {t("adding_events_to")}
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
src={icon}
|
||||
alt="cover"
|
||||
// invert all the icons except app icons
|
||||
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/api") && "dark:invert")}
|
||||
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")}
|
||||
/>
|
||||
)}
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
|
||||
@@ -105,7 +105,6 @@ const nextConfig = {
|
||||
"@calcom/trpc",
|
||||
"@calcom/ui",
|
||||
"lucide-react",
|
||||
"react-timezone-select",
|
||||
],
|
||||
modularizeImports: {
|
||||
"@calcom/ui/components/icon": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.8.11",
|
||||
"version": "2.8.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -113,7 +113,7 @@
|
||||
"react-phone-number-input": "^3.2.7",
|
||||
"react-schemaorg": "^2.0.0",
|
||||
"react-select": "^5.7.0",
|
||||
"react-timezone-select": "^1.5.1",
|
||||
"react-timezone-select": "^1.4.0",
|
||||
"react-use-intercom": "1.5.1",
|
||||
"remark": "^14.0.2",
|
||||
"rrule": "^2.7.1",
|
||||
|
||||
@@ -23,6 +23,7 @@ class MyDocument extends Document<Props> {
|
||||
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||
}
|
||||
|
||||
const asPath = ctx.asPath || "";
|
||||
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||
|
||||
@@ -36,14 +36,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return;
|
||||
}
|
||||
|
||||
// There is actually an existingUser if username matches
|
||||
// OR if email matches and both username and password are set
|
||||
// There is an existingUser if the username matches
|
||||
// OR if the email matches AND either the email is verified
|
||||
// or both username and password are set
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{
|
||||
AND: [{ email: userEmail }],
|
||||
AND: [
|
||||
{ email: userEmail },
|
||||
{
|
||||
OR: [
|
||||
{ emailVerified: { not: null } },
|
||||
{
|
||||
AND: [{ password: { not: null } }, { username: { not: null } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
||||
@@ -27,32 +26,18 @@ const decryptedSchema = z.object({
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
||||
const { action, token, reason } = querySchema.parse(req.query);
|
||||
const { bookingUid, userId } = decryptedSchema.parse(
|
||||
const { bookingUid } = decryptedSchema.parse(
|
||||
JSON.parse(symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || ""))
|
||||
);
|
||||
|
||||
/** We shape the session as required by tRPC router */
|
||||
async function sessionGetter() {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: "" /* Not used in this context */,
|
||||
role: UserPermissionRole.USER,
|
||||
},
|
||||
hasValidLicense: true,
|
||||
expires: "" /* Not used in this context */,
|
||||
};
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({
|
||||
where: { uid: bookingUid },
|
||||
});
|
||||
|
||||
try {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
const ctx = await createContext({ req, res });
|
||||
const caller = viewerRouter.createCaller({ ...ctx, req, res });
|
||||
|
||||
await caller.bookings.confirm({
|
||||
bookingId: booking.id,
|
||||
recurringEventId: booking.recurringEventId || undefined,
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* This file contains tRPC's HTTP response handler
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import * as trpcNext from "@calcom/trpc/server/adapters/next";
|
||||
import { createContext as createTrpcContext } from "@calcom/trpc/server/createContext";
|
||||
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
/**
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
createContext: ({ req, res }) => {
|
||||
const sessionGetter = () => getServerSession({ req, res });
|
||||
|
||||
return createTrpcContext({ req, res }, sessionGetter);
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/error-handling
|
||||
*/
|
||||
onError({ error }) {
|
||||
if (error.code === "INTERNAL_SERVER_ERROR") {
|
||||
// send to bug reporting
|
||||
console.error("Something went wrong", error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Enable query batching
|
||||
*/
|
||||
batching: {
|
||||
enabled: true,
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/caching#api-response-caching
|
||||
*/
|
||||
responseMeta({ ctx, paths, type, errors }) {
|
||||
// Some helpers relevant to this function only
|
||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||
// assuming you have all your public routes with the keyword `public` in them
|
||||
const allPublic = paths && paths.every((path) => path.startsWith("viewer.public."));
|
||||
// checking that no procedures errored
|
||||
const allOk = errors.length === 0;
|
||||
// checking we're doing a query request
|
||||
const isQuery = type === "query";
|
||||
const noHeaders = {};
|
||||
|
||||
// We cannot set headers on SSG queries
|
||||
if (!ctx?.res) return noHeaders;
|
||||
|
||||
const defaultHeaders: Record<"headers", Record<string, string>> = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const timezone = z.string().safeParse(ctx.req?.headers["x-vercel-ip-timezone"]);
|
||||
if (timezone.success) defaultHeaders.headers["x-cal-timezone"] = timezone.data;
|
||||
|
||||
// We need all these conditions to be true to set cache headers
|
||||
if (!(allPublic && allOk && isQuery)) return defaultHeaders;
|
||||
|
||||
// No cache by default
|
||||
defaultHeaders.headers["cache-control"] = `no-cache`;
|
||||
|
||||
// Our cache can change depending on our current paths value. Since paths is an array,
|
||||
// we want to create a map that can match potential paths with their desired cache value
|
||||
const cacheRules = {
|
||||
"viewer.public.session": `no-cache`,
|
||||
"viewer.public.i18n": `no-cache`,
|
||||
// Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321
|
||||
"viewer.public.slots.getSchedule": `no-cache`, // FIXME
|
||||
"viewer.public.cityTimezones": `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate`,
|
||||
} as const;
|
||||
|
||||
// Find which element above is an exact match for this group of paths
|
||||
const matchedPath = paths.find((v) => v in cacheRules) as keyof typeof cacheRules;
|
||||
|
||||
if (matchedPath) defaultHeaders.headers["cache-control"] = cacheRules[matchedPath];
|
||||
|
||||
return defaultHeaders;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
|
||||
|
||||
export default createNextApiHandler(apiKeysRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import appRoutingForms from "@calcom/app-store/routing-forms/trpc-router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(appRoutingForms);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
|
||||
export default createNextApiHandler(appsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
|
||||
export default createNextApiHandler(appsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { authRouter } from "@calcom/trpc/server/routers/viewer/auth/_router";
|
||||
|
||||
export default createNextApiHandler(authRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
|
||||
export default createNextApiHandler(availabilityRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
|
||||
|
||||
export default createNextApiHandler(bookingsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { deploymentSetupRouter } from "@calcom/trpc/server/routers/viewer/deploymentSetup/_router";
|
||||
|
||||
export default createNextApiHandler(deploymentSetupRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(ethRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
export default createNextApiHandler(eventTypesRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { featureFlagRouter } from "@calcom/features/flags/server/router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(featureFlagRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(insightsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { paymentsRouter } from "@calcom/trpc/server/routers/viewer/payments/_router";
|
||||
|
||||
export default createNextApiHandler(paymentsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
||||
|
||||
export default createNextApiHandler(publicViewerRouter, true);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { ssoRouter } from "@calcom/trpc/server/routers/viewer/sso/_router";
|
||||
|
||||
export default createNextApiHandler(ssoRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
|
||||
|
||||
export default createNextApiHandler(slotsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
export default createNextApiHandler(viewerTeamsRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router";
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
|
||||
export default createNextApiHandler(userAdminRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { loggedInViewerRouter } from "@calcom/trpc/server/routers/loggedInViewer/_router";
|
||||
|
||||
export default createNextApiHandler(loggedInViewerRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
export default createNextApiHandler(webhookRouter);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { workflowsRouter } from "@calcom/trpc/server/routers/viewer/workflows/_router";
|
||||
|
||||
export default createNextApiHandler(workflowsRouter);
|
||||
@@ -40,7 +40,7 @@ function AppsSearch({
|
||||
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
|
||||
addOnLeading={<Search className="text-subtle h-4 w-4" />}
|
||||
addOnClassname="!border-muted"
|
||||
containerClassName={classNames("focus:!ring-offset-0", className)}
|
||||
containerClassName={classNames("focus:!ring-offset-0 m-1", className)}
|
||||
type="search"
|
||||
autoComplete="false"
|
||||
onChange={onChange}
|
||||
|
||||
@@ -188,7 +188,7 @@ export default function Availability() {
|
||||
}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="hidden items-center rounded-md px-2 sm:flex sm:hover:bg-gray-100">
|
||||
<div className="sm:hover:bg-muted hidden items-center rounded-md px-2 sm:flex">
|
||||
<Skeleton as={Label} htmlFor="hiddenSwitch" className="mt-2 cursor-pointer self-center pr-2 ">
|
||||
{t("set_to_default")}
|
||||
</Skeleton>
|
||||
|
||||
@@ -309,7 +309,7 @@ export default function Success(props: SuccessProps) {
|
||||
<div className="mt-2 ml-4 -mb-4">
|
||||
<Link
|
||||
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
className="hover:bg-subtle text-subtle dark:hover:text-inverted hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
|
||||
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
|
||||
<ChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -352,6 +352,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
|
||||
const firstItem = types[0];
|
||||
const lastItem = types[types.length - 1];
|
||||
const isManagedEventPrefix = () => {
|
||||
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||
};
|
||||
return (
|
||||
<div className="bg-default border-subtle mb-16 flex overflow-hidden rounded-md border">
|
||||
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
|
||||
@@ -501,7 +504,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
|
||||
isManagedEventType && (
|
||||
!isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
@@ -533,27 +536,31 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
href={calLink}
|
||||
target="_blank"
|
||||
StartIcon={ExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
StartIcon={Clipboard}
|
||||
className="w-full rounded-none text-left">
|
||||
{t("copy_link")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
href={calLink}
|
||||
target="_blank"
|
||||
StartIcon={ExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
StartIcon={Clipboard}
|
||||
className="w-full rounded-none text-left">
|
||||
{t("copy_link")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{isNativeShare ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
@@ -574,36 +581,46 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => router.push("/event-types/" + type.id)}
|
||||
StartIcon={Edit}
|
||||
className="w-full rounded-none">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
StartIcon={Copy}
|
||||
data-testid={"event-type-duplicate-" + type.id}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon={Trash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{!readOnly && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => router.push("/event-types/" + type.id)}
|
||||
StartIcon={Edit}
|
||||
className="w-full rounded-none">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isManagedEventType && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
StartIcon={Copy}
|
||||
data-testid={"event-type-duplicate-" + type.id}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
|
||||
!isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon={Trash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
@@ -616,36 +633,24 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t(
|
||||
`delete_${deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"}_event_type`
|
||||
)}
|
||||
confirmBtnText={t(
|
||||
`confirm_delete_${
|
||||
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
|
||||
}_event_type`
|
||||
)}
|
||||
loadingText={t(
|
||||
`confirm_delete_${
|
||||
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
|
||||
}_event_type`
|
||||
)}
|
||||
title={t(`delete${isManagedEventPrefix()}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteEventTypeHandler(deleteDialogTypeId);
|
||||
}}>
|
||||
<p className="mt-5">
|
||||
{t(
|
||||
`delete_${
|
||||
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
|
||||
}_event_type_description`
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-5">
|
||||
<Trans
|
||||
i18nKey={`delete_${
|
||||
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
|
||||
}_event_type_warning`}
|
||||
/>
|
||||
i18nKey={`delete${isManagedEventPrefix()}_event_type_description`}
|
||||
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
|
||||
<ul>
|
||||
<li>Members assigned to this event type will also have their event types deleted.</li>
|
||||
<li>
|
||||
Anyone who they've shared their link with will no longer be able to book using it.
|
||||
</li>
|
||||
</ul>
|
||||
</Trans>
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -15,7 +15,7 @@ type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away }: PageProps) {
|
||||
return (
|
||||
<main className="flex justify-center">
|
||||
<main className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away }: PageProps) {
|
||||
return (
|
||||
<main className="flex justify-center">
|
||||
<main className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -146,6 +146,7 @@ const AppearanceView = () => {
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
onChange={(value) => {
|
||||
if (!checkWCAGContrastColor("#ffffff", value)) {
|
||||
setLightModeError(true);
|
||||
@@ -167,6 +168,7 @@ const AppearanceView = () => {
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
onChange={(value) => {
|
||||
if (!checkWCAGContrastColor("#101010", value)) {
|
||||
setDarkModeError(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
@@ -29,7 +30,6 @@ import { Plus, Calendar } from "@calcom/ui/components/icon";
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { CalendarSwitch } from "@components/settings/CalendarSwitch";
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
@@ -178,15 +178,17 @@ const CalendarsView = () => {
|
||||
</div>
|
||||
<div className="border-subtle w-full border-t">
|
||||
<p className="text-subtle px-2 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
<ul className="space-y-4 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
name={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
isSelected={cal.isSelected}
|
||||
defaultSelected={cal.externalId === data?.destinationCalendar?.externalId}
|
||||
isChecked={
|
||||
cal.isSelected || cal.externalId === data?.destinationCalendar?.externalId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { AlertCircle, MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
|
||||
import { AlertCircle, MoreHorizontal, Trash, Video, Plus } from "@calcom/ui/components/icon";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -41,6 +41,18 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
);
|
||||
};
|
||||
|
||||
const AddConferencingButton = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
|
||||
{t("add_conferencing_app")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ConferencingLayout = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
@@ -91,7 +103,11 @@ const ConferencingLayout = () => {
|
||||
|
||||
return (
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Meta title={t("conferencing")} description={t("conferencing_description")} />
|
||||
<Meta
|
||||
title={t("conferencing")}
|
||||
description={t("conferencing_description")}
|
||||
CTA={<AddConferencingButton />}
|
||||
/>
|
||||
<List>
|
||||
{apps?.items &&
|
||||
apps.items
|
||||
|
||||
@@ -12,6 +12,7 @@ test.describe("App Store - Authed", () => {
|
||||
await pro.login();
|
||||
await page.goto("/apps/categories/calendar");
|
||||
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
|
||||
await page.waitForURL("/apps/apple-calendar");
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await expect(page.locator(`text=Connect to Apple Server`)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ test.describe("Availablity tests", () => {
|
||||
});
|
||||
|
||||
await test.step("Date override is displayed in troubleshooter", async () => {
|
||||
const response = await page.waitForResponse("**/api/trpc/viewer.availability.schedule.update?batch=1");
|
||||
const response = await page.waitForResponse("**/api/trpc/availability/schedule.update?batch=1");
|
||||
const json = await response.json();
|
||||
// @ts-expect-error trust me bro
|
||||
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
|
||||
|
||||
@@ -94,7 +94,12 @@ testBothBookers.describe("pro user", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Can cancel the recently created booking and rebook the same timeslot", async ({ page, users }) => {
|
||||
test("Can cancel the recently created booking and rebook the same timeslot", async ({
|
||||
page,
|
||||
users,
|
||||
}, testInfo) => {
|
||||
// Because it tests the entire booking flow + the cancellation + rebooking
|
||||
test.setTimeout(testInfo.timeout * 3);
|
||||
await bookFirstEvent(page);
|
||||
|
||||
const [pro] = users.get();
|
||||
@@ -103,7 +108,7 @@ testBothBookers.describe("pro user", () => {
|
||||
await page.goto("/bookings/upcoming");
|
||||
await page.locator('[data-testid="cancel"]').first().click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
return url.pathname.startsWith("/booking/");
|
||||
});
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
|
||||
@@ -126,7 +131,7 @@ testBothBookers.describe("pro user", () => {
|
||||
await page.goto("/bookings/unconfirmed");
|
||||
await Promise.all([
|
||||
page.click('[data-testid="confirm"]'),
|
||||
page.waitForResponse((response) => response.url().includes("/api/trpc/viewer.bookings.confirm")),
|
||||
page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")),
|
||||
]);
|
||||
// This is the only booking in there that needed confirmation and now it should be empty screen
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "متى تم إلغاء هذا الحدث",
|
||||
"new_event_trigger": "متى تم حجز الحدث الجديد",
|
||||
"email_host_action": "إرسال رسالة إلكترونية إلى المضيف",
|
||||
"email_attendee_action": "إرسال رسالة إلكترونية إلى واحد من الحضور",
|
||||
"sms_attendee_action": "إرسال رسالة نصية قصيرة إلى واحد من الحضور",
|
||||
"sms_number_action": "إرسال رسالة نصية قصيرة إلى رقم محدد",
|
||||
"workflows": "سير العمل",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "při zrušení události",
|
||||
"new_event_trigger": "při rezervaci nové události",
|
||||
"email_host_action": "odeslání e-mailu hostiteli",
|
||||
"email_attendee_action": "odeslání e-mailu účastníkovi",
|
||||
"sms_attendee_action": "odeslání SMS účastníkovi",
|
||||
"sms_number_action": "odeslání SMS na konkrétní číslo",
|
||||
"workflows": "Pracovní postupy",
|
||||
|
||||
@@ -1010,7 +1010,6 @@
|
||||
"event_cancelled_trigger": "når begivenheden er aflyst",
|
||||
"new_event_trigger": "når ny begivenhed er booket",
|
||||
"email_host_action": "send e-mail til vært",
|
||||
"email_attendee_action": "send e-mail til deltager",
|
||||
"sms_attendee_action": "Send SMS til deltager",
|
||||
"sms_number_action": "send SMS til et specifikt nummer",
|
||||
"workflows": "Workflows",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "wenn das Ereignis abgesagt wird",
|
||||
"new_event_trigger": "wenn ein neer Termin gebucht wird",
|
||||
"email_host_action": "E-Mail an den Veranstalter senden",
|
||||
"email_attendee_action": "E-Mail an Teilnehmende senden",
|
||||
"sms_attendee_action": "SMS an Teilnehmende senden",
|
||||
"sms_number_action": "SMS an eine bestimmte Nummer senden",
|
||||
"workflows": "Workflows",
|
||||
|
||||
@@ -1039,7 +1039,7 @@
|
||||
"event_cancelled_trigger": "when event is cancelled",
|
||||
"new_event_trigger": "when new event is booked",
|
||||
"email_host_action": "send email to host",
|
||||
"email_attendee_action": "send email to attendee",
|
||||
"email_attendee_action": "send email to attendees",
|
||||
"sms_attendee_action": "send SMS to attendee",
|
||||
"sms_number_action": "send SMS to a specific number",
|
||||
"workflows": "Workflows",
|
||||
@@ -1248,6 +1248,7 @@
|
||||
"calendars_description": "Configure how your event types interact with your calendars",
|
||||
"appearance_description": "Manage settings for your booking appearance",
|
||||
"conferencing_description": "Add your favourite video conferencing apps for your meetings",
|
||||
"add_conferencing_app": "Add Conferencing App",
|
||||
"password_description": "Manage settings for your account passwords",
|
||||
"2fa_description": "Manage settings for your account passwords",
|
||||
"we_just_need_basic_info": "We just need some basic info to get your profile setup.",
|
||||
@@ -1800,5 +1801,17 @@
|
||||
"payment_app_commission": "Require payment ({{paymentFeePercentage}}% + {{fee, currency}} commission per transaction)",
|
||||
"email_invite_team": "{{email}} has been invited",
|
||||
"error_collecting_card": "Error collecting card",
|
||||
"image_size_limit_exceed": "Uploaded image shouldn't exceed 5mb size limit"
|
||||
"image_size_limit_exceed": "Uploaded image shouldn't exceed 5mb size limit",
|
||||
"inline_embed": "Inline Embed",
|
||||
"load_inline_content": "Loads your event type directly inline with your other website content.",
|
||||
"floating_pop_up_button": "Floating pop-up button",
|
||||
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
|
||||
"pop_up_element_click": "Pop up via element click",
|
||||
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
|
||||
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
|
||||
"book_my_cal": "Book my Cal",
|
||||
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
|
||||
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
|
||||
"disable_host_confirmation_emails": "Disable default confirmation emails for host",
|
||||
"disable_host_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the host when the event is booked."
|
||||
}
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "cuando se cancela el evento",
|
||||
"new_event_trigger": "cuando se reserva un nuevo evento",
|
||||
"email_host_action": "enviar correo electrónico al anfitrión",
|
||||
"email_attendee_action": "enviar correo electrónico al asistente",
|
||||
"sms_attendee_action": "enviar SMS al asistente",
|
||||
"sms_number_action": "enviar SMS a un número específico",
|
||||
"workflows": "Flujos de trabajo",
|
||||
|
||||
@@ -1039,7 +1039,7 @@
|
||||
"event_cancelled_trigger": "lorsque l'événement est annulé",
|
||||
"new_event_trigger": "lorsqu'un nouvel événement est réservé",
|
||||
"email_host_action": "envoyer un e-mail à l'hôte",
|
||||
"email_attendee_action": "envoyer un e-mail au participant",
|
||||
"email_attendee_action": "envoyer un e-mail aux participants",
|
||||
"sms_attendee_action": "envoyer un SMS au participant",
|
||||
"sms_number_action": "envoyer un SMS à un numéro spécifique",
|
||||
"workflows": "Workflows",
|
||||
@@ -1248,6 +1248,7 @@
|
||||
"calendars_description": "Configurez la manière dont vos types d'événements interagissent avec vos calendriers.",
|
||||
"appearance_description": "Gérez les paramètres d'apparence de vos réservations.",
|
||||
"conferencing_description": "Ajoutez vos applications de visioconférence préférées pour vos rendez-vous.",
|
||||
"add_conferencing_app": "Ajouter une application de conférence",
|
||||
"password_description": "Gérez les paramètres pour les mots de passe de votre compte.",
|
||||
"2fa_description": "Gérez les paramètres pour les mots de passe de votre compte.",
|
||||
"we_just_need_basic_info": "Nous avons juste besoin de quelques informations de base pour configurer votre profil.",
|
||||
@@ -1800,5 +1801,12 @@
|
||||
"payment_app_commission": "Exiger un paiement ({{paymentFeePercentage}} % + {{fee, currency}} de commission par transaction)",
|
||||
"email_invite_team": "{{email}} a été invité",
|
||||
"error_collecting_card": "Erreur lors de la collecte de la carte",
|
||||
"image_size_limit_exceed": "L'image téléchargée ne doit pas dépasser 5 Mo"
|
||||
"image_size_limit_exceed": "L'image téléchargée ne doit pas dépasser 5 Mo",
|
||||
"inline_embed": "Intégration en ligne",
|
||||
"load_inline_content": "Charge votre type d'événement directement en ligne avec le contenu de votre site web.",
|
||||
"floating_pop_up_button": "Bouton pop-up flottant",
|
||||
"disable_attendees_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour les participants",
|
||||
"disable_attendees_confirmation_emails_description": "Au moins un workflow est actif sur ce type d'événement qui envoie un e-mail aux participants lorsque l'événement est réservé.",
|
||||
"disable_host_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour l'hôte",
|
||||
"disable_host_confirmation_emails_description": "Au moins un workflow est actif sur ce type d'événement qui envoie un e-mail à l'hôte lorsque l'événement est réservé."
|
||||
}
|
||||
|
||||
@@ -1014,7 +1014,6 @@
|
||||
"event_cancelled_trigger": "כאשר אירוע מבוטל",
|
||||
"new_event_trigger": "כאשר אירוע חדש מוזמן",
|
||||
"email_host_action": "שלח מייל למארח",
|
||||
"email_attendee_action": "שלח מייל למשתתף",
|
||||
"sms_attendee_action": "שלח SMS למשתתף",
|
||||
"sms_number_action": "שלח SMS למספר מסוים",
|
||||
"workflows": "תהליכים",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "quando l'evento viene annullato",
|
||||
"new_event_trigger": "quando viene prenotato un nuovo evento",
|
||||
"email_host_action": "invia e-mail all'organizzatore",
|
||||
"email_attendee_action": "invia e-mail al partecipante",
|
||||
"sms_attendee_action": "invia SMS al partecipante",
|
||||
"sms_number_action": "invia SMS a un numero specifico",
|
||||
"workflows": "Flussi di lavoro",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "イベントキャンセル時に",
|
||||
"new_event_trigger": "新しいイベント予約時に",
|
||||
"email_host_action": "ホストにメールを送信",
|
||||
"email_attendee_action": "出席者にメールを送信",
|
||||
"sms_attendee_action": "出席者に SMS を送信",
|
||||
"sms_number_action": "特定の番号に SMS を送信",
|
||||
"workflows": "ワークフロー",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "이벤트 취소 시",
|
||||
"new_event_trigger": "새 이벤트 예약 시",
|
||||
"email_host_action": "호스트에게 이메일 발송",
|
||||
"email_attendee_action": "참석자에게 이메일 발송",
|
||||
"sms_attendee_action": "참석자에게 SMS 발송",
|
||||
"sms_number_action": "특정 번호로 SMS 발송",
|
||||
"workflows": "워크플로",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "wanneer de gebeurtenis is geannuleerd",
|
||||
"new_event_trigger": "wanneer een nieuwe gebeurtenis is geboekt",
|
||||
"email_host_action": "e-mail naar organisator versturen",
|
||||
"email_attendee_action": "e-mail naar deelnemer versturen",
|
||||
"sms_attendee_action": "sms naar deelnemer versturen",
|
||||
"sms_number_action": "sms naar een specifiek nummer sturen",
|
||||
"workflows": "Werkstromen",
|
||||
|
||||
@@ -990,7 +990,6 @@
|
||||
"event_cancelled_trigger": "når hendelsen er avlyst",
|
||||
"new_event_trigger": "når en ny hendelse er booket",
|
||||
"email_host_action": "send e-post til vert",
|
||||
"email_attendee_action": "send e-post til deltakeren",
|
||||
"sms_attendee_action": "send SMS til deltakeren",
|
||||
"sms_number_action": "send SMS til et bestemt nummer",
|
||||
"workflows": "Arbeidsflyter",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "gdy wydarzenie zostanie odwołane",
|
||||
"new_event_trigger": "gdy nowe wydarzenie zostanie zarezerwowane",
|
||||
"email_host_action": "wyślij wiadomość e-mail do hosta",
|
||||
"email_attendee_action": "wyślij wiadomość e-mail do uczestnika",
|
||||
"sms_attendee_action": "wyślij SMS do uczestnika",
|
||||
"sms_number_action": "wyślij wiadomość SMS na określony numer",
|
||||
"workflows": "Przepływy pracy",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "quando o evento é cancelado",
|
||||
"new_event_trigger": "quando um novo evento for reservado",
|
||||
"email_host_action": "enviar e-mail para o host",
|
||||
"email_attendee_action": "enviar e-mail para o participante",
|
||||
"sms_attendee_action": "enviar SMS para participante",
|
||||
"sms_number_action": "enviar SMS para um número específico",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
|
||||
@@ -599,7 +599,7 @@
|
||||
"count_members_one": "{{count}} membro",
|
||||
"count_members_other": "{{count}} membros",
|
||||
"no_assigned_members": "Sem membros atribuídos",
|
||||
"assigned_to": "Atribuído a",
|
||||
"assigned_to": "Asignado a",
|
||||
"start_assigning_members_above": "Comece a atribuir membros acima",
|
||||
"locked_fields_admin_description": "Os membros não poderão editar isto",
|
||||
"locked_fields_member_description": "Esta opção foi bloqueada pelo administrador da equipa",
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "quando o evento for cancelado",
|
||||
"new_event_trigger": "quando um novo evento for reservado",
|
||||
"email_host_action": "enviar email ao anfitrião",
|
||||
"email_attendee_action": "enviar email ao participante",
|
||||
"sms_attendee_action": "enviar SMS ao participante",
|
||||
"sms_number_action": "enviar SMS para um número específico",
|
||||
"workflows": "Fluxos de trabalho",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "când se anulează evenimentul",
|
||||
"new_event_trigger": "când este rezervat noul eveniment",
|
||||
"email_host_action": "trimiteți e-mail la gazdă",
|
||||
"email_attendee_action": "trimiteți e-mail la participant",
|
||||
"sms_attendee_action": "trimiteți SMS la participant",
|
||||
"sms_number_action": "trimiteți SMS la un anumit număr",
|
||||
"workflows": "Fluxuri de lucru",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "когда событие отменено",
|
||||
"new_event_trigger": "когда будет забронировано новое событие",
|
||||
"email_host_action": "отправить письмо организатору",
|
||||
"email_attendee_action": "отправить письмо участнику",
|
||||
"sms_attendee_action": "отправить SMS-сообщение участнику",
|
||||
"sms_number_action": "отправка SMS-сообщение на указанный номер",
|
||||
"workflows": "Рабочие процессы",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "kada je događaj otkazan",
|
||||
"new_event_trigger": "kada je zakazan novi događaj",
|
||||
"email_host_action": "pošalji imejl domaćinu",
|
||||
"email_attendee_action": "pošalji imejl polazniku",
|
||||
"sms_attendee_action": "pošalji SMS polazniku",
|
||||
"sms_number_action": "pošalji SMS na određeni broj",
|
||||
"workflows": "Radni tokovi",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "när händelsen ställs in",
|
||||
"new_event_trigger": "när ny händelse bokas",
|
||||
"email_host_action": "skicka e-post till värd",
|
||||
"email_attendee_action": "skicka e-post till deltagare",
|
||||
"sms_attendee_action": "skicka SMS till deltagare",
|
||||
"sms_number_action": "skicka SMS till ett specifikt nummer",
|
||||
"workflows": "Arbetsflöden",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "etkinlik iptal edildiğinde",
|
||||
"new_event_trigger": "yeni bir etkinlik için rezervasyon yapıldığında",
|
||||
"email_host_action": "organizatöre e-posta gönder",
|
||||
"email_attendee_action": "katılımcıya e-posta gönder",
|
||||
"sms_attendee_action": "katılımcıya SMS gönder",
|
||||
"sms_number_action": "belirli bir numaraya SMS gönder",
|
||||
"workflows": "İş akışları",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "у момент скасування заходу",
|
||||
"new_event_trigger": "у момент бронювання нового заходу",
|
||||
"email_host_action": "надсилати електронний лист ведучому",
|
||||
"email_attendee_action": "надсилати електронний лист учаснику",
|
||||
"sms_attendee_action": "надсилати SMS учаснику",
|
||||
"sms_number_action": "надсилати SMS на певний номер",
|
||||
"workflows": "Робочі процеси",
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "khi sự kiện bị huỷ",
|
||||
"new_event_trigger": "khi sự kiện mới được đặt lịch hẹn",
|
||||
"email_host_action": "gửi email đến chủ sự kiện",
|
||||
"email_attendee_action": "gửi email đến người tham gia",
|
||||
"sms_attendee_action": "gửi SMS đến người tham gia",
|
||||
"sms_number_action": "gửi SMS đến một số cụ thể",
|
||||
"workflows": "Tiến độ công việc",
|
||||
|
||||
@@ -1247,6 +1247,7 @@
|
||||
"calendars_description": "配置您的活动类型与日历的交互方式",
|
||||
"appearance_description": "管理预约页面外观的设置",
|
||||
"conferencing_description": "为您的会议添加最喜欢的视频会议应用",
|
||||
"add_conferencing_app": "添加会议应用",
|
||||
"password_description": "管理账户密码的设置",
|
||||
"2fa_description": "管理账户密码的设置",
|
||||
"we_just_need_basic_info": "我们只需要一些基本信息来设置您的个人资料。",
|
||||
@@ -1783,5 +1784,9 @@
|
||||
"seats_and_no_show_fee_error": "当前无法启用位置和收取失约费",
|
||||
"complete_your_booking": "完成您的预约",
|
||||
"complete_your_booking_subject": "完成您的预约: {{date}} 的 {{title}}",
|
||||
"email_invite_team": "{{email}} 已被邀请"
|
||||
"email_invite_team": "{{email}} 已被邀请",
|
||||
"disable_attendees_confirmation_emails": "禁用参与者的默认确认电子邮件",
|
||||
"disable_attendees_confirmation_emails_description": "该活动类型中至少有一个工作流程处于活动状态,当该活动被预约时,将向参与者发送电子邮件。",
|
||||
"disable_host_confirmation_emails": "禁用主持人的默认确认电子邮件",
|
||||
"disable_host_confirmation_emails_description": "该活动类型中至少有一个工作流程处于活动状态,当该活动被预约时,将向主持人发送电子邮件。"
|
||||
}
|
||||
|
||||
@@ -1038,7 +1038,6 @@
|
||||
"event_cancelled_trigger": "活動取消時",
|
||||
"new_event_trigger": "預約新活動時",
|
||||
"email_host_action": "傳送電子郵件給主辦者",
|
||||
"email_attendee_action": "傳送電子郵件給與會者",
|
||||
"sms_attendee_action": "傳送簡訊給與會者",
|
||||
"sms_number_action": "傳送簡訊至特定號碼",
|
||||
"workflows": "工作流程",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getLocaleFromHeaders } from "@calcom/lib/i18n";
|
||||
import { createProxySSGHelpers } from "@calcom/trpc/react/ssg";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||
@@ -13,16 +14,14 @@ import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||
* Make sure to `return { props: { trpcState: ssr.dehydrate() } }` at the end.
|
||||
*/
|
||||
export async function ssrInit(context: GetServerSidePropsContext) {
|
||||
const { req, res } = context;
|
||||
|
||||
const sessionGetter = () => getServerSession({ req, res });
|
||||
|
||||
const ctx = await createContext(context, sessionGetter);
|
||||
const ctx = await createContext(context);
|
||||
const locale = getLocaleFromHeaders(context.req);
|
||||
const i18n = await serverSideTranslations(getLocaleFromHeaders(context.req), ["common", "vital"]);
|
||||
|
||||
const ssr = createProxySSGHelpers({
|
||||
router: appRouter,
|
||||
transformer: superjson,
|
||||
ctx,
|
||||
ctx: { ...ctx, locale, i18n },
|
||||
});
|
||||
|
||||
// always preload "viewer.public.i18n"
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand: #111827;
|
||||
--cal-brand-emphasis: #101010;
|
||||
--cal-brand: white;
|
||||
--cal-brand-emphasis: #e1e1e1;
|
||||
--cal-brand-text: black;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
it("Adds new users", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const { schedulingType, id, teamId, timeZone, scheduleId, users, ...evType } = mockFindFirstEventType({
|
||||
// eslint-disable-next-line
|
||||
const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
@@ -130,6 +131,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
it("Updates old users", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
const { schedulingType, id, teamId, timeZone, locations, parentId, userId, scheduleId, ...evType } =
|
||||
mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -214,7 +216,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
it("Deletes existent event types for new users added", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const { schedulingType, id, teamId, timeZone, scheduleId, users, ...evType } = mockFindFirstEventType({
|
||||
// eslint-disable-next-line
|
||||
const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
@@ -250,6 +253,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
it("Deletes existent event types for old users updated", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
const { schedulingType, id, teamId, timeZone, users, locations, parentId, userId, ...evType } =
|
||||
mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise
|
||||
if (calendarType?.endsWith("_other_calendar")) {
|
||||
calendarType = calendarType.split("_other_calendar")[0];
|
||||
}
|
||||
const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore];
|
||||
const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore]();
|
||||
if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) {
|
||||
log.warn(`calendar of type ${calendarType} is not implemented`);
|
||||
return null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { appKeysSchema as event_type_app_card_zod_ts } from "./templates/event-t
|
||||
import { appKeysSchema as vital_zod_ts } from "./vital/zod";
|
||||
import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
|
||||
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
|
||||
import { appKeysSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
|
||||
import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
||||
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
@@ -52,6 +53,7 @@ export const appKeysSchemas = {
|
||||
vital: vital_zod_ts,
|
||||
wordpress: wordpress_zod_ts,
|
||||
zapier: zapier_zod_ts,
|
||||
"zoho-bigin": zoho_bigin_zod_ts,
|
||||
zohocrm: zohocrm_zod_ts,
|
||||
zoomvideo: zoomvideo_zod_ts,
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ import whereby_config_json from "./whereby/config.json";
|
||||
import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata";
|
||||
import wordpress_config_json from "./wordpress/config.json";
|
||||
import { metadata as zapier__metadata_ts } from "./zapier/_metadata";
|
||||
import zoho_bigin_config_json from "./zoho-bigin/config.json";
|
||||
import zohocrm_config_json from "./zohocrm/config.json";
|
||||
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
|
||||
|
||||
@@ -122,6 +123,7 @@ export const appStoreMetadata = {
|
||||
wipemycalother: wipemycalother__metadata_ts,
|
||||
wordpress: wordpress_config_json,
|
||||
zapier: zapier__metadata_ts,
|
||||
"zoho-bigin": zoho_bigin_config_json,
|
||||
zohocrm: zohocrm_config_json,
|
||||
zoomvideo: zoomvideo__metadata_ts,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import { appDataSchema as event_type_app_card_zod_ts } from "./templates/event-t
|
||||
import { appDataSchema as vital_zod_ts } from "./vital/zod";
|
||||
import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
|
||||
import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
|
||||
import { appDataSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
|
||||
import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
||||
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
@@ -52,6 +53,7 @@ export const appDataSchemas = {
|
||||
vital: vital_zod_ts,
|
||||
wordpress: wordpress_zod_ts,
|
||||
zapier: zapier_zod_ts,
|
||||
"zoho-bigin": zoho_bigin_zod_ts,
|
||||
zohocrm: zohocrm_zod_ts,
|
||||
zoomvideo: zoomvideo_zod_ts,
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ export const apiHandlers = {
|
||||
wipemycalother: import("./wipemycalother/api"),
|
||||
wordpress: import("./wordpress/api"),
|
||||
zapier: import("./zapier/api"),
|
||||
"zoho-bigin": import("./zoho-bigin/api"),
|
||||
zohocrm: import("./zohocrm/api"),
|
||||
zoomvideo: import("./zoomvideo/api"),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils";
|
||||
import { getRecordingsResponseSchema, getAccessLinkResponseSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
@@ -108,8 +109,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
// added a 1 hour buffer for room expiration
|
||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
|
||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||
|
||||
if (scalePlan === "true") {
|
||||
const hasTeamPlan = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: event.organizer.id,
|
||||
team: {
|
||||
slug: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (scalePlan === "true" && !!hasTeamPlan === true) {
|
||||
return {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
|
||||
+29
-29
@@ -1,33 +1,33 @@
|
||||
const appStore = {
|
||||
// example: import("./example"),
|
||||
applecalendar: import("./applecalendar"),
|
||||
caldavcalendar: import("./caldavcalendar"),
|
||||
closecom: import("./closecom"),
|
||||
dailyvideo: import("./dailyvideo"),
|
||||
googlecalendar: import("./googlecalendar"),
|
||||
googlevideo: import("./googlevideo"),
|
||||
hubspot: import("./hubspot"),
|
||||
huddle01video: import("./huddle01video"),
|
||||
jitsivideo: import("./jitsivideo"),
|
||||
larkcalendar: import("./larkcalendar"),
|
||||
office365calendar: import("./office365calendar"),
|
||||
office365video: import("./office365video"),
|
||||
plausible: import("./plausible"),
|
||||
salesforce: import("./salesforce"),
|
||||
zohocrm: import("./zohocrm"),
|
||||
sendgrid: import("./sendgrid"),
|
||||
stripepayment: import("./stripepayment"),
|
||||
tandemvideo: import("./tandemvideo"),
|
||||
vital: import("./vital"),
|
||||
zoomvideo: import("./zoomvideo"),
|
||||
wipemycalother: import("./wipemycalother"),
|
||||
giphy: import("./giphy"),
|
||||
zapier: import("./zapier"),
|
||||
exchange2013calendar: import("./exchange2013calendar"),
|
||||
exchange2016calendar: import("./exchange2016calendar"),
|
||||
exchangecalendar: import("./exchangecalendar"),
|
||||
facetime: import("./facetime"),
|
||||
sylapsvideo: import("./sylapsvideo"),
|
||||
// example: () => import("./example"),
|
||||
applecalendar: () => import("./applecalendar"),
|
||||
caldavcalendar: () => import("./caldavcalendar"),
|
||||
closecom: () => import("./closecom"),
|
||||
dailyvideo: () => import("./dailyvideo"),
|
||||
googlecalendar: () => import("./googlecalendar"),
|
||||
googlevideo: () => import("./googlevideo"),
|
||||
hubspot: () => import("./hubspot"),
|
||||
huddle01video: () => import("./huddle01video"),
|
||||
jitsivideo: () => import("./jitsivideo"),
|
||||
larkcalendar: () => import("./larkcalendar"),
|
||||
office365calendar: () => import("./office365calendar"),
|
||||
office365video: () => import("./office365video"),
|
||||
plausible: () => import("./plausible"),
|
||||
salesforce: () => import("./salesforce"),
|
||||
zohocrm: () => import("./zohocrm"),
|
||||
sendgrid: () => import("./sendgrid"),
|
||||
stripepayment: () => import("./stripepayment"),
|
||||
tandemvideo: () => import("./tandemvideo"),
|
||||
vital: () => import("./vital"),
|
||||
zoomvideo: () => import("./zoomvideo"),
|
||||
wipemycalother: () => import("./wipemycalother"),
|
||||
giphy: () => import("./giphy"),
|
||||
zapier: () => import("./zapier"),
|
||||
exchange2013calendar: () => import("./exchange2013calendar"),
|
||||
exchange2016calendar: () => import("./exchange2016calendar"),
|
||||
exchangecalendar: () => import("./exchangecalendar"),
|
||||
facetime: () => import("./facetime"),
|
||||
sylapsvideo: () => import("./sylapsvideo"),
|
||||
};
|
||||
|
||||
export default appStore;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { router, publicProcedure } from "@calcom/trpc/server/trpc";
|
||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { ZBalanceInputSchema, ZBalanceOutputSchema } from "./balance.schema";
|
||||
import { ZContractInputSchema, ZContractOutputSchema } from "./contract.schema";
|
||||
|
||||
@@ -179,7 +179,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`);
|
||||
// Can't keep waiting forever. So, added a timeout of 5000ms
|
||||
await page.waitForResponse((response) => response.url().includes("viewer.appRoutingForms.report"), {
|
||||
await page.waitForResponse((response) => response.url().includes("appRoutingForms/report"), {
|
||||
timeout: 5000,
|
||||
});
|
||||
const headerEls = page.locator("[data-testid='reporting-header'] th");
|
||||
|
||||
@@ -1,665 +1 @@
|
||||
import type { App_RoutingForms_Form, User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import { authedProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import ResponseEmail from "./emails/templates/response-email";
|
||||
import { jsonLogicToPrisma } from "./jsonLogicToPrisma";
|
||||
import { createFallbackRoute } from "./lib/createFallbackRoute";
|
||||
import getConnectedForms from "./lib/getConnectedForms";
|
||||
import { getSerializableForm } from "./lib/getSerializableForm";
|
||||
import { isFallbackRoute } from "./lib/isFallbackRoute";
|
||||
import { isFormEditAllowed } from "./lib/isFormEditAllowed";
|
||||
import isRouter from "./lib/isRouter";
|
||||
import isRouterLinkedField from "./lib/isRouterLinkedField";
|
||||
import type { Response, SerializableForm } from "./types/types";
|
||||
import { zodFields, zodRouterRoute, zodRoutes } from "./zod";
|
||||
|
||||
async function onFormSubmission(
|
||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||
response: Response
|
||||
) {
|
||||
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
// Use the label lowercased as the key to identify a field.
|
||||
const key =
|
||||
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||
fieldResponsesByName[key] = fieldResponse.value;
|
||||
}
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: form.user.id,
|
||||
// It isn't an eventType webhook
|
||||
eventTypeId: -1,
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
};
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) => {
|
||||
sendGenericWebhookPayload(
|
||||
webhook.secret,
|
||||
"FORM_SUBMITTED",
|
||||
new Date().toISOString(),
|
||||
webhook,
|
||||
fieldResponsesByName
|
||||
).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
if (form.settings?.emailOwnerOnSubmission) {
|
||||
logger.debug(
|
||||
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
|
||||
);
|
||||
await sendResponseEmail(form, response, form.user.email);
|
||||
}
|
||||
}
|
||||
|
||||
const sendResponseEmail = async (
|
||||
form: Pick<App_RoutingForms_Form, "id" | "name">,
|
||||
response: Response,
|
||||
ownerEmail: string
|
||||
) => {
|
||||
try {
|
||||
const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response });
|
||||
await email.sendEmail();
|
||||
} catch (e) {
|
||||
logger.error("Error sending response email", e);
|
||||
}
|
||||
};
|
||||
|
||||
const appRoutingForms = router({
|
||||
public: router({
|
||||
response: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
formId: z.string(),
|
||||
formFillerId: z.string(),
|
||||
response: z.record(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.array(z.string())]),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { prisma } = ctx;
|
||||
try {
|
||||
const { response, formId } = input;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
if (!serializableForm.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
const serializableFormWithFields = {
|
||||
...serializableForm,
|
||||
fields: serializableForm.fields,
|
||||
};
|
||||
|
||||
const missingFields = serializableFormWithFields.fields
|
||||
.filter((field) => !(field.required ? response[field.id]?.value : true))
|
||||
.map((f) => f.label);
|
||||
|
||||
if (missingFields.length) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Missing required fields ${missingFields.join(", ")}`,
|
||||
});
|
||||
}
|
||||
const invalidFields = serializableFormWithFields.fields
|
||||
.filter((field) => {
|
||||
const fieldValue = response[field.id]?.value;
|
||||
// The field isn't required at this point. Validate only if it's set
|
||||
if (!fieldValue) {
|
||||
return false;
|
||||
}
|
||||
let schema;
|
||||
if (field.type === "email") {
|
||||
schema = z.string().email();
|
||||
} else if (field.type === "phone") {
|
||||
schema = z.any();
|
||||
} else {
|
||||
schema = z.any();
|
||||
}
|
||||
return !schema.safeParse(fieldValue).success;
|
||||
})
|
||||
.map((f) => ({ label: f.label, type: f.type }));
|
||||
|
||||
if (invalidFields.length) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: input,
|
||||
});
|
||||
|
||||
await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response);
|
||||
return dbFormResponse;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
forms: authedProcedure.query(async ({ ctx }) => {
|
||||
const { prisma, user } = ctx;
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const serializableForms = [];
|
||||
for (let i = 0; i < forms.length; i++) {
|
||||
serializableForms.push(await getSerializableForm(forms[i]));
|
||||
}
|
||||
return serializableForms;
|
||||
}),
|
||||
formQuery: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { prisma, user } = ctx;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: input.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await getSerializableForm(form);
|
||||
}),
|
||||
formMutation: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
fields: zodFields,
|
||||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
shouldConnect: z.boolean().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
let { routes: inputRoutes } = input;
|
||||
let { fields: inputFields } = input;
|
||||
inputFields = inputFields || [];
|
||||
inputRoutes = inputRoutes || [];
|
||||
type InputFields = typeof inputFields;
|
||||
type InputRoutes = typeof inputRoutes;
|
||||
let routes: InputRoutes;
|
||||
let fields: InputFields;
|
||||
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
|
||||
|
||||
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
user: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
disabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
routes: true,
|
||||
fields: true,
|
||||
settings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
|
||||
|
||||
if (duplicateFrom) {
|
||||
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
|
||||
} else {
|
||||
[fields, routes] = [inputFields, inputRoutes];
|
||||
if (dbSerializedForm) {
|
||||
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbSerializedForm) {
|
||||
// If it's an existing form being mutated, update fields in the connected forms(if any).
|
||||
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
|
||||
}
|
||||
|
||||
fields = await getUpdatedRouterLinkedFields(fields, routes);
|
||||
|
||||
if (addFallback) {
|
||||
// Add a fallback route if there is none
|
||||
if (!routes.find(isFallbackRoute)) {
|
||||
routes.push(createFallbackRoute());
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.app_RoutingForms_Form.upsert({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
create: {
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
id: id,
|
||||
},
|
||||
update: {
|
||||
disabled: disabled,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
settings: settings === null ? Prisma.JsonNull : settings,
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* If Form has Router Linked fields, enrich them with the latest info from the Router
|
||||
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
|
||||
*/
|
||||
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
|
||||
const routerLinkedFields: Record<string, boolean> = {};
|
||||
for (const [, field] of Object.entries(fields)) {
|
||||
if (!isRouterLinkedField(field)) {
|
||||
continue;
|
||||
}
|
||||
routerLinkedFields[field.routerId] = true;
|
||||
|
||||
if (!routes.some((route) => route.id === field.routerId)) {
|
||||
// If the field is from a router that is not available anymore, mark it as deleted
|
||||
field.deleted = true;
|
||||
continue;
|
||||
}
|
||||
// Get back deleted field as now the Router is there for it.
|
||||
if (field.deleted) field.deleted = false;
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: field.routerId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
|
||||
// There is a field from some router available, make sure that the field has up-to-date info from the router
|
||||
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
|
||||
// Update local field(cache) with router field on every mutation
|
||||
Object.assign(field, routerField);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(routes)) {
|
||||
if (!isRouter(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
|
||||
if (routerLinkedFields[route.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
const fieldsFromRouter = parsedRouterFields
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
...f,
|
||||
routerId: route.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (fieldsFromRouter) {
|
||||
fields = fields.concat(fieldsFromRouter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findFieldWithId(id: string, fields: InputFields) {
|
||||
return fields.find((field) => field.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields in connected forms as per the inputFields
|
||||
*/
|
||||
async function updateFieldsInConnectedForms(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
inputFields: InputFields
|
||||
) {
|
||||
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
});
|
||||
if (!connectedFormDb) {
|
||||
continue;
|
||||
}
|
||||
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
|
||||
|
||||
const fieldsThatAreNotInConnectedForm = (
|
||||
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
|
||||
).map((f) => ({
|
||||
...f,
|
||||
routerId: serializedForm.id,
|
||||
}));
|
||||
|
||||
const updatedConnectedFormFields = connectedFormFields
|
||||
// Update fields that are already in connected form
|
||||
?.map((field) => {
|
||||
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
|
||||
return {
|
||||
...field,
|
||||
...findFieldWithId(field.id, inputFields || []),
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
// Add fields that are not there
|
||||
.concat(fieldsThatAreNotInConnectedForm);
|
||||
|
||||
await prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
data: {
|
||||
fields: updatedConnectedFormFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
|
||||
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldsParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
|
||||
let fields, routes;
|
||||
if (shouldConnect) {
|
||||
routes = [
|
||||
// This connected route would automatically link the fields
|
||||
zodRouterRoute.parse({
|
||||
id: sourceForm.id,
|
||||
isRouter: true,
|
||||
}),
|
||||
];
|
||||
fields =
|
||||
fieldsParsed.data
|
||||
// Deleted fields in the form shouldn't be added to the new form
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
routerId: sourceForm.id,
|
||||
label: "",
|
||||
type: "",
|
||||
};
|
||||
}) || [];
|
||||
} else {
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data || [];
|
||||
// FIXME: Deleted fields shouldn't come in duplicate
|
||||
fields = fieldsParsed.data || [];
|
||||
}
|
||||
return { routes, fields };
|
||||
}
|
||||
|
||||
function markMissingFieldsDeleted(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
fields: InputFields
|
||||
) {
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
|
||||
|
||||
// Add back deleted fields in the end and mark them deleted.
|
||||
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
return fields;
|
||||
}
|
||||
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
|
||||
const routesOfRouter = zodRoutes.parse(router.routes);
|
||||
if (routesOfRouter) {
|
||||
if (routesOfRouter.find(isRouter)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
deleteForm: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const areFormsUsingIt = (
|
||||
await getConnectedForms(prisma, {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
})
|
||||
).length;
|
||||
if (areFormsUsingIt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This form is being used by other forms. Please remove it's usage from there first.",
|
||||
});
|
||||
}
|
||||
return await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
report: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
formId: z.string(),
|
||||
jsonLogicQuery: z.object({
|
||||
logic: z.union([z.record(z.any()), z.null()]),
|
||||
}),
|
||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx: { prisma }, input }) => {
|
||||
// Can be any prisma `where` clause
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prismaWhere: Record<string, any> = input.jsonLogicQuery
|
||||
? jsonLogicToPrisma(input.jsonLogicQuery)
|
||||
: {};
|
||||
const skip = input.cursor ?? 0;
|
||||
const take = 50;
|
||||
logger.debug(
|
||||
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
|
||||
input.jsonLogicQuery
|
||||
)}`
|
||||
);
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: input.formId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Form not found",
|
||||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = await getSerializableForm(form, true);
|
||||
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: input.formId,
|
||||
...prismaWhere,
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
const fields = serializedForm?.fields || [];
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: string[][] = [];
|
||||
rows.forEach((r) => {
|
||||
const rowResponses: string[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r.response) {
|
||||
return;
|
||||
}
|
||||
const response = r.response as Response;
|
||||
const value = response[field.id]?.value || "";
|
||||
let stringValue = "";
|
||||
if (value instanceof Array) {
|
||||
stringValue = value.join(", ");
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
rowResponses.push(stringValue);
|
||||
});
|
||||
});
|
||||
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
|
||||
return {
|
||||
headers,
|
||||
responses,
|
||||
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export default appRoutingForms;
|
||||
export { default } from "./trpc/_router";
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
import type { App_RoutingForms_Form, User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import ResponseEmail from "../emails/templates/response-email";
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
import { createFallbackRoute } from "../lib/createFallbackRoute";
|
||||
import getConnectedForms from "../lib/getConnectedForms";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { isFormEditAllowed } from "../lib/isFormEditAllowed";
|
||||
import isRouter from "../lib/isRouter";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import type { Response, SerializableForm } from "../types/types";
|
||||
import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
|
||||
|
||||
async function onFormSubmission(
|
||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||
response: Response
|
||||
) {
|
||||
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
|
||||
|
||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||
// Use the label lowercased as the key to identify a field.
|
||||
const key =
|
||||
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||
fieldResponsesByName[key] = fieldResponse.value;
|
||||
}
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: form.user.id,
|
||||
// It isn't an eventType webhook
|
||||
eventTypeId: -1,
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
};
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) => {
|
||||
sendGenericWebhookPayload(
|
||||
webhook.secret,
|
||||
"FORM_SUBMITTED",
|
||||
new Date().toISOString(),
|
||||
webhook,
|
||||
fieldResponsesByName
|
||||
).catch((e) => {
|
||||
console.error(`Error executing routing form webhook`, webhook, e);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
if (form.settings?.emailOwnerOnSubmission) {
|
||||
logger.debug(
|
||||
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
|
||||
);
|
||||
await sendResponseEmail(form, response, form.user.email);
|
||||
}
|
||||
}
|
||||
|
||||
const sendResponseEmail = async (
|
||||
form: Pick<App_RoutingForms_Form, "id" | "name">,
|
||||
response: Response,
|
||||
ownerEmail: string
|
||||
) => {
|
||||
try {
|
||||
const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response });
|
||||
await email.sendEmail();
|
||||
} catch (e) {
|
||||
logger.error("Error sending response email", e);
|
||||
}
|
||||
};
|
||||
|
||||
const appRoutingForms = router({
|
||||
public: router({
|
||||
response: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
formId: z.string(),
|
||||
formFillerId: z.string(),
|
||||
response: z.record(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.array(z.string())]),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { prisma } = ctx;
|
||||
try {
|
||||
const { response, formId } = input;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
if (!serializableForm.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
const serializableFormWithFields = {
|
||||
...serializableForm,
|
||||
fields: serializableForm.fields,
|
||||
};
|
||||
|
||||
const missingFields = serializableFormWithFields.fields
|
||||
.filter((field) => !(field.required ? response[field.id]?.value : true))
|
||||
.map((f) => f.label);
|
||||
|
||||
if (missingFields.length) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Missing required fields ${missingFields.join(", ")}`,
|
||||
});
|
||||
}
|
||||
const invalidFields = serializableFormWithFields.fields
|
||||
.filter((field) => {
|
||||
const fieldValue = response[field.id]?.value;
|
||||
// The field isn't required at this point. Validate only if it's set
|
||||
if (!fieldValue) {
|
||||
return false;
|
||||
}
|
||||
let schema;
|
||||
if (field.type === "email") {
|
||||
schema = z.string().email();
|
||||
} else if (field.type === "phone") {
|
||||
schema = z.any();
|
||||
} else {
|
||||
schema = z.any();
|
||||
}
|
||||
return !schema.safeParse(fieldValue).success;
|
||||
})
|
||||
.map((f) => ({ label: f.label, type: f.type }));
|
||||
|
||||
if (invalidFields.length) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: input,
|
||||
});
|
||||
|
||||
await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response);
|
||||
return dbFormResponse;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === "P2002") {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
forms: authedProcedure.query(async ({ ctx }) => {
|
||||
const { prisma, user } = ctx;
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const serializableForms = [];
|
||||
for (let i = 0; i < forms.length; i++) {
|
||||
serializableForms.push(await getSerializableForm(forms[i]));
|
||||
}
|
||||
return serializableForms;
|
||||
}),
|
||||
formQuery: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { prisma, user } = ctx;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: input.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await getSerializableForm(form);
|
||||
}),
|
||||
formMutation: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
fields: zodFields,
|
||||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
shouldConnect: z.boolean().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
let { routes: inputRoutes } = input;
|
||||
let { fields: inputFields } = input;
|
||||
inputFields = inputFields || [];
|
||||
inputRoutes = inputRoutes || [];
|
||||
type InputFields = typeof inputFields;
|
||||
type InputRoutes = typeof inputRoutes;
|
||||
let routes: InputRoutes;
|
||||
let fields: InputFields;
|
||||
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
|
||||
|
||||
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
user: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
disabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
routes: true,
|
||||
fields: true,
|
||||
settings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
|
||||
|
||||
if (duplicateFrom) {
|
||||
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
|
||||
} else {
|
||||
[fields, routes] = [inputFields, inputRoutes];
|
||||
if (dbSerializedForm) {
|
||||
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbSerializedForm) {
|
||||
// If it's an existing form being mutated, update fields in the connected forms(if any).
|
||||
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
|
||||
}
|
||||
|
||||
fields = await getUpdatedRouterLinkedFields(fields, routes);
|
||||
|
||||
if (addFallback) {
|
||||
// Add a fallback route if there is none
|
||||
if (!routes.find(isFallbackRoute)) {
|
||||
routes.push(createFallbackRoute());
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.app_RoutingForms_Form.upsert({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
create: {
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
id: id,
|
||||
},
|
||||
update: {
|
||||
disabled: disabled,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
settings: settings === null ? Prisma.JsonNull : settings,
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* If Form has Router Linked fields, enrich them with the latest info from the Router
|
||||
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
|
||||
*/
|
||||
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
|
||||
const routerLinkedFields: Record<string, boolean> = {};
|
||||
for (const [, field] of Object.entries(fields)) {
|
||||
if (!isRouterLinkedField(field)) {
|
||||
continue;
|
||||
}
|
||||
routerLinkedFields[field.routerId] = true;
|
||||
|
||||
if (!routes.some((route) => route.id === field.routerId)) {
|
||||
// If the field is from a router that is not available anymore, mark it as deleted
|
||||
field.deleted = true;
|
||||
continue;
|
||||
}
|
||||
// Get back deleted field as now the Router is there for it.
|
||||
if (field.deleted) field.deleted = false;
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: field.routerId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
|
||||
// There is a field from some router available, make sure that the field has up-to-date info from the router
|
||||
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
|
||||
// Update local field(cache) with router field on every mutation
|
||||
Object.assign(field, routerField);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(routes)) {
|
||||
if (!isRouter(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
|
||||
if (routerLinkedFields[route.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
const fieldsFromRouter = parsedRouterFields
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
...f,
|
||||
routerId: route.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (fieldsFromRouter) {
|
||||
fields = fields.concat(fieldsFromRouter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findFieldWithId(id: string, fields: InputFields) {
|
||||
return fields.find((field) => field.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields in connected forms as per the inputFields
|
||||
*/
|
||||
async function updateFieldsInConnectedForms(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
inputFields: InputFields
|
||||
) {
|
||||
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
});
|
||||
if (!connectedFormDb) {
|
||||
continue;
|
||||
}
|
||||
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
|
||||
|
||||
const fieldsThatAreNotInConnectedForm = (
|
||||
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
|
||||
).map((f) => ({
|
||||
...f,
|
||||
routerId: serializedForm.id,
|
||||
}));
|
||||
|
||||
const updatedConnectedFormFields = connectedFormFields
|
||||
// Update fields that are already in connected form
|
||||
?.map((field) => {
|
||||
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
|
||||
return {
|
||||
...field,
|
||||
...findFieldWithId(field.id, inputFields || []),
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
// Add fields that are not there
|
||||
.concat(fieldsThatAreNotInConnectedForm);
|
||||
|
||||
await prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
data: {
|
||||
fields: updatedConnectedFormFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
|
||||
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldsParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
|
||||
let fields, routes;
|
||||
if (shouldConnect) {
|
||||
routes = [
|
||||
// This connected route would automatically link the fields
|
||||
zodRouterRoute.parse({
|
||||
id: sourceForm.id,
|
||||
isRouter: true,
|
||||
}),
|
||||
];
|
||||
fields =
|
||||
fieldsParsed.data
|
||||
// Deleted fields in the form shouldn't be added to the new form
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
routerId: sourceForm.id,
|
||||
label: "",
|
||||
type: "",
|
||||
};
|
||||
}) || [];
|
||||
} else {
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data || [];
|
||||
// FIXME: Deleted fields shouldn't come in duplicate
|
||||
fields = fieldsParsed.data || [];
|
||||
}
|
||||
return { routes, fields };
|
||||
}
|
||||
|
||||
function markMissingFieldsDeleted(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
fields: InputFields
|
||||
) {
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
|
||||
|
||||
// Add back deleted fields in the end and mark them deleted.
|
||||
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
return fields;
|
||||
}
|
||||
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
|
||||
const routesOfRouter = zodRoutes.parse(router.routes);
|
||||
if (routesOfRouter) {
|
||||
if (routesOfRouter.find(isRouter)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
deleteForm: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const areFormsUsingIt = (
|
||||
await getConnectedForms(prisma, {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
})
|
||||
).length;
|
||||
if (areFormsUsingIt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This form is being used by other forms. Please remove it's usage from there first.",
|
||||
});
|
||||
}
|
||||
return await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
report: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
formId: z.string(),
|
||||
jsonLogicQuery: z.object({
|
||||
logic: z.union([z.record(z.any()), z.null()]),
|
||||
}),
|
||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx: { prisma }, input }) => {
|
||||
// Can be any prisma `where` clause
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prismaWhere: Record<string, any> = input.jsonLogicQuery
|
||||
? jsonLogicToPrisma(input.jsonLogicQuery)
|
||||
: {};
|
||||
const skip = input.cursor ?? 0;
|
||||
const take = 50;
|
||||
logger.debug(
|
||||
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
|
||||
input.jsonLogicQuery
|
||||
)}`
|
||||
);
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: input.formId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Form not found",
|
||||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = await getSerializableForm(form, true);
|
||||
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: input.formId,
|
||||
...prismaWhere,
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
const fields = serializedForm?.fields || [];
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: string[][] = [];
|
||||
rows.forEach((r) => {
|
||||
const rowResponses: string[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r.response) {
|
||||
return;
|
||||
}
|
||||
const response = r.response as Response;
|
||||
const value = response[field.id]?.value || "";
|
||||
let stringValue = "";
|
||||
if (value instanceof Array) {
|
||||
stringValue = value.join(", ");
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
rowResponses.push(stringValue);
|
||||
});
|
||||
});
|
||||
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
|
||||
return {
|
||||
headers,
|
||||
responses,
|
||||
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export default appRoutingForms;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import getConnectedForms from "../lib/getConnectedForms";
|
||||
import { isFormEditAllowed } from "../lib/isFormEditAllowed";
|
||||
import type { TDeleteFormInputSchema } from "./deleteForm.schema";
|
||||
|
||||
interface DeleteFormHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDeleteFormInputSchema;
|
||||
}
|
||||
export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const areFormsUsingIt = (
|
||||
await getConnectedForms(prisma, {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
})
|
||||
).length;
|
||||
if (areFormsUsingIt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This form is being used by other forms. Please remove it's usage from there first.",
|
||||
});
|
||||
}
|
||||
return await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZDeleteFormInputSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type TDeleteFormInputSchema = z.infer<typeof ZDeleteFormInputSchema>;
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { createFallbackRoute } from "../lib/createFallbackRoute";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { isFormEditAllowed } from "../lib/isFormEditAllowed";
|
||||
import isRouter from "../lib/isRouter";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import type { SerializableForm } from "../types/types";
|
||||
import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
|
||||
import type { TFormMutationInputSchema } from "./formMutation.schema";
|
||||
|
||||
interface FormMutationHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TFormMutationInputSchema;
|
||||
}
|
||||
export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => {
|
||||
const { user, prisma } = ctx;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
let { routes: inputRoutes } = input;
|
||||
let { fields: inputFields } = input;
|
||||
inputFields = inputFields || [];
|
||||
inputRoutes = inputRoutes || [];
|
||||
type InputFields = typeof inputFields;
|
||||
type InputRoutes = typeof inputRoutes;
|
||||
let routes: InputRoutes;
|
||||
let fields: InputFields;
|
||||
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
|
||||
|
||||
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
user: true,
|
||||
name: true,
|
||||
description: true,
|
||||
userId: true,
|
||||
disabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
routes: true,
|
||||
fields: true,
|
||||
settings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
|
||||
|
||||
if (duplicateFrom) {
|
||||
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
|
||||
} else {
|
||||
[fields, routes] = [inputFields, inputRoutes];
|
||||
if (dbSerializedForm) {
|
||||
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbSerializedForm) {
|
||||
// If it's an existing form being mutated, update fields in the connected forms(if any).
|
||||
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
|
||||
}
|
||||
|
||||
fields = await getUpdatedRouterLinkedFields(fields, routes);
|
||||
|
||||
if (addFallback) {
|
||||
// Add a fallback route if there is none
|
||||
if (!routes.find(isFallbackRoute)) {
|
||||
routes.push(createFallbackRoute());
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.app_RoutingForms_Form.upsert({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
create: {
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
id: id,
|
||||
},
|
||||
update: {
|
||||
disabled: disabled,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
settings: settings === null ? Prisma.JsonNull : settings,
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* If Form has Router Linked fields, enrich them with the latest info from the Router
|
||||
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
|
||||
*/
|
||||
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
|
||||
const routerLinkedFields: Record<string, boolean> = {};
|
||||
for (const [, field] of Object.entries(fields)) {
|
||||
if (!isRouterLinkedField(field)) {
|
||||
continue;
|
||||
}
|
||||
routerLinkedFields[field.routerId] = true;
|
||||
|
||||
if (!routes.some((route) => route.id === field.routerId)) {
|
||||
// If the field is from a router that is not available anymore, mark it as deleted
|
||||
field.deleted = true;
|
||||
continue;
|
||||
}
|
||||
// Get back deleted field as now the Router is there for it.
|
||||
if (field.deleted) field.deleted = false;
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: field.routerId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
|
||||
// There is a field from some router available, make sure that the field has up-to-date info from the router
|
||||
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
|
||||
// Update local field(cache) with router field on every mutation
|
||||
Object.assign(field, routerField);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(routes)) {
|
||||
if (!isRouter(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
|
||||
if (routerLinkedFields[route.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
const fieldsFromRouter = parsedRouterFields
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
...f,
|
||||
routerId: route.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (fieldsFromRouter) {
|
||||
fields = fields.concat(fieldsFromRouter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findFieldWithId(id: string, fields: InputFields) {
|
||||
return fields.find((field) => field.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields in connected forms as per the inputFields
|
||||
*/
|
||||
async function updateFieldsInConnectedForms(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
inputFields: InputFields
|
||||
) {
|
||||
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
});
|
||||
if (!connectedFormDb) {
|
||||
continue;
|
||||
}
|
||||
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
|
||||
|
||||
const fieldsThatAreNotInConnectedForm = (
|
||||
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
|
||||
).map((f) => ({
|
||||
...f,
|
||||
routerId: serializedForm.id,
|
||||
}));
|
||||
|
||||
const updatedConnectedFormFields = connectedFormFields
|
||||
// Update fields that are already in connected form
|
||||
?.map((field) => {
|
||||
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
|
||||
return {
|
||||
...field,
|
||||
...findFieldWithId(field.id, inputFields || []),
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
// Add fields that are not there
|
||||
.concat(fieldsThatAreNotInConnectedForm);
|
||||
|
||||
await prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
data: {
|
||||
fields: updatedConnectedFormFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
|
||||
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldsParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
|
||||
let fields, routes;
|
||||
if (shouldConnect) {
|
||||
routes = [
|
||||
// This connected route would automatically link the fields
|
||||
zodRouterRoute.parse({
|
||||
id: sourceForm.id,
|
||||
isRouter: true,
|
||||
}),
|
||||
];
|
||||
fields =
|
||||
fieldsParsed.data
|
||||
// Deleted fields in the form shouldn't be added to the new form
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
routerId: sourceForm.id,
|
||||
label: "",
|
||||
type: "",
|
||||
};
|
||||
}) || [];
|
||||
} else {
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data || [];
|
||||
// FIXME: Deleted fields shouldn't come in duplicate
|
||||
fields = fieldsParsed.data || [];
|
||||
}
|
||||
return { routes, fields };
|
||||
}
|
||||
|
||||
function markMissingFieldsDeleted(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
fields: InputFields
|
||||
) {
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
|
||||
|
||||
// Add back deleted fields in the end and mark them deleted.
|
||||
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
return fields;
|
||||
}
|
||||
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
|
||||
const routesOfRouter = zodRoutes.parse(router.routes);
|
||||
if (routesOfRouter) {
|
||||
if (routesOfRouter.find(isRouter)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import z from "zod";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
|
||||
export const ZFormMutationInputSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
fields: zodFields,
|
||||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
shouldConnect: z.boolean().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
});
|
||||
|
||||
export type TFormMutationInputSchema = z.infer<typeof ZFormMutationInputSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { TFormQueryInputSchema } from "./formQuery.schema";
|
||||
|
||||
interface FormsHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TFormQueryInputSchema;
|
||||
}
|
||||
export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
||||
const { prisma, user } = ctx;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: input.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await getSerializableForm(form);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user