Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -0,0 +1,18 @@
|
|||||||
|
name: Add comment
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- labeled
|
||||||
|
jobs:
|
||||||
|
add-comment:
|
||||||
|
if: github.event.label.name == '🚨 needs approval'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Add comment
|
||||||
|
uses: peter-evans/create-or-update-comment@5f728c3dae25f329afbe34ee4d08eef25569d79f
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.issue.number }}
|
||||||
|
body: |
|
||||||
|
This feature request has not been reviewed yet by the Product Team and needs approval beforehand. Once approved, this issue is available for anyone to work on. **Make sure to reference this issue in your pull request.** :sparkles: Thank you for your contribution! :sparkles:
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
@@ -417,7 +425,7 @@ function EventTypeSingleLayout({
|
|||||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||||
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
|
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
<div className="w-full overflow-hidden ltr:mr-2 rtl:ml-2">
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
|
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.3.7",
|
"version": "3.4.1",
|
||||||
"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+$/, "");
|
||||||
|
|||||||
@@ -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")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -18,6 +18,7 @@ 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,6 +31,8 @@ import Team from "@components/team/screens/Team";
|
|||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
|
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||||
|
|
||||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
function TeamPage({
|
function TeamPage({
|
||||||
@@ -272,6 +275,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
context.req.headers.host ?? "",
|
context.req.headers.host ?? "",
|
||||||
context.params?.orgSlug
|
context.params?.orgSlug
|
||||||
);
|
);
|
||||||
|
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||||
|
|
||||||
const flags = await getFeatureFlagMap(prisma);
|
const flags = await getFeatureFlagMap(prisma);
|
||||||
const team = await getTeamWithMembers({
|
const team = await getTeamWithMembers({
|
||||||
slug: slugify(slug ?? ""),
|
slug: slugify(slug ?? ""),
|
||||||
@@ -279,6 +284,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
isTeamView: true,
|
isTeamView: true,
|
||||||
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
|
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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] - ", {
|
console.info("gSSP, team/[slug] - ", {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -290,6 +290,7 @@
|
|||||||
"where": "Where",
|
"where": "Where",
|
||||||
"add_to_calendar": "Add to calendar",
|
"add_to_calendar": "Add to calendar",
|
||||||
"add_to_calendar_description":"Select where to add events when you’re booked.",
|
"add_to_calendar_description":"Select where to add events when you’re booked.",
|
||||||
|
"add_events_to":"Add events to",
|
||||||
"add_another_calendar": "Add another calendar",
|
"add_another_calendar": "Add another calendar",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"email_sign_in_subject": "Your sign-in link for {{appName}}",
|
"email_sign_in_subject": "Your sign-in link for {{appName}}",
|
||||||
@@ -333,7 +334,7 @@
|
|||||||
"bookerlayout_error_min_one_enabled": "At least one layout has to be enabled.",
|
"bookerlayout_error_min_one_enabled": "At least one layout has to be enabled.",
|
||||||
"bookerlayout_error_default_not_enabled": "The layout you selected as the default view is not part of the enabled layouts.",
|
"bookerlayout_error_default_not_enabled": "The layout you selected as the default view is not part of the enabled layouts.",
|
||||||
"bookerlayout_error_unknown_layout": "The layout you selected is not a valid layout.",
|
"bookerlayout_error_unknown_layout": "The layout you selected is not a valid layout.",
|
||||||
"bookerlayout_override_global_settings": "You can manage this for all your event types in <2>settings / appearance</2> or <6>override for this event only</6>.",
|
"bookerlayout_override_global_settings": "You can manage this for all your event types in Settings -> <2>Appearance</2> or <6>Override</6> for this event only.",
|
||||||
"unexpected_error_try_again": "An unexpected error occurred. Try again.",
|
"unexpected_error_try_again": "An unexpected error occurred. Try again.",
|
||||||
"sunday_time_error": "Invalid time on Sunday",
|
"sunday_time_error": "Invalid time on Sunday",
|
||||||
"monday_time_error": "Invalid time on Monday",
|
"monday_time_error": "Invalid time on Monday",
|
||||||
@@ -459,6 +460,7 @@
|
|||||||
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
|
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
|
||||||
"edit_logo": "Edit logo",
|
"edit_logo": "Edit logo",
|
||||||
"upload_a_logo": "Upload a logo",
|
"upload_a_logo": "Upload a logo",
|
||||||
|
"upload_logo": "Upload logo",
|
||||||
"remove_logo": "Remove logo",
|
"remove_logo": "Remove logo",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"code": "Code",
|
"code": "Code",
|
||||||
@@ -571,6 +573,7 @@
|
|||||||
"your_team_name": "Your team name",
|
"your_team_name": "Your team name",
|
||||||
"team_updated_successfully": "Team updated successfully",
|
"team_updated_successfully": "Team updated successfully",
|
||||||
"your_team_updated_successfully": "Your team has been updated successfully.",
|
"your_team_updated_successfully": "Your team has been updated successfully.",
|
||||||
|
"your_org_updated_successfully": "Your Org has been updated successfully.",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"team_description": "A few sentences about your team. This will appear on your team's url page.",
|
"team_description": "A few sentences about your team. This will appear on your team's url page.",
|
||||||
"org_description": "A few sentences about your organization. This will appear on your organization's url page.",
|
"org_description": "A few sentences about your organization. This will appear on your organization's url page.",
|
||||||
@@ -685,8 +688,8 @@
|
|||||||
"new_event_type_btn": "New event type",
|
"new_event_type_btn": "New event type",
|
||||||
"new_event_type_heading": "Create your first event type",
|
"new_event_type_heading": "Create your first event type",
|
||||||
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
|
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
|
||||||
"event_type_created_successfully": "Event type created successfully",
|
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||||
"event_type_updated_successfully": "Event type updated successfully",
|
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||||
"hours": "Hours",
|
"hours": "Hours",
|
||||||
"people": "People",
|
"people": "People",
|
||||||
@@ -783,6 +786,7 @@
|
|||||||
"disable_guests": "Disable Guests",
|
"disable_guests": "Disable Guests",
|
||||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||||
"private_link": "Generate private link",
|
"private_link": "Generate private link",
|
||||||
|
"enable_private_url": "Enable Private URL",
|
||||||
"private_link_label": "Private link",
|
"private_link_label": "Private link",
|
||||||
"private_link_hint": "Your private link will regenerate after each use",
|
"private_link_hint": "Your private link will regenerate after each use",
|
||||||
"copy_private_link": "Copy private link",
|
"copy_private_link": "Copy private link",
|
||||||
@@ -2079,6 +2083,7 @@
|
|||||||
"allow": "Allow",
|
"allow": "Allow",
|
||||||
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
|
"view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
|
||||||
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
|
"view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.",
|
||||||
|
"you_can_override_calendar_in_advanced_tab":"You can override this on a per-event basis in Advanced settings in each event type.",
|
||||||
"edit_users_availability":"Edit user's availability: {{username}}",
|
"edit_users_availability":"Edit user's availability: {{username}}",
|
||||||
"resend_invitation": "Resend invitation",
|
"resend_invitation": "Resend invitation",
|
||||||
"invitation_resent": "The invitation was resent.",
|
"invitation_resent": "The invitation was resent.",
|
||||||
@@ -2086,6 +2091,7 @@
|
|||||||
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
|
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
|
||||||
"add_new_client": "Add new Client",
|
"add_new_client": "Add new Client",
|
||||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||||
|
"as_csv": "as CSV",
|
||||||
"overlay_my_calendar":"Overlay my calendar",
|
"overlay_my_calendar":"Overlay my calendar",
|
||||||
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||||
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||||
|
|||||||
@@ -681,8 +681,8 @@
|
|||||||
"new_event_type_btn": "Nouveau type d'événement",
|
"new_event_type_btn": "Nouveau type d'événement",
|
||||||
"new_event_type_heading": "Créez votre premier type d'événement",
|
"new_event_type_heading": "Créez votre premier type d'événement",
|
||||||
"new_event_type_description": "Les types d'événements vous permettent de partager des liens qui affichent vos disponibilités sur votre calendrier et permettent aux personnes de réserver des créneaux.",
|
"new_event_type_description": "Les types d'événements vous permettent de partager des liens qui affichent vos disponibilités sur votre calendrier et permettent aux personnes de réserver des créneaux.",
|
||||||
"event_type_created_successfully": "Type d'événement créé avec succès",
|
"event_type_created_successfully": "{{eventTypeTitle}} type d'événement créé avec succès",
|
||||||
"event_type_updated_successfully": "Type d'événement mis à jour avec succès",
|
"event_type_updated_successfully": "{{eventTypeTitle}} type d'événement mis à jour avec succès",
|
||||||
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
|
"event_type_deleted_successfully": "Type d'événement supprimé avec succès",
|
||||||
"hours": "heures",
|
"hours": "heures",
|
||||||
"people": "Personnes",
|
"people": "Personnes",
|
||||||
|
|||||||
@@ -1 +1,128 @@
|
|||||||
{}
|
{
|
||||||
|
"identity_provider": "Identitātes nodrošinātājs",
|
||||||
|
"trial_days_left": "Jūsu PRO izmēģinājuma ir atlikušas $t(day, {\"count\": {{days}} })",
|
||||||
|
"day_one": "{{count}} diena",
|
||||||
|
"day_other": "{{count}} dienas",
|
||||||
|
"second_one": "{{count}} sekunde",
|
||||||
|
"second_other": "{{count}} sekundes",
|
||||||
|
"upgrade_now": "Atjaunināt tagad",
|
||||||
|
"accept_invitation": "Apstiprināt Uzaicinājumu",
|
||||||
|
"calcom_explained": "{{appName}} nodrošina plānošanas infrastruktūru pilnīgi visiem.",
|
||||||
|
"calcom_explained_new_user": "Pabeidziet sava {{appName}} konta iestatīšanu! Jūs esat tikai dažu soļu attālumā no visu plānošanas problēmu atrisināšanas.",
|
||||||
|
"have_any_questions": "Jums ir jautājumi? Mēs esam šeit, lai palīdzētu.",
|
||||||
|
"reset_password_subject": "{{appName}}: Paroles atiestatīšanas instrukcijas",
|
||||||
|
"verify_email_subject": "{{appName}}: Verificējiet savu kontu",
|
||||||
|
"check_your_email": "Pārbaudiet savu e-pastu",
|
||||||
|
"verify_email_page_body": "Mēs nosūtījām e-pastu uz {{email}}. Ir svarīgi verificēt jūsu e-pastu, lai garantētu labāko e-pastu un kalendāru saņemšanu no {{appName}}.",
|
||||||
|
"verify_email_banner_body": "Verificējiet savu e-pasta adresi, lai garantētu labāko e-pastu un kalendāru saņemšanu",
|
||||||
|
"verify_email_email_header": "Verificējiet savu e-pasta adresi",
|
||||||
|
"verify_email_email_button": "Verificēt e-pastu",
|
||||||
|
"copy_somewhere_safe": "Saglabājiet šo API atslēgu drošā vietā. Jūs to nevarēsiet skatīt vēlreiz.",
|
||||||
|
"verify_email_email_body": "Lūdzu, apstipriniet savu e-pasta adresi, noklikšķinot uz zemāk esošās pogas.",
|
||||||
|
"verify_email_by_code_email_body": "Lūdzu, apstipriniet savu e-pasta adresi, izmantojot zemāk esošo kodu.",
|
||||||
|
"verify_email_email_link_text": "Šeit ir saite gadījumam, ja jums nepatīk klikšķināt uz pogām:",
|
||||||
|
"email_verification_code": "Ievadiet verifikācijas kodu",
|
||||||
|
"email_verification_code_placeholder": "Ievadiet verifikācijas kodu, kas tika nosūtīts uz jūsu e-pastu",
|
||||||
|
"incorrect_email_verification_code": "Verifikācijas kods ir nepareizs.",
|
||||||
|
"email_sent": "E-Pasts veiksmīgi nosūtīts",
|
||||||
|
"email_not_sent": "Notika kļūme, sūtot e-pastu",
|
||||||
|
"event_declined_subject": "Noraidīts: {{title}} ({{date}})",
|
||||||
|
"event_cancelled_subject": "Atcelts: {{title}} ({{date}})",
|
||||||
|
"event_request_declined": "Jūsu notikuma pieprasījums tika noraidīts",
|
||||||
|
"event_request_declined_recurring": "Jūsu aktārtotā notikuma pieprasījums tika noraidīts",
|
||||||
|
"event_request_cancelled": "Jūsu ieplānotais notikums tika atcelts",
|
||||||
|
"organizer": "Organizētājs",
|
||||||
|
"need_to_reschedule_or_cancel": "Nepieciešams pārlikt vai atcelt?",
|
||||||
|
"no_options_available": "Nav pieejamu opciju",
|
||||||
|
"cancellation_reason": "Iemesls atcelšanai (neobligāts)",
|
||||||
|
"cancellation_reason_placeholder": "Kāpēc jūs atceļat?",
|
||||||
|
"rejection_reason": "Iemesls noraidīšanai",
|
||||||
|
"rejection_reason_title": "Vai noraidīt rezervācijas pieprasījumu?",
|
||||||
|
"rejection_reason_description": "Vai tiešām vēlaties noraidīt rezervāciju? Mēs informēsim personu, kas mēģināja rezervēt. Zemāk varat norādīt iemeslu.",
|
||||||
|
"rejection_confirmation": "Noraidīt rezervāciju",
|
||||||
|
"manage_this_event": "Pārvaldīt šo notikumu",
|
||||||
|
"invite_team_member": "Uzaicināt komandas biedru",
|
||||||
|
"invite_team_individual_segment": "Uzaicināt personu",
|
||||||
|
"invite_team_bulk_segment": "Lielapjoma importēšana",
|
||||||
|
"invite_team_notifcation_badge": "Uza.",
|
||||||
|
"your_event_has_been_scheduled": "Jūsu notikums ir ieplānots",
|
||||||
|
"your_event_has_been_scheduled_recurring": "Jūsu atkārtotais notikums ir ieplānots",
|
||||||
|
"accept_our_license": "Akceptējiet mūsu licensi, izmainot .env mainīgo <1>NEXT_PUBLIC_LICENSE_CONSENT</1> uz '{{agree}}'.",
|
||||||
|
"remove_banner_instructions": "Lai noņemtu šo baneri, lūdzu atveriet .env failu un nomainat <1>NEXT_PUBLIC_LICENSE_CONSENT</1> mainīgo uz '{{agree}}'.",
|
||||||
|
"error_message": "Kļūmes paziņojums bija: '{{errorMessage}}'",
|
||||||
|
"refund_failed_subject": "Naudas atgriešana neizdevās: {{name}} - {{date}} - {{eventType}}",
|
||||||
|
"refund_failed": "Naudas atgriešana notikumam {{eventType}} ar {{userName}}, šādā datumā: {{date}} neizdevās.",
|
||||||
|
"check_with_provider_and_user": "Lūdzu, sazinieties ar savu maksājumu pakalpojumu sniedzēju un {{user}}, kā ar šo rīkoties.",
|
||||||
|
"a_refund_failed": "Naudas atgriešana neizdevās",
|
||||||
|
"awaiting_payment_subject": "Gaida maksājumu: {{title}} datumā {{date}}",
|
||||||
|
"meeting_awaiting_payment": "Jūsu tikšanās gaida maksājumu",
|
||||||
|
"help": "Palīdzība",
|
||||||
|
"price": "Cena",
|
||||||
|
"paid": "Apmaksāts",
|
||||||
|
"refunded": "Nauda atgriezta",
|
||||||
|
"payment": "Maksājums",
|
||||||
|
"missing_card_fields": "Trūkst karšu lauku",
|
||||||
|
"enter_meeting": "Pievienoties Tikšanās",
|
||||||
|
"video_call_provider": "Video zvanu nodrošinātājs",
|
||||||
|
"meeting_id": "Tikšanās ID",
|
||||||
|
"meeting_password": "Tikšanās Parole",
|
||||||
|
"meeting_url": "Tikšanās URL",
|
||||||
|
"meeting_request_rejected": "Jūsu tikšanās pieprasījums tika noraidīts",
|
||||||
|
"rejected_event_type_with_organizer": "Noraidīts: {{eventType}} ar {{organizer}} datumā {{date}}",
|
||||||
|
"hi": "Sveiki",
|
||||||
|
"join_team": "Pievienoties komandai",
|
||||||
|
"manage_this_team": "Pārvaldīt šo komandu",
|
||||||
|
"team_info": "Komandas Info",
|
||||||
|
"request_another_invitation_email": "Ja nevēlaties izmantot {{toEmail}} kā savu {{appName}} e-pasta adresi vai jums jau ir {{appName}} konts, lūdzu, pieprasiet citu ielūgumu uz to e-pastu.",
|
||||||
|
"you_have_been_invited": "Jūs esat uzaicināts pievienoties komandai {{teamName}}",
|
||||||
|
"user_invited_you": "{{user}} uzaicināja jūs pievienoties {{entity}} {{team}} iekš {{appName}}",
|
||||||
|
"hidden_team_member_title": "Jūs esat paslēpts šajā komandā",
|
||||||
|
"hidden_team_member_message": "Jūsu vieta nav apmaksāta. Vai nu Jauniniet savu plānu uz PRO, vai paziņojat komandas īpašniekam, lai apmaksā jūsu vietu.",
|
||||||
|
"hidden_team_owner_message": "Jums ir nepieciešams Pro konts, lai izmantotu komandas. Līdz jaunināšanai, jūs esat paslēpts.",
|
||||||
|
"link_expires": "p.s. Tas beidzas pēc {{expiresIn}} stundām.",
|
||||||
|
"upgrade_to_per_seat": "Jauniniet uz Par-Vietu",
|
||||||
|
"seat_options_doesnt_support_confirmation": "Vietu opcija neatbalsta apstiprinājuma prasību",
|
||||||
|
"team_upgrade_seats_details": "No {{memberCount}} dalībniekiem jūsu komandā, {{unpaidCount}} vieta(-as) nav apmaksāta(-as). Ar cenu ${{seatPrice}}/mēnesī par vietu, jūsu kopējās izmaksas ir aptuveni ${{totalCost}}/mēnesī.",
|
||||||
|
"team_upgrade_banner_description": "Jūs neesat pabeidzis savas komandas iestatīšanu. Jūsu komanda “{{teamName}}” ir jājaunina.",
|
||||||
|
"upgrade_banner_action": "Jauniniet šeit",
|
||||||
|
"team_upgraded_successfully": "Jūsu komanda tika veiksmīgi jaunināta!",
|
||||||
|
"org_upgrade_banner_description": "Paldies, ka izmēģinājāt mūsu Organization plānu. Mēs pamanījām, ka jūsu Organization “{{teamName}}” ir jājaunina.",
|
||||||
|
"org_upgraded_successfully": "Jūsu Organization tika veiksmīgi jaunināta!",
|
||||||
|
"use_link_to_reset_password": "Izmantojiet saiti zemāk, lai atiestatītu savu paroli",
|
||||||
|
"hey_there": "Sveiks,",
|
||||||
|
"forgot_your_password_calcom": "Aizmirsi savu paroli? - {{appName}}",
|
||||||
|
"delete_webhook_confirmation_message": "Vai tiešām vēlaties dzēst šo webhook? Ja notikums tiks pārlikts vai atcelts, jūs vairs nesaņemsiet {{appName}} reāllaika tikšanās datus norādītajā URL.",
|
||||||
|
"confirm_delete_webhook": "Jā, dzēst webhook",
|
||||||
|
"edit_webhook": "Rediģēt Webhook",
|
||||||
|
"delete_webhook": "Dzēst Webhook",
|
||||||
|
"webhook_status": "Webhook Statuss",
|
||||||
|
"webhook_enabled": "Webhook Iespējots",
|
||||||
|
"webhook_disabled": "Webhook Atspējots",
|
||||||
|
"webhook_response": "Webhook atbilde",
|
||||||
|
"webhook_test": "Webhooket tests",
|
||||||
|
"manage_your_webhook": "Pārvaldīt savu webhook",
|
||||||
|
"webhook_created_successfully": "Webhook veiksmīgi izveidots!",
|
||||||
|
"webhook_updated_successfully": "Webhook veiksmīgi atjaunots!",
|
||||||
|
"webhook_removed_successfully": "Webhook veiksmīgi dzēsts!",
|
||||||
|
"payload_template": "Payload šablons",
|
||||||
|
"dismiss": "Noraidīt",
|
||||||
|
"no_data_yet": "Datu vēl nav",
|
||||||
|
"ping_test": "Ping tests",
|
||||||
|
"add_to_homescreen": "Pievienojiet šo lietotni savam sākuma ekrānam, lai iegūtu ātrāku piekļuvi un uzlabotu pieredzi.",
|
||||||
|
"upcoming": "Gaidāms",
|
||||||
|
"recurring": "Atkārtots",
|
||||||
|
"past": "Pagātne",
|
||||||
|
"booker_event_not_found": "Mēs nevarējām atrast notikumu, kuru mēģināt rezervēt.",
|
||||||
|
"getting_started": "Darba sākšana",
|
||||||
|
"15min_meeting": "15 Min Tikšanās",
|
||||||
|
"30min_meeting": "30 Min Tikšanās",
|
||||||
|
"secret": "Noslēpums",
|
||||||
|
"leave_blank_to_remove_secret": "Atstājiet tukšu, lai noņemtu noslēpumu",
|
||||||
|
"webhook_secret_key_description": "Pārliecinieties, ka jūsu serveris saņem tikai paredzētos {{appName}} pieprasījumus, drošības apsvērumu dēļ",
|
||||||
|
"secret_meeting": "Slepenā Tikšanās",
|
||||||
|
"login_instead": "Tā vietā Autorizēties",
|
||||||
|
"already_have_an_account": "Vai jums jau ir konts?",
|
||||||
|
"create_account": "Izveidot Kontu",
|
||||||
|
"confirm_password": "Apstiprināt paroli",
|
||||||
|
"confirm_auth_change": "Šis mainīs veidu, kā jūs autorizējaties"
|
||||||
|
}
|
||||||
@@ -682,7 +682,7 @@
|
|||||||
"new_event_type_heading": "Crie seu primeiro evento",
|
"new_event_type_heading": "Crie seu primeiro evento",
|
||||||
"new_event_type_description": "Os eventos permitem que você compartilhe links que mostram os horários disponíveis no seu calendário para que os usuários façam agendamentos com você.",
|
"new_event_type_description": "Os eventos permitem que você compartilhe links que mostram os horários disponíveis no seu calendário para que os usuários façam agendamentos com você.",
|
||||||
"event_type_created_successfully": "{{eventTypeTitle}} evento criado com sucesso",
|
"event_type_created_successfully": "{{eventTypeTitle}} evento criado com sucesso",
|
||||||
"event_type_updated_successfully": "Tipo de evento atualizado com sucesso",
|
"event_type_updated_successfully": "{{eventTypeTitle}} tipo de evento atualizado com sucesso",
|
||||||
"event_type_deleted_successfully": "Tipo de evento removido com sucesso",
|
"event_type_deleted_successfully": "Tipo de evento removido com sucesso",
|
||||||
"hours": "Horas",
|
"hours": "Horas",
|
||||||
"people": "Pessoas",
|
"people": "Pessoas",
|
||||||
|
|||||||
@@ -682,7 +682,7 @@
|
|||||||
"new_event_type_heading": "Crie o seu primeiro tipo de evento",
|
"new_event_type_heading": "Crie o seu primeiro tipo de evento",
|
||||||
"new_event_type_description": "Os tipos de evento permitem partilhar ligações que mostram os horários disponíveis na sua agenda, e permitem que as pessoas façam reservas consigo.",
|
"new_event_type_description": "Os tipos de evento permitem partilhar ligações que mostram os horários disponíveis na sua agenda, e permitem que as pessoas façam reservas consigo.",
|
||||||
"event_type_created_successfully": "Tipo de evento {{eventTypeTitle}} criado com sucesso",
|
"event_type_created_successfully": "Tipo de evento {{eventTypeTitle}} criado com sucesso",
|
||||||
"event_type_updated_successfully": "Tipo de evento atualizado com sucesso",
|
"event_type_updated_successfully": "Tipo de evento {{eventTypeTitle}} atualizado com sucesso",
|
||||||
"event_type_deleted_successfully": "Tipo de evento eliminado com sucesso",
|
"event_type_deleted_successfully": "Tipo de evento eliminado com sucesso",
|
||||||
"hours": "Horas",
|
"hours": "Horas",
|
||||||
"people": "Pessoas",
|
"people": "Pessoas",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@
|
|||||||
"set_availability": "Đặt lịch khả dụng của bạn",
|
"set_availability": "Đặt lịch khả dụng của bạn",
|
||||||
"availability_settings": "Thiết lập tình trạng trống lịch",
|
"availability_settings": "Thiết lập tình trạng trống lịch",
|
||||||
"continue_without_calendar": "Tiếp tục mà không có lịch",
|
"continue_without_calendar": "Tiếp tục mà không có lịch",
|
||||||
|
"continue_with": "Tiếp tục với {{appName}}",
|
||||||
"connect_your_calendar": "Kết nối lịch của bạn",
|
"connect_your_calendar": "Kết nối lịch của bạn",
|
||||||
"connect_your_video_app": "Kết nối các ứng dụng video của bạn",
|
"connect_your_video_app": "Kết nối các ứng dụng video của bạn",
|
||||||
"connect_your_video_app_instructions": "Kết nối những ứng dụng video để dùng chúng ở những loại sự kiện của bạn.",
|
"connect_your_video_app_instructions": "Kết nối những ứng dụng video để dùng chúng ở những loại sự kiện của bạn.",
|
||||||
@@ -288,6 +289,8 @@
|
|||||||
"when": "Khi nào",
|
"when": "Khi nào",
|
||||||
"where": "Ở đâu",
|
"where": "Ở đâu",
|
||||||
"add_to_calendar": "Thêm vào lịch",
|
"add_to_calendar": "Thêm vào lịch",
|
||||||
|
"add_to_calendar_description": "Chọn chỗ cần thêm sự kiện mới khi bạn được đặt lịch.",
|
||||||
|
"add_events_to": "Thêm các sự kiện vào",
|
||||||
"add_another_calendar": "Thêm lịch khác",
|
"add_another_calendar": "Thêm lịch khác",
|
||||||
"other": "Khác",
|
"other": "Khác",
|
||||||
"email_sign_in_subject": "Liên kết đăng nhập cho {{appName}}",
|
"email_sign_in_subject": "Liên kết đăng nhập cho {{appName}}",
|
||||||
@@ -422,6 +425,7 @@
|
|||||||
"booking_created": "Lịch hẹn đã được tạo",
|
"booking_created": "Lịch hẹn đã được tạo",
|
||||||
"booking_rejected": "Lịch hẹn đã bị từ chối",
|
"booking_rejected": "Lịch hẹn đã bị từ chối",
|
||||||
"booking_requested": "Lịch hẹn đã được yêu cầu",
|
"booking_requested": "Lịch hẹn đã được yêu cầu",
|
||||||
|
"booking_payment_initiated": "Đã bắt đầu Thanh Toán Đặt Chỗ",
|
||||||
"meeting_ended": "Cuộc họp đã kết thúc",
|
"meeting_ended": "Cuộc họp đã kết thúc",
|
||||||
"form_submitted": "Biểu mẫu đã được gửi",
|
"form_submitted": "Biểu mẫu đã được gửi",
|
||||||
"booking_paid": "Lịch hẹn đã thanh toán",
|
"booking_paid": "Lịch hẹn đã thanh toán",
|
||||||
@@ -456,6 +460,7 @@
|
|||||||
"no_event_types_have_been_setup": "Người dùng này chưa thiết lập bất kỳ loại sự kiện nào.",
|
"no_event_types_have_been_setup": "Người dùng này chưa thiết lập bất kỳ loại sự kiện nào.",
|
||||||
"edit_logo": "Chỉnh sửa logo",
|
"edit_logo": "Chỉnh sửa logo",
|
||||||
"upload_a_logo": "Tải lên logo",
|
"upload_a_logo": "Tải lên logo",
|
||||||
|
"upload_logo": "Tải lên logo",
|
||||||
"remove_logo": "Xóa logo",
|
"remove_logo": "Xóa logo",
|
||||||
"enable": "Bật",
|
"enable": "Bật",
|
||||||
"code": "Mã",
|
"code": "Mã",
|
||||||
@@ -568,6 +573,7 @@
|
|||||||
"your_team_name": "Tên nhóm của bạn",
|
"your_team_name": "Tên nhóm của bạn",
|
||||||
"team_updated_successfully": "Đã cập nhật nhóm thành công",
|
"team_updated_successfully": "Đã cập nhật nhóm thành công",
|
||||||
"your_team_updated_successfully": "Nhóm của bạn đã được cập nhật thành công.",
|
"your_team_updated_successfully": "Nhóm của bạn đã được cập nhật thành công.",
|
||||||
|
"your_org_updated_successfully": "Tổ Chức của bạn đã được cập nhật thành công.",
|
||||||
"about": "Về",
|
"about": "Về",
|
||||||
"team_description": "Một vài câu về nhóm của bạn. Thông tin này sẽ xuất hiện trên trang URL của nhóm của bạn.",
|
"team_description": "Một vài câu về nhóm của bạn. Thông tin này sẽ xuất hiện trên trang URL của nhóm của bạn.",
|
||||||
"org_description": "Một vài câu về tổ chức của bạn. Thông tin này sẽ xuất hiện trên trang URL của tổ chức của bạn.",
|
"org_description": "Một vài câu về tổ chức của bạn. Thông tin này sẽ xuất hiện trên trang URL của tổ chức của bạn.",
|
||||||
@@ -599,6 +605,7 @@
|
|||||||
"hide_book_a_team_member": "Giấu nút Đặt lịch hẹn thành viên nhóm",
|
"hide_book_a_team_member": "Giấu nút Đặt lịch hẹn thành viên nhóm",
|
||||||
"hide_book_a_team_member_description": "Giấu nút Đặt lịch hẹn thành viên nhóm trên trang công khai của bạn.",
|
"hide_book_a_team_member_description": "Giấu nút Đặt lịch hẹn thành viên nhóm trên trang công khai của bạn.",
|
||||||
"danger_zone": "Khu vực nguy hiểm",
|
"danger_zone": "Khu vực nguy hiểm",
|
||||||
|
"account_deletion_cannot_be_undone": "Cẩn thận. Tài khoản đã xóa không thể phục hồi.",
|
||||||
"back": "Trở lại",
|
"back": "Trở lại",
|
||||||
"cancel": "Hủy bỏ",
|
"cancel": "Hủy bỏ",
|
||||||
"cancel_all_remaining": "Hủy tất cả những gì còn lại",
|
"cancel_all_remaining": "Hủy tất cả những gì còn lại",
|
||||||
@@ -688,6 +695,7 @@
|
|||||||
"people": "Mọi người",
|
"people": "Mọi người",
|
||||||
"your_email": "Email của bạn",
|
"your_email": "Email của bạn",
|
||||||
"change_avatar": "Thay đổi hình đại diện",
|
"change_avatar": "Thay đổi hình đại diện",
|
||||||
|
"upload_avatar": "Tải lên Avatar",
|
||||||
"language": "Ngôn ngữ",
|
"language": "Ngôn ngữ",
|
||||||
"timezone": "Múi giờ",
|
"timezone": "Múi giờ",
|
||||||
"first_day_of_week": "Ngày đầu tuần",
|
"first_day_of_week": "Ngày đầu tuần",
|
||||||
@@ -778,6 +786,7 @@
|
|||||||
"disable_guests": "Vô hiệu hóa Khách",
|
"disable_guests": "Vô hiệu hóa Khách",
|
||||||
"disable_guests_description": "Vô hiệu hóa việc thêm khách bổ sung trong khi đặt lịch.",
|
"disable_guests_description": "Vô hiệu hóa việc thêm khách bổ sung trong khi đặt lịch.",
|
||||||
"private_link": "Tạo liên kết riêng tư",
|
"private_link": "Tạo liên kết riêng tư",
|
||||||
|
"enable_private_url": "Bật URL Riêng Tư",
|
||||||
"private_link_label": "Liên kết riêng",
|
"private_link_label": "Liên kết riêng",
|
||||||
"private_link_hint": "Liên kết riêng của bạn sẽ tạo lại sau mỗi lần sử dụng",
|
"private_link_hint": "Liên kết riêng của bạn sẽ tạo lại sau mỗi lần sử dụng",
|
||||||
"copy_private_link": "Sao chép liên kết riêng",
|
"copy_private_link": "Sao chép liên kết riêng",
|
||||||
@@ -1276,6 +1285,7 @@
|
|||||||
"personal_cal_url": "URL {{appName}} cá nhân của tôi",
|
"personal_cal_url": "URL {{appName}} cá nhân của tôi",
|
||||||
"bio_hint": "Nói mấy lời về bản thân. Thông tin này sẽ xuất hiện trên trang url cá nhân của bạn.",
|
"bio_hint": "Nói mấy lời về bản thân. Thông tin này sẽ xuất hiện trên trang url cá nhân của bạn.",
|
||||||
"user_has_no_bio": "Người dùng này chưa thêm vào tiểu sử.",
|
"user_has_no_bio": "Người dùng này chưa thêm vào tiểu sử.",
|
||||||
|
"bio": "Bio",
|
||||||
"delete_account_modal_title": "Xoá tài khoản",
|
"delete_account_modal_title": "Xoá tài khoản",
|
||||||
"confirm_delete_account_modal": "Bạn có chắc chắn muốn xoá tài khoản {{appName}}?",
|
"confirm_delete_account_modal": "Bạn có chắc chắn muốn xoá tài khoản {{appName}}?",
|
||||||
"delete_my_account": "Xoá tài khoản của tôi",
|
"delete_my_account": "Xoá tài khoản của tôi",
|
||||||
@@ -1530,6 +1540,7 @@
|
|||||||
"problem_registering_domain": "Có vấn đề với việc đăng ký tên miền phụ, vui lòng thử lại hoặc liên lạc với quản trị viên",
|
"problem_registering_domain": "Có vấn đề với việc đăng ký tên miền phụ, vui lòng thử lại hoặc liên lạc với quản trị viên",
|
||||||
"team_publish": "Đăng nhóm",
|
"team_publish": "Đăng nhóm",
|
||||||
"number_text_notifications": "Số điện thoại (thông báo tin nhắn văn bản)",
|
"number_text_notifications": "Số điện thoại (thông báo tin nhắn văn bản)",
|
||||||
|
"number_sms_notifications": "Số điện thoại (thông báo SMS)",
|
||||||
"attendee_email_variable": "Email người tham dự",
|
"attendee_email_variable": "Email người tham dự",
|
||||||
"attendee_email_info": "Email người tham gia lịch hẹn",
|
"attendee_email_info": "Email người tham gia lịch hẹn",
|
||||||
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
|
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
|
||||||
@@ -1639,6 +1650,7 @@
|
|||||||
"minimum_round_robin_hosts_count": "Số chủ tọa cần thiết để tham dự",
|
"minimum_round_robin_hosts_count": "Số chủ tọa cần thiết để tham dự",
|
||||||
"hosts": "Chủ tọa",
|
"hosts": "Chủ tọa",
|
||||||
"upgrade_to_enable_feature": "Bạn cần tạo một nhóm để bật tính năng này. Nhấp để tạo một nhóm.",
|
"upgrade_to_enable_feature": "Bạn cần tạo một nhóm để bật tính năng này. Nhấp để tạo một nhóm.",
|
||||||
|
"orgs_upgrade_to_enable_feature": "Bạn cần nâng cấp gói Enterprise để bật tính năng này.",
|
||||||
"new_attendee": "Người tham dự mới",
|
"new_attendee": "Người tham dự mới",
|
||||||
"awaiting_approval": "Đang chờ duyệt",
|
"awaiting_approval": "Đang chờ duyệt",
|
||||||
"requires_google_calendar": "Ứng dụng này yêu cầu bạn liên kết với Google Calendar",
|
"requires_google_calendar": "Ứng dụng này yêu cầu bạn liên kết với Google Calendar",
|
||||||
@@ -1743,6 +1755,7 @@
|
|||||||
"show_on_booking_page": "Hiện trên trang lịch hẹn",
|
"show_on_booking_page": "Hiện trên trang lịch hẹn",
|
||||||
"get_started_zapier_templates": "Bắt đầu với các mẫu Zapier",
|
"get_started_zapier_templates": "Bắt đầu với các mẫu Zapier",
|
||||||
"team_is_unpublished": "{{team}} chưa được công bố",
|
"team_is_unpublished": "{{team}} chưa được công bố",
|
||||||
|
"org_is_unpublished_description": "Liên kết tổ chức này hiện không khả dụng. Hãy liên lạc với chủ tổ chức hoặc yêu cầu họ công bố liên kết đó.",
|
||||||
"team_is_unpublished_description": "Liên kết {{entity}} này hiện không khả dụng. Hãy liên lạc với chủ {{entity}} hoặc yêu cầu họ công bố liên kết đó.",
|
"team_is_unpublished_description": "Liên kết {{entity}} này hiện không khả dụng. Hãy liên lạc với chủ {{entity}} hoặc yêu cầu họ công bố liên kết đó.",
|
||||||
"team_member": "Thành viên nhóm",
|
"team_member": "Thành viên nhóm",
|
||||||
"a_routing_form": "Một biểu mẫu định hướng",
|
"a_routing_form": "Một biểu mẫu định hướng",
|
||||||
@@ -1877,6 +1890,7 @@
|
|||||||
"edit_invite_link": "Sửa cài đặt liên kết",
|
"edit_invite_link": "Sửa cài đặt liên kết",
|
||||||
"invite_link_copied": "Liên kết lời mời đã được sao chép",
|
"invite_link_copied": "Liên kết lời mời đã được sao chép",
|
||||||
"invite_link_deleted": "Liên kết lời mời đã được xoá",
|
"invite_link_deleted": "Liên kết lời mời đã được xoá",
|
||||||
|
"api_key_deleted": "Đã xóa API Key",
|
||||||
"invite_link_updated": "Đã lưu cài đặt cho liên kết lời mời",
|
"invite_link_updated": "Đã lưu cài đặt cho liên kết lời mời",
|
||||||
"link_expires_after": "Các liên kết được đặt hết hạn sau...",
|
"link_expires_after": "Các liên kết được đặt hết hạn sau...",
|
||||||
"one_day": "1 ngày",
|
"one_day": "1 ngày",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const MOCK_DATA: Mockdata = {
|
|||||||
|
|
||||||
describe("Check Booking Limits Tests", () => {
|
describe("Check Booking Limits Tests", () => {
|
||||||
it("Should return no errors", async () => {
|
it("Should return no errors", async () => {
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.count.mockResolvedValue(0);
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||||
@@ -31,7 +30,6 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
});
|
});
|
||||||
it("Should throw an error", async () => {
|
it("Should throw an error", async () => {
|
||||||
// Mock there being two a day
|
// Mock there being two a day
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.count.mockResolvedValue(2);
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||||
@@ -39,7 +37,6 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Should pass with multiple booking limits", async () => {
|
it("Should pass with multiple booking limits", async () => {
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.count.mockResolvedValue(0);
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(
|
checkBookingLimits(
|
||||||
@@ -53,7 +50,6 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
).resolves.toBeTruthy();
|
).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
it("Should pass with multiple booking limits with one undefined", async () => {
|
it("Should pass with multiple booking limits with one undefined", async () => {
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.count.mockResolvedValue(0);
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(
|
checkBookingLimits(
|
||||||
@@ -67,7 +63,6 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
).resolves.toBeTruthy();
|
).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
it("Should handle mutiple limits correctly", async () => {
|
it("Should handle mutiple limits correctly", async () => {
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.count.mockResolvedValue(1);
|
prismaMock.booking.count.mockResolvedValue(1);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimit({
|
checkBookingLimit({
|
||||||
@@ -77,7 +72,6 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
eventId: MOCK_DATA.id,
|
eventId: MOCK_DATA.id,
|
||||||
})
|
})
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.count.mockResolvedValue(3);
|
prismaMock.booking.count.mockResolvedValue(3);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimit({
|
checkBookingLimit({
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type { CompleteEventType, CompleteWorkflowsOnEventTypes } from "@calcom/p
|
|||||||
|
|
||||||
const mockFindFirstEventType = (data?: Partial<CompleteEventType>) => {
|
const mockFindFirstEventType = (data?: Partial<CompleteEventType>) => {
|
||||||
const eventType = buildEventType(data as Partial<EventType>);
|
const eventType = buildEventType(data as Partial<EventType>);
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.eventType.findFirst.mockResolvedValue(eventType as EventType);
|
prismaMock.eventType.findFirst.mockResolvedValue(eventType as EventType);
|
||||||
return eventType;
|
return eventType;
|
||||||
};
|
};
|
||||||
@@ -31,7 +30,7 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
describe("Shortcircuits", () => {
|
describe("Shortcircuits", () => {
|
||||||
it("Returns message 'No managed event type'", async () => {
|
it("Returns message 'No managed event type'", async () => {
|
||||||
mockFindFirstEventType();
|
mockFindFirstEventType();
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
const result = await updateChildrenEventTypes({
|
const result = await updateChildrenEventTypes({
|
||||||
eventTypeId: 1,
|
eventTypeId: 1,
|
||||||
oldEventType: { children: [], team: { name: "" } },
|
oldEventType: { children: [], team: { name: "" } },
|
||||||
@@ -40,7 +39,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(result.newUserIds).toEqual(undefined);
|
expect(result.newUserIds).toEqual(undefined);
|
||||||
@@ -60,7 +58,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(result.newUserIds).toEqual(undefined);
|
expect(result.newUserIds).toEqual(undefined);
|
||||||
@@ -86,7 +83,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(result.newUserIds).toEqual(undefined);
|
expect(result.newUserIds).toEqual(undefined);
|
||||||
@@ -116,7 +112,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||||
@@ -163,7 +158,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: "somestring",
|
hashedLink: "somestring",
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||||
@@ -197,7 +191,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(result.newUserIds).toEqual([]);
|
expect(result.newUserIds).toEqual([]);
|
||||||
@@ -222,7 +215,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
// Have been called
|
// Have been called
|
||||||
@@ -253,7 +245,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||||
@@ -300,7 +291,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||||
@@ -359,7 +349,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
currentUserId: 1,
|
currentUserId: 1,
|
||||||
hashedLink: undefined,
|
hashedLink: undefined,
|
||||||
connectedLink: null,
|
connectedLink: null,
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
});
|
});
|
||||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ it("can find lucky user with maximize availability", async () => {
|
|||||||
});
|
});
|
||||||
const users = [user1, user2];
|
const users = [user1, user2];
|
||||||
// TODO: we may be able to use native prisma generics somehow?
|
// TODO: we may be able to use native prisma generics somehow?
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
prismaMock.user.findMany.mockResolvedValue(users);
|
prismaMock.user.findMany.mockResolvedValue(users);
|
||||||
// @ts-expect-error Prisma v5 typings are not yet available
|
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
|
|||||||
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
|
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
|
||||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
|
import type { BookingReference, Attendee, Booking } from "@prisma/client";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { WebhookTriggerEvents } from "@prisma/client";
|
import type { WebhookTriggerEvents } from "@prisma/client";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
@@ -22,8 +23,8 @@ import type { EventBusyDate } from "@calcom/types/Calendar";
|
|||||||
|
|
||||||
import { getMockPaymentService } from "./MockPaymentService";
|
import { getMockPaymentService } from "./MockPaymentService";
|
||||||
|
|
||||||
logger.setSettings({ minLevel: "silly" });
|
logger.settings.minLevel = 0;
|
||||||
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
|
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
|
||||||
|
|
||||||
type InputWebhook = {
|
type InputWebhook = {
|
||||||
appId: string | null;
|
appId: string | null;
|
||||||
@@ -101,7 +102,7 @@ export type InputEventType = {
|
|||||||
schedule?: InputUser["schedules"][number];
|
schedule?: InputUser["schedules"][number];
|
||||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
|
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
|
||||||
|
|
||||||
type InputBooking = {
|
type WhiteListedBookingProps = {
|
||||||
id?: number;
|
id?: number;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
@@ -111,19 +112,14 @@ type InputBooking = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
attendees?: { email: string }[];
|
attendees?: { email: string }[];
|
||||||
references?: {
|
references?: (Omit<ReturnType<typeof getMockBookingReference>, "credentialId"> & {
|
||||||
type: string;
|
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
|
||||||
uid: string;
|
credentialId?: number | null;
|
||||||
meetingId?: string;
|
})[];
|
||||||
meetingPassword?: string;
|
|
||||||
meetingUrl?: string;
|
|
||||||
bookingId?: number;
|
|
||||||
externalCalendarId?: string;
|
|
||||||
deleted?: boolean;
|
|
||||||
credentialId?: number;
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
|
||||||
|
|
||||||
export const Timezones = {
|
export const Timezones = {
|
||||||
"+5:30": "Asia/Kolkata",
|
"+5:30": "Asia/Kolkata",
|
||||||
"+6:00": "Asia/Dhaka",
|
"+6:00": "Asia/Dhaka",
|
||||||
@@ -267,15 +263,17 @@ async function addBookingsToDb(
|
|||||||
references: any[];
|
references: any[];
|
||||||
})[]
|
})[]
|
||||||
) {
|
) {
|
||||||
|
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
|
||||||
await prismock.booking.createMany({
|
await prismock.booking.createMany({
|
||||||
data: bookings,
|
data: bookings,
|
||||||
});
|
});
|
||||||
log.silly(
|
log.silly(
|
||||||
"TestData: Booking as in DB",
|
"TestData: Bookings as in DB",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
bookings: await prismock.booking.findMany({
|
bookings: await prismock.booking.findMany({
|
||||||
include: {
|
include: {
|
||||||
references: true,
|
references: true,
|
||||||
|
attendees: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -318,6 +316,15 @@ async function addBookings(bookings: InputBooking[]) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (booking.attendees) {
|
||||||
|
bookingCreate.attendees = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
createMany: {
|
||||||
|
data: booking.attendees,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return bookingCreate;
|
return bookingCreate;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -839,6 +846,8 @@ export function mockCalendar(
|
|||||||
const createEventCalls: any[] = [];
|
const createEventCalls: any[] = [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const updateEventCalls: any[] = [];
|
const updateEventCalls: any[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const deleteEventCalls: any[] = [];
|
||||||
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
|
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
|
||||||
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
|
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
|
||||||
lib: {
|
lib: {
|
||||||
@@ -888,6 +897,11 @@ export function mockCalendar(
|
|||||||
url: "https://UNUSED_URL",
|
url: "https://UNUSED_URL",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
deleteEvent: async (...rest: any[]) => {
|
||||||
|
log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest }));
|
||||||
|
// eslint-disable-next-line prefer-rest-params
|
||||||
|
deleteEventCalls.push(rest);
|
||||||
|
},
|
||||||
getAvailability: async (): Promise<EventBusyDate[]> => {
|
getAvailability: async (): Promise<EventBusyDate[]> => {
|
||||||
if (calendarData?.getAvailabilityCrash) {
|
if (calendarData?.getAvailabilityCrash) {
|
||||||
throw new Error("MockCalendarService.getAvailability fake error");
|
throw new Error("MockCalendarService.getAvailability fake error");
|
||||||
@@ -902,6 +916,7 @@ export function mockCalendar(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
createEventCalls,
|
createEventCalls,
|
||||||
|
deleteEventCalls,
|
||||||
updateEventCalls,
|
updateEventCalls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -952,11 +967,13 @@ export function mockVideoApp({
|
|||||||
password: "MOCK_PASS",
|
password: "MOCK_PASS",
|
||||||
url: `http://mock-${metadataLookupKey}.example.com`,
|
url: `http://mock-${metadataLookupKey}.example.com`,
|
||||||
};
|
};
|
||||||
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
|
log.silly("mockVideoApp", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createMeetingCalls: any[] = [];
|
const createMeetingCalls: any[] = [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const updateMeetingCalls: any[] = [];
|
const updateMeetingCalls: any[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const deleteMeetingCalls: any[] = [];
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
|
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
|
||||||
@@ -998,15 +1015,19 @@ export function mockVideoApp({
|
|||||||
if (!calEvent.organizer) {
|
if (!calEvent.organizer) {
|
||||||
throw new Error("calEvent.organizer is not defined");
|
throw new Error("calEvent.organizer is not defined");
|
||||||
}
|
}
|
||||||
log.silly(
|
log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent }));
|
||||||
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
|
||||||
JSON.stringify({ bookingRef, calEvent })
|
|
||||||
);
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||||
...videoMeetingData,
|
...videoMeetingData,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
deleteMeeting: async (...rest: any[]) => {
|
||||||
|
log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest));
|
||||||
|
deleteMeetingCalls.push({
|
||||||
|
credential,
|
||||||
|
args: rest,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1016,6 +1037,7 @@ export function mockVideoApp({
|
|||||||
return {
|
return {
|
||||||
createMeetingCalls,
|
createMeetingCalls,
|
||||||
updateMeetingCalls,
|
updateMeetingCalls,
|
||||||
|
deleteMeetingCalls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,7 +1176,64 @@ export function getExpectedCalEventForBookingRequest({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMockBookingReference(
|
||||||
|
bookingReference: Partial<BookingReference> & Pick<BookingReference, "type" | "uid" | "credentialId">
|
||||||
|
) {
|
||||||
|
let credentialId = bookingReference.credentialId;
|
||||||
|
if (bookingReference.type === appStoreMetadata.dailyvideo.type) {
|
||||||
|
// Right now we seems to be storing credentialId for `dailyvideo` in BookingReference as null. Another possible value is 0 in there.
|
||||||
|
credentialId = null;
|
||||||
|
log.debug("Ensuring null credentialId for dailyvideo");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...bookingReference,
|
||||||
|
credentialId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockBookingAttendee(attendee: Omit<Attendee, "bookingId">) {
|
||||||
|
return {
|
||||||
|
id: attendee.id,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
locale: attendee.locale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const enum BookingLocations {
|
export const enum BookingLocations {
|
||||||
CalVideo = "integrations:daily",
|
CalVideo = "integrations:daily",
|
||||||
ZoomVideo = "integrations:zoom",
|
ZoomVideo = "integrations:zoom",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMockAppStatus = ({
|
||||||
|
slug,
|
||||||
|
failures,
|
||||||
|
success,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
failures: number;
|
||||||
|
success: number;
|
||||||
|
}) => {
|
||||||
|
const foundEntry = Object.entries(appStoreMetadata).find(([, app]) => {
|
||||||
|
return app.slug === slug;
|
||||||
|
});
|
||||||
|
if (!foundEntry) {
|
||||||
|
throw new Error("App not found for the slug");
|
||||||
|
}
|
||||||
|
const foundApp = foundEntry[1];
|
||||||
|
return {
|
||||||
|
appName: foundApp.slug,
|
||||||
|
type: foundApp.type,
|
||||||
|
failures,
|
||||||
|
success,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const getMockFailingAppStatus = ({ slug }: { slug: string }) => {
|
||||||
|
return getMockAppStatus({ slug, failures: 1, success: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMockPassingAppStatus = ({ slug }: { slug: string }) => {
|
||||||
|
return getMockAppStatus({ slug, failures: 0, success: 1 });
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
||||||
|
import { parse } from "node-html-parser";
|
||||||
import ical from "node-ical";
|
import ical from "node-ical";
|
||||||
import { expect } from "vitest";
|
import { expect } from "vitest";
|
||||||
import "vitest-fetch-mock";
|
import "vitest-fetch-mock";
|
||||||
@@ -8,6 +9,7 @@ import "vitest-fetch-mock";
|
|||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
|
||||||
@@ -19,14 +21,14 @@ declare global {
|
|||||||
interface Matchers<R> {
|
interface Matchers<R> {
|
||||||
toHaveEmail(
|
toHaveEmail(
|
||||||
expectedEmail: {
|
expectedEmail: {
|
||||||
//TODO: Support email HTML parsing to target specific elements
|
title?: string;
|
||||||
htmlToContain?: string;
|
|
||||||
to: string;
|
to: string;
|
||||||
noIcs?: true;
|
noIcs?: true;
|
||||||
ics?: {
|
ics?: {
|
||||||
filename: string;
|
filename: string;
|
||||||
iCalUID: string;
|
iCalUID: string;
|
||||||
};
|
};
|
||||||
|
appsStatus?: AppsStatus[];
|
||||||
},
|
},
|
||||||
to: string
|
to: string
|
||||||
): R;
|
): R;
|
||||||
@@ -38,21 +40,23 @@ expect.extend({
|
|||||||
toHaveEmail(
|
toHaveEmail(
|
||||||
emails: Fixtures["emails"],
|
emails: Fixtures["emails"],
|
||||||
expectedEmail: {
|
expectedEmail: {
|
||||||
//TODO: Support email HTML parsing to target specific elements
|
title?: string;
|
||||||
htmlToContain?: string;
|
|
||||||
to: string;
|
to: string;
|
||||||
ics: {
|
ics: {
|
||||||
filename: string;
|
filename: string;
|
||||||
iCalUID: string;
|
iCalUID: string;
|
||||||
};
|
};
|
||||||
noIcs: true;
|
noIcs: true;
|
||||||
|
appsStatus: AppsStatus[];
|
||||||
},
|
},
|
||||||
to: string
|
to: string
|
||||||
) {
|
) {
|
||||||
|
const { isNot } = this;
|
||||||
const testEmail = emails.get().find((email) => email.to.includes(to));
|
const testEmail = emails.get().find((email) => email.to.includes(to));
|
||||||
const emailsToLog = emails
|
const emailsToLog = emails
|
||||||
.get()
|
.get()
|
||||||
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
|
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
|
||||||
|
|
||||||
if (!testEmail) {
|
if (!testEmail) {
|
||||||
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
||||||
return {
|
return {
|
||||||
@@ -63,48 +67,93 @@ expect.extend({
|
|||||||
const ics = testEmail.icalEvent;
|
const ics = testEmail.icalEvent;
|
||||||
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
|
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
|
||||||
|
|
||||||
let isHtmlContained = true;
|
|
||||||
let isToAddressExpected = true;
|
let isToAddressExpected = true;
|
||||||
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
|
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
|
||||||
const isIcsUIDExpected = expectedEmail.ics
|
const isIcsUIDExpected = expectedEmail.ics
|
||||||
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
|
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
|
||||||
: true;
|
: true;
|
||||||
|
const emailDom = parse(testEmail.html);
|
||||||
|
|
||||||
if (expectedEmail.htmlToContain) {
|
const actualEmailContent = {
|
||||||
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
|
title: emailDom.querySelector("title")?.innerText,
|
||||||
|
subject: emailDom.querySelector("subject")?.innerText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedEmailContent = {
|
||||||
|
title: expectedEmail.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmailContentMatched = this.equals(
|
||||||
|
actualEmailContent,
|
||||||
|
expect.objectContaining(expectedEmailContent)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEmailContentMatched) {
|
||||||
|
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
message: () => `Email content ${isNot ? "is" : "is not"} matching`,
|
||||||
|
actual: actualEmailContent,
|
||||||
|
expected: expectedEmailContent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
isToAddressExpected = expectedEmail.to === testEmail.to;
|
isToAddressExpected = expectedEmail.to === testEmail.to;
|
||||||
|
if (!isToAddressExpected) {
|
||||||
if (!isHtmlContained || !isToAddressExpected) {
|
|
||||||
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
message: () => `To address ${isNot ? "is" : "is not"} matching`,
|
||||||
|
actual: testEmail.to,
|
||||||
|
expected: expectedEmail.to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedEmail.noIcs && !isIcsFilenameExpected) {
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
actual: ics?.filename,
|
||||||
|
expected: expectedEmail.ics.filename,
|
||||||
|
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedEmail.noIcs && !isIcsUIDExpected) {
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
actual: JSON.stringify(icsObject),
|
||||||
|
expected: expectedEmail.ics.iCalUID,
|
||||||
|
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedEmail.appsStatus) {
|
||||||
|
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
|
||||||
|
return li.innerText.trim();
|
||||||
|
});
|
||||||
|
const expectedAppStatus = expectedEmail.appsStatus.map((appStatus) => {
|
||||||
|
if (appStatus.success && !appStatus.failures) {
|
||||||
|
return `${appStatus.appName} ✅`;
|
||||||
|
}
|
||||||
|
return `${appStatus.appName} ❌`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAppsStatusCorrect = this.equals(actualAppsStatus, expectedAppStatus);
|
||||||
|
|
||||||
|
if (!isAppsStatusCorrect) {
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
actual: actualAppsStatus,
|
||||||
|
expected: expectedAppStatus,
|
||||||
|
message: () => `AppsStatus ${isNot ? "is" : "isn't"} matching`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pass:
|
pass: true,
|
||||||
isHtmlContained &&
|
message: () => `Email ${isNot ? "is" : "isn't"} correct`,
|
||||||
isToAddressExpected &&
|
|
||||||
(expectedEmail.noIcs ? true : isIcsFilenameExpected && isIcsUIDExpected),
|
|
||||||
message: () => {
|
|
||||||
if (!isHtmlContained) {
|
|
||||||
return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isToAddressExpected) {
|
|
||||||
return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isIcsFilenameExpected) {
|
|
||||||
return `ICS Filename is not as expected. Expected:${expectedEmail.ics.filename} isn't equal to ${ics?.filename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isIcsUIDExpected) {
|
|
||||||
return `ICS UID is not as expected. Expected:${
|
|
||||||
expectedEmail.ics.iCalUID
|
|
||||||
} isn't present in ${JSON.stringify(icsObject)}`;
|
|
||||||
}
|
|
||||||
throw new Error("Unknown error");
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -139,9 +188,10 @@ export function expectWebhookToHaveBeenCalledWith(
|
|||||||
const parsedBody = JSON.parse((body as string) || "{}");
|
const parsedBody = JSON.parse((body as string) || "{}");
|
||||||
|
|
||||||
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
|
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
|
||||||
|
|
||||||
if (parsedBody.payload.metadata?.videoCallUrl) {
|
if (parsedBody.payload.metadata?.videoCallUrl) {
|
||||||
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
|
parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl
|
||||||
? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID")
|
? parsedBody.payload.metadata.videoCallUrl
|
||||||
: parsedBody.payload.metadata.videoCallUrl;
|
: parsedBody.payload.metadata.videoCallUrl;
|
||||||
}
|
}
|
||||||
if (data.payload) {
|
if (data.payload) {
|
||||||
@@ -195,7 +245,7 @@ export function expectSuccessfulBookingCreationEmails({
|
|||||||
}) {
|
}) {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
title: "confirmed_event_type_subject",
|
||||||
to: `${organizer.email}`,
|
to: `${organizer.email}`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
@@ -207,7 +257,7 @@ export function expectSuccessfulBookingCreationEmails({
|
|||||||
|
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
title: "confirmed_event_type_subject",
|
||||||
to: `${booker.name} <${booker.email}>`,
|
to: `${booker.name} <${booker.email}>`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
@@ -221,7 +271,7 @@ export function expectSuccessfulBookingCreationEmails({
|
|||||||
otherTeamMembers.forEach((otherTeamMember) => {
|
otherTeamMembers.forEach((otherTeamMember) => {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
title: "confirmed_event_type_subject",
|
||||||
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
|
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
|
||||||
to: `${otherTeamMember.email}`,
|
to: `${otherTeamMember.email}`,
|
||||||
ics: {
|
ics: {
|
||||||
@@ -238,7 +288,7 @@ export function expectSuccessfulBookingCreationEmails({
|
|||||||
guests.forEach((guest) => {
|
guests.forEach((guest) => {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
title: "confirmed_event_type_subject",
|
||||||
to: `${guest.email}`,
|
to: `${guest.email}`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
@@ -261,7 +311,7 @@ export function expectBrokenIntegrationEmails({
|
|||||||
// Broken Integration email is only sent to the Organizer
|
// Broken Integration email is only sent to the Organizer
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>broken_integration</title>",
|
title: "broken_integration",
|
||||||
to: `${organizer.email}`,
|
to: `${organizer.email}`,
|
||||||
// No ics goes in case of broken integration email it seems
|
// No ics goes in case of broken integration email it seems
|
||||||
// ics: {
|
// ics: {
|
||||||
@@ -274,7 +324,7 @@ export function expectBrokenIntegrationEmails({
|
|||||||
|
|
||||||
// expect(emails).toHaveEmail(
|
// expect(emails).toHaveEmail(
|
||||||
// {
|
// {
|
||||||
// htmlToContain: "<title>confirmed_event_type_subject</title>",
|
// title: "confirmed_event_type_subject",
|
||||||
// to: `${booker.name} <${booker.email}>`,
|
// to: `${booker.name} <${booker.email}>`,
|
||||||
// },
|
// },
|
||||||
// `${booker.name} <${booker.email}>`
|
// `${booker.name} <${booker.email}>`
|
||||||
@@ -294,7 +344,7 @@ export function expectCalendarEventCreationFailureEmails({
|
|||||||
}) {
|
}) {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>broken_integration</title>",
|
title: "broken_integration",
|
||||||
to: `${organizer.email}`,
|
to: `${organizer.email}`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
@@ -306,7 +356,7 @@ export function expectCalendarEventCreationFailureEmails({
|
|||||||
|
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>calendar_event_creation_failure_subject</title>",
|
title: "calendar_event_creation_failure_subject",
|
||||||
to: `${booker.name} <${booker.email}>`,
|
to: `${booker.name} <${booker.email}>`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
@@ -322,27 +372,30 @@ export function expectSuccessfulBookingRescheduledEmails({
|
|||||||
organizer,
|
organizer,
|
||||||
booker,
|
booker,
|
||||||
iCalUID,
|
iCalUID,
|
||||||
|
appsStatus,
|
||||||
}: {
|
}: {
|
||||||
emails: Fixtures["emails"];
|
emails: Fixtures["emails"];
|
||||||
organizer: { email: string; name: string };
|
organizer: { email: string; name: string };
|
||||||
booker: { email: string; name: string };
|
booker: { email: string; name: string };
|
||||||
iCalUID: string;
|
iCalUID: string;
|
||||||
|
appsStatus: AppsStatus[];
|
||||||
}) {
|
}) {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
|
title: "event_type_has_been_rescheduled_on_time_date",
|
||||||
to: `${organizer.email}`,
|
to: `${organizer.email}`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
iCalUID,
|
iCalUID,
|
||||||
},
|
},
|
||||||
|
appsStatus,
|
||||||
},
|
},
|
||||||
`${organizer.email}`
|
`${organizer.email}`
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>event_type_has_been_rescheduled_on_time_date</title>",
|
title: "event_type_has_been_rescheduled_on_time_date",
|
||||||
to: `${booker.name} <${booker.email}>`,
|
to: `${booker.name} <${booker.email}>`,
|
||||||
ics: {
|
ics: {
|
||||||
filename: "event.ics",
|
filename: "event.ics",
|
||||||
@@ -362,7 +415,7 @@ export function expectAwaitingPaymentEmails({
|
|||||||
}) {
|
}) {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>awaiting_payment_subject</title>",
|
title: "awaiting_payment_subject",
|
||||||
to: `${booker.name} <${booker.email}>`,
|
to: `${booker.name} <${booker.email}>`,
|
||||||
noIcs: true,
|
noIcs: true,
|
||||||
},
|
},
|
||||||
@@ -381,7 +434,7 @@ export function expectBookingRequestedEmails({
|
|||||||
}) {
|
}) {
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>event_awaiting_approval_subject</title>",
|
title: "event_awaiting_approval_subject",
|
||||||
to: `${organizer.email}`,
|
to: `${organizer.email}`,
|
||||||
noIcs: true,
|
noIcs: true,
|
||||||
},
|
},
|
||||||
@@ -390,7 +443,7 @@ export function expectBookingRequestedEmails({
|
|||||||
|
|
||||||
expect(emails).toHaveEmail(
|
expect(emails).toHaveEmail(
|
||||||
{
|
{
|
||||||
htmlToContain: "<title>booking_submitted_subject</title>",
|
title: "booking_submitted_subject",
|
||||||
to: `${booker.email}`,
|
to: `${booker.email}`,
|
||||||
noIcs: true,
|
noIcs: true,
|
||||||
},
|
},
|
||||||
@@ -509,6 +562,7 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
|
|||||||
location,
|
location,
|
||||||
subscriberUrl,
|
subscriberUrl,
|
||||||
videoCallUrl,
|
videoCallUrl,
|
||||||
|
payload,
|
||||||
}: {
|
}: {
|
||||||
organizer: { email: string; name: string };
|
organizer: { email: string; name: string };
|
||||||
booker: { email: string; name: string };
|
booker: { email: string; name: string };
|
||||||
@@ -516,10 +570,12 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
|
|||||||
location: string;
|
location: string;
|
||||||
paidEvent?: boolean;
|
paidEvent?: boolean;
|
||||||
videoCallUrl?: string;
|
videoCallUrl?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
||||||
triggerEvent: "BOOKING_RESCHEDULED",
|
triggerEvent: "BOOKING_RESCHEDULED",
|
||||||
payload: {
|
payload: {
|
||||||
|
...payload,
|
||||||
metadata: {
|
metadata: {
|
||||||
...(videoCallUrl ? { videoCallUrl } : null),
|
...(videoCallUrl ? { videoCallUrl } : null),
|
||||||
},
|
},
|
||||||
@@ -624,6 +680,31 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
|
|||||||
expect(externalId).toBe(expected.externalCalendarId);
|
expect(externalId).toBe(expected.externalCalendarId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function expectSuccessfulCalendarEventDeletionInCalendar(
|
||||||
|
calendarMock: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
createEventCalls: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
updateEventCalls: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
deleteEventCalls: any[];
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
externalCalendarId: string;
|
||||||
|
calEvent: Partial<CalendarEvent>;
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
expect(calendarMock.deleteEventCalls.length).toBe(1);
|
||||||
|
const call = calendarMock.deleteEventCalls[0];
|
||||||
|
const uid = call[0];
|
||||||
|
const calendarEvent = call[1];
|
||||||
|
const externalId = call[2];
|
||||||
|
expect(uid).toBe(expected.uid);
|
||||||
|
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
||||||
|
expect(externalId).toBe(expected.externalCalendarId);
|
||||||
|
}
|
||||||
|
|
||||||
export function expectSuccessfulVideoMeetingCreation(
|
export function expectSuccessfulVideoMeetingCreation(
|
||||||
videoMock: {
|
videoMock: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -669,6 +750,26 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
|||||||
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function expectSuccessfulVideoMeetingDeletionInCalendar(
|
||||||
|
videoMock: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
createMeetingCalls: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
updateMeetingCalls: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
deleteMeetingCalls: any[];
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
bookingRef: any;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
expect(videoMock.deleteMeetingCalls.length).toBe(1);
|
||||||
|
const call = videoMock.deleteMeetingCalls[0];
|
||||||
|
const bookingRefUid = call.args[0];
|
||||||
|
expect(bookingRefUid).toEqual(expected.bookingRef.uid);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) {
|
export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) {
|
||||||
// Expect previous booking to be cancelled
|
// Expect previous booking to be cancelled
|
||||||
@@ -678,10 +779,9 @@ export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { fro
|
|||||||
status: BookingStatus.CANCELLED,
|
status: BookingStatus.CANCELLED,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expect new booking to be created
|
// Expect new booking to be created but status would depend on whether the new booking requires confirmation or not.
|
||||||
await expectBookingToBeInDatabase({
|
await expectBookingToBeInDatabase({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
...to,
|
...to,
|
||||||
status: BookingStatus.ACCEPTED,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"edit-app-template": "yarn app-store edit-template",
|
"edit-app-template": "yarn app-store edit-template",
|
||||||
"delete-app-template": "yarn app-store delete-template",
|
"delete-app-template": "yarn app-store delete-template",
|
||||||
"build": "turbo run build --filter=@calcom/web...",
|
"build": "turbo run build --filter=@calcom/web...",
|
||||||
|
"build:ai": "turbo run build --scope=\"@calcom/ai\"",
|
||||||
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
|
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
|
||||||
"db-deploy": "turbo run db-deploy",
|
"db-deploy": "turbo run db-deploy",
|
||||||
"db-seed": "turbo run db-seed",
|
"db-seed": "turbo run db-seed",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface CalendarApp {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
|
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
|
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type ParseRefreshTokenResponse<S extends z.ZodTypeAny> =
|
|||||||
|
|
||||||
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
|
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
|
||||||
let refreshTokenResponse;
|
let refreshTokenResponse;
|
||||||
|
const credentialSyncingEnabled =
|
||||||
|
APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
|
||||||
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
|
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
|
||||||
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
|
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
|
||||||
} else {
|
} else {
|
||||||
@@ -26,7 +28,7 @@ const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
|
|||||||
throw new Error("Invalid refreshed tokens were returned");
|
throw new Error("Invalid refreshed tokens were returned");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!refreshTokenResponse.data.refresh_token) {
|
if (!refreshTokenResponse.data.refresh_token && credentialSyncingEnabled) {
|
||||||
refreshTokenResponse.data.refresh_token = "refresh_token";
|
refreshTokenResponse.data.refresh_token = "refresh_token";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const InstallAppButtonMap = {
|
|||||||
vital: dynamic(() => import("./vital/components/InstallAppButton")),
|
vital: dynamic(() => import("./vital/components/InstallAppButton")),
|
||||||
};
|
};
|
||||||
export const AppSettingsComponentsMap = {
|
export const AppSettingsComponentsMap = {
|
||||||
|
"cal-ai": dynamic(() => import("./cal-ai/components/AppSettingsInterface")),
|
||||||
"general-app-settings": dynamic(() =>
|
"general-app-settings": dynamic(() =>
|
||||||
import("./templates/general-app-settings/components/AppSettingsInterface")
|
import("./templates/general-app-settings/components/AppSettingsInterface")
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default class BasecampCalendarService implements Calendar {
|
|||||||
this.userAgent = user_agent as string;
|
this.userAgent = user_agent as string;
|
||||||
});
|
});
|
||||||
this.auth = this.basecampAuth(credential).then((c) => c);
|
this.auth = this.basecampAuth(credential).then((c) => c);
|
||||||
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private basecampAuth = async (credential: CredentialPayload) => {
|
private basecampAuth = async (credential: CredentialPayload) => {
|
||||||
|
|||||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user