Merge branch 'main' into fix/remove-cred-parse
commit
5b475e3f4f
|
@ -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"
|
||||
|
|
|
@ -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=
|
||||
|
||||
|
|
|
@ -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,7 @@ _The AI agent can only choose from a set of tools, without ever seeing your API
|
|||
|
||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making 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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
@ -22,27 +22,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:
|
||||
|
||||
![Cal.ai architecture](/apps/ai/src/public/architecture.png)
|
||||
|
||||
### 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 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.0",
|
||||
"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"
|
||||
},
|
||||
|
|
|
@ -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 });
|
||||
};
|
|
@ -59,7 +59,7 @@ export const POST = async (request: NextRequest) => {
|
|||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from, credentials: { some: { appId: env.APP_ID } } },
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
// User is not a cal.com user or is using an unverified 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({
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -16,22 +16,25 @@ 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,
|
||||
rateLimitApiKey,
|
||||
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",
|
||||
|
@ -41,7 +44,8 @@ const withMiddleware = label(
|
|||
"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,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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -8,6 +8,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";
|
||||
|
@ -75,6 +76,22 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
|||
const session = useSession();
|
||||
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.lang = locale;
|
||||
|
||||
let direction = window.document.dir || "ltr";
|
||||
|
||||
try {
|
||||
const intlLocale = new Intl.Locale(locale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
direction = intlLocale.textInfo?.direction;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
window.document.dir = direction;
|
||||
}, [locale]);
|
||||
|
||||
const clientViewerI18n = useViewerI18n(locale);
|
||||
const i18n = clientViewerI18n.data?.i18n;
|
||||
|
||||
|
@ -82,6 +99,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
|||
...props,
|
||||
pageProps: {
|
||||
...props.pageProps,
|
||||
|
||||
...i18n,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.3.7",
|
||||
"version": "3.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -4,7 +4,6 @@ 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 +27,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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -26,6 +26,7 @@ function Teams() {
|
|||
CTA={
|
||||
(!user.organizationId || user.organization.isOrgAdmin) && (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
StartIcon={Plus}
|
||||
type="button"
|
||||
|
|
|
@ -7,6 +7,7 @@ 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";
|
||||
|
||||
|
@ -288,6 +289,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 +321,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);
|
||||
|
@ -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,259 @@
|
|||
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("authorized user sees correct translations (de) [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 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 (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 German 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 German 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 German 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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";
|
||||
|
|
|
@ -688,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",
|
||||
|
@ -2091,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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -33,6 +33,19 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||
},
|
||||
});
|
||||
|
||||
await fetch(
|
||||
`${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: session.user.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
|
||||
}
|
||||
|
||||
|
|
|
@ -454,7 +454,8 @@ export default class EventManager {
|
|||
// Because we are just cleaning up the events and meetings, we don't want to throw an error if one of them fails.
|
||||
(await Promise.allSettled(allPromises)).some((result) => {
|
||||
if (result.status === "rejected") {
|
||||
log.error(
|
||||
// Make it a soft error because in case a PENDING booking is rescheduled there would be no calendar events or video meetings.
|
||||
log.warn(
|
||||
"Error deleting calendar event or video meeting for booking",
|
||||
safeStringify({ error: result.reason })
|
||||
);
|
||||
|
|
|
@ -57,7 +57,9 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
|||
start: dayjs(event.start).toDate(),
|
||||
end: dayjs(event.end).toDate(),
|
||||
title: "Busy",
|
||||
status: "ACCEPTED",
|
||||
options: {
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
} as CalendarEvent;
|
||||
});
|
||||
}, [overlayEvents, displayOverlay]);
|
||||
|
|
|
@ -6,7 +6,7 @@ import logger from "@calcom/lib/logger";
|
|||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] });
|
||||
const log = logger.getChildLogger({ prefix: ["[handleBookingRequested] book:user"] });
|
||||
|
||||
/**
|
||||
* Supposed to do whatever is needed when a booking is requested.
|
||||
|
@ -31,6 +31,7 @@ export async function handleBookingRequested(args: {
|
|||
}) {
|
||||
const { evt, booking } = args;
|
||||
|
||||
log.debug("Emails: Sending booking requested emails");
|
||||
await sendOrganizerRequestEmail({ ...evt });
|
||||
await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]);
|
||||
|
||||
|
|
|
@ -979,10 +979,17 @@ async function handler(
|
|||
|
||||
const allCredentials = await getAllCredentials(organizerUser, eventType);
|
||||
|
||||
const isOrganizerRescheduling = organizerUser.id === userId;
|
||||
const { userReschedulingIsOwner, isConfirmedByDefault } = getRequiresConfirmationFlags({
|
||||
eventType,
|
||||
bookingStartTime: reqBody.start,
|
||||
userId,
|
||||
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
|
||||
paymentAppData,
|
||||
});
|
||||
|
||||
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.
|
||||
const attendeeInfoOnReschedule =
|
||||
isOrganizerRescheduling && originalRescheduledBooking
|
||||
userReschedulingIsOwner && originalRescheduledBooking
|
||||
? originalRescheduledBooking.attendees.find((attendee) => attendee.email === bookerEmail)
|
||||
: null;
|
||||
|
||||
|
@ -1088,14 +1095,6 @@ async function handler(
|
|||
t: tOrganizer,
|
||||
};
|
||||
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
if (dayjs(dayjs(reqBody.start).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||
requiresConfirmation = false;
|
||||
}
|
||||
}
|
||||
|
||||
const calEventUserFieldsResponses =
|
||||
"calEventUserFieldsResponses" in reqBody ? reqBody.calEventUserFieldsResponses : null;
|
||||
|
||||
|
@ -1128,7 +1127,7 @@ async function handler(
|
|||
? [organizerUser.destinationCalendar]
|
||||
: null,
|
||||
hideCalendarNotes: eventType.hideCalendarNotes,
|
||||
requiresConfirmation: requiresConfirmation ?? false,
|
||||
requiresConfirmation: !isConfirmedByDefault,
|
||||
eventTypeId: eventType.id,
|
||||
// if seats are not enabled we should default true
|
||||
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
|
||||
|
@ -1210,7 +1209,6 @@ async function handler(
|
|||
const eventTypeInfo: EventTypeInfo = {
|
||||
eventTitle: eventType.title,
|
||||
eventDescription: eventType.description,
|
||||
requiresConfirmation: requiresConfirmation || null,
|
||||
price: paymentAppData.price,
|
||||
currency: eventType.currency,
|
||||
length: eventType.length,
|
||||
|
@ -1438,8 +1436,9 @@ async function handler(
|
|||
}
|
||||
}
|
||||
|
||||
if (noEmail !== true && (!requiresConfirmation || isOrganizerRescheduling)) {
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
const copyEvent = cloneDeep(evt);
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
|
@ -1558,8 +1557,9 @@ async function handler(
|
|||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
if (!requiresConfirmation || isOrganizerRescheduling) {
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
// TODO send reschedule emails to attendees of the old booking
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
|
@ -1890,12 +1890,6 @@ async function handler(
|
|||
evt.recurringEvent = eventType.recurringEvent;
|
||||
}
|
||||
|
||||
// If the user is not the owner of the event, new booking should be always pending.
|
||||
// Otherwise, an owner rescheduling should be always accepted.
|
||||
// Before comparing make sure that userId is set, otherwise undefined === undefined
|
||||
const userReschedulingIsOwner = userId && originalRescheduledBooking?.user?.id === userId;
|
||||
const isConfirmedByDefault = (!requiresConfirmation && !paymentAppData.price) || userReschedulingIsOwner;
|
||||
|
||||
async function createBooking() {
|
||||
if (originalRescheduledBooking) {
|
||||
evt.title = originalRescheduledBooking?.title || evt.title;
|
||||
|
@ -1914,12 +1908,6 @@ async function handler(
|
|||
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
|
||||
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
|
||||
|
||||
// If the user is not the owner of the event, new booking should be always pending.
|
||||
// Otherwise, an owner rescheduling should be always accepted.
|
||||
// Before comparing make sure that userId is set, otherwise undefined === undefined
|
||||
const userReschedulingIsOwner = userId && originalRescheduledBooking?.user?.id === userId;
|
||||
const isConfirmedByDefault = (!requiresConfirmation && !paymentAppData.price) || userReschedulingIsOwner;
|
||||
|
||||
const attendeesData = evt.attendees.map((attendee) => {
|
||||
//if attendee is team member, it should fetch their locale not booker's locale
|
||||
//perhaps make email fetch request to see if his locale is stored, else
|
||||
|
@ -2049,7 +2037,7 @@ async function handler(
|
|||
safeStringify({
|
||||
organizerUser: organizerUser.id,
|
||||
attendeesList: attendeesList.map((guest) => ({ timeZone: guest.timeZone })),
|
||||
requiresConfirmation,
|
||||
requiresConfirmation: evt.requiresConfirmation,
|
||||
isConfirmedByDefault,
|
||||
userReschedulingIsOwner,
|
||||
})
|
||||
|
@ -2214,8 +2202,9 @@ async function handler(
|
|||
videoCallUrl = metadata.hangoutLink || videoCallUrl || updatedEvent?.url;
|
||||
}
|
||||
}
|
||||
if (noEmail !== true && (!requiresConfirmation || isOrganizerRescheduling)) {
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
const copyEvent = cloneDeep(evt);
|
||||
loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalInformation: metadata,
|
||||
|
@ -2226,7 +2215,7 @@ async function handler(
|
|||
}
|
||||
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||
// Create a booking
|
||||
} else if (!requiresConfirmation && !paymentAppData.price) {
|
||||
} else if (isConfirmedByDefault) {
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const createManager = await eventManager.create(evt);
|
||||
|
||||
|
@ -2333,7 +2322,7 @@ async function handler(
|
|||
}
|
||||
|
||||
loggerWithEventDetails.debug(
|
||||
"Sending scheduled emails for booking confirmation",
|
||||
"Emails: Sending scheduled emails for booking confirmation",
|
||||
safeStringify({
|
||||
calEvent: getPiiFreeCalendarEvent(evt),
|
||||
})
|
||||
|
@ -2351,6 +2340,16 @@ async function handler(
|
|||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If isConfirmedByDefault is false, then booking can't be considered ACCEPTED and thus EventManager has no role to play. Booking is created as PENDING
|
||||
loggerWithEventDetails.debug(
|
||||
`EventManager doesn't need to create or reschedule event for booking ${organizerUser.username}`,
|
||||
safeStringify({
|
||||
calEvent: getPiiFreeCalendarEvent(evt),
|
||||
isConfirmedByDefault,
|
||||
paymentValue: paymentAppData.price,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const bookingRequiresPayment =
|
||||
|
@ -2361,7 +2360,7 @@ async function handler(
|
|||
|
||||
if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) {
|
||||
loggerWithEventDetails.debug(
|
||||
`Booking ${organizerUser.username} requires confirmation, sending request emails`,
|
||||
`Emails: Booking ${organizerUser.username} requires confirmation, sending request emails`,
|
||||
safeStringify({
|
||||
calEvent: getPiiFreeCalendarEvent(evt),
|
||||
})
|
||||
|
@ -2458,6 +2457,7 @@ async function handler(
|
|||
videoCallUrl = booking.location;
|
||||
}
|
||||
|
||||
// We are here so, booking doesn't require payment and booking is also created in DB already, through createBooking call
|
||||
if (isConfirmedByDefault) {
|
||||
try {
|
||||
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
||||
|
@ -2479,7 +2479,7 @@ async function handler(
|
|||
|
||||
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
|
||||
} else if (eventType.requiresConfirmation) {
|
||||
} else {
|
||||
// if eventType requires confirmation we will trigger the BOOKING REQUESTED Webhook
|
||||
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REQUESTED;
|
||||
subscriberOptions.triggerEvent = eventTrigger;
|
||||
|
@ -2558,6 +2558,44 @@ async function handler(
|
|||
|
||||
export default handler;
|
||||
|
||||
function getRequiresConfirmationFlags({
|
||||
eventType,
|
||||
bookingStartTime,
|
||||
userId,
|
||||
paymentAppData,
|
||||
originalRescheduledBookingOrganizerId,
|
||||
}: {
|
||||
eventType: Pick<Awaited<ReturnType<typeof getEventTypesFromDB>>, "metadata" | "requiresConfirmation">;
|
||||
bookingStartTime: string;
|
||||
userId: number | undefined;
|
||||
paymentAppData: { price: number };
|
||||
originalRescheduledBookingOrganizerId: number | undefined;
|
||||
}) {
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
if (dayjs(dayjs(bookingStartTime).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||
requiresConfirmation = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is not the owner of the event, new booking should be always pending.
|
||||
// Otherwise, an owner rescheduling should be always accepted.
|
||||
// Before comparing make sure that userId is set, otherwise undefined === undefined
|
||||
const userReschedulingIsOwner = !!(userId && originalRescheduledBookingOrganizerId === userId);
|
||||
const isConfirmedByDefault = (!requiresConfirmation && !paymentAppData.price) || userReschedulingIsOwner;
|
||||
return {
|
||||
/**
|
||||
* Organizer of the booking is rescheduling
|
||||
*/
|
||||
userReschedulingIsOwner,
|
||||
/**
|
||||
* Booking won't need confirmation to be ACCEPTED
|
||||
*/
|
||||
isConfirmedByDefault,
|
||||
};
|
||||
}
|
||||
|
||||
function handleCustomInputs(
|
||||
eventTypeCustomInputs: EventTypeCustomInput[],
|
||||
reqCustomInputs: {
|
||||
|
|
|
@ -13,6 +13,7 @@ import { describe, expect } from "vitest";
|
|||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
import {
|
||||
|
@ -1213,6 +1214,122 @@ describe("handleNewBooking", () => {
|
|||
timeout
|
||||
);
|
||||
|
||||
/**
|
||||
* NOTE: We might want to think about making the bookings get ACCEPTED automatically if the booker is the organizer of the event-type. This is a design decision it seems for now.
|
||||
*/
|
||||
test(
|
||||
`should make a fresh booking in PENDING state even when the booker is the organizer of the event-type
|
||||
1. Should create a booking in the database with status PENDING
|
||||
2. Should send emails to the booker as well as organizer for booking request and awaiting approval
|
||||
3. Should trigger BOOKING_REQUESTED webhook
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
requiresConfirmation: true,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
req.userId = organizer.id;
|
||||
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
});
|
||||
|
||||
expectWorkflowToBeTriggered();
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold is not met
|
||||
1. Should create a booking in the database with status ACCEPTED
|
||||
|
@ -1720,21 +1837,24 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
|
||||
expect(createdBooking.responses).toContain({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
});
|
||||
expect(createdBooking).toContain({
|
||||
location: BookingLocations.CalVideo,
|
||||
paymentUid: paymentUid,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
location: BookingLocations.CalVideo,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
});
|
||||
|
||||
expectWorkflowToBeTriggered();
|
||||
expectAwaitingPaymentEmails({ organizer, booker, emails });
|
||||
|
||||
|
@ -1887,6 +2007,9 @@ describe("handleNewBooking", () => {
|
|||
paymentId: createdBooking.paymentId!,
|
||||
});
|
||||
|
||||
// FIXME: Right now we need to reset the test Emails because email expects only tests first email content for an email address
|
||||
// Reset Test Emails to test for more Emails
|
||||
resetTestEmails();
|
||||
const { webhookResponse } = await mockPaymentSuccessWebhookFromStripe({ externalId });
|
||||
|
||||
expect(webhookResponse?.statusCode).toBe(200);
|
||||
|
@ -1897,6 +2020,12 @@ describe("handleNewBooking", () => {
|
|||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
|
|
|
@ -616,7 +616,7 @@ describe("handleNewBooking", () => {
|
|||
|
||||
describe("Event Type that requires confirmation", () => {
|
||||
test(
|
||||
`should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the schedule
|
||||
`should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the reschedule
|
||||
1. Should cancel the existing booking
|
||||
2. Should delete existing calendar invite and Video meeting
|
||||
2. Should create a new booking in the database in PENDING state
|
||||
|
@ -813,9 +813,8 @@ describe("handleNewBooking", () => {
|
|||
timeout
|
||||
);
|
||||
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
`should rechedule a booking, that requires confirmation, without confirmation - When Organizer is doing the reschedule
|
||||
test(
|
||||
`should rechedule a booking, that requires confirmation, without confirmation - When booker is the organizer of the existing booking as well as the event-type
|
||||
1. Should cancel the existing booking
|
||||
2. Should delete existing calendar invite and Video meeting
|
||||
2. Should create a new booking in the database in ACCEPTED state
|
||||
|
@ -873,6 +872,7 @@ describe("handleNewBooking", () => {
|
|||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
|
@ -1054,9 +1054,209 @@ describe("handleNewBooking", () => {
|
|||
timeout
|
||||
);
|
||||
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
`should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule
|
||||
test(
|
||||
`should rechedule a booking, that requires confirmation, in PENDING state - Even when the rescheduler is the organizer of the event-type but not the organizer of the existing booking
|
||||
1. Should cancel the existing booking
|
||||
2. Should delete existing calendar invite and Video meeting
|
||||
2. Should create a new booking in the database in PENDING state
|
||||
3. Should send booking requested emails to the booker as well as organizer
|
||||
4. Should trigger BOOKING_REQUESTED webhook
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
requiresConfirmation: true,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
references: [
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: 0,
|
||||
}),
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const videoMock = mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "MOCK_ID",
|
||||
},
|
||||
update: {
|
||||
uid: "UPDATED_MOCK_ID",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
start: `${plus1DateString}T04:00:00.000Z`,
|
||||
end: `${plus1DateString}T04:15:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
// Fake the request to be from organizer
|
||||
req.userId = organizer.id;
|
||||
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
expect(createdBooking.responses).toContain({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
});
|
||||
|
||||
await expectBookingInDBToBeRescheduledFromTo({
|
||||
from: {
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
},
|
||||
to: {
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
// Rescheduled booking sill stays in pending state
|
||||
status: BookingStatus.PENDING,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expectWorkflowToBeTriggered();
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
|
||||
expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, {
|
||||
bookingRef: {
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, {
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
calEvent: {
|
||||
videoCallData: expect.objectContaining({
|
||||
url: "http://mock-dailyvideo.example.com",
|
||||
}),
|
||||
},
|
||||
uid: "MOCK_ID",
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule(but he isn't the organizer of the event-type now)
|
||||
1. Should cancel the existing booking
|
||||
2. Should delete existing calendar invite and Video meeting
|
||||
2. Should create a new booking in the database in ACCEPTED state
|
||||
|
@ -1163,9 +1363,9 @@ describe("handleNewBooking", () => {
|
|||
{
|
||||
id: previousOrganizerIdForTheBooking,
|
||||
name: "Previous Organizer",
|
||||
username: "prev-organizer",
|
||||
email: "",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
username: "prev-organizer",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
],
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { TimeRange } from "@calcom/types/schedule";
|
||||
import type { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import { CalendarEvent } from "./types/events";
|
||||
import type { CalendarEvent } from "./types/events";
|
||||
|
||||
const startDate = dayjs().set("hour", 11).set("minute", 0);
|
||||
|
||||
|
@ -11,63 +11,57 @@ export const events: CalendarEvent[] = [
|
|||
title: "Event 1",
|
||||
start: startDate.add(10, "minutes").toDate(),
|
||||
end: startDate.add(45, "minutes").toDate(),
|
||||
allDay: false,
|
||||
options: {
|
||||
allDay: false,
|
||||
borderColor: "#ff0000",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Event 2",
|
||||
start: startDate.add(1, "day").toDate(),
|
||||
end: startDate.add(1, "day").add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
options: {
|
||||
status: "ACCEPTED",
|
||||
allDay: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Event 3",
|
||||
start: startDate.add(2, "day").toDate(),
|
||||
end: startDate.add(2, "day").add(60, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
options: {
|
||||
status: "PENDING",
|
||||
borderColor: "#ff0000",
|
||||
allDay: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Event 4",
|
||||
start: startDate.add(3, "day").toDate(),
|
||||
end: startDate.add(3, "day").add(2, "hour").add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
options: {
|
||||
status: "ACCEPTED",
|
||||
allDay: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Event 4 Overlap",
|
||||
start: startDate.add(3, "day").add(30, "minutes").toDate(),
|
||||
end: startDate.add(3, "day").add(2, "hour").add(45, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Event 1 Overlap",
|
||||
start: startDate.toDate(),
|
||||
end: startDate.add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Event 1 Overlap Two",
|
||||
start: startDate.toDate(),
|
||||
end: startDate.add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
options: {
|
||||
status: "ACCEPTED",
|
||||
allDay: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { cva } from "class-variance-authority";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import type { CalendarEvent } from "../../types/events";
|
||||
|
||||
|
@ -11,6 +12,35 @@ type EventProps = {
|
|||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const eventClasses = cva(
|
||||
"group flex h-full w-full flex-col overflow-y-auto rounded-[4px] px-[6px] py-1 text-xs font-semibold leading-5 ",
|
||||
{
|
||||
variants: {
|
||||
status: {
|
||||
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-gray-900",
|
||||
PENDING: "bg-default text-emphasis border-[1px] border-dashed border-gray-900",
|
||||
REJECTED: "",
|
||||
CANCELLED: "",
|
||||
},
|
||||
disabled: {
|
||||
true: "hover:cursor-default",
|
||||
false: "hover:cursor-pointer",
|
||||
},
|
||||
selected: {
|
||||
true: "bg-inverted text-inverted border-[1px] border-transparent",
|
||||
false: "",
|
||||
},
|
||||
borderColor: {
|
||||
ACCEPTED: "border-gray-900",
|
||||
PENDING: "border-gray-900",
|
||||
REJECTED: "border-gray-900",
|
||||
CANCELLED: "border-gray-900",
|
||||
custom: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function Event({
|
||||
event,
|
||||
currentlySelectedEventId,
|
||||
|
@ -19,27 +49,32 @@ export function Event({
|
|||
onEventClick,
|
||||
}: EventProps) {
|
||||
const selected = currentlySelectedEventId === event.id;
|
||||
const { options } = event;
|
||||
|
||||
const borderColor = options?.borderColor ? "custom" : options?.status;
|
||||
|
||||
const styles = options?.borderColor
|
||||
? {
|
||||
borderColor: options?.borderColor,
|
||||
}
|
||||
: {};
|
||||
|
||||
const Component = onEventClick ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Component
|
||||
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
|
||||
className={classNames(
|
||||
"group flex h-full w-full flex-col overflow-y-auto rounded-[4px] px-[6px] py-1 text-xs font-semibold leading-5 ",
|
||||
event.status === "ACCEPTED" &&
|
||||
!selected &&
|
||||
"bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-gray-900",
|
||||
event.status === "PENDING" &&
|
||||
!selected &&
|
||||
"bg-default text-emphasis border-[1px] border-dashed border-gray-900",
|
||||
selected && "bg-inverted text-inverted border-[1px] border-transparent",
|
||||
disabled ? "hover:cursor-default" : "hover:cursor-pointer"
|
||||
)}>
|
||||
className={eventClasses({
|
||||
status: options?.status,
|
||||
disabled,
|
||||
selected,
|
||||
borderColor,
|
||||
})}
|
||||
style={styles}>
|
||||
<div className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4">
|
||||
{event.title}
|
||||
</div>
|
||||
{eventDuration >= 30 && (
|
||||
{eventDuration > 30 && (
|
||||
<p className="text-subtle text-left text-[10px] leading-none">
|
||||
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
|
||||
</p>
|
||||
|
|
|
@ -23,7 +23,7 @@ export function EventList({ day }: Props) {
|
|||
<>
|
||||
{events
|
||||
.filter((event) => {
|
||||
return dayjs(event.start).isSame(day, "day") && !event.allDay; // Filter all events that are not allDay and that are on the current day
|
||||
return dayjs(event.start).isSame(day, "day") && !event.options?.allDay; // Filter all events that are not allDay and that are on the current day
|
||||
})
|
||||
.map((event, idx, eventsArray) => {
|
||||
let width = 90;
|
||||
|
|
|
@ -5,7 +5,10 @@ export interface CalendarEvent {
|
|||
title: string;
|
||||
start: Date | string; // You can pass in a string from DB since we use dayjs for the dates.
|
||||
end: Date;
|
||||
allDay?: boolean;
|
||||
source?: string;
|
||||
status?: BookingStatus;
|
||||
options?: {
|
||||
status?: BookingStatus;
|
||||
allDay?: boolean;
|
||||
borderColor?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -143,11 +143,24 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
|||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
const emailRegex = /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;
|
||||
reader.onload = (e) => {
|
||||
const contents = e?.target?.result as string;
|
||||
const values = contents?.split(",").map((email) => email.trim().toLocaleLowerCase());
|
||||
newMemberFormMethods.setValue("emailOrUsername", values);
|
||||
const lines = contents.split("\n");
|
||||
const validEmails = [];
|
||||
for (const line of lines) {
|
||||
const columns = line.split(/,|;|\|| /);
|
||||
for (const column of columns) {
|
||||
const email = column.trim().toLowerCase();
|
||||
|
||||
if (emailRegex.test(email)) {
|
||||
validEmails.push(email);
|
||||
break; // Stop checking columns if a valid email is found in this line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newMemberFormMethods.setValue("emailOrUsername", validEmails);
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
|
|
|
@ -133,6 +133,7 @@ export function TeamsListing() {
|
|||
buttonRaw={
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="create-team-btn"
|
||||
disabled={!!isCreateTeamButtonDisabled}
|
||||
tooltip={
|
||||
isCreateTeamButtonDisabled ? t("org_admins_can_create_new_teams") : t("create_new_team")
|
||||
|
|
|
@ -117,7 +117,12 @@ export default function CreateEventTypeDialog({
|
|||
const createMutation = trpc.viewer.eventTypes.create.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace(`/event-types/${eventType.id}`);
|
||||
showToast(t("event_type_created_successfully"), "success");
|
||||
showToast(
|
||||
t("event_type_created_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
|
|
|
@ -66,7 +66,12 @@ const DuplicateDialog = () => {
|
|||
const duplicateMutation = trpc.viewer.eventTypes.duplicate.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace(`/event-types/${eventType.id}`);
|
||||
showToast(t("event_type_created_successfully"), "success");
|
||||
showToast(
|
||||
t("event_type_created_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
|
|
|
@ -3,12 +3,14 @@ import { useState } from "react";
|
|||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import type { FilterContextType } from "./provider";
|
||||
import { FilterProvider } from "./provider";
|
||||
|
||||
export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
// searchParams to get initial values from query params
|
||||
const utils = trpc.useContext();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
@ -105,7 +107,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
|||
...configFilters,
|
||||
...newConfigFilters,
|
||||
});
|
||||
|
||||
utils.viewer.insights.rawData.invalidate();
|
||||
const {
|
||||
selectedMemberUserId,
|
||||
selectedTeamId,
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import { FileDownIcon } from "lucide-react";
|
||||
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger, Button } from "@calcom/ui";
|
||||
|
||||
const Download = () => {
|
||||
const { filter } = useFilterContext();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data, isLoading } = trpc.viewer.insights.rawData.useQuery(
|
||||
{
|
||||
startDate: filter.dateRange[0].toISOString(),
|
||||
endDate: filter.dateRange[1].toISOString(),
|
||||
teamId: filter.selectedTeamId,
|
||||
userId: filter.selectedUserId,
|
||||
eventTypeId: filter.selectedEventTypeId,
|
||||
memberUserId: filter.selectedMemberUserId,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type RawData = RouterOutputs["viewer"]["insights"]["rawData"] | undefined;
|
||||
const handleDownloadClick = async (data: RawData) => {
|
||||
if (!data) return;
|
||||
const { data: csvRaw, filename } = data;
|
||||
|
||||
// Create a Blob from the text data
|
||||
const blob = new Blob([csvRaw], { type: "text/plain" });
|
||||
|
||||
// Create an Object URL for the Blob
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create a download link
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename; // Specify the filename
|
||||
|
||||
// Simulate a click event to trigger the download
|
||||
a.click();
|
||||
|
||||
// Release the Object URL to free up memory
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
EndIcon={FileDownIcon}
|
||||
color="secondary"
|
||||
{...(isLoading && { loading: isLoading })}
|
||||
className="self-end sm:self-baseline">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownItem onClick={() => handleDownloadClick(data)}>{t("as_csv")}</DropdownItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export { Download };
|
|
@ -4,6 +4,7 @@ import { Button, Tooltip } from "@calcom/ui";
|
|||
import { X } from "@calcom/ui/components/icon";
|
||||
|
||||
import { DateSelect } from "./DateSelect";
|
||||
import { Download } from "./Download/index";
|
||||
import { EventTypeList } from "./EventTypeList";
|
||||
import { FilterType } from "./FilterType";
|
||||
import { TeamAndSelfList } from "./TeamAndSelfList";
|
||||
|
@ -72,7 +73,10 @@ export const Filters = () => {
|
|||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup> */}
|
||||
<DateSelect />
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-nowrap sm:justify-between">
|
||||
<Download />
|
||||
<DateSelect />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,6 +3,8 @@ import dayjs from "@calcom/dayjs";
|
|||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import type { RawDataInput } from "./raw-data.schema";
|
||||
|
||||
interface ITimeRange {
|
||||
start: Dayjs;
|
||||
end: Dayjs;
|
||||
|
@ -213,6 +215,235 @@ class EventsInsights {
|
|||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
static getCsvData = async (
|
||||
props: RawDataInput & {
|
||||
organizationId: number | null;
|
||||
isOrgAdminOrOwner: boolean | null;
|
||||
}
|
||||
) => {
|
||||
// Obtain the where conditional
|
||||
const whereConditional = await this.obtainWhereConditional(props);
|
||||
|
||||
const csvData = await prisma.bookingTimeStatus.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
timeStatus: true,
|
||||
eventTypeId: true,
|
||||
eventLength: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
paid: true,
|
||||
userEmail: true,
|
||||
username: true,
|
||||
},
|
||||
where: whereConditional,
|
||||
});
|
||||
|
||||
return csvData;
|
||||
};
|
||||
|
||||
/*
|
||||
* This is meant to be used for all functions inside insights router, ideally we should have a view that have all of this data
|
||||
* The order where will be from the most specific to the least specific
|
||||
* starting from the top will be:
|
||||
* - memberUserId
|
||||
* - eventTypeId
|
||||
* - userId
|
||||
* - teamId
|
||||
* Generics will be:
|
||||
* - isAll
|
||||
* - startDate
|
||||
* - endDate
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
static obtainWhereConditional = async (
|
||||
props: RawDataInput & { organizationId: number | null; isOrgAdminOrOwner: boolean | null }
|
||||
) => {
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
userId,
|
||||
memberUserId,
|
||||
isAll,
|
||||
eventTypeId,
|
||||
organizationId,
|
||||
isOrgAdminOrOwner,
|
||||
} = props;
|
||||
|
||||
// Obtain the where conditional
|
||||
let whereConditional: Prisma.BookingTimeStatusWhereInput = {};
|
||||
let teamConditional: Prisma.TeamWhereInput = {};
|
||||
|
||||
if (startDate && endDate) {
|
||||
whereConditional.createdAt = {
|
||||
gte: dayjs(startDate).toISOString(),
|
||||
lte: dayjs(endDate).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (eventTypeId) {
|
||||
whereConditional["OR"] = [
|
||||
{
|
||||
eventTypeId,
|
||||
},
|
||||
{
|
||||
eventParentId: eventTypeId,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (memberUserId) {
|
||||
whereConditional["userId"] = memberUserId;
|
||||
}
|
||||
if (userId) {
|
||||
whereConditional["teamId"] = null;
|
||||
whereConditional["userId"] = userId;
|
||||
}
|
||||
|
||||
if (isAll && isOrgAdminOrOwner && organizationId) {
|
||||
const teamsFromOrg = await prisma.team.findMany({
|
||||
where: {
|
||||
parentId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (teamsFromOrg.length === 0) {
|
||||
return {};
|
||||
}
|
||||
teamConditional = {
|
||||
id: {
|
||||
in: [organizationId, ...teamsFromOrg.map((t) => t.id)],
|
||||
},
|
||||
};
|
||||
const usersFromOrg = await prisma.membership.findMany({
|
||||
where: {
|
||||
team: teamConditional,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
|
||||
whereConditional = {
|
||||
...whereConditional,
|
||||
OR: [
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromOrg,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
{
|
||||
teamId: {
|
||||
in: [organizationId, ...teamsFromOrg.map((t) => t.id)],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (teamId && !isAll) {
|
||||
const usersFromTeam = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
||||
whereConditional = {
|
||||
...whereConditional,
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeam,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return whereConditional;
|
||||
};
|
||||
|
||||
static userIsOwnerAdminOfTeam = async ({
|
||||
sessionUserId,
|
||||
teamId,
|
||||
}: {
|
||||
sessionUserId: number;
|
||||
teamId: number;
|
||||
}) => {
|
||||
const isOwnerAdminOfTeam = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: sessionUserId,
|
||||
teamId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["OWNER", "ADMIN"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!isOwnerAdminOfTeam;
|
||||
};
|
||||
|
||||
static userIsOwnerAdminOfParentTeam = async ({
|
||||
sessionUserId,
|
||||
teamId,
|
||||
}: {
|
||||
sessionUserId: number;
|
||||
teamId: number;
|
||||
}) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team || team.parentId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isOwnerAdminOfParentTeam = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: sessionUserId,
|
||||
teamId: team.parentId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["OWNER", "ADMIN"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!isOwnerAdminOfParentTeam;
|
||||
};
|
||||
|
||||
static objectToCsv(data: Record<string, unknown>[]) {
|
||||
// if empty data return empty string
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
const header = Object.keys(data[0]).join(",") + "\n";
|
||||
const rows = data.map((obj: any) => Object.values(obj).join(",") + "\n");
|
||||
return header + rows.join("");
|
||||
}
|
||||
}
|
||||
|
||||
export { EventsInsights };
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import z from "zod";
|
||||
|
||||
const rawDataInputSchema = z.object({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
userId: z.coerce.number().optional().nullable(),
|
||||
memberUserId: z.coerce.number().optional().nullable(),
|
||||
isAll: z.coerce.boolean().optional(),
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type RawDataInput = z.infer<typeof rawDataInputSchema>;
|
||||
|
||||
export { rawDataInputSchema };
|
|
@ -3,6 +3,8 @@ import md5 from "md5";
|
|||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
|
@ -1415,4 +1417,33 @@ export const insightsRouter = router({
|
|||
|
||||
return eventTypeResult;
|
||||
}),
|
||||
rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate, teamId, userId, memberUserId, isAll, eventTypeId } = input;
|
||||
|
||||
const isOrgAdminOrOwner = ctx.user.isOwnerAdminOfParentTeam;
|
||||
try {
|
||||
// Get the data
|
||||
const csvData = await EventsInsights.getCsvData({
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
userId,
|
||||
memberUserId,
|
||||
isAll,
|
||||
isOrgAdminOrOwner,
|
||||
eventTypeId,
|
||||
organizationId: ctx.user.organizationId || null,
|
||||
});
|
||||
|
||||
const csvAsString = EventsInsights.objectToCsv(csvData);
|
||||
const downloadAs = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format(
|
||||
"YYYY-MM-DD"
|
||||
)}-${randomString(10)}.csv`;
|
||||
|
||||
return { data: csvAsString, filename: downloadAs };
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
return { data: "", filename: "" };
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -179,7 +179,7 @@ const DateOverrideForm = ({
|
|||
color="primary"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
showToast(t("date_successfully_added"), "success");
|
||||
showToast(t("date_successfully_added"), "success", 500);
|
||||
}}
|
||||
disabled={selectedDates.length === 0}
|
||||
data-testid="add-override-submit-btn">
|
||||
|
|
|
@ -78,7 +78,7 @@ const WebhooksView = () => {
|
|||
<></>
|
||||
)
|
||||
}
|
||||
borderInShellHeader={!(data && data.webhookGroups.length > 0)}
|
||||
borderInShellHeader={data && data.profiles.length === 1}
|
||||
/>
|
||||
<div>
|
||||
<WebhooksList webhooksByViewer={data} />
|
||||
|
|
|
@ -20,3 +20,7 @@ export const setTestEmail = (email: (typeof globalThis.testEmails)[number]) => {
|
|||
export const getTestEmails = () => {
|
||||
return globalThis.testEmails;
|
||||
};
|
||||
|
||||
export const resetTestEmails = () => {
|
||||
globalThis.testEmails = [];
|
||||
};
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
-- View: public.BookingTimeStatus
|
||||
|
||||
-- DROP VIEW public."BookingTimeStatus";
|
||||
|
||||
CREATE OR REPLACE VIEW public."BookingTimeStatus"
|
||||
AS
|
||||
SELECT "Booking".id,
|
||||
"Booking".uid,
|
||||
"Booking"."eventTypeId",
|
||||
"Booking".title,
|
||||
"Booking".description,
|
||||
"Booking"."startTime",
|
||||
"Booking"."endTime",
|
||||
"Booking"."createdAt",
|
||||
"Booking".location,
|
||||
"Booking".paid,
|
||||
"Booking".status,
|
||||
"Booking".rescheduled,
|
||||
"Booking"."userId",
|
||||
et."teamId",
|
||||
et.length AS "eventLength",
|
||||
CASE
|
||||
WHEN "Booking".rescheduled IS TRUE THEN 'rescheduled'::text
|
||||
WHEN "Booking".status = 'cancelled'::"BookingStatus" AND "Booking".rescheduled IS NULL THEN 'cancelled'::text
|
||||
WHEN "Booking"."endTime" < now() THEN 'completed'::text
|
||||
WHEN "Booking"."endTime" > now() THEN 'uncompleted'::text
|
||||
ELSE NULL::text
|
||||
END AS "timeStatus",
|
||||
et."parentId" AS "eventParentId",
|
||||
"u"."email" AS "userEmail",
|
||||
"u"."username" AS "username"
|
||||
FROM "Booking"
|
||||
LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id
|
||||
LEFT JOIN users u ON u.id = "Booking"."userId";
|
||||
|
|
@ -956,6 +956,8 @@ view BookingTimeStatus {
|
|||
eventLength Int?
|
||||
timeStatus String?
|
||||
eventParentId Int?
|
||||
userEmail String?
|
||||
username String?
|
||||
}
|
||||
|
||||
model CalendarCache {
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
"BACKEND_URL",
|
||||
"APP_ID",
|
||||
"APP_URL",
|
||||
"SENDER_DOMAIN",
|
||||
"PARSE_KEY",
|
||||
"NODE_ENV",
|
||||
"OPENAI_API_KEY",
|
||||
|
|
Loading…
Reference in New Issue