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 |
@@ -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:
|
||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
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)
|
||||
|
||||
### Elestio
|
||||
|
||||
[](https://elest.io/open-source/cal.com)
|
||||
|
||||
<!-- ROADMAP -->
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -6,6 +6,9 @@ FRONTEND_URL=http://localhost:3000
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
# This is for the onboard route. Which domain should we send emails from?
|
||||
SENDER_DOMAIN=cal.ai
|
||||
|
||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||
PARSE_KEY=
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||
- List and rearrange your bookings eg. "clear my afternoon"
|
||||
- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
|
||||
|
||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||
|
||||
@@ -14,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/).
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `ai@cal.dev`)
|
||||
- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
- A default sender email (for example, `me@dev.example.com`)
|
||||
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
|
||||
|
||||
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
|
||||
|
||||
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/)
|
||||
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
||||
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
||||
4. Use the nGrok URL from above as the **Destination URL**.
|
||||
5. Activate "POST the raw, full MIME message".
|
||||
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
||||
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
|
||||
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
|
||||
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted.
|
||||
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
|
||||
5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
|
||||
6. Activate "POST the raw, full MIME message".
|
||||
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server.
|
||||
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
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",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.0.131",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.4.6",
|
||||
"next": "^13.5.4",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@ import agent from "../../../utils/agent";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
// Allow agent loop to run for up to 5 minutes
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Launches a LangChain agent to process an incoming email,
|
||||
* then sends the response to the user.
|
||||
@@ -37,6 +40,13 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
|
||||
to: user.email,
|
||||
from: agentEmail,
|
||||
});
|
||||
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
|
||||
@@ -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 { NextResponse } from "next/server";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
@@ -14,6 +15,10 @@ import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
// Allow receive loop to run for up to 30 seconds
|
||||
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* Verifies email signature and app authorization,
|
||||
* then hands off to booking agent.
|
||||
@@ -27,18 +32,37 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
const subject = body.subject || "";
|
||||
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: `ai:email:${envelope.from}`,
|
||||
rateLimitingType: "ai",
|
||||
});
|
||||
} catch (error) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
|
||||
}
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
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`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
@@ -79,7 +106,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${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}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
@@ -106,7 +133,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
@@ -117,7 +144,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
@@ -135,8 +162,8 @@ export const POST = async (request: NextRequest) => {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text,
|
||||
subject: parsed.subject,
|
||||
message: parsed.text || "",
|
||||
subject: parsed.subject || "",
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const env = createEnv({
|
||||
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
@@ -36,6 +37,7 @@ export const env = createEnv({
|
||||
FRONTEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
SENDER_DOMAIN: z.string().min(1),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
OPENAI_API_KEY: z.string().min(1),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -47,7 +47,7 @@ const createBooking = async ({
|
||||
}
|
||||
|
||||
const responses = {
|
||||
id: invite,
|
||||
id: invite.toString(),
|
||||
name: user.username,
|
||||
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 getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import sendBookingLink from "../tools/sendBookingLink";
|
||||
import sendBookingEmail from "../tools/sendBookingEmail";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User, UserList } from "../types/user";
|
||||
@@ -35,7 +35,7 @@ const agent = async (
|
||||
createBookingIfAvailable(apiKey, userId, users),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
sendBookingLink(apiKey, user, users, agentEmail),
|
||||
sendBookingEmail(apiKey, user, users, agentEmail),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
@@ -53,6 +53,8 @@ const agent = async (
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
|
||||
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
|
||||
|
||||
The primary user's id is: ${userId}
|
||||
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
|
||||
*/
|
||||
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 emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g);
|
||||
const usernames = text
|
||||
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
|
||||
?.map((username) => username.slice(1).toLowerCase());
|
||||
const emails = text
|
||||
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
|
||||
?.map((email) => email.toLowerCase());
|
||||
|
||||
const dbUsersFromUsernames = usernames
|
||||
? await prisma.user.findMany({
|
||||
|
||||
@@ -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 { 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.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
|
||||
@@ -12,24 +12,29 @@ import {
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
} from "./httpMethods";
|
||||
import { rateLimitApiKey } from "./rateLimitApiKey";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
import { withPagination } from "./withPagination";
|
||||
|
||||
const withMiddleware = label(
|
||||
{
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_PATCH,
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
verifyApiKey,
|
||||
customPrismaClient,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
captureErrors,
|
||||
},
|
||||
const middleware = {
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_PATCH,
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
customPrismaClient,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
captureErrors,
|
||||
};
|
||||
|
||||
type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder =
|
||||
// The order here, determines the order of execution
|
||||
[
|
||||
"extendRequest",
|
||||
@@ -37,8 +42,10 @@ const withMiddleware = label(
|
||||
// - Put customPrismaClient before verifyApiKey always.
|
||||
"customPrismaClient",
|
||||
"verifyApiKey",
|
||||
"rateLimitApiKey",
|
||||
"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";
|
||||
|
||||
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
||||
credentialId: true,
|
||||
integration: true,
|
||||
externalId: true,
|
||||
eventTypeId: true,
|
||||
@@ -14,9 +15,10 @@ const schemaDestinationCalendarCreateParams = z
|
||||
.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
eventTypeId: z.number(),
|
||||
bookingId: z.number(),
|
||||
userId: z.number(),
|
||||
credentialId: z.number(),
|
||||
eventTypeId: z.number().optional(),
|
||||
bookingId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -45,4 +47,5 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
|
||||
eventTypeId: true,
|
||||
bookingId: 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 type { DestinationCalendarResponse, DestinationCalendarsResponse } from "~/lib/types";
|
||||
import {
|
||||
schemaDestinationCalendarCreateBodyParams,
|
||||
schemaDestinationCalendarReadPublic,
|
||||
} from "~/lib/validations/destination-calendar";
|
||||
|
||||
async function createOrlistAllDestinationCalendars(
|
||||
{ method, body, userId, prisma }: NextApiRequest,
|
||||
res: NextApiResponse<DestinationCalendarsResponse | DestinationCalendarResponse>
|
||||
) {
|
||||
if (method === "GET") {
|
||||
/**
|
||||
* @swagger
|
||||
* /destination-calendars:
|
||||
* get:
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* summary: Find all destination calendars
|
||||
* tags:
|
||||
* - destination-calendars
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
* description: No destination calendars were found
|
||||
*/
|
||||
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
|
||||
const destination_calendars = data.map((destinationCalendar) =>
|
||||
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
|
||||
);
|
||||
if (data) res.status(200).json({ destination_calendars });
|
||||
else
|
||||
(error: Error) =>
|
||||
res.status(404).json({
|
||||
message: "No DestinationCalendars were found",
|
||||
error,
|
||||
});
|
||||
} else if (method === "POST") {
|
||||
/**
|
||||
* @swagger
|
||||
* /destination-calendars:
|
||||
* post:
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* summary: Creates a new destination calendar
|
||||
* requestBody:
|
||||
* description: Create a new destination calendar for your events
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - integration
|
||||
* - externalId
|
||||
* properties:
|
||||
* integration:
|
||||
* type: string
|
||||
* description: 'The integration'
|
||||
* externalId:
|
||||
* type: string
|
||||
* description: 'The external ID of the integration'
|
||||
* eventTypeId:
|
||||
* type: integer
|
||||
* description: 'The ID of the eventType it is associated with'
|
||||
* bookingId:
|
||||
* type: integer
|
||||
* description: 'The booking ID it is associated with'
|
||||
* tags:
|
||||
* - destination-calendars
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, destination calendar created
|
||||
* 400:
|
||||
* description: Bad request. DestinationCalendar body is invalid.
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
*/
|
||||
const safe = schemaDestinationCalendarCreateBodyParams.safeParse(body);
|
||||
if (!safe.success) {
|
||||
res.status(400).json({ message: "Invalid request body" });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await prisma.destinationCalendar.create({ data: { ...safe.data, userId } });
|
||||
const destination_calendar = schemaDestinationCalendarReadPublic.parse(data);
|
||||
|
||||
if (destination_calendar)
|
||||
res.status(201).json({ destination_calendar, message: "DestinationCalendar created successfully" });
|
||||
else
|
||||
(error: Error) =>
|
||||
res.status(400).json({
|
||||
message: "Could not create new destinationCalendar",
|
||||
error,
|
||||
});
|
||||
} else res.status(405).json({ message: `Method ${method} not allowed` });
|
||||
}
|
||||
|
||||
export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllDestinationCalendars);
|
||||
export default withMiddleware()(
|
||||
defaultHandler({
|
||||
GET: import("./_get"),
|
||||
POST: import("./_post"),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -284,8 +284,8 @@ async function postHandler(req: NextApiRequest) {
|
||||
await checkPermissions(req);
|
||||
|
||||
if (parsedBody.parentId) {
|
||||
await checkParentEventOwnership(parsedBody.parentId, userId);
|
||||
await checkUserMembership(parsedBody.parentId, parsedBody.userId);
|
||||
await checkParentEventOwnership(req);
|
||||
await checkUserMembership(req);
|
||||
}
|
||||
|
||||
if (isAdmin && parsedBody.userId) {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
/**
|
||||
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
|
||||
* the team associated with the event type identified by the parentId.
|
||||
*
|
||||
* @param parentId - The ID of the parent event type.
|
||||
* @param userId - The ID of the user.
|
||||
* @param req - The current request
|
||||
*
|
||||
* @throws {HttpError} If the parent event type is not found,
|
||||
* if the parent event type doesn't belong to any team,
|
||||
* or if the user doesn't have ownership or admin rights to the associated team.
|
||||
*/
|
||||
export default async function checkParentEventOwnership(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({
|
||||
where: {
|
||||
id: parentId,
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
/**
|
||||
* Checks if a user, identified by the provided userId, is a member of the team associated
|
||||
* with the event type identified by the parentId.
|
||||
*
|
||||
* @param parentId - The ID of the event type.
|
||||
* @param userId - The ID of the user.
|
||||
* @param req - The current request
|
||||
*
|
||||
* @throws {HttpError} If the event type is not found,
|
||||
* if the event type doesn't belong to any team,
|
||||
* or if the user isn't a member of the associated team.
|
||||
*/
|
||||
export default async function checkUserMembership(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({
|
||||
where: {
|
||||
id: parentId,
|
||||
|
||||
@@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { 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 { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const caller = slotsRouter.createCaller(ctx);
|
||||
try {
|
||||
const input = getScheduleSchema.parse(req.query);
|
||||
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
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 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", () => {
|
||||
test("Missing required data", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
@@ -31,7 +32,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res._getData())).toEqual(
|
||||
expect.objectContaining({
|
||||
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">
|
||||
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
|
||||
<svg
|
||||
className="h-7 w-7 fill-current text-indigo-500"
|
||||
className="h-7 w-7 fill-current text-[#5B93F9]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
enableBackground="new 0 0 50 50">
|
||||
@@ -29,7 +29,7 @@ export default function AddToHomescreen() {
|
||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-inverted ms-3 text-xs font-medium">
|
||||
<p className="text-inverted ms-3 text-xs font-medium dark:text-white">
|
||||
<span className="inline">{t("add_to_homescreen")}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ export default function AddToHomescreen() {
|
||||
type="button"
|
||||
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||
<span className="sr-only">{t("dismiss")}</span>
|
||||
<X className="text-inverted h-6 w-6" aria-hidden="true" />
|
||||
<X className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="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" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
|
||||
@@ -43,7 +43,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="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" />
|
||||
</div>
|
||||
<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).
|
||||
* 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 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
@@ -182,9 +182,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</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
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
@@ -213,8 +213,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
defaultValue={eventType.requiresBookerEmailVerification}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
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")}
|
||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
@@ -230,8 +231,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
defaultValue={eventType.hideCalendarNotes}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
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")}
|
||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||
description={t("disable_notes_description")}
|
||||
@@ -247,9 +249,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
@@ -261,7 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
setRedirectUrlVisible(e);
|
||||
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
|
||||
className="w-full"
|
||||
label={t("redirect_success_booking")}
|
||||
@@ -287,20 +290,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
title={t("enable_private_url")}
|
||||
Badge={
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
|
||||
<Info className="mb-2 ml-1.5 h-4 w-4 cursor-pointer" />
|
||||
<Info className="ml-1.5 h-4 w-4 cursor-pointer" />
|
||||
</a>
|
||||
}
|
||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||
@@ -310,7 +314,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||
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 && (
|
||||
<TextField
|
||||
disabled
|
||||
@@ -353,9 +357,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
@@ -379,13 +384,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}
|
||||
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
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="lg:-ml-2">
|
||||
<div>
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
@@ -395,12 +400,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
disabled={seatsLocked.disabled}
|
||||
defaultValue={value || 2}
|
||||
min={1}
|
||||
containerClassName="max-w-80"
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<div className="mt-4">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
disabled={seatsLocked.disabled}
|
||||
@@ -435,8 +441,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
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")}
|
||||
description={t("disable_attendees_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
@@ -459,8 +466,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
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")}
|
||||
description={t("disable_host_confirmation_emails_description")}
|
||||
checked={value || false}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
@@ -141,17 +140,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
defaultValue: periodType?.type,
|
||||
});
|
||||
|
||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
|
||||
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
|
||||
const periodTypeLocked = shouldLockDisableProps("periodType");
|
||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
||||
|
||||
const optionsPeriod = [
|
||||
{ value: 1, label: t("calendar_days") },
|
||||
{ value: 0, label: t("business_days") },
|
||||
@@ -171,13 +159,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
|
||||
return (
|
||||
<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="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
{t("before_event")}
|
||||
{shouldLockIndicator("bookingLimits")}
|
||||
</Label>
|
||||
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
|
||||
<Controller
|
||||
name="beforeBufferTime"
|
||||
control={formMethods.control}
|
||||
@@ -196,7 +181,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
@@ -210,10 +194,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterBufferTime">
|
||||
{t("after_event")}
|
||||
{shouldLockIndicator("bookingLimits")}
|
||||
</Label>
|
||||
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
|
||||
<Controller
|
||||
name="afterBufferTime"
|
||||
control={formMethods.control}
|
||||
@@ -232,7 +213,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
@@ -248,20 +228,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">
|
||||
{t("minimum_booking_notice")}
|
||||
{shouldLockIndicator("minimumBookingNotice")}
|
||||
</Label>
|
||||
<MinimumBookingNoticeInput
|
||||
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
||||
{...formMethods.register("minimumBookingNotice")}
|
||||
/>
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
|
||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">
|
||||
{t("slot_interval")}
|
||||
{shouldLockIndicator("slotInterval")}
|
||||
</Label>
|
||||
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
control={formMethods.control}
|
||||
@@ -279,7 +250,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||
}}
|
||||
@@ -303,8 +273,8 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
labelClassName="text-sm"
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
@@ -317,17 +287,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
}
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
@@ -340,15 +305,15 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
const isChecked = Object.keys(value ?? {}).length > 0;
|
||||
return (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
@@ -359,11 +324,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
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
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
@@ -380,25 +344,23 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
|
||||
return (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
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
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
{PERIOD_TYPES.map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
@@ -407,14 +369,13 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
@@ -423,14 +384,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
@@ -455,7 +414,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
@@ -478,15 +436,15 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
}}
|
||||
/>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
checked={offsetToggle}
|
||||
onCheckedChange={(active) => {
|
||||
setOffsetToggle(active);
|
||||
@@ -494,11 +452,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
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
|
||||
required
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
containerClassName="max-w-80"
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
|
||||
@@ -392,7 +392,7 @@ export const EventSetupTab = (
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
required
|
||||
label={t("title")}
|
||||
@@ -431,7 +431,7 @@ export const EventSetupTab = (
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-subtle rounded-md border p-6">
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -527,7 +527,7 @@ export const EventSetupTab = (
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-subtle rounded-md border p-6">
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
|
||||
@@ -268,9 +268,11 @@ function EventTypeSingleLayout({
|
||||
</Skeleton>
|
||||
)}
|
||||
<Tooltip
|
||||
sideOffset={4}
|
||||
content={
|
||||
formMethods.watch("hidden") ? t("show_eventtype_on_profile") : t("hide_from_profile")
|
||||
}>
|
||||
}
|
||||
side="bottom">
|
||||
<div className="self-center rounded-md p-2">
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
@@ -291,7 +293,7 @@ function EventTypeSingleLayout({
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
||||
<Tooltip content={t("preview")}>
|
||||
<Tooltip content={t("preview")} side="bottom" sideOffset={4}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="preview-button"
|
||||
@@ -308,6 +310,8 @@ function EventTypeSingleLayout({
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
tooltip={t("copy_link")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Link copied!", "success");
|
||||
@@ -319,6 +323,8 @@ function EventTypeSingleLayout({
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
tooltip={t("embed")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
eventId={eventType.id}
|
||||
/>
|
||||
</>
|
||||
@@ -329,6 +335,8 @@ function EventTypeSingleLayout({
|
||||
variant="icon"
|
||||
StartIcon={Trash}
|
||||
tooltip={t("delete")}
|
||||
tooltipSide="bottom"
|
||||
tooltipOffset={4}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
@@ -417,7 +425,7 @@ function EventTypeSingleLayout({
|
||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
|
||||
</div>
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div className="w-full overflow-hidden ltr:mr-2 rtl:ml-2">
|
||||
<div
|
||||
className={classNames(
|
||||
"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 })}
|
||||
</p>
|
||||
|
||||
<div className="border-subtle mt-8 rounded-md border">
|
||||
<div className="border-subtle my-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
@@ -141,7 +141,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
})}
|
||||
</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">
|
||||
If you wish to edit or manage your web hooks, please head over to
|
||||
<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."
|
||||
/>
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
@@ -78,7 +79,7 @@ export default function RecurringEventController({
|
||||
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 && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -67,9 +67,10 @@ export default function RequiresConfirmationController({
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
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"
|
||||
)}
|
||||
childrenClassName="lg:ml-0"
|
||||
@@ -83,7 +84,7 @@ export default function RequiresConfirmationController({
|
||||
formMethods.setValue("requiresConfirmation", 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
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
@@ -147,7 +148,7 @@ export default function RequiresConfirmationController({
|
||||
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}
|
||||
/>
|
||||
<label
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
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 { ParsedUrlQuery } from "querystring";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
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.
|
||||
**/
|
||||
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 passedProps = {
|
||||
...props,
|
||||
pageProps: {
|
||||
...props.pageProps,
|
||||
|
||||
...i18n,
|
||||
},
|
||||
};
|
||||
@@ -247,8 +284,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<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 */}
|
||||
<CalcomThemeProvider
|
||||
@@ -264,8 +301,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</CustomI18nextProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</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",
|
||||
"version": "3.3.7",
|
||||
"version": "3.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -166,6 +166,7 @@
|
||||
"env-cmd": "^10.1.0",
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
"node-html-parser": "^6.1.10",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
|
||||
@@ -23,7 +23,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
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 { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
@@ -35,6 +35,8 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
|
||||
|
||||
@@ -261,13 +263,14 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
const dataFetchStart = Date.now();
|
||||
const usersWithoutAvatar = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernameList,
|
||||
},
|
||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -275,6 +278,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
email: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
organizationId: true,
|
||||
@@ -312,6 +316,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
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))) {
|
||||
return {
|
||||
notFound: true,
|
||||
|
||||
@@ -15,12 +15,15 @@ import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type({
|
||||
@@ -93,7 +96,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
@@ -115,7 +118,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -150,6 +153,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
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 ssr = await ssrInit(context);
|
||||
const user = await prisma.user.findFirst({
|
||||
@@ -167,7 +184,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
@@ -189,7 +206,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { IncomingMessage } from "http";
|
||||
import type { AppContextType } from "next/dist/shared/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -28,6 +27,7 @@ MyApp.getInitialProps = async (ctx: AppContextType) => {
|
||||
let newLocale = "en";
|
||||
|
||||
if (req) {
|
||||
const { getLocale } = await import("@calcom/features/auth/lib/getLocale");
|
||||
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
|
||||
} else if (typeof window !== "undefined" && window.calNewLocale) {
|
||||
newLocale = window.calNewLocale;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import { dir } from "i18next";
|
||||
import type { NextPageContext } from "next";
|
||||
import type { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
@@ -28,9 +28,12 @@ class MyDocument extends Document<Props> {
|
||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||
}
|
||||
|
||||
const newLocale = ctx.req
|
||||
? await getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||
: "en";
|
||||
const getLocaleModule = ctx.req ? await import("@calcom/features/auth/lib/getLocale") : null;
|
||||
|
||||
const newLocale =
|
||||
ctx.req && getLocaleModule
|
||||
? await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
|
||||
: "en";
|
||||
|
||||
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
|
||||
@@ -48,21 +51,15 @@ class MyDocument extends Document<Props> {
|
||||
render() {
|
||||
const { isEmbed, embedColorScheme } = this.props;
|
||||
const newLocale = this.props.newLocale || "en";
|
||||
const newDir = dir(newLocale);
|
||||
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
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 (
|
||||
<Html
|
||||
lang={newLocale}
|
||||
dir={direction}
|
||||
dir={newDir}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<Head nonce={nonce}>
|
||||
<script
|
||||
|
||||
@@ -26,7 +26,7 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
|
||||
err: AugmentedError;
|
||||
};
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
||||
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||
|
||||
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
||||
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "@calcom/prisma";
|
||||
import type { AppCategories, Prisma } from "@calcom/prisma/client";
|
||||
|
||||
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
|
||||
const log = logger.getChildLogger({
|
||||
const log = logger.getSubLogger({
|
||||
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[api/logo]"] });
|
||||
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
|
||||
|
||||
function removePort(url: string) {
|
||||
return url.replace(/:\d+$/, "");
|
||||
|
||||
@@ -45,6 +45,7 @@ function AppsSearch({
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<TextField
|
||||
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
|
||||
@@ -54,6 +55,7 @@ function AppsSearch({
|
||||
type="search"
|
||||
autoComplete="false"
|
||||
onChange={onChange}
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
created: true,
|
||||
}))
|
||||
);
|
||||
showToast(t("event_type_updated_successfully"), "success");
|
||||
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||
},
|
||||
async onSettled() {
|
||||
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]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssrResponse = await _getServerSideProps(context);
|
||||
if (ssrResponse.notFound) {
|
||||
return ssrResponse;
|
||||
}
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...ssrResponse.props,
|
||||
isEmbed: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
|
||||
@@ -45,7 +45,7 @@ const BillingView = () => {
|
||||
return (
|
||||
<>
|
||||
<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")}>
|
||||
<Button color="primary" href={billingHref} target="_blank" EndIcon={ExternalLink}>
|
||||
{t("billing_portal")}
|
||||
|
||||
@@ -25,7 +25,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<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" />
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@ const ApiKeysView = () => {
|
||||
<div>
|
||||
{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) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
@@ -98,7 +98,7 @@ const ApiKeysView = () => {
|
||||
Icon={LinkIcon}
|
||||
headline={t("create_first_api_key")}
|
||||
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 />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSele
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
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 { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -35,7 +35,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<div className="rounded-b-lg">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</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 = ({
|
||||
user,
|
||||
hasPaidPlan,
|
||||
@@ -137,7 +137,7 @@ const AppearanceView = ({
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||
<p className="text-default">{t("theme_applies_note")}</p>
|
||||
@@ -149,13 +149,13 @@ const AppearanceView = ({
|
||||
mutation.mutate({
|
||||
// Radio values don't support null as values, therefore we convert an empty string
|
||||
// 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">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
value={undefined}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.theme === null}
|
||||
register={userThemeFormMethods.register}
|
||||
@@ -226,11 +226,7 @@ const AppearanceView = ({
|
||||
});
|
||||
}
|
||||
}}
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
isCustomBrandColorChecked && "rounded-b-none"
|
||||
)}>
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
@@ -241,7 +237,7 @@ const AppearanceView = ({
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={user.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
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>
|
||||
<ColorPicker
|
||||
defaultValue={user.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#101010", value);
|
||||
@@ -328,7 +324,7 @@ const AppearanceView = ({
|
||||
setHideBrandingValue(checked);
|
||||
mutation.mutate({ hideBranding: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<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" />
|
||||
@@ -109,11 +109,11 @@ const CalendarsView = () => {
|
||||
selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId;
|
||||
return data.connectedCalendars.length ? (
|
||||
<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">
|
||||
{t("add_to_calendar")}
|
||||
</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 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
|
||||
@@ -137,15 +137,15 @@ const CalendarsView = () => {
|
||||
</Button>
|
||||
</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">
|
||||
{t("check_for_conflicts")}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
@@ -173,7 +173,7 @@ const CalendarsView = () => {
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -16,7 +16,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<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" />
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@ const ConferencingLayout = () => {
|
||||
}
|
||||
return (
|
||||
<AppList
|
||||
listClassName="rounded-xl rounded-t-none border-t-0"
|
||||
listClassName="rounded-lg rounded-t-none border-t-0"
|
||||
handleDisconnect={handleDisconnect}
|
||||
data={data}
|
||||
variant="conferencing"
|
||||
|
||||
@@ -237,7 +237,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
setIsAllowDynamicBookingChecked(checked);
|
||||
mutation.mutate({ allowDynamicBooking: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
@@ -250,7 +250,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
setIsAllowSEOIndexingChecked(checked);
|
||||
mutation.mutate({ allowSEOIndexing: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
@@ -263,7 +263,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
setIsReceiveMonthlyDigestEmailChecked(checked);
|
||||
mutation.mutate({ receiveMonthlyDigestEmail: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<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">
|
||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||
<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>
|
||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
|
||||
|
||||
import type { CalPageWrapper } 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;
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -66,8 +66,8 @@ const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"
|
||||
onCheckedChange={(checked) => {
|
||||
mutation.mutate({ disableImpersonation: !checked });
|
||||
}}
|
||||
switchContainerClassName="rounded-t-none border-t-0"
|
||||
disabled={mutation.isLoading}
|
||||
switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -63,7 +63,7 @@ const TwoFactorAuthView = () => {
|
||||
{user?.twoFactorEnabled ? t("enabled") : t("disabled")}
|
||||
</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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
|
||||
|
||||
import type { CalPageWrapper } 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;
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -18,6 +18,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
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 { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function TeamPage({
|
||||
@@ -272,6 +275,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const team = await getTeamWithMembers({
|
||||
slug: slugify(slug ?? ""),
|
||||
@@ -279,6 +284,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
isTeamView: true,
|
||||
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 metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
||||
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 slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type({
|
||||
@@ -75,6 +78,19 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
context.req.headers.host ?? "",
|
||||
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({
|
||||
where: {
|
||||
|
||||
@@ -26,6 +26,7 @@ function Teams() {
|
||||
CTA={
|
||||
(!user.organizationId || user.organization.isOrgAdmin) && (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
StartIcon={Plus}
|
||||
type="button"
|
||||
|
||||
@@ -3,13 +3,16 @@ import type { Booking, Prisma } from "@prisma/client";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import _dayjs from "@calcom/dayjs";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const translator = short();
|
||||
|
||||
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
|
||||
export const createBookingsFixture = (page: 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 type { API } from "mailhog";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
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.
|
||||
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);
|
||||
if (scenario.teammates) {
|
||||
// Create Teammate users
|
||||
const teamMatesIds = [];
|
||||
for (const teammateObj of scenario.teammates) {
|
||||
const teamUser = await prisma.user.create({
|
||||
data: createUser(workerInfo, teammateObj),
|
||||
@@ -319,8 +320,22 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
}),
|
||||
store.page
|
||||
);
|
||||
teamMatesIds.push(teamUser.id);
|
||||
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);
|
||||
@@ -461,13 +476,14 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
||||
password: hashPassword(uname),
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
locale: opts?.locale ?? "en",
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
? {
|
||||
create: {
|
||||
name: "Working Hours",
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
availability: {
|
||||
createMany: {
|
||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||
@@ -543,7 +559,7 @@ export async function apiLogin(
|
||||
const data = {
|
||||
email: user.email ?? `${user.username}@example.com`,
|
||||
password: user.password ?? user.username,
|
||||
callbackURL: "http://localhost:3000/",
|
||||
callbackURL: WEBAPP_URL,
|
||||
redirect: "false",
|
||||
json: "true",
|
||||
csrfToken,
|
||||
|
||||
@@ -236,4 +236,34 @@ test.describe("Insights", async () => {
|
||||
// expect for "Team: test-insight" text in page
|
||||
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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
@@ -135,6 +135,40 @@ test.describe("Teams", () => {
|
||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// 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 }) => {
|
||||
// Name to be used for both user and team
|
||||
const uniqueName = "test-unique-name";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import _dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
@@ -9,6 +9,9 @@ test.describe.configure({ mode: "parallel" });
|
||||
|
||||
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("Browse upcoming bookings and validate button shows and triggering wipe my cal button", async ({
|
||||
page,
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
"where": "Where",
|
||||
"add_to_calendar": "Add to calendar",
|
||||
"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",
|
||||
"other": "Other",
|
||||
"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_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_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.",
|
||||
"sunday_time_error": "Invalid time on Sunday",
|
||||
"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.",
|
||||
"edit_logo": "Edit logo",
|
||||
"upload_a_logo": "Upload a logo",
|
||||
"upload_logo": "Upload logo",
|
||||
"remove_logo": "Remove logo",
|
||||
"enable": "Enable",
|
||||
"code": "Code",
|
||||
@@ -571,6 +573,7 @@
|
||||
"your_team_name": "Your team name",
|
||||
"team_updated_successfully": "Team updated successfully",
|
||||
"your_team_updated_successfully": "Your team has been updated successfully.",
|
||||
"your_org_updated_successfully": "Your Org has been updated successfully.",
|
||||
"about": "About",
|
||||
"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.",
|
||||
@@ -685,8 +688,8 @@
|
||||
"new_event_type_btn": "New 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.",
|
||||
"event_type_created_successfully": "Event type created successfully",
|
||||
"event_type_updated_successfully": "Event type updated successfully",
|
||||
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||
"hours": "Hours",
|
||||
"people": "People",
|
||||
@@ -783,6 +786,7 @@
|
||||
"disable_guests": "Disable Guests",
|
||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||
"private_link": "Generate private link",
|
||||
"enable_private_url": "Enable Private URL",
|
||||
"private_link_label": "Private link",
|
||||
"private_link_hint": "Your private link will regenerate after each use",
|
||||
"copy_private_link": "Copy private link",
|
||||
@@ -2079,6 +2083,7 @@
|
||||
"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":"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}}",
|
||||
"resend_invitation": "Resend invitation",
|
||||
"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",
|
||||
"add_new_client": "Add new Client",
|
||||
"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_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.",
|
||||
|
||||
@@ -681,8 +681,8 @@
|
||||
"new_event_type_btn": "Nouveau 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.",
|
||||
"event_type_created_successfully": "Type d'événement créé avec succès",
|
||||
"event_type_updated_successfully": "Type d'événement mis à jour avec succès",
|
||||
"event_type_created_successfully": "{{eventTypeTitle}} type d'événement créé 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",
|
||||
"hours": "heures",
|
||||
"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_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_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",
|
||||
"hours": "Horas",
|
||||
"people": "Pessoas",
|
||||
|
||||
@@ -682,7 +682,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
"hours": "Horas",
|
||||
"people": "Pessoas",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"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",
|
||||
"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_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.",
|
||||
@@ -288,6 +289,8 @@
|
||||
"when": "Khi nào",
|
||||
"where": "Ở đâu",
|
||||
"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",
|
||||
"other": "Khác",
|
||||
"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_rejected": "Lịch hẹn đã bị từ chối",
|
||||
"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",
|
||||
"form_submitted": "Biểu mẫu đã được gửi",
|
||||
"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.",
|
||||
"edit_logo": "Chỉnh sửa logo",
|
||||
"upload_a_logo": "Tải lên logo",
|
||||
"upload_logo": "Tải lên logo",
|
||||
"remove_logo": "Xóa logo",
|
||||
"enable": "Bật",
|
||||
"code": "Mã",
|
||||
@@ -568,6 +573,7 @@
|
||||
"your_team_name": "Tên nhóm của bạn",
|
||||
"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_org_updated_successfully": "Tổ Chức của bạn đã được cập nhật thành công.",
|
||||
"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.",
|
||||
"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_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",
|
||||
"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",
|
||||
"cancel": "Hủy bỏ",
|
||||
"cancel_all_remaining": "Hủy tất cả những gì còn lại",
|
||||
@@ -688,6 +695,7 @@
|
||||
"people": "Mọi người",
|
||||
"your_email": "Email của bạn",
|
||||
"change_avatar": "Thay đổi hình đại diện",
|
||||
"upload_avatar": "Tải lên Avatar",
|
||||
"language": "Ngôn ngữ",
|
||||
"timezone": "Múi giờ",
|
||||
"first_day_of_week": "Ngày đầu tuần",
|
||||
@@ -778,6 +786,7 @@
|
||||
"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.",
|
||||
"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_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",
|
||||
@@ -1276,6 +1285,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
@@ -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",
|
||||
"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_sms_notifications": "Số điện thoại (thông báo SMS)",
|
||||
"attendee_email_variable": "Email người tham dự",
|
||||
"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...",
|
||||
@@ -1639,6 +1650,7 @@
|
||||
"minimum_round_robin_hosts_count": "Số chủ tọa cần thiết để tham dự",
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1743,6 +1755,7 @@
|
||||
"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",
|
||||
"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_member": "Thành viên nhóm",
|
||||
"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",
|
||||
"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á",
|
||||
"api_key_deleted": "Đã xóa API Key",
|
||||
"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...",
|
||||
"one_day": "1 ngày",
|
||||
|
||||
@@ -23,7 +23,6 @@ const MOCK_DATA: Mockdata = {
|
||||
|
||||
describe("Check Booking Limits Tests", () => {
|
||||
it("Should return no errors", async () => {
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.booking.count.mockResolvedValue(0);
|
||||
expect(
|
||||
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 () => {
|
||||
// Mock there being two a day
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.booking.count.mockResolvedValue(2);
|
||||
expect(
|
||||
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 () => {
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.booking.count.mockResolvedValue(0);
|
||||
expect(
|
||||
checkBookingLimits(
|
||||
@@ -53,7 +50,6 @@ describe("Check Booking Limits Tests", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
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);
|
||||
expect(
|
||||
checkBookingLimits(
|
||||
@@ -67,7 +63,6 @@ describe("Check Booking Limits Tests", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("Should handle mutiple limits correctly", async () => {
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.booking.count.mockResolvedValue(1);
|
||||
expect(
|
||||
checkBookingLimit({
|
||||
@@ -77,7 +72,6 @@ describe("Check Booking Limits Tests", () => {
|
||||
eventId: MOCK_DATA.id,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.booking.count.mockResolvedValue(3);
|
||||
expect(
|
||||
checkBookingLimit({
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { CompleteEventType, CompleteWorkflowsOnEventTypes } from "@calcom/p
|
||||
|
||||
const mockFindFirstEventType = (data?: Partial<CompleteEventType>) => {
|
||||
const eventType = buildEventType(data as Partial<EventType>);
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.eventType.findFirst.mockResolvedValue(eventType as EventType);
|
||||
return eventType;
|
||||
};
|
||||
@@ -31,7 +30,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
describe("Shortcircuits", () => {
|
||||
it("Returns message 'No managed event type'", async () => {
|
||||
mockFindFirstEventType();
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [], team: { name: "" } },
|
||||
@@ -40,7 +39,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(result.newUserIds).toEqual(undefined);
|
||||
@@ -60,7 +58,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(result.newUserIds).toEqual(undefined);
|
||||
@@ -86,7 +83,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(result.newUserIds).toEqual(undefined);
|
||||
@@ -116,7 +112,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
@@ -163,7 +158,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: "somestring",
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
@@ -197,7 +191,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(result.newUserIds).toEqual([]);
|
||||
@@ -222,7 +215,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
// Have been called
|
||||
@@ -253,7 +245,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
@@ -300,7 +291,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
@@ -359,7 +349,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
currentUserId: 1,
|
||||
hashedLink: undefined,
|
||||
connectedLink: null,
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prisma: prismaMock,
|
||||
});
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
|
||||
@@ -33,10 +33,7 @@ it("can find lucky user with maximize availability", async () => {
|
||||
});
|
||||
const users = [user1, user2];
|
||||
// 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);
|
||||
// @ts-expect-error Prisma v5 typings are not yet available
|
||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -2,6 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store";
|
||||
import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n";
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { BookingReference, Attendee, Booking } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { WebhookTriggerEvents } from "@prisma/client";
|
||||
import type Stripe from "stripe";
|
||||
@@ -22,8 +23,8 @@ import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
import { getMockPaymentService } from "./MockPaymentService";
|
||||
|
||||
logger.setSettings({ minLevel: "silly" });
|
||||
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
|
||||
logger.settings.minLevel = 0;
|
||||
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
|
||||
|
||||
type InputWebhook = {
|
||||
appId: string | null;
|
||||
@@ -101,7 +102,7 @@ export type InputEventType = {
|
||||
schedule?: InputUser["schedules"][number];
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
|
||||
|
||||
type InputBooking = {
|
||||
type WhiteListedBookingProps = {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
userId?: number;
|
||||
@@ -111,19 +112,14 @@ type InputBooking = {
|
||||
title?: string;
|
||||
status: BookingStatus;
|
||||
attendees?: { email: string }[];
|
||||
references?: {
|
||||
type: string;
|
||||
uid: string;
|
||||
meetingId?: string;
|
||||
meetingPassword?: string;
|
||||
meetingUrl?: string;
|
||||
bookingId?: number;
|
||||
externalCalendarId?: string;
|
||||
deleted?: boolean;
|
||||
credentialId?: number;
|
||||
}[];
|
||||
references?: (Omit<ReturnType<typeof getMockBookingReference>, "credentialId"> & {
|
||||
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
|
||||
credentialId?: number | null;
|
||||
})[];
|
||||
};
|
||||
|
||||
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
|
||||
|
||||
export const Timezones = {
|
||||
"+5:30": "Asia/Kolkata",
|
||||
"+6:00": "Asia/Dhaka",
|
||||
@@ -267,15 +263,17 @@ async function addBookingsToDb(
|
||||
references: any[];
|
||||
})[]
|
||||
) {
|
||||
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
|
||||
await prismock.booking.createMany({
|
||||
data: bookings,
|
||||
});
|
||||
log.silly(
|
||||
"TestData: Booking as in DB",
|
||||
"TestData: Bookings as in DB",
|
||||
JSON.stringify({
|
||||
bookings: await prismock.booking.findMany({
|
||||
include: {
|
||||
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;
|
||||
})
|
||||
);
|
||||
@@ -839,6 +846,8 @@ export function mockCalendar(
|
||||
const createEventCalls: any[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updateEventCalls: any[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deleteEventCalls: any[] = [];
|
||||
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
|
||||
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
|
||||
lib: {
|
||||
@@ -888,6 +897,11 @@ export function mockCalendar(
|
||||
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[]> => {
|
||||
if (calendarData?.getAvailabilityCrash) {
|
||||
throw new Error("MockCalendarService.getAvailability fake error");
|
||||
@@ -902,6 +916,7 @@ export function mockCalendar(
|
||||
});
|
||||
return {
|
||||
createEventCalls,
|
||||
deleteEventCalls,
|
||||
updateEventCalls,
|
||||
};
|
||||
}
|
||||
@@ -952,11 +967,13 @@ export function mockVideoApp({
|
||||
password: "MOCK_PASS",
|
||||
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
|
||||
const createMeetingCalls: any[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-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
|
||||
//@ts-ignore
|
||||
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => {
|
||||
@@ -998,15 +1015,19 @@ export function mockVideoApp({
|
||||
if (!calEvent.organizer) {
|
||||
throw new Error("calEvent.organizer is not defined");
|
||||
}
|
||||
log.silly(
|
||||
"mockSuccessfulVideoMeetingCreation.updateMeeting",
|
||||
JSON.stringify({ bookingRef, calEvent })
|
||||
);
|
||||
log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent }));
|
||||
return Promise.resolve({
|
||||
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
|
||||
...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 {
|
||||
createMeetingCalls,
|
||||
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 {
|
||||
CalVideo = "integrations:daily",
|
||||
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 type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
||||
import { parse } from "node-html-parser";
|
||||
import ical from "node-ical";
|
||||
import { expect } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
@@ -8,6 +9,7 @@ import "vitest-fetch-mock";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
@@ -19,14 +21,14 @@ declare global {
|
||||
interface Matchers<R> {
|
||||
toHaveEmail(
|
||||
expectedEmail: {
|
||||
//TODO: Support email HTML parsing to target specific elements
|
||||
htmlToContain?: string;
|
||||
title?: string;
|
||||
to: string;
|
||||
noIcs?: true;
|
||||
ics?: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
};
|
||||
appsStatus?: AppsStatus[];
|
||||
},
|
||||
to: string
|
||||
): R;
|
||||
@@ -38,21 +40,23 @@ expect.extend({
|
||||
toHaveEmail(
|
||||
emails: Fixtures["emails"],
|
||||
expectedEmail: {
|
||||
//TODO: Support email HTML parsing to target specific elements
|
||||
htmlToContain?: string;
|
||||
title?: string;
|
||||
to: string;
|
||||
ics: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
};
|
||||
noIcs: true;
|
||||
appsStatus: AppsStatus[];
|
||||
},
|
||||
to: string
|
||||
) {
|
||||
const { isNot } = this;
|
||||
const testEmail = emails.get().find((email) => email.to.includes(to));
|
||||
const emailsToLog = emails
|
||||
.get()
|
||||
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
|
||||
|
||||
if (!testEmail) {
|
||||
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
||||
return {
|
||||
@@ -63,48 +67,93 @@ expect.extend({
|
||||
const ics = testEmail.icalEvent;
|
||||
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
|
||||
|
||||
let isHtmlContained = true;
|
||||
let isToAddressExpected = true;
|
||||
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
|
||||
const isIcsUIDExpected = expectedEmail.ics
|
||||
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
|
||||
: true;
|
||||
const emailDom = parse(testEmail.html);
|
||||
|
||||
if (expectedEmail.htmlToContain) {
|
||||
isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain);
|
||||
const actualEmailContent = {
|
||||
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;
|
||||
|
||||
if (!isHtmlContained || !isToAddressExpected) {
|
||||
if (!isToAddressExpected) {
|
||||
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 {
|
||||
pass:
|
||||
isHtmlContained &&
|
||||
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");
|
||||
},
|
||||
pass: true,
|
||||
message: () => `Email ${isNot ? "is" : "isn't"} correct`,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -139,9 +188,10 @@ export function expectWebhookToHaveBeenCalledWith(
|
||||
const parsedBody = JSON.parse((body as string) || "{}");
|
||||
|
||||
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
|
||||
|
||||
if (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;
|
||||
}
|
||||
if (data.payload) {
|
||||
@@ -195,7 +245,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
||||
title: "confirmed_event_type_subject",
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -207,7 +257,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
||||
title: "confirmed_event_type_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -221,7 +271,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
otherTeamMembers.forEach((otherTeamMember) => {
|
||||
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
|
||||
to: `${otherTeamMember.email}`,
|
||||
ics: {
|
||||
@@ -238,7 +288,7 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
guests.forEach((guest) => {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>confirmed_event_type_subject</title>",
|
||||
title: "confirmed_event_type_subject",
|
||||
to: `${guest.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -261,7 +311,7 @@ export function expectBrokenIntegrationEmails({
|
||||
// Broken Integration email is only sent to the Organizer
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>broken_integration</title>",
|
||||
title: "broken_integration",
|
||||
to: `${organizer.email}`,
|
||||
// No ics goes in case of broken integration email it seems
|
||||
// ics: {
|
||||
@@ -274,7 +324,7 @@ export function expectBrokenIntegrationEmails({
|
||||
|
||||
// expect(emails).toHaveEmail(
|
||||
// {
|
||||
// htmlToContain: "<title>confirmed_event_type_subject</title>",
|
||||
// title: "confirmed_event_type_subject",
|
||||
// to: `${booker.name} <${booker.email}>`,
|
||||
// },
|
||||
// `${booker.name} <${booker.email}>`
|
||||
@@ -294,7 +344,7 @@ export function expectCalendarEventCreationFailureEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>broken_integration</title>",
|
||||
title: "broken_integration",
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -306,7 +356,7 @@ export function expectCalendarEventCreationFailureEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>calendar_event_creation_failure_subject</title>",
|
||||
title: "calendar_event_creation_failure_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -322,27 +372,30 @@ export function expectSuccessfulBookingRescheduledEmails({
|
||||
organizer,
|
||||
booker,
|
||||
iCalUID,
|
||||
appsStatus,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
iCalUID: string;
|
||||
appsStatus: AppsStatus[];
|
||||
}) {
|
||||
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}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID,
|
||||
},
|
||||
appsStatus,
|
||||
},
|
||||
`${organizer.email}`
|
||||
);
|
||||
|
||||
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}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -362,7 +415,7 @@ export function expectAwaitingPaymentEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>awaiting_payment_subject</title>",
|
||||
title: "awaiting_payment_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
noIcs: true,
|
||||
},
|
||||
@@ -381,7 +434,7 @@ export function expectBookingRequestedEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>event_awaiting_approval_subject</title>",
|
||||
title: "event_awaiting_approval_subject",
|
||||
to: `${organizer.email}`,
|
||||
noIcs: true,
|
||||
},
|
||||
@@ -390,7 +443,7 @@ export function expectBookingRequestedEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
htmlToContain: "<title>booking_submitted_subject</title>",
|
||||
title: "booking_submitted_subject",
|
||||
to: `${booker.email}`,
|
||||
noIcs: true,
|
||||
},
|
||||
@@ -509,6 +562,7 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
|
||||
location,
|
||||
subscriberUrl,
|
||||
videoCallUrl,
|
||||
payload,
|
||||
}: {
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
@@ -516,10 +570,12 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({
|
||||
location: string;
|
||||
paidEvent?: boolean;
|
||||
videoCallUrl?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) {
|
||||
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
||||
triggerEvent: "BOOKING_RESCHEDULED",
|
||||
payload: {
|
||||
...payload,
|
||||
metadata: {
|
||||
...(videoCallUrl ? { videoCallUrl } : null),
|
||||
},
|
||||
@@ -624,6 +680,31 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
|
||||
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(
|
||||
videoMock: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -669,6 +750,26 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
||||
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
|
||||
export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) {
|
||||
// Expect previous booking to be cancelled
|
||||
@@ -678,10 +779,9 @@ export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { fro
|
||||
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({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
...to,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"edit-app-template": "yarn app-store edit-template",
|
||||
"delete-app-template": "yarn app-store delete-template",
|
||||
"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",
|
||||
"db-deploy": "turbo run db-deploy",
|
||||
"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)
|
||||
|
||||
@@ -16,6 +16,8 @@ export type ParseRefreshTokenResponse<S extends z.ZodTypeAny> =
|
||||
|
||||
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
|
||||
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) {
|
||||
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
|
||||
} else {
|
||||
@@ -26,7 +28,7 @@ const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const InstallAppButtonMap = {
|
||||
vital: dynamic(() => import("./vital/components/InstallAppButton")),
|
||||
};
|
||||
export const AppSettingsComponentsMap = {
|
||||
"cal-ai": dynamic(() => import("./cal-ai/components/AppSettingsInterface")),
|
||||
"general-app-settings": dynamic(() =>
|
||||
import("./templates/general-app-settings/components/AppSettingsInterface")
|
||||
),
|
||||
|
||||
@@ -63,7 +63,7 @@ export default class BasecampCalendarService implements Calendar {
|
||||
this.userAgent = user_agent as string;
|
||||
});
|
||||
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) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user