Merge branch 'main' into fix/remove-cred-parse

fix/remove-cred-parse
Joe Au-Yeung 2023-10-16 10:05:55 -04:00 committed by GitHub
commit 5b475e3f4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1579 additions and 246 deletions

View File

@ -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"

View File

@ -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=

View File

@ -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!

View File

@ -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"
},

View File

@ -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 });
};

View File

@ -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.

View File

@ -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

View File

@ -47,7 +47,7 @@ const createBooking = async ({
}
const responses = {
id: invite,
id: invite.toString(),
name: user.username,
email: user.email,
};

View File

@ -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;

View File

@ -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;

View File

@ -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}

View File

@ -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({

View File

@ -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) {

View File

@ -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 };

View File

@ -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);
});
});

View File

@ -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>

View File

@ -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,
},
};

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.3.7",
"version": "3.4.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -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;

View File

@ -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

View File

@ -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();

View File

@ -26,6 +26,7 @@ function Teams() {
CTA={
(!user.organizationId || user.organization.isOrgAdmin) && (
<Button
data-testid="new-team-btn"
variant="fab"
StartIcon={Plus}
type="button"

View File

@ -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,

View File

@ -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");
});
});

View File

@ -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);
}
});
});
});

View File

@ -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";

View File

@ -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.",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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" }) };
}

View File

@ -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 })
);

View File

@ -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]);

View File

@ -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]);

View File

@ -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: {

View File

@ -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,

View File

@ -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",
},
],

View File

@ -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,
},
},
];

View File

@ -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>

View File

@ -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;

View File

@ -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;
};
}

View File

@ -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);

View 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")

View File

@ -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) {

View File

@ -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) {

View File

@ -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,

View File

@ -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 };

View File

@ -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>
);
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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: "" };
}),
});

View File

@ -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">

View File

@ -78,7 +78,7 @@ const WebhooksView = () => {
<></>
)
}
borderInShellHeader={!(data && data.webhookGroups.length > 0)}
borderInShellHeader={data && data.profiles.length === 1}
/>
<div>
<WebhooksList webhooksByViewer={data} />

View File

@ -20,3 +20,7 @@ export const setTestEmail = (email: (typeof globalThis.testEmails)[number]) => {
export const getTestEmails = () => {
return globalThis.testEmails;
};
export const resetTestEmails = () => {
globalThis.testEmails = [];
};

View File

@ -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";

View File

@ -956,6 +956,8 @@ view BookingTimeStatus {
eventLength Int?
timeStatus String?
eventParentId Int?
userEmail String?
username String?
}
model CalendarCache {

View File

@ -51,6 +51,7 @@
"BACKEND_URL",
"APP_ID",
"APP_URL",
"SENDER_DOMAIN",
"PARSE_KEY",
"NODE_ENV",
"OPENAI_API_KEY",