Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a8a0d2d3 | ||
|
|
3093216534 | ||
|
|
99a1c36ffc | ||
|
|
f4e48f5fc1 | ||
|
|
c352dc647e | ||
|
|
34bb069b4a | ||
|
|
39ea9c112d | ||
|
|
e32d4648af | ||
|
|
20b7633ab5 | ||
|
|
6f017a7972 | ||
|
|
cbd0e2d287 | ||
|
|
ff739bf9be | ||
|
|
ff3541910b | ||
|
|
d043de7724 | ||
|
|
be1517facd | ||
|
|
19eced00f5 | ||
|
|
e2414b174a | ||
|
|
5e3c0cdea1 | ||
|
|
6b6d3d90e4 | ||
|
|
9b348adb6a | ||
|
|
feda420f0c | ||
|
|
4b818de0c8 | ||
|
|
614741d207 | ||
|
|
2550485c49 | ||
|
|
efc3e864bb | ||
|
|
1bf56fbe93 | ||
|
|
629629cb9e | ||
|
|
8c0751b186 | ||
|
|
0b46f61a23 | ||
|
|
0c92fbe11d | ||
|
|
59fa713549 | ||
|
|
63416d4f33 | ||
|
|
9e927af813 | ||
|
|
4b8bdeba74 | ||
|
|
4e4d67c8c0 | ||
|
|
ffda234b3c | ||
|
|
0bc44c36db | ||
|
|
f18bee5c3d | ||
|
|
73e16215bd | ||
|
|
75d7d2f172 | ||
|
|
269dca5b6d | ||
|
|
0fd6bed813 | ||
|
|
33bef6acd0 | ||
|
|
a6c4b31845 | ||
|
|
ea8ba8defc | ||
|
|
94cb78491a | ||
|
|
d12a5c5883 | ||
|
|
7a014761dc | ||
|
|
91ac952a72 | ||
|
|
225055fb0c | ||
|
|
d46e80c2ac | ||
|
|
fe364bd2da | ||
|
|
2756dff735 | ||
|
|
401f64b986 | ||
|
|
a02dcf485f | ||
|
|
f2ecd9818a | ||
|
|
bc81f659aa | ||
|
|
f8f038c5e9 | ||
|
|
fefb6acc57 | ||
|
|
461120ad84 | ||
|
|
bab72a5d2e | ||
|
|
e4011b4a23 | ||
|
|
899b59620d | ||
|
|
c75b5bec07 | ||
|
|
f0c10ba5ec | ||
|
|
7f4b95123f | ||
|
|
6238c625ae | ||
|
|
dbe387890f | ||
|
|
25517ad674 | ||
|
|
3047b5319b | ||
|
|
a5fa2ef8d0 | ||
|
|
59faffe0d5 | ||
|
|
b076a7dabc | ||
|
|
cbab279c3b | ||
|
|
61be5c9bc1 | ||
|
|
0a4f7da2df | ||
|
|
a87d82fd47 | ||
|
|
208d040f6f | ||
|
|
8ebcfbb8d1 | ||
|
|
20803451de | ||
|
|
2c5cb6abe4 | ||
|
|
522fd64f69 | ||
|
|
aaa6616777 | ||
|
|
7bc3591080 | ||
|
|
db059d84c3 | ||
|
|
e3a9e61046 | ||
|
|
03103e1efd | ||
|
|
08b72e2b90 | ||
|
|
ed8a14fdd7 | ||
|
|
0e50f6d9f8 | ||
|
|
833fe7c7b7 | ||
|
|
47bb3000f6 | ||
|
|
41dd7bbdde | ||
|
|
dc9a4f0dba | ||
|
|
891a656449 | ||
|
|
916ee66be2 | ||
|
|
9d7d838131 | ||
|
|
df29376b0e | ||
|
|
d72a1ee328 | ||
|
|
ce532e0ff6 | ||
|
|
f824490873 | ||
|
|
ebc70feef0 | ||
|
|
226ac889c7 |
No files matched your search
@@ -13,4 +13,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "app-store, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||||
@@ -381,6 +381,10 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
|||||||
|
|
||||||
[](https://render.com/deploy?repo=https://github.com/calcom/docker)
|
[](https://render.com/deploy?repo=https://github.com/calcom/docker)
|
||||||
|
|
||||||
|
### Elestio
|
||||||
|
|
||||||
|
[](https://elest.io/open-source/cal.com)
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ FRONTEND_URL=http://localhost:3000
|
|||||||
APP_ID=cal-ai
|
APP_ID=cal-ai
|
||||||
APP_URL=http://localhost:3000/apps/cal-ai
|
APP_URL=http://localhost:3000/apps/cal-ai
|
||||||
|
|
||||||
|
# This is for the onboard route. Which domain should we send emails from?
|
||||||
|
SENDER_DOMAIN=cal.ai
|
||||||
|
|
||||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||||
PARSE_KEY=
|
PARSE_KEY=
|
||||||
|
|
||||||
|
|||||||
+34
-18
@@ -1,12 +1,12 @@
|
|||||||
# Cal.com Email Assistant
|
# Cal.ai
|
||||||
|
|
||||||
Welcome to the first stage of Cal.ai!
|
Welcome to [Cal.ai](https://cal.ai)!
|
||||||
|
|
||||||
This app lets you chat with your calendar via email:
|
This app lets you chat with your calendar via email:
|
||||||
|
|
||||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
- List and rearrange your bookings eg. "clear my afternoon"
|
||||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
|
||||||
|
|
||||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||||
|
|
||||||
@@ -14,7 +14,11 @@ _The AI agent can only choose from a set of tools, without ever seeing your API
|
|||||||
|
|
||||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||||
|
|
||||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
|
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
|
||||||
|
|
||||||
|
## Recognition
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -22,27 +26,39 @@ Incoming emails are routed by email address. Addresses are verified by [DKIM rec
|
|||||||
|
|
||||||
If you haven't yet, please run the [root setup](/README.md) steps.
|
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||||
|
|
||||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need:
|
||||||
|
|
||||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||||
- A default sender email (for example, `ai@cal.dev`)
|
- A default sender email (for example, `me@dev.example.com`)
|
||||||
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||||
|
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
|
||||||
|
|
||||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||||
|
|
||||||
|
### Agent Architecture
|
||||||
|
|
||||||
|
The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
|
||||||
|
|
||||||
|
Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
|
||||||
|
|
||||||
|
Here is the full architecture:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Email Router
|
### Email Router
|
||||||
|
|
||||||
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||||
|
|
||||||
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||||
|
|
||||||
1. [Sign up for an account](https://signup.sendgrid.com/)
|
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
|
||||||
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
|
||||||
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted.
|
||||||
4. Use the nGrok URL from above as the **Destination URL**.
|
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
|
||||||
5. Activate "POST the raw, full MIME message".
|
5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
|
||||||
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
6. Activate "POST the raw, full MIME message".
|
||||||
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server.
|
||||||
|
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||||
|
|
||||||
Please feel free to improve any part of this architecture.
|
Please feel free to improve any part of this architecture!
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/ai",
|
"name": "@calcom/ai",
|
||||||
"version": "1.1.1",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.6.1",
|
"@t3-oss/env-nextjs": "^0.6.1",
|
||||||
"langchain": "^0.0.131",
|
"langchain": "^0.0.131",
|
||||||
"mailparser": "^3.6.5",
|
"mailparser": "^3.6.5",
|
||||||
"next": "^13.4.6",
|
"next": "^13.5.4",
|
||||||
"supports-color": "8.1.1",
|
"supports-color": "8.1.1",
|
||||||
"zod": "^3.22.2"
|
"zod": "^3.22.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import agent from "../../../utils/agent";
|
|||||||
import sendEmail from "../../../utils/sendEmail";
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||||
|
|
||||||
|
// Allow agent loop to run for up to 5 minutes
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launches a LangChain agent to process an incoming email,
|
* Launches a LangChain agent to process an incoming email,
|
||||||
* then sends the response to the user.
|
* then sends the response to the user.
|
||||||
@@ -37,6 +40,13 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
return new NextResponse("ok");
|
return new NextResponse("ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
|
||||||
|
to: user.email,
|
||||||
|
from: agentEmail,
|
||||||
|
});
|
||||||
|
|
||||||
return new NextResponse(
|
return new NextResponse(
|
||||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import { env } from "../../../env.mjs";
|
||||||
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
const { userId } = await request.json();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response("User not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
subject: "Welcome to Cal AI",
|
||||||
|
to: user.email,
|
||||||
|
from: `${user.username}@${env.SENDER_DOMAIN}`,
|
||||||
|
text: `Hi ${
|
||||||
|
user.name || `@${user.username}`
|
||||||
|
},\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
|
||||||
|
user.username
|
||||||
|
}@${
|
||||||
|
env.SENDER_DOMAIN
|
||||||
|
}.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
|
||||||
|
html: `Hi ${
|
||||||
|
user.name || `@${user.username}`
|
||||||
|
},<br><br>I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.<br><br>Here are some things you can ask me:<br><br>- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)<br>- "What meetings do I have today?" (I'll show you your schedule)<br>- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)<br><br>I'm still learning, so if you have any feedback, please send it to <a href="https://twitter.com/calcom">@calcom</a> on X!<br><br>Remember, you can always reach me here, at ${
|
||||||
|
user.username
|
||||||
|
}@${env.SENDER_DOMAIN}.<br><br>Looking forward to working together (:<br><br>- Cal AI`,
|
||||||
|
});
|
||||||
|
return new Response("OK", { status: 200 });
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { simpleParser } from "mailparser";
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { env } from "../../../env.mjs";
|
import { env } from "../../../env.mjs";
|
||||||
@@ -14,6 +15,10 @@ import now from "../../../utils/now";
|
|||||||
import sendEmail from "../../../utils/sendEmail";
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||||
|
|
||||||
|
// Allow receive loop to run for up to 30 seconds
|
||||||
|
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
|
||||||
|
export const maxDuration = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies email signature and app authorization,
|
* Verifies email signature and app authorization,
|
||||||
* then hands off to booking agent.
|
* then hands off to booking agent.
|
||||||
@@ -27,18 +32,37 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const body = Object.fromEntries(formData);
|
const body = Object.fromEntries(formData);
|
||||||
|
|
||||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
|
||||||
const signature = (body.dkim as string).includes(" : pass");
|
|
||||||
|
|
||||||
const envelope = JSON.parse(body.envelope as string);
|
const envelope = JSON.parse(body.envelope as string);
|
||||||
|
|
||||||
const aiEmail = envelope.to[0];
|
const aiEmail = envelope.to[0];
|
||||||
|
const subject = body.subject || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
identifier: `ai:email:${envelope.from}`,
|
||||||
|
rateLimitingType: "ai",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
|
||||||
|
}
|
||||||
|
|
||||||
// Parse email from mixed MIME type
|
// Parse email from mixed MIME type
|
||||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||||
|
|
||||||
if (!parsed.text && !parsed.subject) {
|
if (!parsed.text && !parsed.subject) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,14 +79,17 @@ export const POST = async (request: NextRequest) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
|
where: { email: envelope.from },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||||
|
const signature = (body.dkim as string).includes(" : pass");
|
||||||
|
|
||||||
// User is not a cal.com user or is using an unverified email.
|
// User is not a cal.com user or is using an unverified email.
|
||||||
if (!signature || !user) {
|
if (!signature || !user) {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||||
subject: `Re: ${body.subject}`,
|
subject: `Re: ${subject}`,
|
||||||
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||||
to: envelope.from,
|
to: envelope.from,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
@@ -79,7 +106,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||||
subject: `Re: ${body.subject}`,
|
subject: `Re: ${subject}`,
|
||||||
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||||
to: envelope.from,
|
to: envelope.from,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
@@ -106,7 +133,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
if ("error" in availability) {
|
if ("error" in availability) {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
subject: `Re: ${body.subject}`,
|
subject: `Re: ${subject}`,
|
||||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||||
to: user.email,
|
to: user.email,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
@@ -117,7 +144,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
|
|
||||||
if ("error" in eventTypes) {
|
if ("error" in eventTypes) {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
subject: `Re: ${body.subject}`,
|
subject: `Re: ${subject}`,
|
||||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||||
to: user.email,
|
to: user.email,
|
||||||
from: aiEmail,
|
from: aiEmail,
|
||||||
@@ -135,8 +162,8 @@ export const POST = async (request: NextRequest) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
apiKey,
|
apiKey,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
message: parsed.text,
|
message: parsed.text || "",
|
||||||
subject: parsed.subject,
|
subject: parsed.subject || "",
|
||||||
replyTo: aiEmail,
|
replyTo: aiEmail,
|
||||||
user: {
|
user: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const env = createEnv({
|
|||||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
APP_ID: process.env.APP_ID,
|
APP_ID: process.env.APP_ID,
|
||||||
APP_URL: process.env.APP_URL,
|
APP_URL: process.env.APP_URL,
|
||||||
|
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
|
||||||
PARSE_KEY: process.env.PARSE_KEY,
|
PARSE_KEY: process.env.PARSE_KEY,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
@@ -36,6 +37,7 @@ export const env = createEnv({
|
|||||||
FRONTEND_URL: z.string().url(),
|
FRONTEND_URL: z.string().url(),
|
||||||
APP_ID: z.string().min(1),
|
APP_ID: z.string().min(1),
|
||||||
APP_URL: z.string().url(),
|
APP_URL: z.string().url(),
|
||||||
|
SENDER_DOMAIN: z.string().min(1),
|
||||||
PARSE_KEY: z.string().min(1),
|
PARSE_KEY: z.string().min(1),
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
OPENAI_API_KEY: z.string().min(1),
|
OPENAI_API_KEY: z.string().min(1),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -47,7 +47,7 @@ const createBooking = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responses = {
|
const responses = {
|
||||||
id: invite,
|
id: invite.toString(),
|
||||||
name: user.username,
|
name: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "~/src/env.mjs";
|
||||||
|
import type { User, UserList } from "~/src/types/user";
|
||||||
|
import sendEmail from "~/src/utils/sendEmail";
|
||||||
|
|
||||||
|
export const sendBookingEmail = async ({
|
||||||
|
user,
|
||||||
|
agentEmail,
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
eventTypeSlug,
|
||||||
|
slots,
|
||||||
|
date,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
user: User;
|
||||||
|
users: UserList;
|
||||||
|
agentEmail: string;
|
||||||
|
subject: string;
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
eventTypeSlug: string;
|
||||||
|
slots?: {
|
||||||
|
time: string;
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
date: {
|
||||||
|
date: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
// const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
|
||||||
|
const timeUrls = slots?.map(({ time, text }) => {
|
||||||
|
return {
|
||||||
|
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateUrl = {
|
||||||
|
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
|
||||||
|
text: date.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
cc: user.email,
|
||||||
|
from: agentEmail,
|
||||||
|
text: message
|
||||||
|
.split("[[[Slots]]]")
|
||||||
|
.join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
|
||||||
|
.split("[[[Link]]]")
|
||||||
|
.join(`${dateUrl.text}: ${dateUrl.url}`),
|
||||||
|
html: message
|
||||||
|
.split("\n")
|
||||||
|
.join("<br>")
|
||||||
|
.split("[[[Slots]]]")
|
||||||
|
.join(timeUrls?.map(({ url, text }) => `<a href="${url}">${text}</a>`).join("<br>"))
|
||||||
|
.split("[[[Link]]]")
|
||||||
|
.join(`<a href="${dateUrl.url}">${dateUrl.text}</a>`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return "Booking link sent";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description:
|
||||||
|
"Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
|
||||||
|
func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await sendBookingEmail({
|
||||||
|
apiKey,
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
agentEmail,
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
eventTypeSlug,
|
||||||
|
slots,
|
||||||
|
date,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "sendBookingEmail",
|
||||||
|
|
||||||
|
schema: z.object({
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
|
||||||
|
),
|
||||||
|
subject: z.string(),
|
||||||
|
to: z
|
||||||
|
.string()
|
||||||
|
.describe("email address to send the booking link to. Primary user is automatically CC'd"),
|
||||||
|
eventTypeSlug: z.string().describe("the slug of the event type to book"),
|
||||||
|
slots: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
|
||||||
|
text: z.string().describe("minimum readable label. Ex. 4pm."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.describe("Time slots the external user can click"),
|
||||||
|
date: z
|
||||||
|
.object({
|
||||||
|
date: z.string().describe("YYYY-MM-DD"),
|
||||||
|
text: z.string().describe('"See all times" or similar'),
|
||||||
|
})
|
||||||
|
.describe(
|
||||||
|
"A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sendBookingEmailTool;
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { DynamicStructuredTool } from "langchain/tools";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { env } from "~/src/env.mjs";
|
|
||||||
import type { User, UserList } from "~/src/types/user";
|
|
||||||
import sendEmail from "~/src/utils/sendEmail";
|
|
||||||
|
|
||||||
export const sendBookingLink = async ({
|
|
||||||
user,
|
|
||||||
agentEmail,
|
|
||||||
subject,
|
|
||||||
to,
|
|
||||||
message,
|
|
||||||
eventTypeSlug,
|
|
||||||
date,
|
|
||||||
}: {
|
|
||||||
apiKey: string;
|
|
||||||
user: User;
|
|
||||||
users: UserList;
|
|
||||||
agentEmail: string;
|
|
||||||
subject: string;
|
|
||||||
to: string[];
|
|
||||||
message: string;
|
|
||||||
eventTypeSlug: string;
|
|
||||||
date: string;
|
|
||||||
}) => {
|
|
||||||
const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
|
|
||||||
|
|
||||||
await sendEmail({
|
|
||||||
subject,
|
|
||||||
to,
|
|
||||||
cc: user.email,
|
|
||||||
from: agentEmail,
|
|
||||||
text: message.split("[[[Booking Link]]]").join(url),
|
|
||||||
html: message
|
|
||||||
.split("\n")
|
|
||||||
.join("<br>")
|
|
||||||
.split("[[[Booking Link]]]")
|
|
||||||
.join(`<a href="${url}">Booking Link</a>`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return "Booking link sent";
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendBookingLinkTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
|
|
||||||
return new DynamicStructuredTool({
|
|
||||||
description: "Send a booking link via email. Useful for scheduling with non cal users.",
|
|
||||||
func: async ({ message, subject, to, eventTypeSlug, date }) => {
|
|
||||||
return JSON.stringify(
|
|
||||||
await sendBookingLink({
|
|
||||||
apiKey,
|
|
||||||
user,
|
|
||||||
users,
|
|
||||||
agentEmail,
|
|
||||||
subject,
|
|
||||||
to,
|
|
||||||
message,
|
|
||||||
eventTypeSlug,
|
|
||||||
date,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
name: "sendBookingLink",
|
|
||||||
|
|
||||||
schema: z.object({
|
|
||||||
message: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Make sure to nicely format the message and introduce yourself as the primary user's booking assistant. Make sure to include a spot for the link using: [[[Booking Link]]]"
|
|
||||||
),
|
|
||||||
subject: z.string(),
|
|
||||||
to: z
|
|
||||||
.array(z.string())
|
|
||||||
.describe("array of emails to send the booking link to. Primary user is automatically CC'd"),
|
|
||||||
eventTypeSlug: z.string().describe("the slug of the event type to book"),
|
|
||||||
date: z.string().describe("the date (yyyy-mm-dd) to suggest for the booking"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default sendBookingLinkTool;
|
|
||||||
@@ -6,7 +6,7 @@ import createBookingIfAvailable from "../tools/createBooking";
|
|||||||
import deleteBooking from "../tools/deleteBooking";
|
import deleteBooking from "../tools/deleteBooking";
|
||||||
import getAvailability from "../tools/getAvailability";
|
import getAvailability from "../tools/getAvailability";
|
||||||
import getBookings from "../tools/getBookings";
|
import getBookings from "../tools/getBookings";
|
||||||
import sendBookingLink from "../tools/sendBookingLink";
|
import sendBookingEmail from "../tools/sendBookingEmail";
|
||||||
import updateBooking from "../tools/updateBooking";
|
import updateBooking from "../tools/updateBooking";
|
||||||
import type { EventType } from "../types/eventType";
|
import type { EventType } from "../types/eventType";
|
||||||
import type { User, UserList } from "../types/user";
|
import type { User, UserList } from "../types/user";
|
||||||
@@ -35,7 +35,7 @@ const agent = async (
|
|||||||
createBookingIfAvailable(apiKey, userId, users),
|
createBookingIfAvailable(apiKey, userId, users),
|
||||||
updateBooking(apiKey, userId),
|
updateBooking(apiKey, userId),
|
||||||
deleteBooking(apiKey),
|
deleteBooking(apiKey),
|
||||||
sendBookingLink(apiKey, user, users, agentEmail),
|
sendBookingEmail(apiKey, user, users, agentEmail),
|
||||||
];
|
];
|
||||||
|
|
||||||
const model = new ChatOpenAI({
|
const model = new ChatOpenAI({
|
||||||
@@ -53,6 +53,8 @@ const agent = async (
|
|||||||
Make sure your final answers are definitive, complete and well formatted.
|
Make sure your final answers are definitive, complete and well formatted.
|
||||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||||
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||||
|
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
|
||||||
|
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
|
||||||
|
|
||||||
The primary user's id is: ${userId}
|
The primary user's id is: ${userId}
|
||||||
The primary user's username is: ${user.username}
|
The primary user's username is: ${user.username}
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import type { UserList } from "../types/user";
|
|||||||
* Extracts usernames (@Example) and emails (hi@example.com) from a string
|
* Extracts usernames (@Example) and emails (hi@example.com) from a string
|
||||||
*/
|
*/
|
||||||
export const extractUsers = async (text: string) => {
|
export const extractUsers = async (text: string) => {
|
||||||
const usernames = text.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)?.map((username) => username.slice(1));
|
const usernames = text
|
||||||
const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
|
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
|
||||||
|
?.map((username) => username.slice(1).toLowerCase());
|
||||||
|
const emails = text
|
||||||
|
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
|
||||||
|
?.map((email) => email.toLowerCase());
|
||||||
|
|
||||||
const dbUsersFromUsernames = usernames
|
const dbUsersFromUsernames = usernames
|
||||||
? await prisma.user.findMany({
|
? await prisma.user.findMany({
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NextMiddleware } from "next-api-middleware";
|
||||||
|
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
|
|
||||||
|
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
|
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||||
|
|
||||||
|
// TODO: Add a way to add trusted api keys
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
identifier: req.query.apiKey as string,
|
||||||
|
rateLimitingType: "api",
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
|||||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
import { isAdminGuard } from "../utils/isAdmin";
|
||||||
|
|
||||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||||
export const dateNotInPast = function (date: Date) {
|
export const dateNotInPast = function (date: Date) {
|
||||||
|
|||||||
@@ -12,24 +12,29 @@ import {
|
|||||||
HTTP_GET_OR_POST,
|
HTTP_GET_OR_POST,
|
||||||
HTTP_GET_DELETE_PATCH,
|
HTTP_GET_DELETE_PATCH,
|
||||||
} from "./httpMethods";
|
} from "./httpMethods";
|
||||||
|
import { rateLimitApiKey } from "./rateLimitApiKey";
|
||||||
import { verifyApiKey } from "./verifyApiKey";
|
import { verifyApiKey } from "./verifyApiKey";
|
||||||
import { withPagination } from "./withPagination";
|
import { withPagination } from "./withPagination";
|
||||||
|
|
||||||
const withMiddleware = label(
|
const middleware = {
|
||||||
{
|
HTTP_GET_OR_POST,
|
||||||
HTTP_GET_OR_POST,
|
HTTP_GET_DELETE_PATCH,
|
||||||
HTTP_GET_DELETE_PATCH,
|
HTTP_GET,
|
||||||
HTTP_GET,
|
HTTP_PATCH,
|
||||||
HTTP_PATCH,
|
HTTP_POST,
|
||||||
HTTP_POST,
|
HTTP_DELETE,
|
||||||
HTTP_DELETE,
|
addRequestId,
|
||||||
addRequestId,
|
verifyApiKey,
|
||||||
verifyApiKey,
|
rateLimitApiKey,
|
||||||
customPrismaClient,
|
customPrismaClient,
|
||||||
extendRequest,
|
extendRequest,
|
||||||
pagination: withPagination,
|
pagination: withPagination,
|
||||||
captureErrors,
|
captureErrors,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
type Middleware = keyof typeof middleware;
|
||||||
|
|
||||||
|
const middlewareOrder =
|
||||||
// The order here, determines the order of execution
|
// The order here, determines the order of execution
|
||||||
[
|
[
|
||||||
"extendRequest",
|
"extendRequest",
|
||||||
@@ -37,8 +42,10 @@ const withMiddleware = label(
|
|||||||
// - Put customPrismaClient before verifyApiKey always.
|
// - Put customPrismaClient before verifyApiKey always.
|
||||||
"customPrismaClient",
|
"customPrismaClient",
|
||||||
"verifyApiKey",
|
"verifyApiKey",
|
||||||
|
"rateLimitApiKey",
|
||||||
"addRequestId",
|
"addRequestId",
|
||||||
] // <-- Provide a list of middleware to call automatically
|
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||||
);
|
|
||||||
|
|
||||||
export { withMiddleware };
|
const withMiddleware = label(middleware, middlewareOrder);
|
||||||
|
|
||||||
|
export { withMiddleware, middleware, middlewareOrder };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||||
|
|
||||||
|
export function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
|
||||||
|
/** Guard: Only admins can query other users */
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
||||||
|
}
|
||||||
|
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
|
||||||
|
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
||||||
|
|
||||||
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
||||||
|
credentialId: true,
|
||||||
integration: true,
|
integration: true,
|
||||||
externalId: true,
|
externalId: true,
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
@@ -14,9 +15,10 @@ const schemaDestinationCalendarCreateParams = z
|
|||||||
.object({
|
.object({
|
||||||
integration: z.string(),
|
integration: z.string(),
|
||||||
externalId: z.string(),
|
externalId: z.string(),
|
||||||
eventTypeId: z.number(),
|
credentialId: z.number(),
|
||||||
bookingId: z.number(),
|
eventTypeId: z.number().optional(),
|
||||||
userId: z.number(),
|
bookingId: z.number().optional(),
|
||||||
|
userId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -45,4 +47,5 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
|
|||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
bookingId: true,
|
bookingId: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
credentialId: true,
|
||||||
});
|
});
|
||||||
@@ -75,6 +75,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
|||||||
theme: true,
|
theme: true,
|
||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
|
hideBranding: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
@@ -95,6 +96,7 @@ const schemaUserEditParams = z.object({
|
|||||||
weekStart: z.nativeEnum(weekdays).optional(),
|
weekStart: z.nativeEnum(weekdays).optional(),
|
||||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
|
hideBranding: z.boolean().optional(),
|
||||||
timeZone: timeZone.optional(),
|
timeZone: timeZone.optional(),
|
||||||
theme: z.nativeEnum(theme).optional().nullable(),
|
theme: z.nativeEnum(theme).optional().nullable(),
|
||||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||||
@@ -115,6 +117,7 @@ const schemaUserCreateParams = z.object({
|
|||||||
weekStart: z.nativeEnum(weekdays).optional(),
|
weekStart: z.nativeEnum(weekdays).optional(),
|
||||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
|
hideBranding: z.boolean().optional(),
|
||||||
timeZone: timeZone.optional(),
|
timeZone: timeZone.optional(),
|
||||||
theme: z.nativeEnum(theme).optional().nullable(),
|
theme: z.nativeEnum(theme).optional().nullable(),
|
||||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||||
@@ -157,6 +160,7 @@ export const schemaUserReadPublic = User.pick({
|
|||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
|
hideBranding: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
|
||||||
import type { DestinationCalendarResponse } from "~/lib/types";
|
|
||||||
import {
|
|
||||||
schemaDestinationCalendarEditBodyParams,
|
|
||||||
schemaDestinationCalendarReadPublic,
|
|
||||||
} from "~/lib/validations/destination-calendar";
|
|
||||||
import {
|
|
||||||
schemaQueryIdParseInt,
|
|
||||||
withValidQueryIdTransformParseInt,
|
|
||||||
} from "~/lib/validations/shared/queryIdTransformParseInt";
|
|
||||||
|
|
||||||
export async function destionationCalendarById(
|
|
||||||
{ method, query, body, userId, prisma }: NextApiRequest,
|
|
||||||
res: NextApiResponse<DestinationCalendarResponse>
|
|
||||||
) {
|
|
||||||
const safeQuery = schemaQueryIdParseInt.safeParse(query);
|
|
||||||
const safeBody = schemaDestinationCalendarEditBodyParams.safeParse(body);
|
|
||||||
if (!safeQuery.success) {
|
|
||||||
res.status(400).json({ message: "Your query was invalid" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
|
|
||||||
const userDestinationCalendars = data.map((destinationCalendar) => destinationCalendar.id);
|
|
||||||
// FIXME: Should we also check ownership of bokingId and eventTypeId to avoid users cross-pollinating other users calendars.
|
|
||||||
// On a related note, moving from sequential integer IDs to UUIDs would be a good idea. and maybe help avoid having this problem.
|
|
||||||
if (userDestinationCalendars.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" });
|
|
||||||
else {
|
|
||||||
switch (method) {
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars/{id}:
|
|
||||||
* get:
|
|
||||||
* summary: Find a destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to get
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: OK
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
* 404:
|
|
||||||
* description: DestinationCalendar was not found
|
|
||||||
* patch:
|
|
||||||
* summary: Edit an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to edit
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* requestBody:
|
|
||||||
* description: Create a new booking related to one of your event-types
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* integration:
|
|
||||||
* type: string
|
|
||||||
* description: 'The integration'
|
|
||||||
* externalId:
|
|
||||||
* type: string
|
|
||||||
* description: 'The external ID of the integration'
|
|
||||||
* eventTypeId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The ID of the eventType it is associated with'
|
|
||||||
* bookingId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The booking ID it is associated with'
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar edited successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar body is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
* delete:
|
|
||||||
* summary: Remove an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to delete
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar removed successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar id is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
case "GET":
|
|
||||||
await prisma.destinationCalendar
|
|
||||||
.findUnique({ where: { id: safeQuery.data.id } })
|
|
||||||
.then((data) => schemaDestinationCalendarReadPublic.parse(data))
|
|
||||||
.then((destination_calendar) => res.status(200).json({ destination_calendar }))
|
|
||||||
.catch((error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars/{id}:
|
|
||||||
* patch:
|
|
||||||
* summary: Edit an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to edit
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar edited successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar body is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
case "PATCH":
|
|
||||||
if (!safeBody.success) {
|
|
||||||
{
|
|
||||||
res.status(400).json({ message: "Invalid request body" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await prisma.destinationCalendar
|
|
||||||
.update({ where: { id: safeQuery.data.id }, data: safeBody.data })
|
|
||||||
.then((data) => schemaDestinationCalendarReadPublic.parse(data))
|
|
||||||
.then((destination_calendar) => res.status(200).json({ destination_calendar }))
|
|
||||||
.catch((error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars/{id}:
|
|
||||||
* delete:
|
|
||||||
* summary: Remove an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to delete
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar removed successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar id is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
case "DELETE":
|
|
||||||
await prisma.destinationCalendar
|
|
||||||
.delete({
|
|
||||||
where: { id: safeQuery.data.id },
|
|
||||||
})
|
|
||||||
.then(() =>
|
|
||||||
res.status(200).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} deleted`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
res.status(405).json({ message: "Method not allowed" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withMiddleware("HTTP_GET_DELETE_PATCH")(
|
|
||||||
withValidQueryIdTransformParseInt(destionationCalendarById)
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
async function authMiddleware(req: NextApiRequest) {
|
||||||
|
const { userId, isAdmin, prisma } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(req.query);
|
||||||
|
if (isAdmin) return;
|
||||||
|
const userEventTypes = await prisma.eventType.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userEventTypeIds = userEventTypes.map((eventType) => eventType.id);
|
||||||
|
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
OR: [{ userId }, { eventTypeId: { in: userEventTypeIds } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!destinationCalendar)
|
||||||
|
throw new HttpError({ statusCode: 404, message: "Destination calendar not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authMiddleware;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Remove an existing destination calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the destination calendar to delete
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK, destinationCalendar removed successfully
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Destination calendar not found
|
||||||
|
*/
|
||||||
|
export async function deleteHandler(req: NextApiRequest) {
|
||||||
|
const { prisma, query } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
await prisma.destinationCalendar.delete({ where: { id } });
|
||||||
|
return { message: `OK, Destination Calendar removed successfully` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(deleteHandler);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar";
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Find a destination calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the destination calendar to get
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Destination calendar not found
|
||||||
|
*/
|
||||||
|
export async function getHandler(req: NextApiRequest) {
|
||||||
|
const { prisma, query } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse({ ...destinationCalendar }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(getHandler);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
schemaDestinationCalendarEditBodyParams,
|
||||||
|
schemaDestinationCalendarReadPublic,
|
||||||
|
} from "~/lib/validations/destination-calendar";
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars/{id}:
|
||||||
|
* patch:
|
||||||
|
* summary: Edit an existing destination calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the destination calendar to edit
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* requestBody:
|
||||||
|
* description: Create a new booking related to one of your event-types
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* integration:
|
||||||
|
* type: string
|
||||||
|
* description: 'The integration'
|
||||||
|
* externalId:
|
||||||
|
* type: string
|
||||||
|
* description: 'The external ID of the integration'
|
||||||
|
* eventTypeId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The ID of the eventType it is associated with'
|
||||||
|
* bookingId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The booking ID it is associated with'
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Destination calendar not found
|
||||||
|
*/
|
||||||
|
export async function patchHandler(req: NextApiRequest) {
|
||||||
|
const { prisma, query, body } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
||||||
|
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsedBody,
|
||||||
|
});
|
||||||
|
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(patchHandler);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
|
||||||
|
import authMiddleware from "./_auth-middleware";
|
||||||
|
|
||||||
|
export default withMiddleware()(
|
||||||
|
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
await authMiddleware(req);
|
||||||
|
return defaultHandler({
|
||||||
|
GET: import("./_get"),
|
||||||
|
PATCH: import("./_patch"),
|
||||||
|
DELETE: import("./_delete"),
|
||||||
|
})(req, res);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
|
||||||
|
import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars:
|
||||||
|
* get:
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* summary: Find all destination calendars
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: No destination calendars were found
|
||||||
|
*/
|
||||||
|
async function getHandler(req: NextApiRequest) {
|
||||||
|
const { userId, prisma } = req;
|
||||||
|
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||||
|
|
||||||
|
const userEventTypes = await prisma.eventType.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userEventTypeIds = userEventTypes.map((eventType) => eventType.id);
|
||||||
|
|
||||||
|
const allDestinationCalendars = await prisma.destinationCalendar.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ userId: { in: userIds } }, { eventTypeId: { in: userEventTypeIds } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allDestinationCalendars.length === 0)
|
||||||
|
new HttpError({ statusCode: 404, message: "No destination calendars were found" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinationCalendars: allDestinationCalendars.map((destinationCalendar) =>
|
||||||
|
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(getHandler);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
schemaDestinationCalendarReadPublic,
|
||||||
|
schemaDestinationCalendarCreateBodyParams,
|
||||||
|
} from "~/lib/validations/destination-calendar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars:
|
||||||
|
* post:
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* summary: Creates a new destination calendar
|
||||||
|
* requestBody:
|
||||||
|
* description: Create a new destination calendar for your events
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - integration
|
||||||
|
* - externalId
|
||||||
|
* - credentialId
|
||||||
|
* properties:
|
||||||
|
* integration:
|
||||||
|
* type: string
|
||||||
|
* description: 'The integration'
|
||||||
|
* externalId:
|
||||||
|
* type: string
|
||||||
|
* description: 'The external ID of the integration'
|
||||||
|
* credentialId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The credential ID it is associated with'
|
||||||
|
* eventTypeId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The ID of the eventType it is associated with'
|
||||||
|
* bookingId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The booking ID it is associated with'
|
||||||
|
* userId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The user it is associated with'
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: OK, destination calendar created
|
||||||
|
* 400:
|
||||||
|
* description: Bad request. DestinationCalendar body is invalid.
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
*/
|
||||||
|
async function postHandler(req: NextApiRequest) {
|
||||||
|
const { userId, isAdmin, prisma, body } = req;
|
||||||
|
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
||||||
|
await checkPermissions(req, userId);
|
||||||
|
|
||||||
|
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
||||||
|
|
||||||
|
/* Check if credentialId data matches the ownership and integration passed in */
|
||||||
|
const credential = await prisma.credential.findFirst({
|
||||||
|
where: { type: parsedBody.integration, userId: assignedUserId },
|
||||||
|
select: { id: true, type: true, userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, credential id invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsedBody.eventTypeId) {
|
||||||
|
const eventType = await prisma.eventType.findFirst({
|
||||||
|
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
||||||
|
});
|
||||||
|
if (!eventType)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, eventTypeId invalid",
|
||||||
|
});
|
||||||
|
parsedBody.userId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
||||||
|
message: "Destination calendar created successfully",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPermissions(req: NextApiRequest, userId: number) {
|
||||||
|
const { isAdmin } = req;
|
||||||
|
const body = schemaDestinationCalendarCreateBodyParams.parse(req.body);
|
||||||
|
|
||||||
|
/* Non-admin users can only create destination calendars for themselves */
|
||||||
|
if (!isAdmin && body.userId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "ADMIN required for `userId`",
|
||||||
|
});
|
||||||
|
/* Admin users are required to pass in a userId */
|
||||||
|
if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" });
|
||||||
|
/* User should only be able to create for their own destination calendars*/
|
||||||
|
if (!isAdmin && body.eventTypeId) {
|
||||||
|
const ownsEventType = await req.prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } });
|
||||||
|
if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
// TODO:: Add support for team event types with validation
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(postHandler);
|
||||||
@@ -1,114 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import { defaultHandler } from "@calcom/lib/server";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
import type { DestinationCalendarResponse, DestinationCalendarsResponse } from "~/lib/types";
|
|
||||||
import {
|
|
||||||
schemaDestinationCalendarCreateBodyParams,
|
|
||||||
schemaDestinationCalendarReadPublic,
|
|
||||||
} from "~/lib/validations/destination-calendar";
|
|
||||||
|
|
||||||
async function createOrlistAllDestinationCalendars(
|
export default withMiddleware()(
|
||||||
{ method, body, userId, prisma }: NextApiRequest,
|
defaultHandler({
|
||||||
res: NextApiResponse<DestinationCalendarsResponse | DestinationCalendarResponse>
|
GET: import("./_get"),
|
||||||
) {
|
POST: import("./_post"),
|
||||||
if (method === "GET") {
|
})
|
||||||
/**
|
);
|
||||||
* @swagger
|
|
||||||
* /destination-calendars:
|
|
||||||
* get:
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* summary: Find all destination calendars
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: OK
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
* 404:
|
|
||||||
* description: No destination calendars were found
|
|
||||||
*/
|
|
||||||
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
|
|
||||||
const destination_calendars = data.map((destinationCalendar) =>
|
|
||||||
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
|
|
||||||
);
|
|
||||||
if (data) res.status(200).json({ destination_calendars });
|
|
||||||
else
|
|
||||||
(error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: "No DestinationCalendars were found",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
} else if (method === "POST") {
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars:
|
|
||||||
* post:
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* summary: Creates a new destination calendar
|
|
||||||
* requestBody:
|
|
||||||
* description: Create a new destination calendar for your events
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - integration
|
|
||||||
* - externalId
|
|
||||||
* properties:
|
|
||||||
* integration:
|
|
||||||
* type: string
|
|
||||||
* description: 'The integration'
|
|
||||||
* externalId:
|
|
||||||
* type: string
|
|
||||||
* description: 'The external ID of the integration'
|
|
||||||
* eventTypeId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The ID of the eventType it is associated with'
|
|
||||||
* bookingId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The booking ID it is associated with'
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destination calendar created
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar body is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
const safe = schemaDestinationCalendarCreateBodyParams.safeParse(body);
|
|
||||||
if (!safe.success) {
|
|
||||||
res.status(400).json({ message: "Invalid request body" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await prisma.destinationCalendar.create({ data: { ...safe.data, userId } });
|
|
||||||
const destination_calendar = schemaDestinationCalendarReadPublic.parse(data);
|
|
||||||
|
|
||||||
if (destination_calendar)
|
|
||||||
res.status(201).json({ destination_calendar, message: "DestinationCalendar created successfully" });
|
|
||||||
else
|
|
||||||
(error: Error) =>
|
|
||||||
res.status(400).json({
|
|
||||||
message: "Could not create new destinationCalendar",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
} else res.status(405).json({ message: `Method ${method} not allowed` });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllDestinationCalendars);
|
|
||||||
@@ -284,8 +284,8 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
if (parsedBody.parentId) {
|
if (parsedBody.parentId) {
|
||||||
await checkParentEventOwnership(parsedBody.parentId, userId);
|
await checkParentEventOwnership(req);
|
||||||
await checkUserMembership(parsedBody.parentId, parsedBody.userId);
|
await checkUserMembership(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdmin && parsedBody.userId) {
|
if (isAdmin && parsedBody.userId) {
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
|
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
|
||||||
* the team associated with the event type identified by the parentId.
|
* the team associated with the event type identified by the parentId.
|
||||||
*
|
*
|
||||||
* @param parentId - The ID of the parent event type.
|
* @param req - The current request
|
||||||
* @param userId - The ID of the user.
|
|
||||||
*
|
*
|
||||||
* @throws {HttpError} If the parent event type is not found,
|
* @throws {HttpError} If the parent event type is not found,
|
||||||
* if the parent event type doesn't belong to any team,
|
* if the parent event type doesn't belong to any team,
|
||||||
* or if the user doesn't have ownership or admin rights to the associated team.
|
* or if the user doesn't have ownership or admin rights to the associated team.
|
||||||
*/
|
*/
|
||||||
export default async function checkParentEventOwnership(parentId: number, userId: number) {
|
export default async function checkParentEventOwnership(req: NextApiRequest) {
|
||||||
|
const { userId, prisma, body } = req;
|
||||||
|
/** These are already parsed upstream, we can assume they're good here. */
|
||||||
|
const parentId = Number(body.parentId);
|
||||||
const parentEventType = await prisma.eventType.findUnique({
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: parentId,
|
id: parentId,
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a user, identified by the provided userId, is a member of the team associated
|
* Checks if a user, identified by the provided userId, is a member of the team associated
|
||||||
* with the event type identified by the parentId.
|
* with the event type identified by the parentId.
|
||||||
*
|
*
|
||||||
* @param parentId - The ID of the event type.
|
* @param req - The current request
|
||||||
* @param userId - The ID of the user.
|
|
||||||
*
|
*
|
||||||
* @throws {HttpError} If the event type is not found,
|
* @throws {HttpError} If the event type is not found,
|
||||||
* if the event type doesn't belong to any team,
|
* if the event type doesn't belong to any team,
|
||||||
* or if the user isn't a member of the associated team.
|
* or if the user isn't a member of the associated team.
|
||||||
*/
|
*/
|
||||||
export default async function checkUserMembership(parentId: number, userId?: number) {
|
export default async function checkUserMembership(req: NextApiRequest) {
|
||||||
|
const { prisma, body } = req;
|
||||||
|
/** These are already parsed upstream, we can assume they're good here. */
|
||||||
|
const parentId = Number(body.parentId);
|
||||||
|
const userId = Number(body.userId);
|
||||||
const parentEventType = await prisma.eventType.findUnique({
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: parentId,
|
id: parentId,
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
|
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||||
|
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
/** @see https://trpc.io/docs/server-side-calls */
|
|
||||||
const ctx = await createContext({ req, res });
|
|
||||||
const caller = slotsRouter.createCaller(ctx);
|
|
||||||
try {
|
try {
|
||||||
|
const input = getScheduleSchema.parse(req.query);
|
||||||
|
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof TRPCError) {
|
if (cause instanceof TRPCError) {
|
||||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
|||||||
* timeZone:
|
* timeZone:
|
||||||
* description: The user's time zone
|
* description: The user's time zone
|
||||||
* type: string
|
* type: string
|
||||||
|
* hideBranding:
|
||||||
|
* description: Remove branding from the user's calendar page
|
||||||
|
* type: boolean
|
||||||
* theme:
|
* theme:
|
||||||
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
|
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
|
||||||
* type: string
|
* type: string
|
||||||
@@ -79,7 +82,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
|||||||
* - users
|
* - users
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: OK, user edited successfuly
|
* description: OK, user edited successfully
|
||||||
* 400:
|
* 400:
|
||||||
* description: Bad request. User body is invalid.
|
* description: Bad request. User body is invalid.
|
||||||
* 401:
|
* 401:
|
||||||
@@ -94,9 +97,10 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
|
||||||
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
||||||
// disable role changes unless admin.
|
// disable role or branding changes unless admin.
|
||||||
if (!isAdmin && body.role) {
|
if (!isAdmin) {
|
||||||
body.role = undefined;
|
if (body.role) body.role = undefined;
|
||||||
|
if (body.hideBranding) body.hideBranding = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSchedules = await prisma.schedule.findMany({
|
const userSchedules = await prisma.schedule.findMany({
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
|||||||
* darkBrandColor:
|
* darkBrandColor:
|
||||||
* description: The new user's brand color for dark mode
|
* description: The new user's brand color for dark mode
|
||||||
* type: string
|
* type: string
|
||||||
|
* hideBranding:
|
||||||
|
* description: Remove branding from the user's calendar page
|
||||||
|
* type: boolean
|
||||||
* weekStart:
|
* weekStart:
|
||||||
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
|
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
|
||||||
* type: string
|
* type: string
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// TODO: Fix tests (These test were never running due to the vitest workspace config)
|
||||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -21,7 +22,7 @@ vi.mock("@calcom/lib/server/i18n", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/bookings", () => {
|
describe.skipIf(true)("POST /api/bookings", () => {
|
||||||
describe("Errors", () => {
|
describe("Errors", () => {
|
||||||
test("Missing required data", async () => {
|
test("Missing required data", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
@@ -31,7 +32,7 @@ describe("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { addRequestId } from "../../../lib/helpers/addRequestid";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Adds a request ID", () => {
|
||||||
|
it("Should attach a request ID to the request", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: addRequestId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.getHeader("Calcom-Response-ID")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { httpMethod } from "../../../lib/helpers/httpMethods";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTTP Methods function only allows the correct HTTP Methods", () => {
|
||||||
|
it("Should allow the passed in Method", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: httpMethod("POST"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
it("Should allow the passed in Method", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: httpMethod("GET"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(405);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||||
|
|
||||||
|
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||||
|
|
||||||
|
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
|
||||||
|
return {
|
||||||
|
default: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("~/lib/utils/isAdmin", () => {
|
||||||
|
return {
|
||||||
|
isAdminGuard: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Verify API key", () => {
|
||||||
|
it("It should throw an error if the api key is not valid", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: verifyApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(checkLicense).mockResolvedValue(false);
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
it("It should thow an error if no api key is provided", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: verifyApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(checkLicense).mockResolvedValue(true);
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not sure if there is much point testing this order is actually applied via an integration test:
|
||||||
|
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
|
||||||
|
describe("API - withMiddleware test", () => {
|
||||||
|
it("Custom prisma should be before verifyApiKey", async () => {
|
||||||
|
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
|
||||||
|
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
|
||||||
|
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello World
|
||||||
@@ -20,7 +20,7 @@ export default function AddToHomescreen() {
|
|||||||
<div className="flex w-0 flex-1 items-center">
|
<div className="flex w-0 flex-1 items-center">
|
||||||
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
|
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
|
||||||
<svg
|
<svg
|
||||||
className="h-7 w-7 fill-current text-indigo-500"
|
className="h-7 w-7 fill-current text-[#5B93F9]"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 50 50"
|
viewBox="0 0 50 50"
|
||||||
enableBackground="new 0 0 50 50">
|
enableBackground="new 0 0 50 50">
|
||||||
@@ -29,7 +29,7 @@ export default function AddToHomescreen() {
|
|||||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
|
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<p className="text-inverted ms-3 text-xs font-medium">
|
<p className="text-inverted ms-3 text-xs font-medium dark:text-white">
|
||||||
<span className="inline">{t("add_to_homescreen")}</span>
|
<span className="inline">{t("add_to_homescreen")}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,7 @@ export default function AddToHomescreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||||
<span className="sr-only">{t("dismiss")}</span>
|
<span className="sr-only">{t("dismiss")}</span>
|
||||||
<X className="text-inverted h-6 w-6" aria-hidden="true" />
|
<X className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function PageWrapper(props: AppProps) {
|
|||||||
<Head>
|
<Head>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
<DefaultSeo
|
<DefaultSeo
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
|||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
<div className=" bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
|
||||||
<CreditCard className="m-auto h-6 w-6" />
|
<CreditCard className="m-auto h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent enableOverflow>
|
<DialogContent enableOverflow>
|
||||||
<div className="flex flex-row space-x-3">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||||
<Clock className="m-auto h-6 w-6" />
|
<Clock className="m-auto h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
* a team event. Since we don't have logic to handle each attendee calendar (for now).
|
||||||
* This will fallback to each user selected destination calendar.
|
* This will fallback to each user selected destination calendar.
|
||||||
*/}
|
*/}
|
||||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||||
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -182,9 +182,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
|
||||||
|
|
||||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||||
<FormBuilder
|
<FormBuilder
|
||||||
title={t("booking_questions_title")}
|
title={t("booking_questions_title")}
|
||||||
description={t("booking_questions_description")}
|
description={t("booking_questions_description")}
|
||||||
@@ -213,8 +213,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
defaultValue={eventType.requiresBookerEmailVerification}
|
defaultValue={eventType.requiresBookerEmailVerification}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||||
title={t("requires_booker_email_verification")}
|
title={t("requires_booker_email_verification")}
|
||||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||||
description={t("description_requires_booker_email_verification")}
|
description={t("description_requires_booker_email_verification")}
|
||||||
@@ -230,8 +231,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
defaultValue={eventType.hideCalendarNotes}
|
defaultValue={eventType.hideCalendarNotes}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_notes")}
|
title={t("disable_notes")}
|
||||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||||
description={t("disable_notes_description")}
|
description={t("disable_notes_description")}
|
||||||
@@ -247,9 +249,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||||
redirectUrlVisible && "rounded-b-none"
|
redirectUrlVisible && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
@@ -261,7 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
setRedirectUrlVisible(e);
|
setRedirectUrlVisible(e);
|
||||||
onChange(e ? value : "");
|
onChange(e ? value : "");
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label={t("redirect_success_booking")}
|
label={t("redirect_success_booking")}
|
||||||
@@ -287,20 +290,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||||
hashedLinkVisible && "rounded-b-none"
|
hashedLinkVisible && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
data-testid="hashedLinkCheck"
|
data-testid="hashedLinkCheck"
|
||||||
title={t("private_link")}
|
title={t("enable_private_url")}
|
||||||
Badge={
|
Badge={
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
|
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
|
||||||
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
|
<Info className="ml-1.5 h-4 w-4 cursor-pointer" />
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||||
@@ -310,7 +314,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||||
setHashedLinkVisible(e);
|
setHashedLinkVisible(e);
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
disabled
|
||||||
@@ -353,9 +357,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||||
value && "rounded-b-none"
|
value && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
@@ -379,13 +384,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
}
|
}
|
||||||
onChange(e);
|
onChange(e);
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<Controller
|
<Controller
|
||||||
name="seatsPerTimeSlot"
|
name="seatsPerTimeSlot"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.seatsPerTimeSlot}
|
defaultValue={eventType.seatsPerTimeSlot}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<div className="lg:-ml-2">
|
<div>
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
name="seatsPerTimeSlot"
|
name="seatsPerTimeSlot"
|
||||||
@@ -395,12 +400,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
disabled={seatsLocked.disabled}
|
disabled={seatsLocked.disabled}
|
||||||
defaultValue={value || 2}
|
defaultValue={value || 2}
|
||||||
min={1}
|
min={1}
|
||||||
|
containerClassName="max-w-80"
|
||||||
addOnSuffix={<>{t("seats")}</>}
|
addOnSuffix={<>{t("seats")}</>}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange(Math.abs(Number(e.target.value)));
|
onChange(Math.abs(Number(e.target.value)));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<div className="mt-4">
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
description={t("show_attendees")}
|
description={t("show_attendees")}
|
||||||
disabled={seatsLocked.disabled}
|
disabled={seatsLocked.disabled}
|
||||||
@@ -435,8 +441,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_attendees_confirmation_emails")}
|
title={t("disable_attendees_confirmation_emails")}
|
||||||
description={t("disable_attendees_confirmation_emails_description")}
|
description={t("disable_attendees_confirmation_emails_description")}
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
@@ -459,8 +466,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName="border-subtle rounded-md border py-6 px-4 sm:px-6"
|
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||||
title={t("disable_host_confirmation_emails")}
|
title={t("disable_host_confirmation_emails")}
|
||||||
description={t("disable_host_confirmation_emails_description")}
|
description={t("disable_host_confirmation_emails_description")}
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { UseFormRegisterReturn } from "react-hook-form";
|
|||||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||||
import type { SingleValue } from "react-select";
|
import type { SingleValue } from "react-select";
|
||||||
|
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||||
@@ -141,17 +140,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
defaultValue: periodType?.type,
|
defaultValue: periodType?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
|
|
||||||
eventType,
|
|
||||||
t("locked_fields_admin_description"),
|
|
||||||
t("locked_fields_member_description")
|
|
||||||
);
|
|
||||||
|
|
||||||
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
|
|
||||||
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
|
|
||||||
const periodTypeLocked = shouldLockDisableProps("periodType");
|
|
||||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
|
||||||
|
|
||||||
const optionsPeriod = [
|
const optionsPeriod = [
|
||||||
{ value: 1, label: t("calendar_days") },
|
{ value: 1, label: t("calendar_days") },
|
||||||
{ value: 0, label: t("business_days") },
|
{ value: 0, label: t("business_days") },
|
||||||
@@ -171,13 +159,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="beforeBufferTime">
|
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
|
||||||
{t("before_event")}
|
|
||||||
{shouldLockIndicator("bookingLimits")}
|
|
||||||
</Label>
|
|
||||||
<Controller
|
<Controller
|
||||||
name="beforeBufferTime"
|
name="beforeBufferTime"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -196,7 +181,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val) onChange(val.value);
|
if (val) onChange(val.value);
|
||||||
}}
|
}}
|
||||||
@@ -210,10 +194,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="afterBufferTime">
|
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
|
||||||
{t("after_event")}
|
|
||||||
{shouldLockIndicator("bookingLimits")}
|
|
||||||
</Label>
|
|
||||||
<Controller
|
<Controller
|
||||||
name="afterBufferTime"
|
name="afterBufferTime"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -232,7 +213,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val) onChange(val.value);
|
if (val) onChange(val.value);
|
||||||
}}
|
}}
|
||||||
@@ -248,20 +228,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="minimumBookingNotice">
|
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
|
||||||
{t("minimum_booking_notice")}
|
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||||
{shouldLockIndicator("minimumBookingNotice")}
|
|
||||||
</Label>
|
|
||||||
<MinimumBookingNoticeInput
|
|
||||||
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
|
||||||
{...formMethods.register("minimumBookingNotice")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="slotInterval">
|
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
|
||||||
{t("slot_interval")}
|
|
||||||
{shouldLockIndicator("slotInterval")}
|
|
||||||
</Label>
|
|
||||||
<Controller
|
<Controller
|
||||||
name="slotInterval"
|
name="slotInterval"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
@@ -279,7 +250,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||||
}}
|
}}
|
||||||
@@ -303,8 +273,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
return (
|
return (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
|
labelClassName="text-sm"
|
||||||
title={t("limit_booking_frequency")}
|
title={t("limit_booking_frequency")}
|
||||||
{...bookingLimitsLocked}
|
|
||||||
description={t("limit_booking_frequency_description")}
|
description={t("limit_booking_frequency_description")}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(active) => {
|
onCheckedChange={(active) => {
|
||||||
@@ -317,17 +287,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||||
isChecked && "rounded-b-none"
|
isChecked && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0">
|
childrenClassName="lg:ml-0">
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<IntervalLimitsManager
|
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
|
||||||
disabled={bookingLimitsLocked.disabled}
|
|
||||||
propertyName="bookingLimits"
|
|
||||||
defaultLimit={1}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
);
|
);
|
||||||
@@ -340,15 +305,15 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||||
return (
|
return (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||||
isChecked && "rounded-b-none"
|
isChecked && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
title={t("limit_total_booking_duration")}
|
title={t("limit_total_booking_duration")}
|
||||||
description={t("limit_total_booking_duration_description")}
|
description={t("limit_total_booking_duration_description")}
|
||||||
{...durationLimitsLocked}
|
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(active) => {
|
onCheckedChange={(active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -359,11 +324,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
formMethods.setValue("durationLimits", {});
|
formMethods.setValue("durationLimits", {});
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<IntervalLimitsManager
|
<IntervalLimitsManager
|
||||||
propertyName="durationLimits"
|
propertyName="durationLimits"
|
||||||
defaultLimit={60}
|
defaultLimit={60}
|
||||||
disabled={durationLimitsLocked.disabled}
|
|
||||||
step={15}
|
step={15}
|
||||||
textFieldSuffix={t("minutes")}
|
textFieldSuffix={t("minutes")}
|
||||||
/>
|
/>
|
||||||
@@ -380,25 +344,23 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||||
isChecked && "rounded-b-none"
|
isChecked && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
title={t("limit_future_bookings")}
|
title={t("limit_future_bookings")}
|
||||||
description={t("limit_future_bookings_description")}
|
description={t("limit_future_bookings_description")}
|
||||||
{...periodTypeLocked}
|
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<RadioGroup.Root
|
<RadioGroup.Root
|
||||||
defaultValue={watchPeriodType}
|
defaultValue={watchPeriodType}
|
||||||
value={watchPeriodType}
|
value={watchPeriodType}
|
||||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||||
{PERIOD_TYPES.filter((opt) =>
|
{PERIOD_TYPES.map((period) => {
|
||||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
|
||||||
).map((period) => {
|
|
||||||
if (period.type === "UNLIMITED") return null;
|
if (period.type === "UNLIMITED") return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -407,14 +369,13 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||||
)}
|
)}
|
||||||
key={period.type}>
|
key={period.type}>
|
||||||
{!periodTypeLocked.disabled && (
|
<RadioGroup.Item
|
||||||
<RadioGroup.Item
|
id={period.type}
|
||||||
id={period.type}
|
value={period.type}
|
||||||
value={period.type}
|
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
</RadioGroup.Item>
|
||||||
</RadioGroup.Item>
|
|
||||||
)}
|
|
||||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||||
{period.type === "ROLLING" && (
|
{period.type === "ROLLING" && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -423,14 +384,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
type="number"
|
type="number"
|
||||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||||
placeholder="30"
|
placeholder="30"
|
||||||
disabled={periodTypeLocked.disabled}
|
|
||||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||||
defaultValue={eventType.periodDays || 30}
|
defaultValue={eventType.periodDays || 30}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
options={optionsPeriod}
|
options={optionsPeriod}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isDisabled={periodTypeLocked.disabled}
|
|
||||||
onChange={(opt) => {
|
onChange={(opt) => {
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"periodCountCalendarDays",
|
"periodCountCalendarDays",
|
||||||
@@ -455,7 +414,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
startDate={formMethods.getValues("periodDates").startDate}
|
startDate={formMethods.getValues("periodDates").startDate}
|
||||||
endDate={formMethods.getValues("periodDates").endDate}
|
endDate={formMethods.getValues("periodDates").endDate}
|
||||||
disabled={periodTypeLocked.disabled}
|
|
||||||
onDatesChange={({ startDate, endDate }) => {
|
onDatesChange={({ startDate, endDate }) => {
|
||||||
formMethods.setValue("periodDates", {
|
formMethods.setValue("periodDates", {
|
||||||
startDate,
|
startDate,
|
||||||
@@ -478,15 +436,15 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle mt-6 rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||||
offsetToggle && "rounded-b-none"
|
offsetToggle && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
title={t("offset_toggle")}
|
title={t("offset_toggle")}
|
||||||
description={t("offset_toggle_description")}
|
description={t("offset_toggle_description")}
|
||||||
{...offsetStartLockedProps}
|
|
||||||
checked={offsetToggle}
|
checked={offsetToggle}
|
||||||
onCheckedChange={(active) => {
|
onCheckedChange={(active) => {
|
||||||
setOffsetToggle(active);
|
setOffsetToggle(active);
|
||||||
@@ -494,11 +452,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
|||||||
formMethods.setValue("offsetStart", 0);
|
formMethods.setValue("offsetStart", 0);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
{...offsetStartLockedProps}
|
containerClassName="max-w-80"
|
||||||
label={t("offset_start")}
|
label={t("offset_start")}
|
||||||
{...formMethods.register("offsetStart")}
|
{...formMethods.register("offsetStart")}
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
addOnSuffix={<>{t("minutes")}</>}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export const EventSetupTab = (
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-subtle space-y-6 rounded-md border p-6">
|
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
label={t("title")}
|
label={t("title")}
|
||||||
@@ -431,7 +431,7 @@ export const EventSetupTab = (
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-subtle rounded-md border p-6">
|
<div className="border-subtle rounded-lg border p-6">
|
||||||
{multipleDuration ? (
|
{multipleDuration ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -527,7 +527,7 @@ export const EventSetupTab = (
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-subtle rounded-md border p-6">
|
<div className="border-subtle rounded-lg border p-6">
|
||||||
<div>
|
<div>
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
{t("location")}
|
{t("location")}
|
||||||
|
|||||||
@@ -268,9 +268,11 @@ function EventTypeSingleLayout({
|
|||||||
</Skeleton>
|
</Skeleton>
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
sideOffset={4}
|
||||||
content={
|
content={
|
||||||
formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")
|
formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")
|
||||||
}>
|
}
|
||||||
|
side="bottom">
|
||||||
<div className="self-center rounded-md p-2">
|
<div className="self-center rounded-md p-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="hiddenSwitch"
|
id="hiddenSwitch"
|
||||||
@@ -291,7 +293,7 @@ function EventTypeSingleLayout({
|
|||||||
{!isManagedEventType && (
|
{!isManagedEventType && (
|
||||||
<>
|
<>
|
||||||
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
||||||
<Tooltip content={t("preview")}>
|
<Tooltip content={t("preview")} side="bottom" sideOffset={4}>
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
data-testid="preview-button"
|
data-testid="preview-button"
|
||||||
@@ -308,6 +310,8 @@ function EventTypeSingleLayout({
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
StartIcon={LinkIcon}
|
StartIcon={LinkIcon}
|
||||||
tooltip={t("copy_link")}
|
tooltip={t("copy_link")}
|
||||||
|
tooltipSide="bottom"
|
||||||
|
tooltipOffset={4}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(permalink);
|
navigator.clipboard.writeText(permalink);
|
||||||
showToast("Link copied!", "success");
|
showToast("Link copied!", "success");
|
||||||
@@ -319,6 +323,8 @@ function EventTypeSingleLayout({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
tooltip={t("embed")}
|
tooltip={t("embed")}
|
||||||
|
tooltipSide="bottom"
|
||||||
|
tooltipOffset={4}
|
||||||
eventId={eventType.id}
|
eventId={eventType.id}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -329,6 +335,8 @@ function EventTypeSingleLayout({
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
StartIcon={Trash}
|
StartIcon={Trash}
|
||||||
tooltip={t("delete")}
|
tooltip={t("delete")}
|
||||||
|
tooltipSide="bottom"
|
||||||
|
tooltipOffset={4}
|
||||||
disabled={!hasPermsToDelete}
|
disabled={!hasPermsToDelete}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
|||||||
{t("add_webhook_description", { appName: APP_NAME })}
|
{t("add_webhook_description", { appName: APP_NAME })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="border-subtle mt-8 rounded-md border">
|
<div className="border-subtle my-8 rounded-md border">
|
||||||
{webhooks.map((webhook, index) => {
|
{webhooks.map((webhook, index) => {
|
||||||
return (
|
return (
|
||||||
<WebhookListItem
|
<WebhookListItem
|
||||||
@@ -141,7 +141,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-default mt-8 text-sm font-normal">
|
<p className="text-default text-sm font-normal">
|
||||||
<Trans i18nKey="edit_or_manage_webhooks">
|
<Trans i18nKey="edit_or_manage_webhooks">
|
||||||
If you wish to edit or manage your web hooks, please head over to
|
If you wish to edit or manage your web hooks, please head over to
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ export default function RecurringEventController({
|
|||||||
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
|
title="Experimental: Recurring Events are currently experimental and causes some issues sometimes when checking for availability. We are working on fixing this."
|
||||||
/>
|
/>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||||
recurringEventState !== null && "rounded-b-none"
|
recurringEventState !== null && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
@@ -78,7 +79,7 @@ export default function RecurringEventController({
|
|||||||
setRecurringEventState(newVal);
|
setRecurringEventState(newVal);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
{recurringEventState && (
|
{recurringEventState && (
|
||||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ export default function RequiresConfirmationController({
|
|||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
render={() => (
|
render={() => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
|
labelClassName="text-sm"
|
||||||
toggleSwitchAtTheEnd={true}
|
toggleSwitchAtTheEnd={true}
|
||||||
switchContainerClassName={classNames(
|
switchContainerClassName={classNames(
|
||||||
"border-subtle rounded-md border py-6 px-4 sm:px-6",
|
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||||
requiresConfirmation && "rounded-b-none"
|
requiresConfirmation && "rounded-b-none"
|
||||||
)}
|
)}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0"
|
||||||
@@ -83,7 +84,7 @@ export default function RequiresConfirmationController({
|
|||||||
formMethods.setValue("requiresConfirmation", val);
|
formMethods.setValue("requiresConfirmation", val);
|
||||||
onRequiresConfirmation(val);
|
onRequiresConfirmation(val);
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||||
<RadioGroup.Root
|
<RadioGroup.Root
|
||||||
defaultValue={
|
defaultValue={
|
||||||
requiresConfirmation
|
requiresConfirmation
|
||||||
@@ -147,7 +148,7 @@ export default function RequiresConfirmationController({
|
|||||||
val
|
val
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield]"
|
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
|
||||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||||
|
import { dir } from "i18next";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { SessionProvider, useSession } from "next-auth/react";
|
import { SessionProvider, useSession } from "next-auth/react";
|
||||||
import { EventCollectionProvider } from "next-collect/client";
|
import { EventCollectionProvider } from "next-collect/client";
|
||||||
@@ -8,6 +9,7 @@ import { ThemeProvider } from "next-themes";
|
|||||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||||
import type { ParsedUrlQuery } from "querystring";
|
import type { ParsedUrlQuery } from "querystring";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||||
@@ -71,13 +73,48 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
|||||||
/**
|
/**
|
||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
**/
|
**/
|
||||||
const clientViewerI18n = useViewerI18n(props.pageProps.newLocale);
|
|
||||||
|
const session = useSession();
|
||||||
|
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
|
||||||
|
delete window.document.documentElement["lang"];
|
||||||
|
|
||||||
|
window.document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
// Next.js writes the locale to the same attribute
|
||||||
|
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
|
||||||
|
// which can result in a race condition
|
||||||
|
// this property descriptor ensures this never happens
|
||||||
|
Object.defineProperty(window.document.documentElement, "lang", {
|
||||||
|
configurable: true,
|
||||||
|
// value: locale,
|
||||||
|
set: function (this) {
|
||||||
|
// empty setter on purpose
|
||||||
|
},
|
||||||
|
get: function () {
|
||||||
|
return locale;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
window.document.documentElement.lang = locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.document.dir = dir(locale);
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const clientViewerI18n = useViewerI18n(locale);
|
||||||
const i18n = clientViewerI18n.data?.i18n;
|
const i18n = clientViewerI18n.data?.i18n;
|
||||||
|
|
||||||
const passedProps = {
|
const passedProps = {
|
||||||
...props,
|
...props,
|
||||||
pageProps: {
|
pageProps: {
|
||||||
...props.pageProps,
|
...props.pageProps,
|
||||||
|
|
||||||
...i18n,
|
...i18n,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -247,8 +284,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||||||
|
|
||||||
const RemainingProviders = (
|
const RemainingProviders = (
|
||||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
<SessionProvider session={pageProps.session ?? undefined}>
|
||||||
<SessionProvider session={pageProps.session ?? undefined}>
|
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||||
<CalcomThemeProvider
|
<CalcomThemeProvider
|
||||||
@@ -264,8 +301,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||||||
</FeatureFlagsProvider>
|
</FeatureFlagsProvider>
|
||||||
</CalcomThemeProvider>
|
</CalcomThemeProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SessionProvider>
|
</CustomI18nextProvider>
|
||||||
</CustomI18nextProvider>
|
</SessionProvider>
|
||||||
</EventCollectionProvider>
|
</EventCollectionProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import type { RedirectType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||||
|
export const getTemporaryOrgRedirect = async ({
|
||||||
|
slug,
|
||||||
|
redirectType,
|
||||||
|
eventTypeSlug,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
redirectType: RedirectType;
|
||||||
|
eventTypeSlug: string | null;
|
||||||
|
}) => {
|
||||||
|
const prisma = (await import("@calcom/prisma")).default;
|
||||||
|
log.debug(
|
||||||
|
`Looking for redirect for`,
|
||||||
|
safeStringify({
|
||||||
|
slug,
|
||||||
|
redirectType,
|
||||||
|
eventTypeSlug,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const redirect = await prisma.tempOrgRedirect.findUnique({
|
||||||
|
where: {
|
||||||
|
from_type_fromOrgId: {
|
||||||
|
type: redirectType,
|
||||||
|
from: slug,
|
||||||
|
fromOrgId: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -235,7 +235,7 @@ const nextConfig = {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
...matcherConfigRootPath,
|
...matcherConfigRootPath,
|
||||||
destination: "/team/:orgSlug",
|
destination: "/team/:orgSlug?isOrgProfile=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...matcherConfigUserRoute,
|
...matcherConfigUserRoute,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.3.7",
|
"version": "3.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -166,6 +166,7 @@
|
|||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
"module-alias": "^2.2.2",
|
"module-alias": "^2.2.2",
|
||||||
"msw": "^0.42.3",
|
"msw": "^0.42.3",
|
||||||
|
"node-html-parser": "^6.1.10",
|
||||||
"postcss": "^8.4.18",
|
"postcss": "^8.4.18",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.6",
|
"tailwindcss-animate": "^1.0.6",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
|||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { EventType, User } from "@calcom/prisma/client";
|
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
|
||||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||||
@@ -35,6 +35,8 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
|
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
|
||||||
|
|
||||||
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
|
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
|
||||||
|
|
||||||
@@ -261,13 +263,14 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
context.params?.orgSlug
|
context.params?.orgSlug
|
||||||
);
|
);
|
||||||
const usernameList = getUsernameList(context.query.user as string);
|
const usernameList = getUsernameList(context.query.user as string);
|
||||||
|
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||||
const dataFetchStart = Date.now();
|
const dataFetchStart = Date.now();
|
||||||
const usersWithoutAvatar = await prisma.user.findMany({
|
const usersWithoutAvatar = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
username: {
|
username: {
|
||||||
in: usernameList,
|
in: usernameList,
|
||||||
},
|
},
|
||||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -275,6 +278,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
bio: true,
|
bio: true,
|
||||||
|
metadata: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
organizationId: true,
|
organizationId: true,
|
||||||
@@ -312,6 +316,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||||||
avatar: `/${user.username}/avatar.png`,
|
avatar: `/${user.username}/avatar.png`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (!isOrgContext) {
|
||||||
|
const redirect = await getTemporaryOrgRedirect({
|
||||||
|
slug: usernameList[0],
|
||||||
|
redirectType: RedirectType.User,
|
||||||
|
eventTypeSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
return redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations
|
|||||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { RedirectType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
|
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||||
|
|
||||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||||
|
|
||||||
export default function Type({
|
export default function Type({
|
||||||
@@ -93,7 +96,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
}
|
}
|
||||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||||||
if (!eventData) {
|
if (!eventData) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -150,6 +153,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
context.params?.orgSlug
|
context.params?.orgSlug
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||||
|
|
||||||
|
if (!isOrgContext) {
|
||||||
|
const redirect = await getTemporaryOrgRedirect({
|
||||||
|
slug: usernames[0],
|
||||||
|
redirectType: RedirectType.User,
|
||||||
|
eventTypeSlug: slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
return redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { ssrInit } = await import("@server/lib/ssr");
|
const { ssrInit } = await import("@server/lib/ssr");
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
@@ -167,7 +184,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
let booking: GetBookingType | null = null;
|
let booking: GetBookingType | null = null;
|
||||||
@@ -189,7 +206,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
if (!eventData) {
|
if (!eventData) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { IncomingMessage } from "http";
|
|||||||
import type { AppContextType } from "next/dist/shared/lib/utils";
|
import type { AppContextType } from "next/dist/shared/lib/utils";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers";
|
import type { AppProps } from "@lib/app-providers";
|
||||||
@@ -28,6 +27,7 @@ MyApp.getInitialProps = async (ctx: AppContextType) => {
|
|||||||
let newLocale = "en";
|
let newLocale = "en";
|
||||||
|
|
||||||
if (req) {
|
if (req) {
|
||||||
|
const { getLocale } = await import("@calcom/features/auth/lib/getLocale");
|
||||||
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
|
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
|
||||||
} else if (typeof window !== "undefined" && window.calNewLocale) {
|
} else if (typeof window !== "undefined" && window.calNewLocale) {
|
||||||
newLocale = window.calNewLocale;
|
newLocale = window.calNewLocale;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { IncomingMessage } from "http";
|
import type { IncomingMessage } from "http";
|
||||||
|
import { dir } from "i18next";
|
||||||
import type { NextPageContext } from "next";
|
import type { NextPageContext } from "next";
|
||||||
import type { DocumentContext, DocumentProps } from "next/document";
|
import type { DocumentContext, DocumentProps } from "next/document";
|
||||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { csp } from "@lib/csp";
|
import { csp } from "@lib/csp";
|
||||||
@@ -28,9 +28,12 @@ class MyDocument extends Document<Props> {
|
|||||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLocale = ctx.req
|
const getLocaleModule = ctx.req ? await import("@calcom/features/auth/lib/getLocale") : null;
|
||||||
? await getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
|
||||||
: "en";
|
const newLocale =
|
||||||
|
ctx.req && getLocaleModule
|
||||||
|
? await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||||
|
: "en";
|
||||||
|
|
||||||
const asPath = ctx.asPath || "";
|
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
|
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||||
@@ -48,21 +51,15 @@ class MyDocument extends Document<Props> {
|
|||||||
render() {
|
render() {
|
||||||
const { isEmbed, embedColorScheme } = this.props;
|
const { isEmbed, embedColorScheme } = this.props;
|
||||||
const newLocale = this.props.newLocale || "en";
|
const newLocale = this.props.newLocale || "en";
|
||||||
|
const newDir = dir(newLocale);
|
||||||
|
|
||||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||||
|
|
||||||
const intlLocale = new Intl.Locale(newLocale);
|
|
||||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
|
||||||
const direction = intlLocale.textInfo?.direction;
|
|
||||||
if (!direction) {
|
|
||||||
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html
|
<Html
|
||||||
lang={newLocale}
|
lang={newLocale}
|
||||||
dir={direction}
|
dir={newDir}
|
||||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||||
<Head nonce={nonce}>
|
<Head nonce={nonce}>
|
||||||
<script
|
<script
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
|
|||||||
err: AugmentedError;
|
err: AugmentedError;
|
||||||
};
|
};
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||||
|
|
||||||
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
||||||
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { prisma } from "@calcom/prisma";
|
|||||||
import type { AppCategories, Prisma } from "@calcom/prisma/client";
|
import type { AppCategories, Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
|
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
|
||||||
const log = logger.getChildLogger({
|
const log = logger.getSubLogger({
|
||||||
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
|
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "@calcom/lib/constants";
|
} from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[api/logo]"] });
|
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
|
||||||
|
|
||||||
function removePort(url: string) {
|
function removePort(url: string) {
|
||||||
return url.replace(/:\d+$/, "");
|
return url.replace(/:\d+$/, "");
|
||||||
|
|||||||
@@ -34,13 +34,35 @@ async function getIdentityData(req: NextApiRequest) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = await prisma.user.findFirst({
|
let user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
username,
|
username,
|
||||||
organization: orgQuery,
|
organization: orgQuery,
|
||||||
},
|
},
|
||||||
select: { avatar: true, email: true },
|
select: { avatar: true, email: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented
|
||||||
|
* Try the non-org user temporarily to support users part of a team but not part of the organization
|
||||||
|
* This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG.
|
||||||
|
* Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially.
|
||||||
|
*/
|
||||||
|
// No user found in the org, try the non-org user that might be part of the team that's part of an org
|
||||||
|
if (!user && orgQuery) {
|
||||||
|
// The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member.
|
||||||
|
user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username,
|
||||||
|
organization: null,
|
||||||
|
},
|
||||||
|
select: { avatar: true, email: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* TEMPORARY CODE ENDS
|
||||||
|
*/
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: username,
|
name: username,
|
||||||
email: user?.email,
|
email: user?.email,
|
||||||
@@ -48,6 +70,7 @@ async function getIdentityData(req: NextApiRequest) {
|
|||||||
org,
|
org,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (teamname) {
|
if (teamname) {
|
||||||
const team = await prisma.team.findFirst({
|
const team = await prisma.team.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function AppsSearch({
|
|||||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
|
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
|
||||||
@@ -54,6 +55,7 @@ function AppsSearch({
|
|||||||
type="search"
|
type="search"
|
||||||
autoComplete="false"
|
autoComplete="false"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
placeholder={t("search")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -18,6 +18,7 @@ import { ssrInit } from "@server/lib/ssr";
|
|||||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
export function Logout(props: Props) {
|
export function Logout(props: Props) {
|
||||||
|
const [btnLoading, setBtnLoading] = useState<boolean>(false);
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
if (status === "authenticated") signOut({ redirect: false });
|
if (status === "authenticated") signOut({ redirect: false });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,6 +36,11 @@ export function Logout(props: Props) {
|
|||||||
return "hope_to_see_you_soon";
|
return "hope_to_see_you_soon";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigateToLogin = () => {
|
||||||
|
setBtnLoading(true);
|
||||||
|
router.push("/auth/login");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
|
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -50,7 +56,11 @@ export function Logout(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button href="/auth/login" className="flex w-full justify-center">
|
<Button
|
||||||
|
data-testid="logout-btn"
|
||||||
|
onClick={navigateToLogin}
|
||||||
|
className="flex w-full justify-center"
|
||||||
|
loading={btnLoading}>
|
||||||
{t("go_back_login")}
|
{t("go_back_login")}
|
||||||
</Button>
|
</Button>
|
||||||
</AuthContainer>
|
</AuthContainer>
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ export default function Success(props: SuccessProps) {
|
|||||||
const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone();
|
const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone();
|
||||||
|
|
||||||
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
|
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
|
||||||
|
let rescheduleLocation: string | undefined;
|
||||||
|
if (
|
||||||
|
typeof props.bookingInfo.responses.location === "object" &&
|
||||||
|
"optionValue" in props.bookingInfo.responses.location
|
||||||
|
) {
|
||||||
|
rescheduleLocation = props.bookingInfo.responses.location.optionValue;
|
||||||
|
}
|
||||||
|
|
||||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||||
props?.bookingInfo?.metadata || {}
|
props?.bookingInfo?.metadata || {}
|
||||||
@@ -295,7 +302,14 @@ export default function Success(props: SuccessProps) {
|
|||||||
bookingInfo.status
|
bookingInfo.status
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rescheduleLocationToDisplay = getSuccessPageLocationMessage(
|
||||||
|
rescheduleLocation ?? "",
|
||||||
|
t,
|
||||||
|
bookingInfo.status
|
||||||
|
);
|
||||||
|
|
||||||
const providerName = guessEventLocationType(location)?.label;
|
const providerName = guessEventLocationType(location)?.label;
|
||||||
|
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||||
@@ -467,18 +481,50 @@ export default function Success(props: SuccessProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="mt-3 font-medium">{t("where")}</div>
|
<div className="mt-3 font-medium">{t("where")}</div>
|
||||||
<div className="col-span-2 mt-3" data-testid="where">
|
<div className="col-span-2 mt-3" data-testid="where">
|
||||||
{locationToDisplay.startsWith("http") ? (
|
{!rescheduleLocation || locationToDisplay === rescheduleLocationToDisplay ? (
|
||||||
<a
|
locationToDisplay.startsWith("http") ? (
|
||||||
href={locationToDisplay}
|
<a
|
||||||
target="_blank"
|
href={locationToDisplay}
|
||||||
title={locationToDisplay}
|
target="_blank"
|
||||||
className="text-default flex items-center gap-2 underline"
|
title={locationToDisplay}
|
||||||
rel="noreferrer">
|
className="text-default flex items-center gap-2"
|
||||||
{providerName || "Link"}
|
rel="noreferrer">
|
||||||
<ExternalLink className="text-default inline h-4 w-4" />
|
{providerName || "Link"}
|
||||||
</a>
|
<ExternalLink className="text-default inline h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
locationToDisplay
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
locationToDisplay
|
<>
|
||||||
|
{!!formerTime &&
|
||||||
|
(locationToDisplay.startsWith("http") ? (
|
||||||
|
<a
|
||||||
|
href={locationToDisplay}
|
||||||
|
target="_blank"
|
||||||
|
title={locationToDisplay}
|
||||||
|
className="text-default flex items-center gap-2 line-through"
|
||||||
|
rel="noreferrer">
|
||||||
|
{providerName || "Link"}
|
||||||
|
<ExternalLink className="text-default inline h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="line-through">{locationToDisplay}</p>
|
||||||
|
))}
|
||||||
|
{rescheduleLocationToDisplay.startsWith("http") ? (
|
||||||
|
<a
|
||||||
|
href={rescheduleLocationToDisplay}
|
||||||
|
target="_blank"
|
||||||
|
title={rescheduleLocationToDisplay}
|
||||||
|
className="text-default flex items-center gap-2"
|
||||||
|
rel="noreferrer">
|
||||||
|
{rescheduleProviderName || "Link"}
|
||||||
|
<ExternalLink className="text-default inline h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
rescheduleLocationToDisplay
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1042,7 +1088,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const parsedQuery = querySchema.safeParse(context.query);
|
const parsedQuery = querySchema.safeParse(context.query);
|
||||||
|
|
||||||
if (!parsedQuery.success) return { notFound: true };
|
if (!parsedQuery.success) return { notFound: true } as const;
|
||||||
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
||||||
|
|
||||||
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
|
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
|
||||||
@@ -1100,7 +1146,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
if (!bookingInfoRaw) {
|
if (!bookingInfoRaw) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTypeRaw = !bookingInfoRaw.eventTypeId
|
const eventTypeRaw = !bookingInfoRaw.eventTypeId
|
||||||
@@ -1109,7 +1155,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
if (!eventTypeRaw) {
|
if (!eventTypeRaw) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
|
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
|
||||||
@@ -1130,7 +1176,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
if (!eventTypeRaw.owner)
|
if (!eventTypeRaw.owner)
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
} as const;
|
||||||
eventTypeRaw.users.push({
|
eventTypeRaw.users.push({
|
||||||
...eventTypeRaw.owner,
|
...eventTypeRaw.owner,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
|
import { getServerSideProps as _getServerSideProps } from "../[uid]";
|
||||||
|
|
||||||
|
export { default } from "../[uid]";
|
||||||
|
|
||||||
|
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||||
@@ -184,7 +184,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
created: true,
|
created: true,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
showToast(t("event_type_updated_successfully"), "success");
|
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||||
},
|
},
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.viewer.eventTypes.get.invalidate();
|
await utils.viewer.eventTypes.get.invalidate();
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Trash,
|
Trash,
|
||||||
Upload,
|
Upload,
|
||||||
User as UserIcon,
|
|
||||||
Users,
|
Users,
|
||||||
} from "@calcom/ui/components/icon";
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
@@ -821,34 +820,6 @@ const Actions = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const orgBranding = useOrgBranding();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
className="my-4"
|
|
||||||
severity="info"
|
|
||||||
title={t("set_up_your_profile")}
|
|
||||||
message={t("set_up_your_profile_description", { orgName: orgBranding?.name })}
|
|
||||||
CustomIcon={UserIcon}
|
|
||||||
actions={
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button color="minimal" className="text-sky-700 hover:bg-sky-100" onClick={closeAction}>
|
|
||||||
{t("dismiss")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
className="border-sky-700 bg-sky-50 text-sky-700 hover:border-sky-900 hover:bg-sky-200"
|
|
||||||
href="/getting-started">
|
|
||||||
{t("set_up")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
@@ -984,7 +955,6 @@ const EventTypesPage = () => {
|
|||||||
heading={t("event_types_page_title")}
|
heading={t("event_types_page_title")}
|
||||||
hideHeadingOnMobile
|
hideHeadingOnMobile
|
||||||
subtitle={t("event_types_page_subtitle")}
|
subtitle={t("event_types_page_subtitle")}
|
||||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
|
||||||
beforeCTAactions={<Actions />}
|
beforeCTAactions={<Actions />}
|
||||||
CTA={<CTA data={data} />}>
|
CTA={<CTA data={data} />}>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||||
|
|
||||||
export { default } from "../[type]";
|
export { default } from "../[type]";
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||||
const ssrResponse = await _getServerSideProps(context);
|
|
||||||
if (ssrResponse.notFound) {
|
|
||||||
return ssrResponse;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...ssrResponse,
|
|
||||||
props: {
|
|
||||||
...ssrResponse.props,
|
|
||||||
isEmbed: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -45,7 +45,7 @@ const BillingView = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
|
<Meta title={t("billing")} description={t("manage_billing_description")} borderInShellHeader={true} />
|
||||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
||||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||||
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
||||||
{t("billing_portal")}
|
{t("billing_portal")}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@ const ApiKeysView = () => {
|
|||||||
<div>
|
<div>
|
||||||
{data?.length ? (
|
{data?.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="border-subtle rounded-b-md border border-t-0">
|
<div className="border-subtle rounded-b-lg border border-t-0">
|
||||||
{data.map((apiKey, index) => (
|
{data.map((apiKey, index) => (
|
||||||
<ApiKeyListItem
|
<ApiKeyListItem
|
||||||
key={apiKey.id}
|
key={apiKey.id}
|
||||||
@@ -98,7 +98,7 @@ const ApiKeysView = () => {
|
|||||||
Icon={LinkIcon}
|
Icon={LinkIcon}
|
||||||
headline={t("create_first_api_key")}
|
headline={t("create_first_api_key")}
|
||||||
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
description={t("create_first_api_key_description", { appName: APP_NAME })}
|
||||||
className="rounded-b-md rounded-t-none border-t-0"
|
className="rounded-b-lg rounded-t-none border-t-0"
|
||||||
buttonRaw={<NewApiKeyButton />}
|
buttonRaw={<NewApiKeyButton />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSele
|
|||||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||||
import { classNames } from "@calcom/lib";
|
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
|
||||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -35,7 +35,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} borderInShellHeader={false} />
|
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||||
<div className="border-subtle mt-6 space-y-6 rounded-t-xl border border-b-0 px-4 py-6 sm:px-6">
|
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||||
|
<SkeletonText className="h-8 w-1/3" />
|
||||||
|
</div>
|
||||||
|
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||||
@@ -48,7 +51,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
|
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-b-xl">
|
<div className="rounded-b-lg">
|
||||||
<SectionBottomActions align="end">
|
<SectionBottomActions align="end">
|
||||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||||
</SectionBottomActions>
|
</SectionBottomActions>
|
||||||
@@ -57,9 +60,6 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
|
||||||
const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
|
||||||
|
|
||||||
const AppearanceView = ({
|
const AppearanceView = ({
|
||||||
user,
|
user,
|
||||||
hasPaidPlan,
|
hasPaidPlan,
|
||||||
@@ -137,7 +137,7 @@ const AppearanceView = ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||||
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||||
<p className="text-default">{t("theme_applies_note")}</p>
|
<p className="text-default">{t("theme_applies_note")}</p>
|
||||||
@@ -149,13 +149,13 @@ const AppearanceView = ({
|
|||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
// Radio values don't support null as values, therefore we convert an empty string
|
// Radio values don't support null as values, therefore we convert an empty string
|
||||||
// back to null here.
|
// back to null here.
|
||||||
theme: values.theme || null,
|
theme: values.theme ?? null,
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||||
<ThemeLabel
|
<ThemeLabel
|
||||||
variant="system"
|
variant="system"
|
||||||
value={null}
|
value={undefined}
|
||||||
label={t("theme_system")}
|
label={t("theme_system")}
|
||||||
defaultChecked={user.theme === null}
|
defaultChecked={user.theme === null}
|
||||||
register={userThemeFormMethods.register}
|
register={userThemeFormMethods.register}
|
||||||
@@ -226,11 +226,7 @@ const AppearanceView = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
childrenClassName="lg:ml-0"
|
childrenClassName="lg:ml-0">
|
||||||
switchContainerClassName={classNames(
|
|
||||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
|
||||||
isCustomBrandColorChecked && "rounded-b-none"
|
|
||||||
)}>
|
|
||||||
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||||
<Controller
|
<Controller
|
||||||
name="brandColor"
|
name="brandColor"
|
||||||
@@ -241,7 +237,7 @@ const AppearanceView = ({
|
|||||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
defaultValue={user.brandColor}
|
defaultValue={user.brandColor}
|
||||||
resetDefaultValue="#292929"
|
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
try {
|
try {
|
||||||
checkWCAGContrastColor("#ffffff", value);
|
checkWCAGContrastColor("#ffffff", value);
|
||||||
@@ -273,7 +269,7 @@ const AppearanceView = ({
|
|||||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
defaultValue={user.darkBrandColor}
|
defaultValue={user.darkBrandColor}
|
||||||
resetDefaultValue="#fafafa"
|
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
try {
|
try {
|
||||||
checkWCAGContrastColor("#101010", value);
|
checkWCAGContrastColor("#101010", value);
|
||||||
@@ -328,7 +324,7 @@ const AppearanceView = ({
|
|||||||
setHideBrandingValue(checked);
|
setHideBrandingValue(checked);
|
||||||
mutation.mutate({ hideBranding: checked });
|
mutation.mutate({ hideBranding: checked });
|
||||||
}}
|
}}
|
||||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
switchContainerClassName="mt-6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import PageWrapper from "@components/PageWrapper";
|
|||||||
const SkeletonLoader = () => {
|
const SkeletonLoader = () => {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<div className="border-subtle mt-8 space-y-6 rounded-xl border px-4 py-6 sm:px-6">
|
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-4 py-6 sm:px-6">
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
@@ -109,11 +109,11 @@ const CalendarsView = () => {
|
|||||||
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||||
return data.connectedCalendars.length ? (
|
return data.connectedCalendars.length ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
<div className="border-subtle mt-8 rounded-t-lg border px-4 py-6 sm:px-6">
|
||||||
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
<h2 className="text-emphasis mb-1 text-base font-bold leading-5 tracking-wide">
|
||||||
{t("add_to_calendar")}
|
{t("add_to_calendar")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-default text-sm">{t("add_to_calendar_description")}</p>
|
<p className="text-default text-sm leading-tight">{t("add_to_calendar_description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
<div className="border-subtle flex w-full flex-col space-y-3 border border-x border-y-0 px-4 py-6 sm:px-6">
|
||||||
<DestinationCalendarSelector
|
<DestinationCalendarSelector
|
||||||
@@ -137,15 +137,15 @@ const CalendarsView = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</SectionBottomActions>
|
</SectionBottomActions>
|
||||||
|
|
||||||
<div className="border-subtle mt-8 rounded-t-xl border px-4 py-6 sm:px-6">
|
<div className="border-subtle mt-8 rounded-t-lg border px-4 py-6 sm:px-6">
|
||||||
<h4 className="text-emphasis text-base font-semibold leading-5">
|
<h4 className="text-emphasis text-base font-semibold leading-5">
|
||||||
{t("check_for_conflicts")}
|
{t("check_for_conflicts")}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-default pb-2 text-sm leading-5">{t("select_calendars")}</p>
|
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List
|
<List
|
||||||
className="border-subtle flex flex-col gap-6 rounded-b-xl border border-t-0 p-6"
|
className="border-subtle flex flex-col gap-6 rounded-b-lg border border-t-0 p-6"
|
||||||
noBorderTreatment>
|
noBorderTreatment>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
@@ -173,7 +173,7 @@ const CalendarsView = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item?.error === undefined && item.calendars && (
|
{item?.error === undefined && item.calendars && (
|
||||||
<ListItem className="flex-col rounded-md">
|
<ListItem className="flex-col rounded-lg">
|
||||||
<div className="flex w-full flex-1 items-center space-x-3 p-4 rtl:space-x-reverse">
|
<div className="flex w-full flex-1 items-center space-x-3 p-4 rtl:space-x-reverse">
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-xl border border-t-0 px-6 py-4">
|
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
<SkeletonText className="h-8 w-full" />
|
<SkeletonText className="h-8 w-full" />
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@ const ConferencingLayout = () => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AppList
|
<AppList
|
||||||
listClassName="rounded-xl rounded-t-none border-t-0"
|
listClassName="rounded-lg rounded-t-none border-t-0"
|
||||||
handleDisconnect={handleDisconnect}
|
handleDisconnect={handleDisconnect}
|
||||||
data={data}
|
data={data}
|
||||||
variant="conferencing"
|
variant="conferencing"
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
setIsAllowDynamicBookingChecked(checked);
|
setIsAllowDynamicBookingChecked(checked);
|
||||||
mutation.mutate({ allowDynamicBooking: checked });
|
mutation.mutate({ allowDynamicBooking: checked });
|
||||||
}}
|
}}
|
||||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
switchContainerClassName="mt-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
@@ -250,7 +250,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
setIsAllowSEOIndexingChecked(checked);
|
setIsAllowSEOIndexingChecked(checked);
|
||||||
mutation.mutate({ allowSEOIndexing: checked });
|
mutation.mutate({ allowSEOIndexing: checked });
|
||||||
}}
|
}}
|
||||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
switchContainerClassName="mt-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
@@ -263,7 +263,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
|||||||
setIsReceiveMonthlyDigestEmailChecked(checked);
|
setIsReceiveMonthlyDigestEmailChecked(checked);
|
||||||
mutation.mutate({ receiveMonthlyDigestEmail: checked });
|
mutation.mutate({ receiveMonthlyDigestEmail: checked });
|
||||||
}}
|
}}
|
||||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
switchContainerClassName="mt-6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
|||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
|
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-4 py-8">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||||
@@ -279,7 +279,7 @@ const ProfileView = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border-subtle mt-6 rounded-xl rounded-b-none border border-b-0 p-6">
|
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||||
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
|
|
||||||
|
|
||||||
import type { CalPageWrapper } from "@components/PageWrapper";
|
import type { CalPageWrapper } from "@components/PageWrapper";
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
const Page = TeamBillingView as CalPageWrapper;
|
import BillingPage from "../../settings/billing/index";
|
||||||
|
|
||||||
|
const Page = BillingPage as CalPageWrapper;
|
||||||
Page.PageWrapper = PageWrapper;
|
Page.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
@@ -66,8 +66,8 @@ const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"
|
|||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutation.mutate({ disableImpersonation: !checked });
|
mutation.mutate({ disableImpersonation: !checked });
|
||||||
}}
|
}}
|
||||||
|
switchContainerClassName="rounded-t-none border-t-0"
|
||||||
disabled={mutation.isLoading}
|
disabled={mutation.isLoading}
|
||||||
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const TwoFactorAuthView = () => {
|
|||||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6"
|
switchContainerClassName="rounded-t-none border-t-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EnableTwoFactorModal
|
<EnableTwoFactorModal
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
|
|
||||||
|
|
||||||
import type { CalPageWrapper } from "@components/PageWrapper";
|
import type { CalPageWrapper } from "@components/PageWrapper";
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
const Page = TeamBillingView as CalPageWrapper;
|
import BillingPage from "../../billing";
|
||||||
|
|
||||||
|
const Page = BillingPage as CalPageWrapper;
|
||||||
Page.PageWrapper = PageWrapper;
|
Page.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// This route is reachable by
|
||||||
|
// 1. /team/[slug]
|
||||||
|
// 2. / (when on org domain e.g. http://calcom.cal.com/. This is through a rewrite from next.config.js)
|
||||||
|
// Also the getServerSideProps and default export are reused by
|
||||||
|
// 1. org/[orgSlug]/team/[slug]
|
||||||
|
// 2. org/[orgSlug]/[user]/[type]
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -12,12 +18,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { RedirectType } from "@calcom/prisma/client";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||||
@@ -30,8 +38,10 @@ import Team from "@components/team/screens/Team";
|
|||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||||
|
|
||||||
|
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
|
||||||
function TeamPage({
|
function TeamPage({
|
||||||
team,
|
team,
|
||||||
isUnpublished,
|
isUnpublished,
|
||||||
@@ -272,26 +282,47 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
context.req.headers.host ?? "",
|
context.req.headers.host ?? "",
|
||||||
context.params?.orgSlug
|
context.params?.orgSlug
|
||||||
);
|
);
|
||||||
|
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||||
|
|
||||||
|
// Provided by Rewrite from next.config.js
|
||||||
|
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||||
const flags = await getFeatureFlagMap(prisma);
|
const flags = await getFeatureFlagMap(prisma);
|
||||||
|
const isOrganizationFeatureEnabled = flags["organizations"];
|
||||||
|
|
||||||
|
log.debug("getServerSideProps", {
|
||||||
|
isOrgProfile,
|
||||||
|
isOrganizationFeatureEnabled,
|
||||||
|
isValidOrgDomain,
|
||||||
|
currentOrgDomain,
|
||||||
|
});
|
||||||
|
|
||||||
const team = await getTeamWithMembers({
|
const team = await getTeamWithMembers({
|
||||||
slug: slugify(slug ?? ""),
|
slug: slugify(slug ?? ""),
|
||||||
orgSlug: currentOrgDomain,
|
orgSlug: currentOrgDomain,
|
||||||
isTeamView: true,
|
isTeamView: true,
|
||||||
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
|
isOrgView: isValidOrgDomain && isOrgProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isOrgContext && slug) {
|
||||||
|
const redirect = await getTemporaryOrgRedirect({
|
||||||
|
slug: slug,
|
||||||
|
redirectType: RedirectType.Team,
|
||||||
|
eventTypeSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
return redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
||||||
console.info("gSSP, team/[slug] - ", {
|
|
||||||
isValidOrgDomain,
|
|
||||||
currentOrgDomain,
|
|
||||||
ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES,
|
|
||||||
flags: JSON.stringify(flags),
|
|
||||||
});
|
|
||||||
// Taking care of sub-teams and orgs
|
// Taking care of sub-teams and orgs
|
||||||
if (
|
if (
|
||||||
(!isValidOrgDomain && team?.parent) ||
|
(!isValidOrgDomain && team?.parent) ||
|
||||||
(!isValidOrgDomain && !!metadata?.isOrganization) ||
|
(!isValidOrgDomain && !!metadata?.isOrganization) ||
|
||||||
flags["organizations"] !== true
|
!isOrganizationFeatureEnabled
|
||||||
) {
|
) {
|
||||||
return { notFound: true } as const;
|
return { notFound: true } as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
|
|||||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { RedirectType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
import PageWrapper from "@components/PageWrapper";
|
import PageWrapper from "@components/PageWrapper";
|
||||||
|
|
||||||
|
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
|
||||||
|
|
||||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||||
|
|
||||||
export default function Type({
|
export default function Type({
|
||||||
@@ -75,6 +78,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
context.req.headers.host ?? "",
|
context.req.headers.host ?? "",
|
||||||
context.params?.orgSlug
|
context.params?.orgSlug
|
||||||
);
|
);
|
||||||
|
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||||
|
|
||||||
|
if (!isOrgContext) {
|
||||||
|
const redirect = await getTemporaryOrgRedirect({
|
||||||
|
slug: teamSlug,
|
||||||
|
redirectType: RedirectType.Team,
|
||||||
|
eventTypeSlug: meetingSlug,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
return redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const team = await prisma.team.findFirst({
|
const team = await prisma.team.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function Teams() {
|
|||||||
CTA={
|
CTA={
|
||||||
(!user.organizationId || user.organization.isOrgAdmin) && (
|
(!user.organizationId || user.organization.isOrgAdmin) && (
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="new-team-btn"
|
||||||
variant="fab"
|
variant="fab"
|
||||||
StartIcon={Plus}
|
StartIcon={Plus}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { loginUser } from "../fixtures/regularBookings";
|
||||||
|
import { test } from "../lib/fixtures";
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Each Other Question", () => {
|
||||||
|
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, users, bookingPage }) => {
|
||||||
|
await loginUser(users);
|
||||||
|
await page.goto("/event-types");
|
||||||
|
await bookingPage.goToEventType("30 min");
|
||||||
|
await bookingPage.goToTab("event_advanced_tab_title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Address Question", () => {
|
||||||
|
test("Phone and Address required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Address question (both required)",
|
||||||
|
secondQuestion: "address",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and Address not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Address question (only phone required)",
|
||||||
|
secondQuestion: "address",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and checkbox group Question", () => {
|
||||||
|
const bookingOptions = { hasPlaceholder: false, isRequired: true };
|
||||||
|
test("Phone and checkbox group required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and checkbox group question (both required)",
|
||||||
|
secondQuestion: "checkbox",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and checkbox group not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and checkbox group question (only phone required)",
|
||||||
|
secondQuestion: "checkbox",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and checkbox Question", () => {
|
||||||
|
test("Phone and checkbox required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and checkbox question (both required)",
|
||||||
|
secondQuestion: "boolean",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
test("Phone and checkbox not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and checkbox (only phone required)",
|
||||||
|
secondQuestion: "boolean",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Long text Question", () => {
|
||||||
|
test("Phone and Long text required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Long Text question (both required)",
|
||||||
|
secondQuestion: "textarea",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and Long text not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Long Text question (only phone required)",
|
||||||
|
secondQuestion: "textarea",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Multi email Question", () => {
|
||||||
|
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||||
|
test("Phone and Multi email required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion(
|
||||||
|
"multiemail",
|
||||||
|
"multiemail-test",
|
||||||
|
"multiemail test",
|
||||||
|
true,
|
||||||
|
"multiemail test"
|
||||||
|
);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Multi Email question (both required)",
|
||||||
|
secondQuestion: "multiemail",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and Multi email not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion(
|
||||||
|
"multiemail",
|
||||||
|
"multiemail-test",
|
||||||
|
"multiemail test",
|
||||||
|
false,
|
||||||
|
"multiemail test"
|
||||||
|
);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Multi Email question (only phone required)",
|
||||||
|
secondQuestion: "multiemail",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and multiselect Question", () => {
|
||||||
|
test("Phone and multiselect text required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Multi Select question (both required)",
|
||||||
|
secondQuestion: "multiselect",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and multiselect text not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Multi Select question (only phone required)",
|
||||||
|
secondQuestion: "multiselect",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Number Question", () => {
|
||||||
|
test("Phone and Number required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Number question (both required)",
|
||||||
|
secondQuestion: "number",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and Number not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Number question (only phone required)",
|
||||||
|
secondQuestion: "number",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Radio group Question", () => {
|
||||||
|
test("Phone and Radio group required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Radio question (both required)",
|
||||||
|
secondQuestion: "radio",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and Radio group not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Radio question (only phone required)",
|
||||||
|
secondQuestion: "radio",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and select Question", () => {
|
||||||
|
test("Phone and select required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Select question (both required)",
|
||||||
|
secondQuestion: "select",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and select not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Select question (only phone required)",
|
||||||
|
secondQuestion: "select",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking With Phone Question and Short text question", () => {
|
||||||
|
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||||
|
test("Phone and Short text required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Text question (both required)",
|
||||||
|
secondQuestion: "text",
|
||||||
|
options: bookingOptions,
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Phone and Short text not required", async ({ bookingPage }) => {
|
||||||
|
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||||
|
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||||
|
await bookingPage.updateEventType();
|
||||||
|
const eventTypePage = await bookingPage.previewEventType();
|
||||||
|
await bookingPage.selectTimeSlot(eventTypePage);
|
||||||
|
await bookingPage.fillAndConfirmBooking({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||||
|
question: "phone",
|
||||||
|
fillText: "Test Phone question and Text question (only phone required)",
|
||||||
|
secondQuestion: "text",
|
||||||
|
options: { ...bookingOptions, isRequired: false },
|
||||||
|
});
|
||||||
|
await bookingPage.cancelAndRescheduleBooking(eventTypePage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,13 +3,16 @@ import type { Booking, Prisma } from "@prisma/client";
|
|||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import _dayjs from "@calcom/dayjs";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
|
||||||
type BookingFixture = ReturnType<typeof createBookingFixture>;
|
type BookingFixture = ReturnType<typeof createBookingFixture>;
|
||||||
|
|
||||||
|
// We default all dayjs calls to use Europe/London timezone
|
||||||
|
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
|
||||||
|
|
||||||
// creates a user fixture instance and stores the collection
|
// creates a user fixture instance and stores the collection
|
||||||
export const createBookingsFixture = (page: Page) => {
|
export const createBookingsFixture = (page: Page) => {
|
||||||
const store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
|
const store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import type { createUsersFixture } from "./users";
|
||||||
|
|
||||||
|
const reschedulePlaceholderText = "Let others know why you need to reschedule";
|
||||||
|
export const scheduleSuccessfullyText = "This meeting is scheduled";
|
||||||
|
|
||||||
|
const EMAIL = "test@test.com";
|
||||||
|
const EMAIL2 = "test2@test.com";
|
||||||
|
const PHONE = "+55 (32) 983289947";
|
||||||
|
|
||||||
|
type BookingOptions = {
|
||||||
|
hasPlaceholder?: boolean;
|
||||||
|
isReschedule?: boolean;
|
||||||
|
isRequired?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuestionActions {
|
||||||
|
[key: string]: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type customLocators = {
|
||||||
|
shouldChangeSelectLocator: boolean;
|
||||||
|
shouldUseLastRadioGroupLocator: boolean;
|
||||||
|
shouldUseFirstRadioGroupLocator: boolean;
|
||||||
|
shouldChangeMultiSelectLocator: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type fillAndConfirmBookingParams = {
|
||||||
|
eventTypePage: Page;
|
||||||
|
placeholderText: string;
|
||||||
|
question: string;
|
||||||
|
fillText: string;
|
||||||
|
secondQuestion: string;
|
||||||
|
options: BookingOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserFixture = ReturnType<typeof createUsersFixture>;
|
||||||
|
|
||||||
|
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
|
||||||
|
const questionActions: QuestionActions = {
|
||||||
|
phone: async () => {
|
||||||
|
await eventTypePage.locator('input[name="phone-test"]').clear();
|
||||||
|
await eventTypePage.locator('input[name="phone-test"]').fill(PHONE);
|
||||||
|
},
|
||||||
|
multiemail: async () => {
|
||||||
|
await eventTypePage.getByRole("button", { name: `${questionType} test` }).click();
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).fill(EMAIL);
|
||||||
|
await eventTypePage.getByTestId("add-another-guest").last().click();
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).last().fill(EMAIL2);
|
||||||
|
},
|
||||||
|
checkbox: async () => {
|
||||||
|
if (customLocators.shouldUseLastRadioGroupLocator || customLocators.shouldChangeMultiSelectLocator) {
|
||||||
|
await eventTypePage.getByLabel("Option 1").last().click();
|
||||||
|
await eventTypePage.getByLabel("Option 2").last().click();
|
||||||
|
} else if (customLocators.shouldUseFirstRadioGroupLocator) {
|
||||||
|
await eventTypePage.getByLabel("Option 1").first().click();
|
||||||
|
await eventTypePage.getByLabel("Option 2").first().click();
|
||||||
|
} else {
|
||||||
|
await eventTypePage.getByLabel("Option 1").click();
|
||||||
|
await eventTypePage.getByLabel("Option 2").click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
multiselect: async () => {
|
||||||
|
if (customLocators.shouldChangeMultiSelectLocator) {
|
||||||
|
await eventTypePage.locator("form svg").nth(1).click();
|
||||||
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
|
} else {
|
||||||
|
await eventTypePage.locator("form svg").last().click();
|
||||||
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boolean: async () => {
|
||||||
|
await eventTypePage.getByLabel(`${questionType} test`).check();
|
||||||
|
},
|
||||||
|
radio: async () => {
|
||||||
|
await eventTypePage.locator('[id="radio-test\\.option\\.0\\.radio"]').click();
|
||||||
|
},
|
||||||
|
select: async () => {
|
||||||
|
if (customLocators.shouldChangeSelectLocator) {
|
||||||
|
await eventTypePage.locator("form svg").nth(1).click();
|
||||||
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
|
} else {
|
||||||
|
await eventTypePage.locator("form svg").last().click();
|
||||||
|
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
number: async () => {
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("123");
|
||||||
|
},
|
||||||
|
address: async () => {
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("address test");
|
||||||
|
},
|
||||||
|
textarea: async () => {
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("textarea test");
|
||||||
|
},
|
||||||
|
text: async () => {
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||||
|
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (questionActions[questionType]) {
|
||||||
|
await questionActions[questionType]();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loginUser(users: UserFixture) {
|
||||||
|
const pro = await users.create({ name: "testuser" });
|
||||||
|
await pro.apiLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBookingPageFixture(page: Page) {
|
||||||
|
return {
|
||||||
|
goToEventType: async (eventType: string) => {
|
||||||
|
await page.getByRole("link", { name: eventType }).click();
|
||||||
|
},
|
||||||
|
goToTab: async (tabName: string) => {
|
||||||
|
await page.getByTestId(`vertical-tab-${tabName}`).click();
|
||||||
|
},
|
||||||
|
addQuestion: async (
|
||||||
|
questionType: string,
|
||||||
|
identifier: string,
|
||||||
|
label: string,
|
||||||
|
isRequired: boolean,
|
||||||
|
placeholder?: string
|
||||||
|
) => {
|
||||||
|
await page.getByTestId("add-field").click();
|
||||||
|
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
|
||||||
|
await page.getByTestId(`select-option-${questionType}`).click();
|
||||||
|
await page.getByLabel("Identifier").dblclick();
|
||||||
|
await page.getByLabel("Identifier").fill(identifier);
|
||||||
|
await page.getByLabel("Label").click();
|
||||||
|
await page.getByLabel("Label").fill(label);
|
||||||
|
if (placeholder) {
|
||||||
|
await page.getByLabel("Placeholder").click();
|
||||||
|
await page.getByLabel("Placeholder").fill(placeholder);
|
||||||
|
}
|
||||||
|
if (!isRequired) {
|
||||||
|
await page.getByRole("radio", { name: "No" }).click();
|
||||||
|
}
|
||||||
|
await page.getByTestId("field-add-save").click();
|
||||||
|
},
|
||||||
|
updateEventType: async () => {
|
||||||
|
await page.getByTestId("update-eventtype").click();
|
||||||
|
},
|
||||||
|
previewEventType: async () => {
|
||||||
|
const eventtypePromise = page.waitForEvent("popup");
|
||||||
|
await page.getByTestId("preview-button").click();
|
||||||
|
return eventtypePromise;
|
||||||
|
},
|
||||||
|
selectTimeSlot: async (eventTypePage: Page) => {
|
||||||
|
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||||
|
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||||
|
}
|
||||||
|
await eventTypePage.getByTestId("time").first().click();
|
||||||
|
},
|
||||||
|
clickReschedule: async () => {
|
||||||
|
await page.getByText("Reschedule").click();
|
||||||
|
},
|
||||||
|
navigateToAvailableTimeSlot: async () => {
|
||||||
|
while (await page.getByRole("button", { name: "View next" }).isVisible()) {
|
||||||
|
await page.getByRole("button", { name: "View next" }).click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectFirstAvailableTime: async () => {
|
||||||
|
await page.getByTestId("time").first().click();
|
||||||
|
},
|
||||||
|
fillRescheduleReasonAndConfirm: async () => {
|
||||||
|
await page.getByPlaceholder(reschedulePlaceholderText).click();
|
||||||
|
await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
|
||||||
|
await page.getByTestId("confirm-reschedule-button").click();
|
||||||
|
},
|
||||||
|
verifyReschedulingSuccess: async () => {
|
||||||
|
await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible();
|
||||||
|
},
|
||||||
|
cancelBookingWithReason: async () => {
|
||||||
|
await page.getByTestId("cancel").click();
|
||||||
|
await page.getByTestId("cancel_reason").fill("Test cancel");
|
||||||
|
await page.getByTestId("confirm_cancel").click();
|
||||||
|
},
|
||||||
|
verifyBookingCancellation: async () => {
|
||||||
|
await expect(page.getByTestId("cancelled-headline")).toBeVisible();
|
||||||
|
},
|
||||||
|
cancelAndRescheduleBooking: async (eventTypePage: Page) => {
|
||||||
|
await eventTypePage.getByText("Reschedule").click();
|
||||||
|
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||||
|
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||||
|
}
|
||||||
|
await eventTypePage.getByTestId("time").first().click();
|
||||||
|
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).click();
|
||||||
|
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
|
||||||
|
await eventTypePage.getByTestId("confirm-reschedule-button").click();
|
||||||
|
await expect(eventTypePage.getByText(scheduleSuccessfullyText)).toBeVisible();
|
||||||
|
await eventTypePage.getByTestId("cancel").click();
|
||||||
|
await eventTypePage.getByTestId("cancel_reason").fill("Test cancel");
|
||||||
|
await eventTypePage.getByTestId("confirm_cancel").click();
|
||||||
|
await expect(eventTypePage.getByTestId("cancelled-headline")).toBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
fillAndConfirmBooking: async ({
|
||||||
|
eventTypePage,
|
||||||
|
placeholderText,
|
||||||
|
question,
|
||||||
|
fillText,
|
||||||
|
secondQuestion,
|
||||||
|
options,
|
||||||
|
}: fillAndConfirmBookingParams) => {
|
||||||
|
const confirmButton = options.isReschedule ? "confirm-reschedule-button" : "confirm-book-button";
|
||||||
|
|
||||||
|
await expect(eventTypePage.getByText(`${secondQuestion} test`).first()).toBeVisible();
|
||||||
|
await eventTypePage.getByPlaceholder(placeholderText).fill(fillText);
|
||||||
|
|
||||||
|
// Change the selector for specifics cases related to select question
|
||||||
|
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
|
||||||
|
question === "select" && ["multiemail", "multiselect"].includes(secondQuestion);
|
||||||
|
|
||||||
|
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
||||||
|
question === "radio" && secondQuestion === "checkbox";
|
||||||
|
|
||||||
|
const shouldUseFirstRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
||||||
|
question === "checkbox" && secondQuestion === "radio";
|
||||||
|
|
||||||
|
const shouldChangeMultiSelectLocator = (question: string, secondQuestion: string): boolean =>
|
||||||
|
question === "multiselect" &&
|
||||||
|
["address", "checkbox", "multiemail", "select"].includes(secondQuestion);
|
||||||
|
|
||||||
|
const customLocators = {
|
||||||
|
shouldChangeSelectLocator: shouldChangeSelectLocator(question, secondQuestion),
|
||||||
|
shouldUseLastRadioGroupLocator: shouldUseLastRadioGroupLocator(question, secondQuestion),
|
||||||
|
shouldUseFirstRadioGroupLocator: shouldUseFirstRadioGroupLocator(question, secondQuestion),
|
||||||
|
shouldChangeMultiSelectLocator: shouldChangeMultiSelectLocator(question, secondQuestion),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill the first question
|
||||||
|
await fillQuestion(eventTypePage, question, customLocators);
|
||||||
|
|
||||||
|
// Fill the second question if is required
|
||||||
|
options.isRequired && (await fillQuestion(eventTypePage, secondQuestion, customLocators));
|
||||||
|
|
||||||
|
await eventTypePage.getByTestId(confirmButton).click();
|
||||||
|
const scheduleSuccessfullyPage = eventTypePage.getByText(scheduleSuccessfullyText);
|
||||||
|
await scheduleSuccessfullyPage.waitFor({ state: "visible" });
|
||||||
|
await expect(scheduleSuccessfullyPage).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,14 +4,14 @@ import { Prisma as PrismaType } from "@prisma/client";
|
|||||||
import { hashSync as hash } from "bcryptjs";
|
import { hashSync as hash } from "bcryptjs";
|
||||||
import type { API } from "mailhog";
|
import type { API } from "mailhog";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||||
import type { TimeZoneEnum } from "./types";
|
import { TimeZoneEnum } from "./types";
|
||||||
|
|
||||||
// Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests.
|
// Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests.
|
||||||
export function hashPassword(password: string) {
|
export function hashPassword(password: string) {
|
||||||
@@ -288,6 +288,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||||||
const teamEvent = await createTeamEventType(user, team, scenario);
|
const teamEvent = await createTeamEventType(user, team, scenario);
|
||||||
if (scenario.teammates) {
|
if (scenario.teammates) {
|
||||||
// Create Teammate users
|
// Create Teammate users
|
||||||
|
const teamMatesIds = [];
|
||||||
for (const teammateObj of scenario.teammates) {
|
for (const teammateObj of scenario.teammates) {
|
||||||
const teamUser = await prisma.user.create({
|
const teamUser = await prisma.user.create({
|
||||||
data: createUser(workerInfo, teammateObj),
|
data: createUser(workerInfo, teammateObj),
|
||||||
@@ -319,8 +320,22 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||||||
}),
|
}),
|
||||||
store.page
|
store.page
|
||||||
);
|
);
|
||||||
|
teamMatesIds.push(teamUser.id);
|
||||||
store.users.push(teammateFixture);
|
store.users.push(teammateFixture);
|
||||||
}
|
}
|
||||||
|
// Add Teammates to OrgUsers
|
||||||
|
if (scenario.isOrg) {
|
||||||
|
await prisma.team.update({
|
||||||
|
where: {
|
||||||
|
id: team.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
orgUsers: {
|
||||||
|
connect: teamMatesIds.map((userId) => ({ id: userId })).concat([{ id: user.id }]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const userFixture = createUserFixture(user, store.page);
|
const userFixture = createUserFixture(user, store.page);
|
||||||
@@ -461,13 +476,14 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||||||
password: hashPassword(uname),
|
password: hashPassword(uname),
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||||
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
|
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||||
locale: opts?.locale ?? "en",
|
locale: opts?.locale ?? "en",
|
||||||
schedules:
|
schedules:
|
||||||
opts?.completedOnboarding ?? true
|
opts?.completedOnboarding ?? true
|
||||||
? {
|
? {
|
||||||
create: {
|
create: {
|
||||||
name: "Working Hours",
|
name: "Working Hours",
|
||||||
|
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||||
availability: {
|
availability: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||||
@@ -543,7 +559,7 @@ export async function apiLogin(
|
|||||||
const data = {
|
const data = {
|
||||||
email: user.email ?? `${user.username}@example.com`,
|
email: user.email ?? `${user.username}@example.com`,
|
||||||
password: user.password ?? user.username,
|
password: user.password ?? user.username,
|
||||||
callbackURL: "http://localhost:3000/",
|
callbackURL: WEBAPP_URL,
|
||||||
redirect: "false",
|
redirect: "false",
|
||||||
json: "true",
|
json: "true",
|
||||||
csrfToken,
|
csrfToken,
|
||||||
|
|||||||
@@ -236,4 +236,34 @@ test.describe("Insights", async () => {
|
|||||||
// expect for "Team: test-insight" text in page
|
// expect for "Team: test-insight" text in page
|
||||||
expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy();
|
expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should test download button", async ({ page, users }) => {
|
||||||
|
const owner = await users.create();
|
||||||
|
const member = await users.create();
|
||||||
|
|
||||||
|
await createTeamsAndMembership(owner.id, member.id);
|
||||||
|
|
||||||
|
await owner.apiLogin();
|
||||||
|
|
||||||
|
await page.goto("/insights");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
|
||||||
|
// Expect download button to be visible
|
||||||
|
expect(await page.locator("text=Download").isVisible()).toBeTruthy();
|
||||||
|
|
||||||
|
// Click on Download button
|
||||||
|
await page.getByText("Download").click();
|
||||||
|
|
||||||
|
// Expect as csv option to be visible
|
||||||
|
expect(await page.locator("text=as CSV").isVisible()).toBeTruthy();
|
||||||
|
|
||||||
|
// Start waiting for download before clicking. Note no await.
|
||||||
|
await page.getByText("as CSV").click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||||
|
await download.saveAs("./" + "test-insights.csv");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
|||||||
import { createBookingsFixture } from "../fixtures/bookings";
|
import { createBookingsFixture } from "../fixtures/bookings";
|
||||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||||
import { createPaymentsFixture } from "../fixtures/payments";
|
import { createPaymentsFixture } from "../fixtures/payments";
|
||||||
|
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||||
import { createServersFixture } from "../fixtures/servers";
|
import { createServersFixture } from "../fixtures/servers";
|
||||||
import { createUsersFixture } from "../fixtures/users";
|
import { createUsersFixture } from "../fixtures/users";
|
||||||
@@ -24,6 +25,7 @@ export interface Fixtures {
|
|||||||
prisma: typeof prisma;
|
prisma: typeof prisma;
|
||||||
emails?: API;
|
emails?: API;
|
||||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||||
|
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -80,4 +82,8 @@ export const test = base.extend<Fixtures>({
|
|||||||
await use(undefined);
|
await use(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
bookingPage: async ({ page }, use) => {
|
||||||
|
const bookingPage = createBookingPageFixture(page);
|
||||||
|
await use(bookingPage);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (de)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "de",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=de]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Willkommen zurück", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (ar)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "ar",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("أهلاً بك من جديد", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (zh)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "zh",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=zh]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("欢迎回来", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (zh-CN)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "zh-CN",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=zh-CN]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("欢迎回来", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (zh-TW)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "zh-TW",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=zh-TW]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("歡迎回來", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (pt)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "pt",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Olá novamente", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (pt-br)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "pt-br",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bem-vindo(a) novamente", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (es-419)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "es-419",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=es-419]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bienvenido de nuevo", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees correct translations (de)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a de user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "de",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /event-types and show German translations", async () => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=de]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Ereignistypen", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Event Types", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /bookings and show German translations", async () => {
|
||||||
|
await page.goto("/bookings");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=de]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Buchungen", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload the /bookings and show German translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=de]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Buchungen", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees correct translations (pt-br)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a pt-br user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "pt-br",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Tipos de Eventos", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Event Types", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.goto("/bookings");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Reservas", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Reservas", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees correct translations (ar)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a de user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "ar",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /event-types and show Arabic translations", async () => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("أنواع الحدث", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Event Types", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /bookings and show Arabic translations", async () => {
|
||||||
|
await page.goto("/bookings");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("عمليات الحجز", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload the /bookings and show Arabic translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("عمليات الحجز", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees changed translations (de->ar)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a de user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "de",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should change the language and show Arabic translations", async () => {
|
||||||
|
await page.goto("/settings/my-account/general");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
|
||||||
|
await page.locator("#react-select-2-option-0").click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Aktualisieren" }).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Einstellungen erfolgreich aktualisiert" })
|
||||||
|
.waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("عام", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Allgemein", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload and show Arabic translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("عام", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Allgemein", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees changed translations (de->pt-BR) [locale1]", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a de user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "de",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should change the language and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.goto("/settings/my-account/general");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
|
||||||
|
await page.locator("#react-select-2-option-14").click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Aktualisieren" }).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Einstellungen erfolgreich aktualisiert" })
|
||||||
|
.waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Geral", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Allgemein", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Geral", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Allgemein", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ test.describe("user can login & logout succesfully", async () => {
|
|||||||
const signOutBtn = await page.locator(`text=${signOutLabel}`);
|
const signOutBtn = await page.locator(`text=${signOutLabel}`);
|
||||||
await signOutBtn.click();
|
await signOutBtn.click();
|
||||||
|
|
||||||
await page.locator('a[href="/auth/login"]').click();
|
await page.locator("[data-testid=logout-btn]").click();
|
||||||
|
|
||||||
// Reroute to the home page to check if the login form shows up
|
// Reroute to the home page to check if the login form shows up
|
||||||
await expect(page.locator(`[data-testid=login-form]`)).toBeVisible();
|
await expect(page.locator(`[data-testid=login-form]`)).toBeVisible();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
|||||||
@@ -135,6 +135,40 @@ test.describe("Teams", () => {
|
|||||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||||
// TODO: Assert whether the user received an email
|
// TODO: Assert whether the user received an email
|
||||||
});
|
});
|
||||||
|
test("Non admin team members cannot create team in org", async ({ page, users }) => {
|
||||||
|
const teamMateName = "teammate-1";
|
||||||
|
|
||||||
|
const owner = await users.create(undefined, {
|
||||||
|
hasTeam: true,
|
||||||
|
isOrg: true,
|
||||||
|
teammates: [{ name: teamMateName }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allUsers = await users.get();
|
||||||
|
const memberUser = allUsers.find((user) => user.name === teamMateName);
|
||||||
|
|
||||||
|
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||||
|
if (memberUser) {
|
||||||
|
await memberUser.apiLogin();
|
||||||
|
|
||||||
|
await page.goto("/teams");
|
||||||
|
await expect(page.locator("[data-testid=new-team-btn]")).toBeHidden();
|
||||||
|
await expect(page.locator("[data-testid=create-team-btn]")).toHaveAttribute("disabled", "");
|
||||||
|
|
||||||
|
const uniqueName = "test-unique-team-name";
|
||||||
|
|
||||||
|
// Go directly to the create team page
|
||||||
|
await page.goto("/settings/teams/new");
|
||||||
|
// Fill input[name="name"]
|
||||||
|
await page.locator('input[name="name"]').fill(uniqueName);
|
||||||
|
await page.locator("text=Continue").click();
|
||||||
|
await expect(page.locator("[data-testid=alert]")).toBeVisible();
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
const org = await owner.getOrg();
|
||||||
|
await prisma.team.delete({ where: { id: org.teamId } });
|
||||||
|
}
|
||||||
|
});
|
||||||
test("Can create team with same name as user", async ({ page, users }) => {
|
test("Can create team with same name as user", async ({ page, users }) => {
|
||||||
// Name to be used for both user and team
|
// Name to be used for both user and team
|
||||||
const uniqueName = "test-unique-name";
|
const uniqueName = "test-unique-name";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import _dayjs from "@calcom/dayjs";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
@@ -9,6 +9,9 @@ test.describe.configure({ mode: "parallel" });
|
|||||||
|
|
||||||
test.afterEach(({ users }) => users.deleteAll());
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
|
// We default all dayjs calls to use Europe/London timezone
|
||||||
|
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
|
||||||
|
|
||||||
test.describe("Wipe my Cal App Test", () => {
|
test.describe("Wipe my Cal App Test", () => {
|
||||||
test("Browse upcoming bookings and validate button shows and triggering wipe my cal button", async ({
|
test("Browse upcoming bookings and validate button shows and triggering wipe my cal button", async ({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -1957,8 +1957,6 @@
|
|||||||
"org_no_teams_yet": "هذه المنظمة ليس لديها فرق بعد",
|
"org_no_teams_yet": "هذه المنظمة ليس لديها فرق بعد",
|
||||||
"org_no_teams_yet_description": "إذا كنت مسؤول، تأكد من إنشاء فرق لعرضها هنا.",
|
"org_no_teams_yet_description": "إذا كنت مسؤول، تأكد من إنشاء فرق لعرضها هنا.",
|
||||||
"set_up": "الإعداد",
|
"set_up": "الإعداد",
|
||||||
"set_up_your_profile": "إعداد ملفك الشخصي",
|
|
||||||
"set_up_your_profile_description": "دع الناس يعرفون من أنت داخل {{orgName}}، ومتى يتعاملون مع الرابط العام الخاص بك.",
|
|
||||||
"my_profile": "الملف الشخصي",
|
"my_profile": "الملف الشخصي",
|
||||||
"my_settings": "الإعدادات",
|
"my_settings": "الإعدادات",
|
||||||
"crm": "CRM",
|
"crm": "CRM",
|
||||||
|
|||||||
@@ -1957,8 +1957,6 @@
|
|||||||
"org_no_teams_yet": "Tato organizace zatím neobsahuje žádné týmy",
|
"org_no_teams_yet": "Tato organizace zatím neobsahuje žádné týmy",
|
||||||
"org_no_teams_yet_description": "Pokud jste správce, nezapomeňte vytvořit týmy, které se zde budou zobrazovat.",
|
"org_no_teams_yet_description": "Pokud jste správce, nezapomeňte vytvořit týmy, které se zde budou zobrazovat.",
|
||||||
"set_up": "Nastavení",
|
"set_up": "Nastavení",
|
||||||
"set_up_your_profile": "Nastavení profilu",
|
|
||||||
"set_up_your_profile_description": "Dejte lidem vědět, co v rámci organizace {{orgName}} děláte, pokud si otevřou váš veřejný odkaz.",
|
|
||||||
"my_profile": "Můj profil",
|
"my_profile": "Můj profil",
|
||||||
"my_settings": "Moje nastavení",
|
"my_settings": "Moje nastavení",
|
||||||
"crm": "CRM",
|
"crm": "CRM",
|
||||||
|
|||||||
@@ -1965,8 +1965,6 @@
|
|||||||
"org_no_teams_yet": "Diese Organization hat noch keine Teams",
|
"org_no_teams_yet": "Diese Organization hat noch keine Teams",
|
||||||
"org_no_teams_yet_description": "Falls Sie ein Administrator sind, sollten Sie unbedingt Teams erstellen, welche hier angezeigt werden können.",
|
"org_no_teams_yet_description": "Falls Sie ein Administrator sind, sollten Sie unbedingt Teams erstellen, welche hier angezeigt werden können.",
|
||||||
"set_up": "Einrichten",
|
"set_up": "Einrichten",
|
||||||
"set_up_your_profile": "Richten Sie Ihr Profil ein",
|
|
||||||
"set_up_your_profile_description": "Lassen Sie Leute beim Klicken auf Ihren öffentlichen Link wissen, welche Funktion Sie innerhalb von {{orgName}} haben.",
|
|
||||||
"my_profile": "Mein Profil",
|
"my_profile": "Mein Profil",
|
||||||
"my_settings": "Meine Einstellungen",
|
"my_settings": "Meine Einstellungen",
|
||||||
"crm": "CRM",
|
"crm": "CRM",
|
||||||
|
|||||||
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user