diff --git a/apps/ai/next.config.js b/apps/ai/next.config.js index 2a1040b258..747ded2324 100644 --- a/apps/ai/next.config.js +++ b/apps/ai/next.config.js @@ -5,6 +5,15 @@ plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })); /** @type {import("next").NextConfig} */ const nextConfig = { + async redirects() { + return [ + { + source: "/", + destination: "https://cal.com/ai", + permanent: true, + }, + ]; + }, i18n: { defaultLocale: "en", locales: ["en"], diff --git a/apps/ai/package.json b/apps/ai/package.json index 6a5de24ec4..5c338be474 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/ai", - "version": "0.1.0", + "version": "1.0.1", "private": true, "author": "Cal.com Inc.", "dependencies": { diff --git a/apps/ai/src/app/api/receive/route.ts b/apps/ai/src/app/api/receive/route.ts index e398bd7006..b66b639bb5 100644 --- a/apps/ai/src/app/api/receive/route.ts +++ b/apps/ai/src/app/api/receive/route.ts @@ -45,6 +45,7 @@ export const POST = async (request: NextRequest) => { select: { email: true, id: true, + timeZone: true, credentials: { select: { appId: true, @@ -55,15 +56,17 @@ export const POST = async (request: NextRequest) => { where: { email: envelope.from }, }); - if (!signature || !user?.email || !user?.id) { + // User is not a cal.com user or is using an unverified email. + if (!signature || !user) { await sendEmail({ + html: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address.`, subject: `Re: ${body.subject}`, - text: "Sorry, you are not authorized to use this service. Please verify your email address and try again.", - to: user?.email || "", + text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`, + to: envelope.from, from: aiEmail, }); - return new NextResponse(); + return new NextResponse("ok"); } const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key; @@ -93,8 +96,8 @@ export const POST = async (request: NextRequest) => { fetchAvailability({ apiKey, userId: user.id, - dateFrom: now, - dateTo: now, + dateFrom: now(user.timeZone), + dateTo: now(user.timeZone), }), ]); @@ -120,7 +123,7 @@ export const POST = async (request: NextRequest) => { return new NextResponse("Error fetching event types. Please try again.", { status: 400 }); } - const { timeZone, workingHours } = availability; + const { workingHours } = availability; const appHost = getHostFromHeaders(request.headers); @@ -135,7 +138,7 @@ export const POST = async (request: NextRequest) => { user: { email: user.email, eventTypes, - timeZone, + timeZone: user.timeZone, workingHours, }, }), diff --git a/apps/ai/src/tools/createBookingIfAvailable.ts b/apps/ai/src/tools/createBookingIfAvailable.ts index 4e95a7f743..976758b0ea 100644 --- a/apps/ai/src/tools/createBookingIfAvailable.ts +++ b/apps/ai/src/tools/createBookingIfAvailable.ts @@ -52,9 +52,8 @@ const createBooking = async ({ method: "POST", }); - if (response.status === 401) { - throw new Error("Unauthorized"); - } + // Let GPT handle this. This will happen when wrong event type id is used. + // if (response.status === 401) throw new Error("Unauthorized"); const data = await response.json(); diff --git a/apps/ai/src/tools/deleteBooking.ts b/apps/ai/src/tools/deleteBooking.ts index 6fbb590e3c..bcaa3f56cf 100644 --- a/apps/ai/src/tools/deleteBooking.ts +++ b/apps/ai/src/tools/deleteBooking.ts @@ -31,7 +31,8 @@ const cancelBooking = async ({ method: "DELETE", }); - if (response.status === 401) throw new Error("Unauthorized"); + // Let GPT handle this. This will happen when wrong booking id is used. + // if (response.status === 401) throw new Error("Unauthorized"); const data = await response.json(); diff --git a/apps/ai/src/tools/getAvailability.ts b/apps/ai/src/tools/getAvailability.ts index fc56c54982..bd0ebf8788 100644 --- a/apps/ai/src/tools/getAvailability.ts +++ b/apps/ai/src/tools/getAvailability.ts @@ -35,9 +35,7 @@ export const fetchAvailability = async ({ const response = await fetch(url); - if (response.status === 401) { - throw new Error("Unauthorized"); - } + if (response.status === 401) throw new Error("Unauthorized"); const data = await response.json(); diff --git a/apps/ai/src/tools/updateBooking.ts b/apps/ai/src/tools/updateBooking.ts index 658760c567..4aa03044b1 100644 --- a/apps/ai/src/tools/updateBooking.ts +++ b/apps/ai/src/tools/updateBooking.ts @@ -41,7 +41,8 @@ const editBooking = async ({ method: "PATCH", }); - if (response.status === 401) throw new Error("Unauthorized"); + // Let GPT handle this. This will happen when wrong booking id is used. + // if (response.status === 401) throw new Error("Unauthorized"); const data = await response.json(); diff --git a/apps/ai/src/utils/agent.ts b/apps/ai/src/utils/agent.ts index c3389ca6ad..c6ec0dd0ba 100644 --- a/apps/ai/src/utils/agent.ts +++ b/apps/ai/src/utils/agent.ts @@ -44,7 +44,7 @@ const agent = async (input: string, user: User, apiKey: string, userId: number) 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 the user should be formatted per that user's timezone. - Current UTC time is: ${now} + The current time in the user's timezone is: ${now(user.timeZone)} The user's time zone is: ${user.timeZone} The user's event types are: ${user.eventTypes .map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`) diff --git a/apps/ai/src/utils/now.ts b/apps/ai/src/utils/now.ts index a1d76cdce6..fc77993ca0 100644 --- a/apps/ai/src/utils/now.ts +++ b/apps/ai/src/utils/now.ts @@ -1 +1,5 @@ -export default new Date().toISOString(); +export default function now(timeZone: string) { + return new Date().toLocaleString("en-US", { + timeZone, + }); +} diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index d1b1fa1d65..aae5e10f7d 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -98,14 +98,7 @@ const UserProfile = () => { return (
- {user && ( - - )} + {user && } & WithNonceProps) { const searchParams = useSearchParams(); + const isTeamInvite = searchParams.get("teamInvite"); + const { t } = useLocale(); const router = useRouter(); const formSchema = z @@ -95,7 +97,9 @@ export default function Login({ callbackUrl = safeCallbackUrl || ""; const LoginFooter = ( - + {t("dont_have_an_account")} ); @@ -184,6 +188,9 @@ export default function Login({ ? LoginFooter : null }> + {isTeamInvite && ( + + )}
diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index fa21ad94a5..8b54d23b62 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -376,9 +376,12 @@ const EventTypePage = (props: EventTypeSetupProps) => { bookerLayouts, // eslint-disable-next-line @typescript-eslint/no-unused-vars multipleDurationEnabled, + length, ...input } = values; + if (!Number(length)) throw new Error(t("event_setup_length_error")); + if (bookingLimits) { const isValid = validateIntervalLimitOrder(bookingLimits); if (!isValid) throw new Error(t("event_setup_booking_limits_error")); @@ -396,7 +399,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { if (metadata?.multipleDuration.length < 1) { throw new Error(t("event_setup_multiple_duration_error")); } else { - if (!input.length && !metadata?.multipleDuration?.includes(input.length)) { + if (!length && !metadata?.multipleDuration?.includes(length)) { throw new Error(t("event_setup_multiple_duration_default_error")); } } @@ -410,6 +413,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { const { availability, ...rest } = input; updateMutation.mutate({ ...rest, + length, locations, recurringEvent, periodStartDate: periodDates.startDate, @@ -467,9 +471,12 @@ const EventTypePage = (props: EventTypeSetupProps) => { seatsPerTimeSlotEnabled, // eslint-disable-next-line @typescript-eslint/no-unused-vars multipleDurationEnabled, + length, ...input } = values; + if (!Number(length)) throw new Error(t("event_setup_length_error")); + if (bookingLimits) { const isValid = validateIntervalLimitOrder(bookingLimits); if (!isValid) throw new Error(t("event_setup_booking_limits_error")); @@ -487,7 +494,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { if (metadata?.multipleDuration.length < 1) { throw new Error(t("event_setup_multiple_duration_error")); } else { - if (!input.length && !metadata?.multipleDuration?.includes(input.length)) { + if (!length && !metadata?.multipleDuration?.includes(length)) { throw new Error(t("event_setup_multiple_duration_default_error")); } } @@ -496,6 +503,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { const { availability, ...rest } = input; updateMutation.mutate({ ...rest, + length, locations, recurringEvent, periodStartDate: periodDates.startDate, diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index a569425d22..fa6142d746 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -425,7 +425,7 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => { )} /> )} - {isManagedEventType && ( + {isManagedEventType && type?.children && type.children?.length > 0 && ( ( <> - +
; type SignupProps = inferSSRProps; +const getSafeCallbackUrl = (url: string | null) => { + if (!url) return null; + + let callbackUrl = url; + + if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1); + + // If not absolute URL, make it absolute + if (!/^https?:\/\//.test(callbackUrl)) { + callbackUrl = `${WEBAPP_URL}/${callbackUrl}`; + } + + const safeCallbackUrl = getSafeRedirectUrl(callbackUrl); + + return safeCallbackUrl; +}; + export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) { const searchParams = useSearchParams(); const telemetry = useTelemetry(); @@ -55,6 +73,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup throw new Error(err.message); } }; + const callbackUrl = getSafeCallbackUrl(searchParams.get("callbackUrl")); const signUp: SubmitHandler = async (data) => { await fetch("/api/auth/signup", { @@ -72,13 +91,10 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup .then(async () => { telemetry.event(telemetryEventTypes.signup, collectPageParameters()); const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started"; + await signIn<"credentials">("credentials", { ...data, - callbackUrl: `${ - searchParams?.get("callbackUrl") - ? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}` - : `${WEBAPP_URL}/${verifyOrGettingStarted}` - }?from=signup`, + callbackUrl: `${callbackUrl ? callbackUrl : `${WEBAPP_URL}/${verifyOrGettingStarted}`}?from=signup`, }); }) .catch((err) => { @@ -157,9 +173,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup className="w-full justify-center" onClick={() => signIn("Cal.com", { - callbackUrl: searchParams?.get("callbackUrl") - ? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}` - : `${WEBAPP_URL}/getting-started`, + callbackUrl: callbackUrl ? callbackUrl : `${WEBAPP_URL}/getting-started`, }) }> {t("login_instead")} diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx index 392c54f59e..892e1bfedb 100644 --- a/apps/web/pages/teams/index.tsx +++ b/apps/web/pages/teams/index.tsx @@ -1,9 +1,11 @@ import type { GetServerSidePropsContext } from "next"; import { getLayout } from "@calcom/features/MainLayout"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { TeamsListing } from "@calcom/features/ee/teams/components"; import { ShellMain } from "@calcom/features/shell/Shell"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui"; @@ -41,6 +43,21 @@ function Teams() { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); await ssr.viewer.me.prefetch(); + const session = await getServerSession({ req: context.req, res: context.res }); + const token = context.query?.token; + const resolvedUrl = context.resolvedUrl; + + const callbackUrl = token ? getSafeRedirectUrl(`${WEBAPP_URL}${resolvedUrl}`) : null; + + if (!session) { + return { + redirect: { + destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}&teamInvite=true` : "/auth/login", + permanent: false, + }, + props: {}, + }; + } return { props: { trpcState: ssr.dehydrate() } }; }; diff --git a/apps/web/playwright/fixtures/routingForms.ts b/apps/web/playwright/fixtures/routingForms.ts new file mode 100644 index 0000000000..751503e315 --- /dev/null +++ b/apps/web/playwright/fixtures/routingForms.ts @@ -0,0 +1,61 @@ +import { v4 as uuidv4 } from "uuid"; + +import { prisma } from "@calcom/prisma"; + +type Route = { + id: string; + action: { + type: string; + value: string; + }; + isFallback: boolean; + queryValue: { + id: string; + type: string; + }; +}; + +export const createRoutingFormsFixture = () => { + return { + async create({ + userId, + teamId, + name, + fields, + routes = [], + }: { + name: string; + userId: number; + teamId: number | null; + routes?: Route[]; + fields: { + type: string; + label: string; + identifier?: string; + required: boolean; + }[]; + }) { + return await prisma.app_RoutingForms_Form.create({ + data: { + name, + userId, + teamId, + routes: [ + ...routes, + // Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly. + { + id: "898899aa-4567-489a-bcde-f1823f708646", + action: { type: "customPageMessage", value: "Fallback Message" }, + isFallback: true, + queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" }, + }, + ], + fields: fields.map((f) => ({ + id: uuidv4(), + ...f, + })), + }, + }); + }, + }; +}; diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index b8c6dc4e88..3d8fb05490 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds"; import { createPaymentsFixture } from "../fixtures/payments"; +import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; import { createUsersFixture } from "../fixtures/users"; @@ -23,6 +24,7 @@ export interface Fixtures { servers: ReturnType; prisma: typeof prisma; emails?: API; + routingForms: ReturnType; } declare global { @@ -71,6 +73,9 @@ export const test = base.extend({ prisma: async ({}, use) => { await use(prisma); }, + routingForms: async ({}, use) => { + await use(createRoutingFormsFixture()); + }, emails: async ({}, use) => { if (IS_MAILHOG_ENABLED) { const mailhogAPI = mailhog(); diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index a56f5a425b..af0be0f580 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -1,3 +1,4 @@ +import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { test } from "./lib/fixtures"; @@ -9,6 +10,9 @@ import { gotoRoutingLink, } from "./lib/testUtils"; +// remove dynamic properties that differs depending on where you run the tests +const dynamic = "[redacted/dynamic]"; + test.afterEach(({ users }) => users.deleteAll()); test.describe("BOOKING_CREATED", async () => { @@ -55,8 +59,6 @@ test.describe("BOOKING_CREATED", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const body: any = request.body; - // remove dynamic properties that differs depending on where you run the tests - const dynamic = "[redacted/dynamic]"; body.createdAt = dynamic; body.payload.startTime = dynamic; body.payload.endTime = dynamic; @@ -187,8 +189,6 @@ test.describe("BOOKING_REJECTED", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = request.body as any; - // remove dynamic properties that differs depending on where you run the tests - const dynamic = "[redacted/dynamic]"; body.createdAt = dynamic; body.payload.startTime = dynamic; body.payload.endTime = dynamic; @@ -311,8 +311,6 @@ test.describe("BOOKING_REQUESTED", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = request.body as any; - // remove dynamic properties that differs depending on where you run the tests - const dynamic = "[redacted/dynamic]"; body.createdAt = dynamic; body.payload.startTime = dynamic; body.payload.endTime = dynamic; @@ -391,54 +389,136 @@ test.describe("BOOKING_REQUESTED", async () => { }); test.describe("FORM_SUBMITTED", async () => { - test("can submit a form and get a submission event", async ({ page, users }) => { + test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => { const webhookReceiver = createHttpServer(); - const user = await users.create(); + const user = await users.create(null, { + hasTeam: true, + }); await user.apiLogin(); - await page.goto("/settings/teams/new"); - await page.waitForLoadState("networkidle"); - const teamName = `${user.username}'s Team`; - // Create a new team - await page.locator('input[name="name"]').fill(teamName); - await page.locator('input[name="slug"]').fill(teamName); - await page.locator('button[type="submit"]').click(); - - await page.locator("text=Publish team").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); - - await page.waitForLoadState("networkidle"); - - await page.waitForLoadState("networkidle"); await page.goto(`/settings/developer/webhooks/new`); // Add webhook await page.fill('[name="subscriberUrl"]', webhookReceiver.url); await page.fill('[name="secret"]', "secret"); - await Promise.all([page.click("[type=submit]"), page.goForward()]); + await page.click("[type=submit]"); // Page contains the url expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined(); await page.waitForLoadState("networkidle"); - await page.goto("/routing-forms/forms"); - await page.click('[data-testid="new-routing-form"]'); - // Choose to create the Form for the user(which is the first option) and not the team - await page.click('[data-testid="option-0"]'); - await page.fill("input[name]", "TEST FORM"); - await page.click('[data-testid="add-form"]'); - await page.waitForSelector('[data-testid="add-field"]'); - const url = page.url(); - const formId = new URL(url).pathname.split("/").at(-1); + const form = await routingForms.create({ + name: "Test Form", + userId: user.id, + teamId: null, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + ], + }); - await gotoRoutingLink({ page, formId: formId }); + await gotoRoutingLink({ page, formId: form.id }); + const fieldName = "name"; + await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe"); page.click('button[type="submit"]'); await waitFor(() => { expect(webhookReceiver.requestList.length).toBe(1); }); + const [request] = webhookReceiver.requestList; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body = request.body as any; + body.createdAt = dynamic; + expect(body).toEqual({ + triggerEvent: "FORM_SUBMITTED", + createdAt: dynamic, + payload: { + formId: form.id, + formName: form.name, + teamId: null, + responses: { + name: { + value: "John Doe", + }, + }, + }, + name: "John Doe", + }); + + webhookReceiver.close(); + }); + + test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => { + const webhookReceiver = createHttpServer(); + const user = await users.create(null, { + hasTeam: true, + }); + await user.apiLogin(); + + await page.goto(`/settings/developer/webhooks`); + const teamId = await clickFirstTeamWebhookCta(page); + + // Add webhook + await page.fill('[name="subscriberUrl"]', webhookReceiver.url); + await page.fill('[name="secret"]', "secret"); + await page.click("[type=submit]"); + + const form = await routingForms.create({ + name: "Test Form", + userId: user.id, + teamId: teamId, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + ], + }); + + await gotoRoutingLink({ page, formId: form.id }); + const fieldName = "name"; + await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe"); + page.click('button[type="submit"]'); + await waitFor(() => { + expect(webhookReceiver.requestList.length).toBe(1); + }); + const [request] = webhookReceiver.requestList; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body = request.body as any; + body.createdAt = dynamic; + expect(body).toEqual({ + triggerEvent: "FORM_SUBMITTED", + createdAt: dynamic, + payload: { + formId: form.id, + formName: form.name, + teamId, + responses: { + name: { + value: "John Doe", + }, + }, + }, + name: "John Doe", + }); + webhookReceiver.close(); }); }); + +async function clickFirstTeamWebhookCta(page: Page) { + await page.click('[data-testid="new_webhook"]'); + await page.click('[data-testid="option-team-1"]'); + await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new"); + const url = page.url(); + const teamId = Number(new URL(url).searchParams.get("teamId")) as number; + return teamId; +} diff --git a/apps/web/public/avatar.svg b/apps/web/public/avatar.svg new file mode 100644 index 0000000000..477277f509 --- /dev/null +++ b/apps/web/public/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/static/locales/ar/common.json b/apps/web/public/static/locales/ar/common.json index 9c4418b2aa..230f09817d 100644 --- a/apps/web/public/static/locales/ar/common.json +++ b/apps/web/public/static/locales/ar/common.json @@ -13,7 +13,7 @@ "reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور", "verify_email_subject": "{{appName}}: تأكيد حسابك", "check_your_email": "تحقق من بريدك الإلكتروني", - "verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم من {{appName}}.", + "verify_email_page_body": "أرسلنا رسالة إلكترونية إلى {{email}}. من المهم تأكيد عنوان بريدك الإلكتروني لضمان تسليم البريد الإلكتروني والتقويم من {{appName}}.", "verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم", "verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني", "verify_email_email_button": "تأكيد البريد الإلكتروني", @@ -130,7 +130,7 @@ "upgrade_banner_action": "قم بالترقية هنا", "team_upgraded_successfully": "تمت ترقية فريقك بنجاح!", "org_upgrade_banner_description": "شكرًا لك على تجربة خطة Organization الجديدة التي نقدمها. لقد لاحظنا أن منظمتك \"{{teamName}}\" بحاجة إلى الترقية.", - "org_upgraded_successfully": "تمت ترقية منظمتك بنجاح!", + "org_upgraded_successfully": "تمت ترقية خطة Organization بنجاح!", "use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور", "hey_there": "مرحبًا،", "forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - {{appName}}", @@ -550,7 +550,7 @@ "team_description": "بضع جمل عن فريقك. ستظهر على صفحة رابط فريقك.", "org_description": "بضع جمل عن منظمتك. ستظهر على صفحة رابط منظمتك.", "members": "الأعضاء", - "organization_members": "أعضاء المنظمة", + "organization_members": "أعضاء Organization", "member": "العضو", "number_member_one": "{{count}} عضو", "number_member_other": "{{count}} من الأعضاء", @@ -1652,9 +1652,8 @@ "delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.", "organizer_timezone": "منظم المناطق الزمنية", "email_user_cta": "عرض الدعوة", - "email_no_user_invite_heading": "تمت دعوتك للانضمام إلى {{appName}} {{entity}}", "email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.", - "email_user_invite_subheading": "لقد دعاك {{invitedBy}} للانضمام إلى {{entity}} `{{teamName}}` على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت و{{entity}} من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.", + "email_user_invite_subheading_team": "دعاك {{invitedBy}} للانضمام إلى فريقه {{teamName}} على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.", "email_no_user_invite_steps_intro": "سنرشدك خلال بضع خطوات قصيرة وستستمتع بجدول زمني خالٍ من التوتر مع {{entity}} في لمح البصر.", "email_no_user_step_one": "اختر اسم المستخدم الخاص بك", "email_no_user_step_two": "ربط حساب التقويم الخاص بك", @@ -1871,7 +1870,6 @@ "first_event_type_webhook_description": "قم بإنشاء أول شبكة ويب هوك لهذا النوع من الأحداث", "install_app_on": "تثبيت التطبيق على", "create_for": "إنشاء من أجل", - "setup_organization": "إعداد منظمة", "organization_banner_description": "إنشاء بيئات حيث يمكن لفرقك إنشاء تطبيقات مشتركة ومهام سير العمل وأنواع الأحداث باستخدام الجدولة الدوارة والجماعية.", "organization_banner_title": "إدارة المنظمات ذات الفرق المتعددة", "set_up_your_organization": "إعداد منظمتك", diff --git a/apps/web/public/static/locales/cs/common.json b/apps/web/public/static/locales/cs/common.json index 3e822775d4..4026f8039c 100644 --- a/apps/web/public/static/locales/cs/common.json +++ b/apps/web/public/static/locales/cs/common.json @@ -118,7 +118,7 @@ "team_info": "Informace o týmu", "request_another_invitation_email": "Pokud si nepřejete použít e-mail {{toEmail}} pro {{appName}} nebo už účet na {{appName}} máte, požádejte prosím o další pozvánku na jiný e-mail.", "you_have_been_invited": "Byli jste pozváni do týmu {{teamName}}", - "user_invited_you": "Uživatel {{user}} vás pozval, abyste se připojili k subjektu {{entity}} {{team}} v aplikaci {{appName}}", + "user_invited_you": "Uživatel {{user}} vás pozval, abyste se připojili k {{entity}} {{team}} v aplikaci {{appName}}", "hidden_team_member_title": "V tomto týmu nejste viditelní", "hidden_team_member_message": "Vaše místo není zaplacené. Buď přejděte na tarif Pro, nebo informujte vlastníka týmu, že za vaše místo může zaplatit.", "hidden_team_owner_message": "Pro využívání týmů potřebujete tarif Pro, budete skrytí, než přejdete na vyšší verzi.", @@ -130,7 +130,7 @@ "upgrade_banner_action": "Upgradovat zde", "team_upgraded_successfully": "Váš tým byl úspěšně přešel na vyšší verzi!", "org_upgrade_banner_description": "Děkujeme, že jste vyzkoušeli náš tarif Organizace. Všimli jsme si, že vaše organizace „{{teamName}}“ vyžaduje upgrade.", - "org_upgraded_successfully": "Vaše organizace byla upgradována!", + "org_upgraded_successfully": "Váš upgrade na tarif Organization byl úspěšně dokončen!", "use_link_to_reset_password": "Pro obnovení hesla použijte odkaz níž", "hey_there": "Zdravíme,", "forgot_your_password_calcom": "Zapomněli jste heslo? - {{appName}}", @@ -307,7 +307,7 @@ "layout": "Rozvržení", "bookerlayout_default_title": "Výchozí zobrazení", "bookerlayout_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat.", - "bookerlayout_user_settings_title": "Rozvržení rezervace", + "bookerlayout_user_settings_title": "Rozvržení rezervací", "bookerlayout_user_settings_description": "Můžete si jich vybrat více a vaši rezervující si mohou zobrazení přepínat. Lze nadefinovat pro každou událost zvlášť.", "bookerlayout_month_view": "Měsíční", "bookerlayout_week_view": "Týdenní", @@ -403,7 +403,7 @@ "recording_ready": "Odkaz ke stažení záznamu je připraven", "booking_created": "Rezervace vytvořena", "booking_rejected": "Rezervace byla zamítnuta", - "booking_requested": "Byla vyžádána rezervace", + "booking_requested": "Váš požadavek na rezervaci byl úspěšně odeslán", "meeting_ended": "Schůzka skončila", "form_submitted": "Formulář byl odeslán", "event_triggers": "Eventy na základě akce", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Opravdu chcete odstranit konfiguraci {{connectionType}}? Členové vašeho týmu, kteří používají přihlášení {{connectionType}}, ztratí přístup k webu Cal.com.", "organizer_timezone": "Časové pásmo organizátora", "email_user_cta": "Zobrazit pozvánku", - "email_no_user_invite_heading": "Byli jste pozváni, abyste se připojili k subjektu {{entity}} v aplikaci {{appName}}", "email_no_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového ping-pongu.", - "email_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k týmu {{teamName}} subjektu {{entity}} v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu subjektu {{entity}} umožňuje plánovat schůzky bez e-mailového pingpongu.", + "email_user_invite_subheading_team": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu „{{teamName}}“ v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového pingpongu.", "email_no_user_invite_steps_intro": "Provedeme vás několika krátkými kroky a za malou chvíli si budete se svým subjektem {{entity}} užívat plánování bez stresu.", "email_no_user_step_one": "Vyberte si uživatelské jméno", "email_no_user_step_two": "Připojte svůj účet kalendáře", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Vytvořte svůj první webhook pro tento typ události", "install_app_on": "Nainstalovat aplikaci v", "create_for": "Vytvořit pro", - "setup_organization": "Nastavení organizace", "organization_banner_description": "Vytvořte prostředí, ve kterém budou moct vaše týmy vytvářet sdílené aplikace, pracovní postupy a typy událostí s kolektivním plánováním nebo plánováním typu round-robin.", "organization_banner_title": "Správa organizace s více týmy", "set_up_your_organization": "Nastavení organizace", @@ -1889,12 +1887,12 @@ "admin_username": "Uživatelské jméno správce", "organization_name": "Název organizace", "organization_url": "Adresa URL organizace", - "organization_verify_header": "Ověřte e-mailovou adresu organizace", + "organization_verify_header": "Ověřte svou e-mailovou adresu organizace", "organization_verify_email_body": "Pomocí uvedeného kódu ověřte vaši e-mailovou adresu, abyste mohli pokračovat v nastavení vaší organizace.", "additional_url_parameters": "Další parametry adresy URL", "about_your_organization": "Informace o vaší organizaci", "about_your_organization_description": "Organizace jsou sdílená prostředí, ve kterých můžete vytvořit několik týmů se sdílenými členy, typy událostí, aplikacemi, pracovními postupy a mnoha dalšími věcmi.", - "create_your_teams": "Vytvoření týmů", + "create_your_teams": "Vytvořit tým", "create_your_teams_description": "Přidejte do své organizace členy a pusťte se do společného plánování", "invite_organization_admins": "Pozvěte správce své organizace", "invite_organization_admins_description": "Tito správci budou mít přístup ke všem týmům ve vaší organizaci. Správce a členy týmů můžete přidat později.", @@ -1934,11 +1932,11 @@ "404_the_org": "Organizace", "404_the_team": "Tým", "404_claim_entity_org": "Vyžádejte si subdoménu pro svou organizaci", - "404_claim_entity_team": "Vyžádejte si tento tým a začněte kolektivně spravovat rozvrhy", + "404_claim_entity_team": "Vyžádejte si přístup k tomuto týmu a začněte kolektivně spravovat rozvrhy", "insights_all_org_filter": "Všechny aplikace", "insights_team_filter": "Tým: {{teamName}}", "insights_user_filter": "Uživatel: {{userName}}", - "insights_subtitle": "Zobrazte si přehled o rezervacích napříč vašimi událostmi", + "insights_subtitle": "Zobrazte si Insight rezervací napříč vašimi událostmi", "custom_plan": "Vlastní plán", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index e6e7471bb7..8bbd40732d 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -1679,9 +1679,8 @@ "delete_sso_configuration_confirmation_description": "Sind Sie sicher, dass Sie die {{connectionType}}-Konfiguration löschen möchten? Ihre Teammitglieder, die sich über {{connectionType}} anmelden, werden keinen Zugriff mehr auf Cal.com haben.", "organizer_timezone": "Zeitzone des Veranstalters", "email_user_cta": "Einladung anzeigen", - "email_no_user_invite_heading": "Sie wurden eingeladen, einem {{entity}} in {{appName}} beizutreten", "email_no_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem Team in {{appName}} beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne ständiges hin und her zu planen.", - "email_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem {{entity}} „{{teamName}}“ in {{appName}} beizutreten. {{appName}} ist der Terminplaner, der es Ihnen und Ihrem {{entity}} ermöglicht, Meetings ohne ständiges hin und her zu planen.", + "email_user_invite_subheading_team": "{{invitedBy}} hat Sie eingeladen, dem Team „{{teamName}}“ beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne Hin und Her zu planen.", "email_no_user_invite_steps_intro": "Wir begleiten Sie durch ein paar Schritte, danach können Sie in kürzester Zeit stressfreie Planung mit Ihrem {{entity}} genießen.", "email_no_user_step_one": "Wählen Sie Ihren Benutzernamen", "email_no_user_step_two": "Verbinden Sie Ihr Kalenderkonto", @@ -1902,7 +1901,6 @@ "install_app_on": "App installieren auf", "create_for": "Erstellen für", "currency": "Währung", - "setup_organization": "Eine Organization einrichten", "organization_banner_description": "Schaffen Sie Umgebungen, in der Teams gemeinsame Apps, Workflows und Termintypen mit Round Robin und kollektiver Terminplanung erstellen können.", "organization_banner_title": "Verwalten Sie Organizations mit mehreren Teams", "set_up_your_organization": "Ihre Organization einrichten", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index f252a96d2f..a10b48fef4 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -45,6 +45,7 @@ "invite_team_individual_segment": "Invite individual", "invite_team_bulk_segment": "Bulk import", "invite_team_notifcation_badge": "Inv.", + "signin_or_signup_to_accept_invite": "You need to Sign in or Sign up to see team invitation.", "your_event_has_been_scheduled": "Your event has been scheduled", "your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled", "accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT to '{{agree}}'.", @@ -1053,12 +1054,15 @@ "how_you_want_add_cal_site": "How do you want to add {{appName}} to your site?", "choose_ways_put_cal_site": "Choose one of the following ways to put {{appName}} on your site.", "setting_up_zapier": "Setting up your Zapier integration", + "setting_up_make": "Setting up your Make integration", "generate_api_key": "Generate API key", "generate_api_key_description": "Generate an API key to use with {{appName}} at", "your_unique_api_key": "Your unique API key", "copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.", "zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.<1>Select Cal.com as your Trigger app. Also choose a Trigger event.<2>Choose your account and then enter your Unique API Key.<3>Test your Trigger.<4>You're set!", + "make_setup_instructions": "<0>Go to <1><0>Make Invite Link and install the Cal.com app.<1>Log into your Make account and create a new Scenario.<2>Select Cal.com as your Trigger app. Also choose a Trigger event.<3>Choose your account and then enter your Unique API Key.<4>Test your Trigger.<5>You're set!", "install_zapier_app": "Please first install the Zapier App in the app store.", + "install_make_app": "Please first install the Make App in the app store.", "connect_apple_server": "Connect to Apple Server", "calendar_url": "Calendar URL", "apple_server_generate_password": "Generate an app specific password to use with {{appName}} at", @@ -1684,9 +1688,11 @@ "delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.", "organizer_timezone": "Organizer timezone", "email_user_cta": "View Invitation", - "email_no_user_invite_heading": "You’ve been invited to join a {{appName}} {{entity}}", + "email_no_user_invite_heading_team": "You’ve been invited to join a {{appName}} team", + "email_no_user_invite_heading_org": "You’ve been invited to join a {{appName}} organization", "email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email_user_invite_subheading": "{{invitedBy}} has invited you to join their {{entity}} `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your {{entity}} to schedule meetings without the email tennis.", + "email_user_invite_subheading_team": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email_user_invite_subheading_org": "{{invitedBy}} has invited you to join their organization `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your organization to schedule meetings without the email tennis.", "email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.", "email_no_user_step_one": "Choose your username", "email_no_user_step_two": "Connect your calendar account", @@ -1909,7 +1915,6 @@ "install_app_on": "Install app on", "create_for": "Create for", "currency": "Currency", - "setup_organization": "Setup an Organization", "organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.", "organization_banner_title": "Manage organizations with multiple teams", "set_up_your_organization": "Set up your organization", @@ -2038,5 +2043,7 @@ "seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations", "include_calendar_event": "Include calendar event", "recently_added":"Recently added", + "no_members_found": "No members found", + "event_setup_length_error":"Event Setup: The duration must be at least 1 minute.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 1d2f29032d..62ca4d6420 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "¿Está seguro de que desea eliminar la configuración de {{connectionType}}? Los miembros de su equipo que utilicen el inicio de sesión {{connectionType}} ya no podrán acceder a Cal.com.", "organizer_timezone": "Zona horaria del organizador", "email_user_cta": "Ver la invitación", - "email_no_user_invite_heading": "Lo invitaron a unirse a {{appName}} {{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} lo invitó a unirse a su equipo en {{appName}}. {{appName}} es el programador de eventos que les permite a usted y a su equipo programar reuniones sin necesidad de enviar correos electrónicos.", - "email_user_invite_subheading": "{{invitedBy}} lo ha invitado a unirse a su {{entity}} \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su {{entity}} programar reuniones sin necesidad de correos electrónicos.", + "email_user_invite_subheading_team": "{{invitedBy}} lo ha invitado a unirse a su equipo \"{{teamName}}\" en {{appName}}. {{appName}} es el planificador de eventos que le permite a usted y a su equipo programar reuniones sin correos electrónicos de ida y vuelta.", "email_no_user_invite_steps_intro": "Lo guiaremos a través de unos pocos pasos y disfrutará de una programación sin estrés con su {{entity}} en muy poco tiempo.", "email_no_user_step_one": "Elija su nombre de usuario", "email_no_user_step_two": "Conecte su cuenta de calendario", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Cree su primer webhook para este tipo de evento", "install_app_on": "Instalar aplicación en", "create_for": "Crear para", - "setup_organization": "Configurar una organización", "organization_banner_description": "Cree entornos en los que sus equipos puedan crear aplicaciones, flujos de trabajo y tipos de eventos compartidos con programación por turnos y colectiva.", "organization_banner_title": "Gestione organizaciones con varios equipos", "set_up_your_organization": "Configure su organización", diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 91a6731da8..7714f2eb44 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -45,6 +45,7 @@ "invite_team_individual_segment": "Inviter une personne", "invite_team_bulk_segment": "Importation multiple", "invite_team_notifcation_badge": "Inv.", + "signin_or_signup_to_accept_invite": "Vous devez vous connecter ou vous inscrire pour voir l'invitation d'équipe.", "your_event_has_been_scheduled": "Votre événement a été planifié", "your_event_has_been_scheduled_recurring": "Votre événement récurrent a été planifié", "accept_our_license": "Acceptez notre licence en changeant la variable .env <1>NEXT_PUBLIC_LICENSE_CONSENT en « {{agree}} ».", @@ -1053,12 +1054,15 @@ "how_you_want_add_cal_site": "Comment souhaitez-vous ajouter {{appName}} à votre site ?", "choose_ways_put_cal_site": "Choisissez l'une des méthodes suivantes pour mettre {{appName}} sur votre site.", "setting_up_zapier": "Configuration de votre intégration Zapier", + "setting_up_make": "Configuration de votre intégration Make", "generate_api_key": "Générer une clé API", "generate_api_key_description": "Générer une clé API à utiliser avec {{appName}} sur", "your_unique_api_key": "Votre clé API unique", "copy_safe_api_key": "Copiez cette clé API et conservez-la dans un endroit sûr. Si vous perdez cette clé, vous devrez en générer une nouvelle.", - "zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.<1>Sélectionnez Cal.com comme application Trigger. Choisissez également un événement Trigger.<2>Choisissez votre compte, puis saisissez votre clé API unique.<3>Testez votre Trigger.<4>Vous êtes prêt !", + "zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.<1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.<2>Choisissez votre compte, puis saisissez votre clé API unique.<3>Testez votre déclencheur.<4>Vous êtes prêt !", + "make_setup_instructions": "<0>Connectez-vous à votre compte Make et créez un nouveau Scénario.<1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.<2>Choisissez votre compte, puis saisissez votre clé API unique.<3>Testez votre déclencheur.<4>Vous êtes prêt !", "install_zapier_app": "Veuillez d'abord installer l'application Zapier dans l'App Store.", + "install_make_app": "Veuillez d'abord installer l'application Make dans l'App Store.", "connect_apple_server": "Se connecter au serveur d'Apple", "calendar_url": "Lien du calendrier", "apple_server_generate_password": "Générez un mot de passe pour application à utiliser avec {{appName}} sur", @@ -1116,7 +1120,7 @@ "add_new_workflow": "Ajouter un nouveau workflow", "reschedule_event_trigger": "lorsque l'événement est replanifié", "trigger": "Déclencheur", - "triggers": "Déclencher", + "triggers": "Déclencheurs", "action": "Action", "workflows_to_automate_notifications": "Créez des workflows pour automatiser les notifications et les rappels.", "workflow_name": "Nom du workflow", @@ -1684,9 +1688,8 @@ "delete_sso_configuration_confirmation_description": "Voulez-vous vraiment supprimer la configuration {{connectionType}} ? Les membres de votre équipe utilisant la connexion {{connectionType}} ne pourront plus accéder à Cal.com.", "organizer_timezone": "Fuseau horaire de l'organisateur", "email_user_cta": "Voir l'invitation", - "email_no_user_invite_heading": "Vous avez été invité à rejoindre une {{entity}} {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} vous a invité à rejoindre son équipe sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre équipe d'organiser des rendez-vous sans échanges d'e-mails.", - "email_user_invite_subheading": "{{invitedBy}} vous a invité à rejoindre son {{entity}} « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre {{entity}} d'organiser des rendez-vous sans échanges d'e-mails.", + "email_user_invite_subheading_team": "{{invitedBy}} vous a invité à rejoindre son équipe « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre équipe d'organiser des rendez-vous sans échanges d'e-mails.", "email_no_user_invite_steps_intro": "Nous vous guiderons à travers quelques étapes courtes et vous profiterez d'une planification sans stress avec votre {{entity}} en un rien de temps.", "email_no_user_step_one": "Choisissez votre nom d'utilisateur", "email_no_user_step_two": "Connectez votre compte de calendrier", @@ -1909,7 +1912,6 @@ "install_app_on": "Installer l’application sur", "create_for": "Créer pour", "currency": "Devise", - "setup_organization": "Configurer une organisation", "organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification round-robin et collective.", "organization_banner_title": "Gérer les organisations avec plusieurs équipes", "set_up_your_organization": "Configurer votre organisation", @@ -2037,5 +2039,7 @@ "team_no_event_types": "Cette équipe n'a aucun type d'événement", "seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples", "include_calendar_event": "Inclure l'événement du calendrier", + "recently_added": "Ajouté récemment", + "no_members_found": "Aucun membre trouvé", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index ae12fe0bfc..cdcb1e0f22 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "אתה בטוח שאתה רוצה למחוק את הגדרות {{connectionType}}? חברי הצוות שלך שמשתמשים ב- {{connectionType}} להזדהות לא יוכלו להשתמש בו להכנס ל- Cal.com.", "organizer_timezone": "מארגן אזורי זמן", "email_user_cta": "צפה בהזמנה", - "email_no_user_invite_heading": "הוזמנת להצטרף ל{{entity}} של {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.", - "email_user_invite_subheading": "{{invitedBy}} הזמין/ה אותך להצטרף ל-{{entity}} שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ול-{{entity}} שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.", + "email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.", "email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.", "email_no_user_step_one": "בחר שם משתמש", "email_no_user_step_two": "קשר את לוח השנה שלך", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "צור/צרי את ה-Webhook הראשון שלך עבור סוג האירוע הזה", "install_app_on": "התקנת האפליקציה ב-", "create_for": "צור/צרי עבור", - "setup_organization": "הגדרת Organization", "organization_banner_description": "צור/צרי סביבות שבהן הצוותים שלך יוכלו ליצור אפליקציות, תהליכי עבודה וסוגי אירועים משותפים, עם תכונות כמו סבב וקביעת מועדים שיתופית.", "organization_banner_title": "ניהול ארגונים עם צוותים מרובים", "set_up_your_organization": "הגדרת הארגון שלך", diff --git a/apps/web/public/static/locales/it/common.json b/apps/web/public/static/locales/it/common.json index c05727f638..0908513682 100644 --- a/apps/web/public/static/locales/it/common.json +++ b/apps/web/public/static/locales/it/common.json @@ -403,7 +403,7 @@ "recording_ready": "Il link per scaricare la registrazione è pronto", "booking_created": "Prenotazione Creata", "booking_rejected": "Prenotazione Rifiutata", - "booking_requested": "Prenotazione Richiesta", + "booking_requested": "Richiesta di prenotazione inviata", "meeting_ended": "Riunione terminata", "form_submitted": "Modulo inviato", "event_triggers": "Attivatori Evento", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Eliminare la configurazione {{connectionType}}? I membri del tuo team che utilizzano l'accesso {{connectionType}} non saranno più in grado di accedere a Cal.com.", "organizer_timezone": "Fuso orario organizzatore", "email_user_cta": "Visualizza invito", - "email_no_user_invite_heading": "Hai ricevuto un invito a partecipare a un/una {{entity}} di {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} ti ha invitato a unirti al suo team su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette al tuo team di pianificare le riunioni senza scambiare decine di e-mail.", - "email_user_invite_subheading": "{{invitedBy}} ti ha invitato a partecipare al/alla {{entity}} `{{teamName}}` su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette a te e al tuo/tua {{entity}} di pianificare le riunioni senza scambiarvi decine di e-mail.", + "email_user_invite_subheading_team": "{{invitedBy}} ti ha invitato a unirti al suo team `{{teamName}}` su {{appName}}. {{appName}} è uno strumento di pianificazione di eventi che permette a te e al tuo team di pianificare le riunioni senza scambiare decine di e-mail.", "email_no_user_invite_steps_intro": "Ti assisteremo nei passaggi iniziali e potrai rapidamente pianificare eventi per il/la {{entity}} senza fatica.", "email_no_user_step_one": "Scegli il tuo nome utente", "email_no_user_step_two": "Collega il tuo account di calendario", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Crea il primo webhook per questo tipo di evento", "install_app_on": "Installa l'app su", "create_for": "Crea per", - "setup_organization": "Imposta un'organizzazione", "organization_banner_description": "Crea ambienti dove i tuoi team potranno creare e condividere applicazioni, flussi di lavoro e tipi di eventi con pianificazioni di gruppo e round robin.", "organization_banner_title": "Gestisci organizzazioni con più team", "set_up_your_organization": "Imposta la tua organizzazione", @@ -1889,7 +1887,7 @@ "admin_username": "Nome utente dell'amministratore", "organization_name": "Nome dell'organizzazione", "organization_url": "URL dell'organizzazione", - "organization_verify_header": "Verifica l'e-mail della tua organizzazione", + "organization_verify_header": "Verifica il tuo indirizzo e-mail", "organization_verify_email_body": "Usa il codice sottostante per verificare il tuo indirizzo e-mail e proseguire la configurazione dell'organizzazione.", "additional_url_parameters": "Parametri aggiuntivi dell'URL", "about_your_organization": "Informazioni sulla tua organizzazione", diff --git a/apps/web/public/static/locales/ja/common.json b/apps/web/public/static/locales/ja/common.json index bfc0d4367f..ce3b4d8048 100644 --- a/apps/web/public/static/locales/ja/common.json +++ b/apps/web/public/static/locales/ja/common.json @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "{{connectionType}} の構成を削除してもよろしいですか?{{connectionType}} でのログインを使用しているチームメンバーは、Cal.com にアクセスできなくなります。", "organizer_timezone": "主催者のタイムゾーン", "email_user_cta": "招待を表示", - "email_no_user_invite_heading": "{{appName}} の {{entity}} への参加に招待されました", "email_no_user_invite_subheading": "{{invitedBy}} は {{appName}} のチームに参加するようあなたを招待しました。{{appName}} は、イベント調整スケジューラーです。チームと延々とメールのやりとりをすることなくミーティングのスケジュール設定を行うことができます。", - "email_user_invite_subheading": "{{invitedBy}} から {{appName}} の {{entity}} \"{{teamName}}\" に参加するよう招待されました。{{appName}} はイベント調整スケジューラーであり、{{entity}} 内で延々とメールのやりとりを続けることなくミーティングのスケジュールを設定できます。", + "email_user_invite_subheading_team": "{{invitedBy}}から{{appName}}の「{{teamName}}」に参加するよう招待されました。{{appName}}はイベント調整スケジューラーで、チーム内で延々とメールのやりとりをすることなく、ミーティングのスケジュールを設定できます。", "email_no_user_invite_steps_intro": "いくつかの短い手順を踏むだけで、すぐに {{entity}} のストレスフリーなスケジュール設定をお楽しみいただけます。", "email_no_user_step_one": "ユーザー名を選択", "email_no_user_step_two": "カレンダーアカウントを接続", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "このイベントの種類の最初の Webhook を作成", "install_app_on": "次のアカウントにアプリをインストール", "create_for": "作成対象", - "setup_organization": "組織をセットアップする", "organization_banner_description": "チームがラウンドロビンスケジューリングや一括スケジューリングを活用して、共有アプリ、ワークフロー、イベントの種類を作成できる環境を作成します。", "organization_banner_title": "複数のチームを持つ組織を管理する", "set_up_your_organization": "組織をセットアップする", diff --git a/apps/web/public/static/locales/ko/common.json b/apps/web/public/static/locales/ko/common.json index 94e539998a..0e9406406c 100644 --- a/apps/web/public/static/locales/ko/common.json +++ b/apps/web/public/static/locales/ko/common.json @@ -21,7 +21,7 @@ "verify_email_email_link_text": "버튼 클릭이 마음에 들지 않는 경우 다음 링크를 사용하세요.", "email_sent": "이메일이 성공적으로 전송되었습니다", "event_declined_subject": "거절됨: {{date}} {{title}}", - "event_cancelled_subject": "취소됨: {{title}} 날짜 {{date}}", + "event_cancelled_subject": "취소됨: {{title}}, 날짜 {{date}}", "event_request_declined": "회의 요청이 거절되었습니다.", "event_request_declined_recurring": "되풀이 이벤트 요청이 거부되었습니다", "event_request_cancelled": "예약된 일정이 취소되었습니다", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "{{connectionType}} 구성을 삭제하시겠습니까? {{connectionType}} 로그인을 사용하는 팀원은 더 이상 Cal.com에 액세스할 수 없습니다.", "organizer_timezone": "주최자 시간대", "email_user_cta": "초대 보기", - "email_no_user_invite_heading": "{{appName}} {{entity}}에 참여 초대를 받으셨습니다", "email_no_user_invite_subheading": "{{invitedBy}} 님이 귀하를 {{appName}}에서 자신의 팀에 초대했습니다. {{appName}} 앱은 귀하와 귀하의 팀이 이메일을 주고 받지 않고 회의 일정을 잡을 수 있게 해주는 이벤트 정리 스케줄러입니다.", - "email_user_invite_subheading": "{{invitedBy}} 님이 {{appName}}에서 자신의 {{entity}} `{{teamName}}`에 귀하의 참여를 초대했습니다. {{appName}}은(는) 사용자와 {{entity}}이(가) 이메일을 주고 받지 않고도 회의 일정을 잡을 수 있게 해주는 이벤트 조정 스케줄러입니다.", + "email_user_invite_subheading_team": "{{invitedBy}}님이 {{appName}}에서 자신의 `{{teamName}}` 팀에 가입하도록 당신을 초대했습니다. {{appName}}은 유저와 팀이 이메일을 주고 받지 않고도 회의 일정을 잡을 수 있게 하는 이벤트 조율 스케줄러입니다.", "email_no_user_invite_steps_intro": "안내해 드릴 몇 가지 간단한 단계만 거치면 곧 스트레스 없이 편리하게 {{entity}} 일정을 관리할 수 있게 될 것입니다.", "email_no_user_step_one": "사용자 이름 선택", "email_no_user_step_two": "캘린더 계정 연결", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "이 이벤트 유형에 대한 첫 번째 웹훅 만들기", "install_app_on": "앱 설치 대상", "create_for": "작성 대상", - "setup_organization": "조직 설정", "organization_banner_description": "팀이 라운드 로빈 및 공동 예약을 통해 공유 앱, 워크플로 및 이벤트 유형을 생성할 수 있는 환경을 만듭니다.", "organization_banner_title": "여러 팀으로 조직 관리", "set_up_your_organization": "조직 설정", diff --git a/apps/web/public/static/locales/nl/common.json b/apps/web/public/static/locales/nl/common.json index f032e6c161..504585a88c 100644 --- a/apps/web/public/static/locales/nl/common.json +++ b/apps/web/public/static/locales/nl/common.json @@ -1663,9 +1663,8 @@ "delete_sso_configuration_confirmation_description": "Weet u zeker dat u de {{connectionType}}-configuratie wilt verwijderen? Uw teamleden die {{connectionType}}-aanmelding gebruiken hebben niet langer toegang tot Cal.com.", "organizer_timezone": "Tijdzone organisator", "email_user_cta": "Uitnodiging weergeven", - "email_no_user_invite_heading": "U bent uitgenodigd om lid te worden van {{appName}} {{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} heeft u uitgenodigd om lid te worden van zijn team op {{appName}}. {{appName}} is de gebeurtenissenplanner die u en uw team in staat stelt vergaderingen te plannen zonder heen en weer te e-mailen.", - "email_user_invite_subheading": "{{invitedBy}} heeft u uitgenodigd om lid te worden van zijn {{entity}} '{{teamName}}' op {{appName}}. {{appName}} is de gebeurtenissenplanner die u en uw {{entity}} in staat stelt afspraken te plannen zonder heen en weer te e-mailen.", + "email_user_invite_subheading_team": "{{invitedBy}} heeft u uitgenodigd om lid te worden van zijn team '{{teamName}}' op {{appName}}. {{appName}} is de gebeurtenissenplanner die u en uw team in staat stelt afspraken te plannen zonder heen en weer te e-mailen.", "email_no_user_invite_steps_intro": "We doorlopen samen een paar korte stappen en in een mum van tijd kunt u genieten van een stressvrije planning met uw {{entity}}.", "email_no_user_step_one": "Kies uw gebruikersnaam", "email_no_user_step_two": "Koppel uw agenda-account", @@ -1885,7 +1884,6 @@ "first_event_type_webhook_description": "Maak uw eerste webhook voor dit gebeurtenistype", "install_app_on": "App installeren op", "create_for": "Maken voor", - "setup_organization": "Een organisatie instellen", "organization_banner_description": "Maak een omgeving waarin uw teams gedeelde apps, werkstromen en gebeurtenistypen kunnen maken met round-robin en collectieve planning.", "organization_banner_title": "Beheer organisaties met meerdere teams", "set_up_your_organization": "Stel uw organisatie in", diff --git a/apps/web/public/static/locales/pl/common.json b/apps/web/public/static/locales/pl/common.json index 12690da345..606680d0de 100644 --- a/apps/web/public/static/locales/pl/common.json +++ b/apps/web/public/static/locales/pl/common.json @@ -12,7 +12,7 @@ "have_any_questions": "Masz pytania? Jesteśmy tutaj, aby pomóc.", "reset_password_subject": "{{appName}}: Instrukcje resetowania hasła", "verify_email_subject": "{{appName}}: zweryfikuj swoje konto", - "check_your_email": "Sprawdź swój adres e-mail", + "check_your_email": "Sprawdź swoją pocztę e-mail", "verify_email_page_body": "Wysłaliśmy wiadomość e-mail na adres {{email}}. Weryfikacja adresu e-mail jest ważna, ponieważ umożliwia zagwarantowanie prawidłowego działania kalendarza i dostarczania wiadomości e-mail z aplikacji {{appName}}.", "verify_email_banner_body": "Potwierdź adres e-mail, aby upewnić się, że wiadomości e-mail i powiadomienia z kalendarza będą do Ciebie docierać.", "verify_email_email_header": "Zweryfikuj swój adres e-mail", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Czy na pewno chcesz usunąć konfigurację {{connectionType}}? Członkowie zespołu, którzy używają logowania {{connectionType}} nie będą już mogli uzyskać dostępu do Cal.com.", "organizer_timezone": "Strefa czasowa organizatora", "email_user_cta": "Wyświetl zaproszenie", - "email_no_user_invite_heading": "Zaproszono Cię do dołączenia do aplikacji {{appName}} ({{entity}})", "email_no_user_invite_subheading": "Użytkownik {{invitedBy}} zaprasza Cię do dołączenia do jego zespołu w aplikacji {{appName}}. Aplikacja {{appName}} to terminarz do planowania wydarzeń, który umożliwi Tobie i Twojemu zespołowi planowanie spotkań bez czasochłonnej wymiany wiadomości e-mail.", - "email_user_invite_subheading": "{{invitedBy}} zaprasza Cię do dołączenia do: {{entity}} „{{teamName}}” w aplikacji {{appName}}. Aplikacja {{appName}} to terminarz do planowania wydarzeń, który umożliwi {{entity}} planowanie spotkań bez czasochłonnej wymiany wiadomości e-mail.", + "email_user_invite_subheading_team": "Użytkownik {{invitedBy}} zaprasza Cię do dołączenia do jego zespołu „{{teamName}}” w aplikacji {{appName}}. Aplikacja {{appName}} to terminarz do planowania wydarzeń, który umożliwi Tobie i Twojemu zespołowi planowanie spotkań bez czasochłonnej wymiany wiadomości e-mail.", "email_no_user_invite_steps_intro": "Przeprowadzimy Cię przez kilka krótkich kroków i wkrótce w ramach {{entity}} zaczniesz z przyjemnością korzystać z bezstresowego planowania.", "email_no_user_step_one": "Wybierz nazwę użytkownika", "email_no_user_step_two": "Połącz swoje konto kalendarza", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Utwórz swój pierwszy element webhook dla tego typu wydarzenia", "install_app_on": "Zainstaluj aplikację na koncie", "create_for": "Utwórz dla", - "setup_organization": "Skonfiguruj organizację", "organization_banner_description": "Utwórz środowisko, w którym zespoły będą mogły tworzyć wspólne aplikacje, przepływy pracy i typy wydarzeń przy użyciu algorytmu karuzelowego lub zbiorowego ustalania harmonogramu.", "organization_banner_title": "Zarządzaj organizacjami z wieloma zespołami", "set_up_your_organization": "Skonfiguruj swoją organizację", @@ -1889,7 +1887,7 @@ "admin_username": "Nazwa użytkownika administratora", "organization_name": "Nazwa organizacji", "organization_url": "Adres URL organizacji", - "organization_verify_header": "Zweryfikuj adres e-mail swojej organizacji", + "organization_verify_header": "Zweryfikuj swój adres e-mail w organizacji", "organization_verify_email_body": "Użyj poniższego kodu, aby zweryfikować adres e-mail i kontynuować konfigurację swojej organizacji.", "additional_url_parameters": "Dodatkowe parametry adresu URL", "about_your_organization": "Informacje o Twojej organizacji", diff --git a/apps/web/public/static/locales/pt-BR/common.json b/apps/web/public/static/locales/pt-BR/common.json index fc44597c36..e8be958c9a 100644 --- a/apps/web/public/static/locales/pt-BR/common.json +++ b/apps/web/public/static/locales/pt-BR/common.json @@ -1675,9 +1675,8 @@ "delete_sso_configuration_confirmation_description": "Você tem certeza que deseja remover a configuração de {{connectionType}}? Os membros do seu time que utilizam {{connectionType}} para fazer login não conseguirão acessar o Cal.com.", "organizer_timezone": "Fuso horário do organizador", "email_user_cta": "Ver convite", - "email_no_user_invite_heading": "Você recebeu um convite para ingressar em {{entity}} de {{appName}}", "email_no_user_invite_subheading": "Você recebeu um convite de {{invitedBy}} para ingressar em sua equipe em {{appName}}. {{appName}} é um agendador que concilia eventos e permite que sua equipe agende reuniões sem precisar trocar e-mails.", - "email_user_invite_subheading": "Você recebeu um convite de {{invitedBy}} para ingressar na equipe \"{{teamName}}\" de {{entity}} em {{appName}}. {{appName}} é um agendador que concilia eventos e permite que {{entity}} agende reuniões sem precisar trocar e-mails.", + "email_user_invite_subheading_team": "Você recebeu um convite de {{invitedBy}} para ingressar na equipe \"{{teamName}}\" em {{appName}}. {{appName}} é um agendador que concilia eventos e permite que sua equipe agende reuniões sem precisar trocar e-mails.", "email_no_user_invite_steps_intro": "Orientaremos ao longo de alguns passos rápidos para que você comece logo a agendar eventos com {{entity}} sem preocupação.", "email_no_user_step_one": "Escolha seu nome de usuário", "email_no_user_step_two": "Conecte com sua conta de calendário", @@ -1898,7 +1897,6 @@ "install_app_on": "Instalar aplicativo em", "create_for": "Criar para", "currency": "Moeda", - "setup_organization": "Definir uma Organização", "organization_banner_description": "Crie um ambiente onde suas equipes podem criar tipos de evento, fluxos de trabalho e aplicativos compartilhados com agendamento coletivo e round robin.", "organization_banner_title": "Gerenciar organizações com múltiplas equipes", "set_up_your_organization": "Configurar sua organização", diff --git a/apps/web/public/static/locales/pt/common.json b/apps/web/public/static/locales/pt/common.json index d639b327ff..7ee5e2676c 100644 --- a/apps/web/public/static/locales/pt/common.json +++ b/apps/web/public/static/locales/pt/common.json @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Tem a certeza que pretende eliminar a configuração {{connectionType}}? Os membros da sua equipa que utilizem {{connectionType}} para o início de sessão deixarão de poder aceder a Cal.com.", "organizer_timezone": "Fuso horário do organizador", "email_user_cta": "Ver convite", - "email_no_user_invite_heading": "Foi convidado para se juntar a um {{entity}} {{appName}}", "email_no_user_invite_subheading": "Recebeu um convite de {{invitedBy}} para fazer parte da respetiva equipa em {{appName}}. {{appName}} é um ferramenta de conciliação e agendamento de eventos que lhe permite a si e à sua equipa agendar reuniões sem o pingue-pongue de e-mails.", - "email_user_invite_subheading": "Recebeu um convite de {{invitedBy}} para fazer parte da sua {{entity}} `{{teamName}}` em {{appName}}. {{appName}} é uma ferramenta de conciliação e agendamento de eventos que lhe permite a si e à sua {{entity}} agendar reuniões sem o pingue-pongue de mensagens eletrónicas.", + "email_user_invite_subheading_team": "Recebeu um convite de {{invitedBy}} para fazer parte da equipa `{{teamName}}` em {{appName}}. {{appName}} é uma ferramenta de conciliação e agendamento de eventos que lhe permite a si e à sua equipa agendar reuniões sem o pingue-pongue de mensagens eletrónicas.", "email_no_user_invite_steps_intro": "Vamos ajudar com alguns pequenos passos para que você e a sua {{entity}} possam desfrutar rapidamente de uma gestão de agendamentos sem complicações.", "email_no_user_step_one": "Escolha o seu nome de utilizador", "email_no_user_step_two": "Associe a sua conta de calendário", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Crie o seu primeiro webhook para este tipo de evento", "install_app_on": "Instalar aplicação em", "create_for": "Criar para", - "setup_organization": "Configurar uma Organização", "organization_banner_description": "Crie um ambiente onde as suas equipas possam criar aplicações partilhadas, fluxos de trabalho e tipos de eventos com distribuição equilibrada e agendamento coletivo.", "organization_banner_title": "Faça a gestão de múltiplas organizações com múltiplas equipas", "set_up_your_organization": "Configurar a sua organização", diff --git a/apps/web/public/static/locales/ro/common.json b/apps/web/public/static/locales/ro/common.json index d0591eac04..b62d6c3630 100644 --- a/apps/web/public/static/locales/ro/common.json +++ b/apps/web/public/static/locales/ro/common.json @@ -118,7 +118,7 @@ "team_info": "Informații echipă", "request_another_invitation_email": "Dacă preferați să nu utilizați {{toEmail}} ca e-mail {{appName}} sau aveți deja un cont {{appName}}, vă rugăm să solicitați o altă invitație la acel e-mail.", "you_have_been_invited": "Ați fost invitat să vă alăturați echipei {{teamName}}", - "user_invited_you": "{{user}} v-a invitat să faceți parte din {{entity}} {{team}} de pe {{appName}}", + "user_invited_you": "{{user}} v-a invitat să faceți parte din {{entity}} {{team}} pe {{appName}}", "hidden_team_member_title": "Sunteți ascuns în această echipă", "hidden_team_member_message": "Licența dvs. nu este plătită. Fie faceți upgrade la Pro, fie anunțați proprietarul echipei că vă poate plăti licența.", "hidden_team_owner_message": "Aveți nevoie de un cont Pro pentru a utiliza echipe. Sunteți ascuns până când faceți upgrade.", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Sigur doriți să ștergeți configurația {{connectionType}}? Membrii echipei dvs. care utilizează conectarea prin {{connectionType}} nu vor mai putea accesa Cal.com.", "organizer_timezone": "Fusul orar al organizatorului", "email_user_cta": "Vizualizare invitație", - "email_no_user_invite_heading": "Ați fost invitat să faceți parte dintr-o {{entity}} {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} v-a invitat să faceți parte din echipa sa de pe {{appName}}. {{appName}} este un instrument de planificare a evenimentelor, care vă permite dvs. și echipei dvs. să programați ședințe fără a face ping-pong prin e-mail.", - "email_user_invite_subheading": "{{invitedBy}} v-a invitat să faceți parte din {{entity}} „{{teamName}}” de pe {{appName}}. {{appName}} este un instrument de planificare a evenimentelor, prin care dvs. și {{entity}} dvs. puteți să programați ședințe fără a face ping-pong prin e-mail.", + "email_user_invite_subheading_team": "{{invitedBy}} v-a invitat să faceți parte din echipa sa „{{teamName}}” de pe {{appName}}. {{appName}} este un instrument de planificare a evenimentelor, care vă permite dvs. și echipei dvs. să programați ședințe fără a face ping-pong prin e-mail.", "email_no_user_invite_steps_intro": "Vom parcurge împreună câțiva pași simpli și vă veți bucura alături de {{entity}} dvs. de programări fără probleme, în cel mai scurt timp.", "email_no_user_step_one": "Alegeți-vă numele de utilizator", "email_no_user_step_two": "Conectați-vă contul de calendar", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Creați primul dvs. webhook pentru acest tip de eveniment", "install_app_on": "Instalați aplicația în", "create_for": "Creare pentru", - "setup_organization": "Configurarați o organizație", "organization_banner_description": "Dezvoltați medii în care echipele dvs. să poată crea aplicații, fluxuri de lucru și tipuri de evenimente comune, folosind programarea colectivă sau cu alocare prin rotație.", "organization_banner_title": "Gestionați organizații cu mai multe echipe", "set_up_your_organization": "Configurați organizația", diff --git a/apps/web/public/static/locales/ru/common.json b/apps/web/public/static/locales/ru/common.json index fc6ab5fc0c..ffdad17293 100644 --- a/apps/web/public/static/locales/ru/common.json +++ b/apps/web/public/static/locales/ru/common.json @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Удалить конфигурацию {{connectionType}}? Участники команды, входящие в Cal.com через {{connectionType}}, больше не смогут получить к нему доступ.", "organizer_timezone": "Часовой пояс организатора", "email_user_cta": "Посмотреть приглашение", - "email_no_user_invite_heading": "Вас пригласили в {{entity}} {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} пригласил(-а) вас в команду в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого пользователи и целые команды могут планировать встречи без утомительной переписки по электронной почте.", - "email_user_invite_subheading": "{{invitedBy}} пригласил(-а) вас в {{entity}} `{{teamName}}` в {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого вы и ваша {{entity}} можете планировать встречи без утомительной переписки по электронной почте.", + "email_user_invite_subheading_team": "{{invitedBy}} пригласил(а) вас в команду в `{{teamName}}` в приложении {{appName}}. {{appName}} — это гибкий планировщик событий, с помощью которого пользователи и целые команды могут планировать встречи без утомительной переписки по электронной почте.", "email_no_user_invite_steps_intro": "Всего несколько шагов — и вы сможете оперативно и без стресса планировать встречи в рамках вашей {{entity}}.", "email_no_user_step_one": "Выберите имя пользователя", "email_no_user_step_two": "Подключите аккаунт календаря", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Создайте первый вебхук для этого типа событий", "install_app_on": "Установить приложение в", "create_for": "Создать для", - "setup_organization": "Настроите организацию", "organization_banner_description": "Создавайте рабочие среды, в рамках которых ваши команды смогут создавать общие приложения, рабочие процессы и типы событий с назначением участников по очереди и коллективным планированием.", "organization_banner_title": "Управляйте организациями с несколькими командами", "set_up_your_organization": "Настройте организацию", diff --git a/apps/web/public/static/locales/sr/common.json b/apps/web/public/static/locales/sr/common.json index 0bc4e29495..a6ad7d83ff 100644 --- a/apps/web/public/static/locales/sr/common.json +++ b/apps/web/public/static/locales/sr/common.json @@ -1660,9 +1660,8 @@ "delete_sso_configuration_confirmation_description": "Da li ste sigurni da želite da izbrišete {{connectionType}} konfiguraciju? Članovi vašeg tima koji koriste {{connectionType}} prijavljivanje više neće moći da pristupe Cal.com-u.", "organizer_timezone": "Vremenska zona organizatora", "email_user_cta": "Prikaži pozivnicu", - "email_no_user_invite_heading": "Pozvani ste da se pridružite {{appName}} {{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} vas je pozvao/la da se pridružite njihovom timu u {{appName}}. {{appName}} je planer za koordinaciju događaja koji omogućava vama i vašem timu da zakazujete sastanke bez dopisivanja imejlovima.", - "email_user_invite_subheading": "{{invitedBy}} vas je pozvao/la da se pridružite {{entity}} `{{teamName}}` na {{appName}}. {{appName}} je uređivač rasporeda koji omogućava vama i vašem {{entity}} da zakažete sastanke bez razmene imejlova.", + "email_user_invite_subheading_team": "{{invitedBy}} vas je pozvao/la da se pridružite njihovom timu `{{teamName}}` u {{appName}}. {{appName}} je planer za koordinaciju događaja koji omogućava vama i vašem timu da zakazujete sastanke bez dopisivanja imejlovima.", "email_no_user_invite_steps_intro": "Provešćemo vas u nekoliko kratkiih koraka i za tren ćete uživati u zakazivanju bez stresa sa vašim {{entity}}.", "email_no_user_step_one": "Izaberite korisničko ime", "email_no_user_step_two": "Povežite se sa nalogom kalendara", @@ -1879,7 +1878,6 @@ "first_event_type_webhook_description": "Napravite svoj prvi webhook za ovaj tip događaja", "install_app_on": "Instaliraj aplikaciju", "create_for": "Napravi za", - "setup_organization": "Postavi organizaciju", "organization_banner_description": "Kreirajte okruženje gde vaši timovi mogu da postave deljene aplikacije, radne tokove i vrste događaja sa kružnom dodelom i zajedničko zakazivanje.", "organization_banner_title": "Upravljajte organizacijama sa više timova", "set_up_your_organization": "Postavite svoju organizaciju", diff --git a/apps/web/public/static/locales/sv/common.json b/apps/web/public/static/locales/sv/common.json index 2b67373ec6..8293ce4cd9 100644 --- a/apps/web/public/static/locales/sv/common.json +++ b/apps/web/public/static/locales/sv/common.json @@ -21,7 +21,7 @@ "verify_email_email_link_text": "Här är länken om du inte gillar att klicka på knappar:", "email_sent": "E-postmeddelandet har skickats", "event_declined_subject": "Avvisades: {{title}} kl. {{date}}", - "event_cancelled_subject": "Avbröts: {{title}} {{date}}", + "event_cancelled_subject": "Avbokad: {{title}} {{date}}", "event_request_declined": "Din bokningsförfrågan har avbjöts", "event_request_declined_recurring": "Din återkommande händelse har avböjts", "event_request_cancelled": "Din schemalagda bokning ställdes in", @@ -306,7 +306,7 @@ "password_has_been_reset_login": "Ditt lösenord har återställts. Du kan nu logga in med ditt nyskapade lösenord.", "layout": "Layout", "bookerlayout_default_title": "Standardvy", - "bookerlayout_description": "Du kan välja flera och dina bokare kan byta vy.", + "bookerlayout_description": "Du kan välja flera och dina bokare kan byta vyer.", "bookerlayout_user_settings_title": "Bokningslayout", "bookerlayout_user_settings_description": "Du kan välja flera och bokare kan byta vy. Detta kan åsidosättas för varje enskild händelse.", "bookerlayout_month_view": "Månad", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Vill du verkligen ta bort {{connectionType}}-konfigurationen? Dina teammedlemmar som använder {{connectionType}}-inloggning kan inte längre komma åt Cal.com.", "organizer_timezone": "Organisatörens tidszon", "email_user_cta": "Visa inbjudan", - "email_no_user_invite_heading": "Du har blivit inbjuden att gå med i en/ett {{appName}}-{{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} har bjudit in dig att gå med i sitt team på {{appName}}. {{appName}} är en händelsejonglerande schemaläggare som du och ditt team kan använda för att planera möten utan att skicka e-post fram och tillbaka.", - "email_user_invite_subheading": "{{invitedBy}} har bjudit in dig att gå med i deras {{entity}} '{{teamName}}' på {{appName}}. {{appName}} är schemaläggaren som gör det möjligt för dig och din/ditt {{entity}} att schemalägga möten utan att skicka e-post fram och tillbaka.", + "email_user_invite_subheading_team": "{{invitedBy}} har bjudit in dig att gå med i sitt team {{teamName}} den {{appName}}. {{appName}} är en händelsejonglerande schemaläggare som du och ditt team kan använda för att planera möten utan att skicka e-post fram och tillbaka.", "email_no_user_invite_steps_intro": "Vi guidar dig genom några korta steg och du kommer att kunna schemalägga med din/ditt {{entity}} på nolltid utan stress.", "email_no_user_step_one": "Välj ditt användarnamn", "email_no_user_step_two": "Anslut ditt kalenderkonto", @@ -1687,7 +1686,7 @@ "attendee_no_longer_attending": "En deltagare deltar inte längre i din händelse", "attendee_no_longer_attending_subtitle": "{{name}} har avbokat. Detta innebär att en plats har blivit ledig för denna tid", "create_event_on": "Skapa händelse i", - "create_routing_form_on": "Skapa omdirigeringsformulär", + "create_routing_form_on": "Skapa omdirigeringsformulär på", "default_app_link_title": "Skapa en standardapplänk", "default_app_link_description": "Om du skapar en standardapplänk kan alla händelsetyper som nyligen skapats använda den applänk som du har angett.", "organizer_default_conferencing_app": "Arrangörens standardapp", @@ -1878,18 +1877,17 @@ "first_event_type_webhook_description": "Skapa din första webhook för denna händelsetyp", "install_app_on": "Installera appen på", "create_for": "Skapa för", - "setup_organization": "Konfigurera en organisation", - "organization_banner_description": "Skapa en miljö där dina team kan skapa delade appar, arbetsflöden och händelsetyper med round-robin och kollektiv schemaläggning.", + "organization_banner_description": "Skapa miljöer där dina team kan skapa delade appar, arbetsflöden och händelsetyper med round-robin och kollektiv schemaläggning.", "organization_banner_title": "Hantera organisationer med flera team", "set_up_your_organization": "Konfigurera din organisation", "organizations_description": "Organisationer är delade miljöer där team kan skapa delade händelsetyper, appar, arbetsflöden och mycket mer.", "must_enter_organization_name": "Ett organisationsnamn måste anges", "must_enter_organization_admin_email": "En organisations e-postadress måste anges", - "admin_email": "Din organisations e-postadress", + "admin_email": "Din e-postadress i organisationen", "admin_username": "Administratörens användarnamn", "organization_name": "Organisationens namn", "organization_url": "Organisationens URL", - "organization_verify_header": "Verifiera din organisations e-postadress", + "organization_verify_header": "Verifiera din e-postadress i organisationen", "organization_verify_email_body": "Använd koden nedan för att verifiera din e-postadress så att du kan fortsätta att konfigurera din organisation.", "additional_url_parameters": "Ytterligare URL-parametrar", "about_your_organization": "Om din organisation", @@ -1940,5 +1938,5 @@ "insights_user_filter": "Användare: {{userName}}", "insights_subtitle": "Visa Insights-bokningar för dina händelser", "custom_plan": "Anpassad plan", - "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Lägg till dina nya strängar här ovanför ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" + "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/tr/common.json b/apps/web/public/static/locales/tr/common.json index 97286b0d8a..c2199b453b 100644 --- a/apps/web/public/static/locales/tr/common.json +++ b/apps/web/public/static/locales/tr/common.json @@ -130,7 +130,7 @@ "upgrade_banner_action": "Buradan yükseltin", "team_upgraded_successfully": "Ekibiniz başarıyla yükseltildi!", "org_upgrade_banner_description": "Yeni Organization planımızı denediğiniz için teşekkür ederiz. \"{{teamName}}\" adlı Kuruluşunuzun planının yükseltilmesi gerektiğini fark ettik.", - "org_upgraded_successfully": "Kuruluşunuz başarıyla yükseltildi!", + "org_upgraded_successfully": "Organization'ınız başarıyla yükseltildi!", "use_link_to_reset_password": "Şifrenizi sıfırlamak için aşağıdaki bağlantıyı kullanın", "hey_there": "Selam,", "forgot_your_password_calcom": "Şifrenizi mi unuttunuz? - {{appName}}", @@ -551,7 +551,7 @@ "team_description": "Birkaç cümleyle ekibinizden bahsedin. Bu, ekibinizin URL sayfasında görünecektir.", "org_description": "Birkaç cümleyle kuruluşunuzdan bahsedin. Bu, kuruluşunuzun URL sayfasında görünecektir.", "members": "Üyeler", - "organization_members": "Kuruluş üyeleri", + "organization_members": "Organization üyeleri", "member": "Üye", "number_member_one": "{{count}} üye", "number_member_other": "{{count}} üye", @@ -662,7 +662,7 @@ "event_type_updated_successfully": "{{eventTypeTitle}} etkinlik türü başarıyla güncellendi", "event_type_deleted_successfully": "Etkinlik türü başarıyla silindi", "hours": "Saat", - "people": "İnsan", + "people": "İnsanlar", "your_email": "E-postanız", "change_avatar": "Avatarı değiştir", "language": "Dil", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "{{connectionType}} yapılandırmasını silmek istediğinizden emin misiniz? {{connectionType}} girişini kullanan ekip üyeleriniz artık Cal.com'a erişemeyecek.", "organizer_timezone": "Organizatörün saat dilimi", "email_user_cta": "Daveti görüntüle", - "email_no_user_invite_heading": "{{appName}} {{entity}} grubuna katılmaya davet edildiniz", "email_no_user_invite_subheading": "{{invitedBy}}, sizi {{appName}} ekibine katılmaya davet etti. {{appName}}, size ve ekibinize e-posta iletişimine ihtiyaç duymadan toplantı planlama yapma olanağı sağlayan bir etkinlik planlayıcıdır.", - "email_user_invite_subheading": "{{invitedBy}}, sizi {{appName}} uygulamasındaki {{entity}} `{{teamName}}` ekibine katılmaya davet etti. {{appName}}, size ve {{entity}} ekibinize e-posta iletişimine ihtiyaç duymadan toplantı planlama yapma olanağı sağlayan bir etkinlik planlayıcıdır.", + "email_user_invite_subheading_team": "{{invitedBy}}, sizi {{appName}} uygulamasındaki `{{teamName}}` ekibine katılmaya davet etti. {{appName}}, size ve ekibinize e-posta iletişimine ihtiyaç duymadan toplantı planlama yapma olanağı sağlayan bir etkinlik planlayıcıdır.", "email_no_user_invite_steps_intro": "{{entity}} ekibinizle birlikte kısa sürede ve sorunsuz planlama yapmanın keyfini çıkarmanız için birkaç adımda size rehberlik edeceğiz.", "email_no_user_step_one": "Kullanıcı adınızı seçin", "email_no_user_step_two": "Takvim hesabınızı bağlayın", @@ -1876,9 +1875,8 @@ "connect_google_workspace": "Google Workspace'i bağlayın", "google_workspace_admin_tooltip": "Bu özelliği kullanmak için Çalışma Alanı Yöneticisi olmalısınız", "first_event_type_webhook_description": "Bu etkinlik türü için ilk web kancanızı oluşturun", - "install_app_on": "Uygulamayı şurada yükle:", + "install_app_on": "Uygulamayı şuraya yükle:", "create_for": "Oluşturun", - "setup_organization": "Kuruluş oluşturun", "organization_banner_description": "Ekiplerinizin, döngüsel ve toplu planlama ile paylaşılan uygulamalar, iş akışları ve olay türleri oluşturabileceği bir ortam oluşturun.", "organization_banner_title": "Kuruluşları birden çok ekiple yönetin", "set_up_your_organization": "Kuruluşunuzu düzenleyin", diff --git a/apps/web/public/static/locales/uk/common.json b/apps/web/public/static/locales/uk/common.json index acd64b9308..0c05867f25 100644 --- a/apps/web/public/static/locales/uk/common.json +++ b/apps/web/public/static/locales/uk/common.json @@ -551,7 +551,7 @@ "team_description": "Кілька речень про вашу команду. Ця інформація з’явиться на сторінці за URL-адресою вашої команди.", "org_description": "Кілька речень про вашу організацію. Ця інформація з’явиться на сторінці за URL-адресою вашої організації.", "members": "Учасники", - "organization_members": "Учасники організації", + "organization_members": "Учасники Organization", "member": "Учасник", "number_member_one": "{{count}} учасник", "number_member_other": "Учасників: {{count}}", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Справді видалити конфігурацію {{connectionType}}? Учасники вашої команди, які входять через {{connectionType}}, не зможуть ввійти в Cal.com.", "organizer_timezone": "Часовий пояс організатора", "email_user_cta": "Переглянути запрошення", - "email_no_user_invite_heading": "Вас запрошено приєднатися: {{entity}} в застосунку {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} запрошує вас приєднатися до команди в {{appName}}. {{appName}} — планувальник подій, який дає змогу вам і вашій команді планувати зустрічі без тривалої переписки електронною поштою.", - "email_user_invite_subheading": "{{invitedBy}} запрошує вас приєднатися до {{entity}} «{{teamName}}» в застосунку {{appName}}. {{appName}} — планувальник подій, завдяки якому ви й ваша {{entity}} можете планувати наради без довгого листування.", + "email_user_invite_subheading_team": "{{invitedBy}} запрошує вас приєднатися до команди «{{teamName}}» в {{appName}}. {{appName}} — планувальник подій, який дає змогу вам і вашій команді планувати зустрічі без тривалої переписки електронною поштою.", "email_no_user_invite_steps_intro": "Ми ознайомимо вас із застосунком, і вже скоро ви й ваша {{entity}} насолоджуватиметеся плануванням без жодного клопоту.", "email_no_user_step_one": "Виберіть ім’я користувача", "email_no_user_step_two": "Підключіть обліковий запис календаря", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Створіть свій перший вебгук для цього типу заходів", "install_app_on": "Установити застосунок у", "create_for": "Створити для", - "setup_organization": "Налаштувати організацію", "organization_banner_description": "Підготуйте середовища, де ваші команди можуть створювати спільні застосунки, робочі процеси й типи заходів із циклічним і колективним плануванням.", "organization_banner_title": "Керування організаціями з кількома командами", "set_up_your_organization": "Налаштуйте свою організацію", @@ -1938,7 +1936,7 @@ "insights_all_org_filter": "Усі", "insights_team_filter": "Команда: {{teamName}}", "insights_user_filter": "Користувач: {{userName}}", - "insights_subtitle": "Переглядайте аналітичні дані про бронювання для своїх заходів", + "insights_subtitle": "Переглядайте дані Insights про бронювання для своїх заходів", "custom_plan": "Користувацький план", - "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Додайте нові рядки вище ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" + "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/vi/common.json b/apps/web/public/static/locales/vi/common.json index b16f03ba66..a8b2542174 100644 --- a/apps/web/public/static/locales/vi/common.json +++ b/apps/web/public/static/locales/vi/common.json @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "Bạn có chắc chắn muốn xóa cấu hình {{connectionType}} không? Các thành viên trong nhóm của bạn sử dụng thông tin đăng nhập {{connectionType}} sẽ không thể truy cập vào Cal.com được nữa.", "organizer_timezone": "Múi giờ của người tổ chức", "email_user_cta": "Xem lời mời", - "email_no_user_invite_heading": "Bạn đã được mời gia nhập {{entity}} {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} đã mời bạn gia nhập nhóm của họ trên {{appName}}. {{appName}} là công cụ lên lịch sắp xếp sự kiện cho phép bạn và nhóm bạn lên lịch các cuộc gặp mà không cần trao đổi email nhiều.", - "email_user_invite_subheading": "{{invitedBy}} đã mời bạn gia nhập {{entity}} '{{teamName}}' của họ trên {{appName}}. {{appName}} là công cụ lên lịch sắp xếp sự kiện cho phép bạn và {{entity}} của bạn lên lịch các cuộc gặp mà không cần trao đổi email nhiều.", + "email_user_invite_subheading_team": "{{invitedBy}} đã mời bạn gia nhập nhóm '{{teamName}}' của họ trên {{appName}}. {{appName}} là công cụ lên lịch sắp xếp sự kiện cho phép bạn và nhóm bạn lên lịch các cuộc gặp mà không cần trao đổi email nhiều.", "email_no_user_invite_steps_intro": "Chúng tôi sẽ hướng dẫn cho bạn qua vài bước nhỏ và bạn sẽ tận hưởng được ngay cảm giác thoải mái không căng thẳng trong việc lên lịch cùng {{entity}} của mình.", "email_no_user_step_one": "Chọn tên người dùng của bạn", "email_no_user_step_two": "Kết nối tài khoản lịch của bạn", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "Tạo webhook đầu tiên của bạn cho loại sự kiện này", "install_app_on": "Cài đặt ứng dụng cho", "create_for": "Tạo cho", - "setup_organization": "Thiết lập một Tổ chức", "organization_banner_description": "Tạo môi trường tại đó các nhóm của bạn có thể tạo những ứng dụng, tiến độ công việc và loại sự kiện mà có thể chia sẻ được, với chức năng đặt lịch dạng round-robin và tập thể.", "organization_banner_title": "Quản lý các tổ chức có nhiều nhóm", "set_up_your_organization": "Thiết lập tổ chức của bạn", diff --git a/apps/web/public/static/locales/zh-CN/common.json b/apps/web/public/static/locales/zh-CN/common.json index 70368c4d67..4b82558d4b 100644 --- a/apps/web/public/static/locales/zh-CN/common.json +++ b/apps/web/public/static/locales/zh-CN/common.json @@ -1663,9 +1663,8 @@ "delete_sso_configuration_confirmation_description": "确定要删除 {{connectionType}} 配置吗?使用 {{connectionType}} 登录的团队成员将无法再访问 Cal.com。", "organizer_timezone": "组织者时区", "email_user_cta": "查看邀请", - "email_no_user_invite_heading": "您已被邀请加入 {{appName}} {{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} 已邀请您加入他们在 {{appName}} 上的团队。{{appName}} 是一个活动安排调度程序,让您和您的团队无需通过电子邮件沟通即可安排会议。", - "email_user_invite_subheading": "{{invitedBy}} 已邀请您加入他们在 {{appName}} 上的 {{entity}}“{{teamName}}”。{{appName}} 是一个活动日程安排程序,让您和您的 {{entity}} 无需通过电子邮件沟通即可安排会议。", + "email_user_invite_subheading_team": "{{invitedBy}} 已邀请您加入他们在 {{appName}} 上的团队“{{teamName}}”。{{appName}} 是一个活动安排调度程序,让您和您的团队无需通过电子邮件沟通即可安排会议。", "email_no_user_invite_steps_intro": "我们将引导您完成几个简短步骤,您将立即与您的 {{entity}} 一起享受无压力的日程安排。", "email_no_user_step_one": "选择您的用户名", "email_no_user_step_two": "连接您的日历账户", @@ -1882,7 +1881,6 @@ "first_event_type_webhook_description": "为此活动类型创建第一个 Webhook", "install_app_on": "安装应用的账户", "create_for": "创建", - "setup_organization": "设置组织", "organization_banner_description": "创建环境,以便让您的团队可以在其中通过轮流和集体日程安排来创建共享的应用、工作流程和活动类型。", "organization_banner_title": "管理具有多个团队的组织", "set_up_your_organization": "设置您的组织", diff --git a/apps/web/public/static/locales/zh-TW/common.json b/apps/web/public/static/locales/zh-TW/common.json index 9f3b804b00..79b67099a9 100644 --- a/apps/web/public/static/locales/zh-TW/common.json +++ b/apps/web/public/static/locales/zh-TW/common.json @@ -89,7 +89,7 @@ "event_has_been_rescheduled": "已更新 - 已經重新預定活動時間", "request_reschedule_subtitle": "{{organizer}} 已取消預約,並要求您選擇其他時間。", "request_reschedule_title_organizer": "您已要求 {{attendee}} 重新預約", - "request_reschedule_subtitle_organizer": "您已取消預約,{{attendee}} 應和您重新挑選新的預約時間。", + "request_reschedule_subtitle_organizer": "您已取消預約,{{attendee}} 應和您重新挑選預約時間。", "rescheduled_event_type_subject": "已送出重新預定的申請:在 {{date}} 與 {{name}} 的 {{eventType}}", "requested_to_reschedule_subject_attendee": "需進行重新預約操作:請為與 {{name}} 的 {{eventType}} 預訂新時間", "hi_user_name": "哈囉 {{name}}", @@ -306,7 +306,7 @@ "password_has_been_reset_login": "密碼已經重置。現在即可使用新建立的密碼登入。", "layout": "版面", "bookerlayout_default_title": "預設檢視", - "bookerlayout_description": "您可以選取多個版面,以便預約者切換檢視。", + "bookerlayout_description": "您可以選取多種檢視方式供預約者切換。", "bookerlayout_user_settings_title": "預約版面", "bookerlayout_user_settings_description": "您可以選取多個版面,以便預約者切換檢視。您可根據不同活動來覆寫此設定。", "bookerlayout_month_view": "月", @@ -1659,9 +1659,8 @@ "delete_sso_configuration_confirmation_description": "確定要刪除 {{connectionType}} 設定?使用 {{connectionType}} 登入的團隊成員將再也無法存取 Cal.com。", "organizer_timezone": "主辦者時區", "email_user_cta": "檢視邀請", - "email_no_user_invite_heading": "您已獲邀加入 {{appName}} {{entity}}", "email_no_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的團隊。{{appName}} 是活動多工排程工具,可讓您和您的團隊無須透過繁複的電子郵件往來就能輕鬆預定會議。", - "email_user_invite_subheading": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的「{{teamName}}」{{entity}}。{{appName}} 為一款靈活的日程管理工具,可讓您和 {{entity}} 輕鬆預定會議,而無須透過繁複的電子郵件往來。", + "email_user_invite_subheading_team": "{{invitedBy}} 已邀請您加入對方在 {{appName}} 上的「{{teamName}}」團隊。{{appName}} 為一款活動多功能排程工具,可讓您和團隊無須透過繁複的電子郵件往來,就能輕鬆預定會議。", "email_no_user_invite_steps_intro": "我們會逐步引導您完成幾個簡短的步驟,然後您就能馬上與 {{entity}} 享受沒有壓力的排程預定功能。", "email_no_user_step_one": "選擇使用者名稱", "email_no_user_step_two": "連結行事曆帳號", @@ -1687,7 +1686,7 @@ "attendee_no_longer_attending": "一位與會者不再參加您的活動", "attendee_no_longer_attending_subtitle": "{{name}} 已取消,這代表此時段空出了一個座位", "create_event_on": "活動建立日期", - "create_routing_form_on": "引導表單的建立位置", + "create_routing_form_on": "引導表單的建立帳戶", "default_app_link_title": "設定預設應用程式連結", "default_app_link_description": "設定預設應用程式連結,即可讓所有全新建立的活動類型使用您設定的應用程式連結。", "organizer_default_conferencing_app": "主辦者的預設應用程式", @@ -1841,7 +1840,7 @@ "invite_link_copied": "邀請連結已複製", "invite_link_deleted": "邀請連結已刪除", "invite_link_updated": "邀請連結設定已儲存", - "link_expires_after": "連結設定效期…", + "link_expires_after": "將連結效期設為…", "one_day": "1 天", "seven_days": "7 天", "thirty_days": "30 天", @@ -1878,7 +1877,6 @@ "first_event_type_webhook_description": "為此活動類型建立第一個 Webhook", "install_app_on": "應用程式安裝帳號", "create_for": "建立", - "setup_organization": "設定組織", "organization_banner_description": "建立環境,供團隊利用輪流制和全體預定功能建立共用的應用程式、工作流程和活動類型。", "organization_banner_title": "管理有多個團隊的組織", "set_up_your_organization": "設定組織", @@ -1938,7 +1936,7 @@ "insights_all_org_filter": "所有應用程式", "insights_team_filter": "團隊:{{teamName}}", "insights_user_filter": "使用者:{{userName}}", - "insights_subtitle": "查看所有活動的預約洞見", + "insights_subtitle": "查看所有活動的預約 Insight", "custom_plan": "自訂方案", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 請在此處新增您的字串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/fixtures/fixtures.ts b/apps/web/test/fixtures/fixtures.ts new file mode 100644 index 0000000000..121c188bb3 --- /dev/null +++ b/apps/web/test/fixtures/fixtures.ts @@ -0,0 +1,20 @@ +// my-test.ts +import { test as base } from "vitest"; + +import { getTestEmails } from "@calcom/lib/testEmails"; + +export interface Fixtures { + emails: ReturnType; +} + +export const test = base.extend({ + emails: async ({}, use) => { + await use(getEmailsFixture()); + }, +}); + +function getEmailsFixture() { + return { + get: getTestEmails, + }; +} diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index c7205a5ee3..7a7b5edf1b 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -1,23 +1,14 @@ -import type { - EventType as PrismaEventType, - User as PrismaUser, - Booking as PrismaBooking, - App as PrismaApp, -} from "@prisma/client"; - import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager"; import prismaMock from "../../../../tests/libs/__mocks__/prisma"; import { diff } from "jest-diff"; -import { v4 as uuidv4 } from "uuid"; import { describe, expect, vi, beforeEach, afterEach, test } from "vitest"; -import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; -import type { SchedulingType } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; +import { getDate, getGoogleCalendarCredential, createBookingScenario} from "../utils/bookingScenario"; // TODO: Mock properly prismaMock.eventType.findUnique.mockResolvedValue(null); @@ -129,6 +120,7 @@ const TestData = { }, users: { example: { + name: "Example", username: "example", defaultScheduleId: 1, email: "example@example.com", @@ -151,63 +143,6 @@ const TestData = { }, }; -type App = { - slug: string; - dirName: string; -}; - -type InputCredential = typeof TestData.credentials.google; - -type InputSelectedCalendar = typeof TestData.selectedCalendars.google; - -type InputUser = typeof TestData.users.example & { id: number } & { - credentials?: InputCredential[]; - selectedCalendars?: InputSelectedCalendar[]; - schedules: { - id: number; - name: string; - availability: { - userId: number | null; - eventTypeId: number | null; - days: number[]; - startTime: Date; - endTime: Date; - date: string | null; - }[]; - timeZone: string; - }[]; -}; - -type InputEventType = { - id: number; - title?: string; - length?: number; - offsetStart?: number; - slotInterval?: number; - minimumBookingNotice?: number; - users?: { id: number }[]; - hosts?: { id: number }[]; - schedulingType?: SchedulingType; - beforeEventBuffer?: number; - afterEventBuffer?: number; -}; - -type InputBooking = { - userId?: number; - eventTypeId: number; - startTime: string; - endTime: string; - title?: string; - status: BookingStatus; - attendees?: { email: string }[]; -}; - -type InputHost = { - id: number; - userId: number; - eventTypeId: number; - isFixed: boolean; -}; const cleanup = async () => { await prisma.eventType.deleteMany(); @@ -241,7 +176,6 @@ describe("getSchedule", () => { ]); const scenarioData = { - hosts: [], eventTypes: [ { id: 1, @@ -350,7 +284,6 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T06:15:00.000Z`, }, ], - hosts: [], }); // Day Plus 2 is completely free - It only has non accepted bookings @@ -449,7 +382,6 @@ describe("getSchedule", () => { schedules: [TestData.schedules.IstWorkHours], }, ], - hosts: [], }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); @@ -550,7 +482,6 @@ describe("getSchedule", () => { schedules: [TestData.schedules.IstWorkHours], }, ], - hosts: [], }); const { dateString: todayDateString } = getDate(); const { dateString: minus1DateString } = getDate({ dateIncrement: -1 }); @@ -634,7 +565,6 @@ describe("getSchedule", () => { selectedCalendars: [TestData.selectedCalendars.google], }, ], - hosts: [], apps: [TestData.apps.googleCalendar], }; @@ -710,7 +640,6 @@ describe("getSchedule", () => { }, ], apps: [TestData.apps.googleCalendar], - hosts: [], }; createBookingScenario(scenarioData); @@ -768,7 +697,6 @@ describe("getSchedule", () => { selectedCalendars: [TestData.selectedCalendars.google], }, ], - hosts: [], apps: [TestData.apps.googleCalendar], }; @@ -834,7 +762,6 @@ describe("getSchedule", () => { schedules: [TestData.schedules.IstWorkHoursWithDateOverride(plus2DateString)], }, ], - hosts: [], }; createBookingScenario(scenarioData); @@ -913,15 +840,6 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T04:15:00.000Z`, }, ], - hosts: [ - // This user is a host of our Collective event - { - id: 1, - eventTypeId: 1, - userId: 101, - isFixed: true, - }, - ], }); // Requesting this user's availability for their @@ -1022,7 +940,6 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T05:45:00.000Z`, }, ], - hosts: [], }); const scheduleForTeamEventOnADayWithNoBooking = await getSchedule({ @@ -1162,7 +1079,6 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T04:15:00.000Z`, }, ], - hosts: [], }); const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule({ input: { @@ -1224,219 +1140,3 @@ describe("getSchedule", () => { }); }); -function getGoogleCalendarCredential() { - return { - type: "google_calendar", - key: { - scope: - "https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly", - token_type: "Bearer", - expiry_date: 1656999025367, - access_token: "ACCESS_TOKEN", - refresh_token: "REFRESH_TOKEN", - }, - }; -} - -function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { - const baseEventType = { - title: "Base EventType Title", - slug: "base-event-type-slug", - timeZone: null, - beforeEventBuffer: 0, - afterEventBuffer: 0, - schedulingType: null, - - //TODO: What is the purpose of periodStartDate and periodEndDate? Test these? - periodStartDate: new Date("2022-01-21T09:03:48.000Z"), - periodEndDate: new Date("2022-01-21T09:03:48.000Z"), - periodCountCalendarDays: false, - periodDays: 30, - seatsPerTimeSlot: null, - metadata: {}, - minimumBookingNotice: 0, - offsetStart: 0, - }; - const foundEvents: Record = {}; - const eventTypesWithUsers = eventTypes.map((eventType) => { - if (!eventType.slotInterval && !eventType.length) { - throw new Error("eventTypes[number]: slotInterval or length must be defined"); - } - if (foundEvents[eventType.id]) { - throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`); - } - foundEvents[eventType.id] = true; - const users = - eventType.users?.map((userWithJustId) => { - return usersStore.find((user) => user.id === userWithJustId.id); - }) || []; - return { - ...baseEventType, - ...eventType, - users, - }; - }); - - logger.silly("TestData: Creating EventType", eventTypes); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - prismaMock.eventType.findUnique.mockImplementation(({ where }) => { - return new Promise((resolve) => { - const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & { - users: PrismaUser[]; - }; - resolve(eventType); - }); - }); -} - -async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) { - logger.silly("TestData: Creating Bookings", bookings); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - prismaMock.booking.findMany.mockImplementation((findManyArg) => { - const where = findManyArg?.where || {}; - return new Promise((resolve) => { - resolve( - bookings - // We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed - .filter((booking) => { - /** - * A user is considered busy within a given time period if there - * is a booking they own OR host. This function mocks some of the logic - * for each condition. For details see the following ticket: - * https://github.com/calcom/cal.com/issues/6374 - */ - - // ~~ FIRST CONDITION ensures that this booking is owned by this user - // and that the status is what we want - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const statusIn = where.OR[0].status?.in || []; - const firstConditionMatches = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - statusIn.includes(booking.status) && booking.userId === where.OR[0].userId; - - // We return this booking if either condition is met - return firstConditionMatches; - }) - .map((booking) => ({ - uid: uuidv4(), - title: "Test Booking Title", - ...booking, - eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId), - })) as unknown as PrismaBooking[] - ); - }); - }); -} - -function addUsers(users: InputUser[]) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => { - return new Promise((resolve) => { - resolve({ - email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`, - } as unknown as PrismaUser); - }); - }); - - prismaMock.user.findMany.mockResolvedValue( - users.map((user) => { - return { - ...user, - username: `IntegrationTestUser${user.id}`, - email: `IntegrationTestUser${user.id}@example.com`, - }; - }) as unknown as PrismaUser[] - ); -} -type ScenarioData = { - // TODO: Support multiple bookings and add tests with that. - bookings?: InputBooking[]; - users: InputUser[]; - hosts: InputHost[]; - credentials?: InputCredential[]; - apps?: App[]; - selectedCalendars?: InputSelectedCalendar[]; - eventTypes: InputEventType[]; - calendarBusyTimes?: { - start: string; - end: string; - }[]; -}; - -function createBookingScenario(data: ScenarioData) { - logger.silly("TestData: Creating Scenario", data); - - addUsers(data.users); - - const eventType = addEventTypes(data.eventTypes, data.users); - if (data.apps) { - prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]); - // FIXME: How do we know which app to return? - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - prismaMock.app.findUnique.mockImplementation(({ where: { slug: whereSlug } }) => { - return new Promise((resolve) => { - if (!data.apps) { - resolve(null); - return; - } - resolve((data.apps.find(({ slug }) => slug == whereSlug) as PrismaApp) || null); - }); - }); - } - data.bookings = data.bookings || []; - addBookings(data.bookings, data.eventTypes); - - return { - eventType, - }; -} - -/** - * This fn indents to dynamically compute day, month, year for the purpose of testing. - * We are not using DayJS because that's actually being tested by this code. - * - `dateIncrement` adds the increment to current day - * - `monthIncrement` adds the increment to current month - * - `yearIncrement` adds the increment to current year - */ -const getDate = (param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {}) => { - let { dateIncrement, monthIncrement, yearIncrement } = param; - dateIncrement = dateIncrement || 0; - monthIncrement = monthIncrement || 0; - yearIncrement = yearIncrement || 0; - - let _date = new Date().getDate() + dateIncrement; - let year = new Date().getFullYear() + yearIncrement; - - // Make it start with 1 to match with DayJS requiremet - let _month = new Date().getMonth() + monthIncrement + 1; - - // If last day of the month(As _month is plus 1 already it is going to be the 0th day of next month which is the last day of current month) - const lastDayOfMonth = new Date(year, _month, 0).getDate(); - const numberOfDaysForNextMonth = +_date - +lastDayOfMonth; - if (numberOfDaysForNextMonth > 0) { - _date = numberOfDaysForNextMonth; - _month = _month + 1; - } - - if (_month === 13) { - _month = 1; - year = year + 1; - } - - const date = _date < 10 ? "0" + _date : _date; - const month = _month < 10 ? "0" + _month : _month; - - return { - date, - month, - year, - dateString: `${year}-${month}-${date}`, - }; -}; diff --git a/apps/web/test/utils/bookingScenario.ts b/apps/web/test/utils/bookingScenario.ts new file mode 100644 index 0000000000..8b0f448e41 --- /dev/null +++ b/apps/web/test/utils/bookingScenario.ts @@ -0,0 +1,742 @@ +import type { + EventType as PrismaEventType, + User as PrismaUser, + Booking as PrismaBooking, + App as PrismaApp, +} from "@prisma/client"; +import type { Prisma } from "@prisma/client"; +import type { WebhookTriggerEvents } from "@prisma/client"; +import { v4 as uuidv4 } from "uuid"; +import { expect } from "vitest"; +import "vitest-fetch-mock"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import logger from "@calcom/lib/logger"; +import type { SchedulingType } from "@calcom/prisma/enums"; +import type { BookingStatus } from "@calcom/prisma/enums"; +import type { EventBusyDate } from "@calcom/types/Calendar"; +import type { Fixtures } from "@calcom/web/test/fixtures/fixtures"; + +import appStoreMock from "../../../../tests/libs/__mocks__/app-store"; +import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n"; +import prismaMock from "../../../../tests/libs/__mocks__/prisma"; + +type App = { + slug: string; + dirName: string; +}; + +type InputWebhook = { + appId: string | null; + userId?: number | null; + teamId?: number | null; + eventTypeId?: number; + active: boolean; + eventTriggers: WebhookTriggerEvents[]; + subscriberUrl: string; +}; +/** + * Data to be mocked + */ +type ScenarioData = { + // hosts: { id: number; eventTypeId?: number; userId?: number; isFixed?: boolean }[]; + /** + * Prisma would return these eventTypes + */ + eventTypes: InputEventType[]; + /** + * Prisma would return these users + */ + users: InputUser[]; + /** + * Prisma would return these apps + */ + apps?: App[]; + bookings?: InputBooking[]; + webhooks?: InputWebhook[]; +}; + +type InputCredential = typeof TestData.credentials.google; + +type InputSelectedCalendar = typeof TestData.selectedCalendars.google; + +type InputUser = typeof TestData.users.example & { id: number } & { + credentials?: InputCredential[]; + selectedCalendars?: InputSelectedCalendar[]; + schedules: { + id: number; + name: string; + availability: { + userId: number | null; + eventTypeId: number | null; + days: number[]; + startTime: Date; + endTime: Date; + date: string | null; + }[]; + timeZone: string; + }[]; +}; + +type InputEventType = { + id: number; + title?: string; + length?: number; + offsetStart?: number; + slotInterval?: number; + minimumBookingNotice?: number; + /** + * These user ids are `ScenarioData["users"]["id"]` + */ + users?: { id: number }[]; + hosts?: { id: number }[]; + schedulingType?: SchedulingType; + beforeEventBuffer?: number; + afterEventBuffer?: number; + requiresConfirmation?: boolean; +}; + +type InputBooking = { + userId?: number; + eventTypeId: number; + startTime: string; + endTime: string; + title?: string; + status: BookingStatus; + attendees?: { email: string }[]; +}; + +const Timezones = { + "+5:30": "Asia/Kolkata", + "+6:00": "Asia/Dhaka", +}; + +function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { + const baseEventType = { + title: "Base EventType Title", + slug: "base-event-type-slug", + timeZone: null, + beforeEventBuffer: 0, + afterEventBuffer: 0, + schedulingType: null, + + //TODO: What is the purpose of periodStartDate and periodEndDate? Test these? + periodStartDate: new Date("2022-01-21T09:03:48.000Z"), + periodEndDate: new Date("2022-01-21T09:03:48.000Z"), + periodCountCalendarDays: false, + periodDays: 30, + seatsPerTimeSlot: null, + metadata: {}, + minimumBookingNotice: 0, + offsetStart: 0, + }; + const foundEvents: Record = {}; + const eventTypesWithUsers = eventTypes.map((eventType) => { + if (!eventType.slotInterval && !eventType.length) { + throw new Error("eventTypes[number]: slotInterval or length must be defined"); + } + if (foundEvents[eventType.id]) { + throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`); + } + foundEvents[eventType.id] = true; + const users = + eventType.users?.map((userWithJustId) => { + return usersStore.find((user) => user.id === userWithJustId.id); + }) || []; + return { + ...baseEventType, + ...eventType, + workflows: [], + users, + }; + }); + + logger.silly("TestData: Creating EventType", eventTypes); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const eventTypeMock = ({ where }) => { + return new Promise((resolve) => { + const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & { + users: PrismaUser[]; + }; + resolve(eventType); + }); + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.eventType.findUnique.mockImplementation(eventTypeMock); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.eventType.findUniqueOrThrow.mockImplementation(eventTypeMock); +} + +async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) { + logger.silly("TestData: Creating Bookings", bookings); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.booking.findMany.mockImplementation((findManyArg) => { + const where = findManyArg?.where || {}; + return new Promise((resolve) => { + resolve( + bookings + // We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed + .filter((booking) => { + /** + * A user is considered busy within a given time period if there + * is a booking they own OR host. This function mocks some of the logic + * for each condition. For details see the following ticket: + * https://github.com/calcom/cal.com/issues/6374 + */ + + // ~~ FIRST CONDITION ensures that this booking is owned by this user + // and that the status is what we want + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const statusIn = where.OR[0].status?.in || []; + const firstConditionMatches = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + statusIn.includes(booking.status) && booking.userId === where.OR[0].userId; + + // We return this booking if either condition is met + return firstConditionMatches; + }) + .map((booking) => ({ + uid: uuidv4(), + title: "Test Booking Title", + ...booking, + eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId), + })) as unknown as PrismaBooking[] + ); + }); + }); +} + +async function addWebhooks(webhooks: InputWebhook[]) { + prismaMock.webhook.findMany.mockResolvedValue( + webhooks.map((webhook) => { + return { + ...webhook, + payloadTemplate: null, + secret: null, + id: uuidv4(), + createdAt: new Date(), + userId: webhook.userId || null, + eventTypeId: webhook.eventTypeId || null, + teamId: webhook.teamId || null, + }; + }) + ); +} + +function addUsers(users: InputUser[]) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => { + return new Promise((resolve) => { + resolve({ + email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`, + } as unknown as PrismaUser); + }); + }); + + prismaMock.user.findMany.mockResolvedValue( + users.map((user) => { + return { + ...user, + username: `IntegrationTestUser${user.id}`, + email: `IntegrationTestUser${user.id}@example.com`, + }; + }) as unknown as PrismaUser[] + ); +} + +export async function createBookingScenario(data: ScenarioData) { + logger.silly("TestData: Creating Scenario", data); + addUsers(data.users); + + const eventType = addEventTypes(data.eventTypes, data.users); + if (data.apps) { + prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const appMock = ({ where: { slug: whereSlug } }) => { + return new Promise((resolve) => { + if (!data.apps) { + resolve(null); + return; + } + + const foundApp = data.apps.find(({ slug }) => slug == whereSlug); + //TODO: Pass just the app name in data.apps and maintain apps in a separate object or load them dyamically + resolve( + ({ + ...foundApp, + ...(foundApp?.slug ? TestData.apps[foundApp.slug as keyof typeof TestData.apps] || {} : {}), + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + categories: [], + } as PrismaApp) || null + ); + }); + }; + // FIXME: How do we know which app to return? + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.app.findUnique.mockImplementation(appMock); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.app.findFirst.mockImplementation(appMock); + } + data.bookings = data.bookings || []; + allowSuccessfulBookingCreation(); + addBookings(data.bookings, data.eventTypes); + // mockBusyCalendarTimes([]); + addWebhooks(data.webhooks || []); + return { + eventType, + }; +} + +/** + * This fn indents to /ally compute day, month, year for the purpose of testing. + * We are not using DayJS because that's actually being tested by this code. + * - `dateIncrement` adds the increment to current day + * - `monthIncrement` adds the increment to current month + * - `yearIncrement` adds the increment to current year + */ +export const getDate = ( + param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {} +) => { + let { dateIncrement, monthIncrement, yearIncrement } = param; + dateIncrement = dateIncrement || 0; + monthIncrement = monthIncrement || 0; + yearIncrement = yearIncrement || 0; + + let _date = new Date().getDate() + dateIncrement; + let year = new Date().getFullYear() + yearIncrement; + + // Make it start with 1 to match with DayJS requiremet + let _month = new Date().getMonth() + monthIncrement + 1; + + // If last day of the month(As _month is plus 1 already it is going to be the 0th day of next month which is the last day of current month) + const lastDayOfMonth = new Date(year, _month, 0).getDate(); + const numberOfDaysForNextMonth = +_date - +lastDayOfMonth; + if (numberOfDaysForNextMonth > 0) { + _date = numberOfDaysForNextMonth; + _month = _month + 1; + } + + if (_month === 13) { + _month = 1; + year = year + 1; + } + + const date = _date < 10 ? "0" + _date : _date; + const month = _month < 10 ? "0" + _month : _month; + + return { + date, + month, + year, + dateString: `${year}-${month}-${date}`, + }; +}; + +export function getMockedCredential({ + metadataLookupKey, + key, +}: { + metadataLookupKey: string; + key: { + expiry_date?: number; + token_type?: string; + access_token?: string; + refresh_token?: string; + scope: string; + }; +}) { + return { + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + appId: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].slug, + key: { + expiry_date: Date.now() + 1000000, + token_type: "Bearer", + access_token: "ACCESS_TOKEN", + refresh_token: "REFRESH_TOKEN", + ...key, + }, + }; +} + +export function getGoogleCalendarCredential() { + return getMockedCredential({ + metadataLookupKey: "googlecalendar", + key: { + scope: + "https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly", + }, + }); +} + +export function getZoomAppCredential() { + return getMockedCredential({ + metadataLookupKey: "zoomvideo", + key: { + scope: "meeting:writed", + }, + }); +} + +export const TestData = { + selectedCalendars: { + google: { + integration: "google_calendar", + externalId: "john@example.com", + }, + }, + credentials: { + google: getGoogleCalendarCredential(), + }, + schedules: { + IstWorkHours: { + id: 1, + name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", + availability: [ + { + userId: null, + eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:30:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + date: null, + }, + ], + timeZone: Timezones["+5:30"], + }, + IstWorkHoursWithDateOverride: (dateString: string) => ({ + id: 1, + name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)", + availability: [ + { + userId: null, + eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:30:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + date: null, + }, + { + userId: null, + eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date(`1970-01-01T14:00:00.000Z`), + endTime: new Date(`1970-01-01T18:00:00.000Z`), + date: dateString, + }, + ], + timeZone: Timezones["+5:30"], + }), + }, + users: { + example: { + name: "Example", + email: "example@example.com", + username: "example", + defaultScheduleId: 1, + timeZone: Timezones["+5:30"], + }, + }, + apps: { + "google-calendar": { + slug: "google-calendar", + dirName: "whatever", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + keys: { + expiry_date: Infinity, + client_id: "client_id", + client_secret: "client_secret", + redirect_uris: ["http://localhost:3000/auth/callback"], + }, + }, + "daily-video": { + slug: "daily-video", + dirName: "whatever", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + keys: { + expiry_date: Infinity, + api_key: "", + scale_plan: "false", + client_id: "client_id", + client_secret: "client_secret", + redirect_uris: ["http://localhost:3000/auth/callback"], + }, + }, + }, +}; + +function allowSuccessfulBookingCreation() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + prismaMock.booking.create.mockImplementation(function (booking) { + return booking.data; + }); +} + +export class MockError extends Error { + constructor(message: string) { + super(message); + this.name = "MockError"; + } +} + +export function getOrganizer({ + name, + email, + id, + schedules, + credentials, + selectedCalendars, +}: { + name: string; + email: string; + id: number; + schedules: InputUser["schedules"]; + credentials?: InputCredential[]; + selectedCalendars?: InputSelectedCalendar[]; +}) { + return { + ...TestData.users.example, + name, + email, + id, + schedules, + credentials, + selectedCalendars, + }; +} + +export function getScenarioData({ + organizer, + eventTypes, + usersApartFromOrganizer = [], + apps = [], + webhooks, +}: // hosts = [], +{ + organizer: ReturnType; + eventTypes: ScenarioData["eventTypes"]; + apps: ScenarioData["apps"]; + usersApartFromOrganizer?: ScenarioData["users"]; + webhooks?: ScenarioData["webhooks"]; + // hosts?: ScenarioData["hosts"]; +}) { + const users = [organizer, ...usersApartFromOrganizer]; + eventTypes.forEach((eventType) => { + if ( + eventType.users?.filter((eventTypeUser) => { + return !users.find((userToCreate) => userToCreate.id === eventTypeUser.id); + }).length + ) { + throw new Error(`EventType ${eventType.id} has users that are not present in ScenarioData["users"]`); + } + }); + return { + // hosts: [...hosts], + eventTypes: [...eventTypes], + users, + apps: [...apps], + webhooks, + }; +} + +export function mockEnableEmailFeature() { + prismaMock.feature.findMany.mockResolvedValue([ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + { + slug: "emails", + // It's a kill switch + enabled: false, + }, + ]); +} + +export function mockNoTranslations() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + i18nMock.getTranslation.mockImplementation(() => { + return new Promise((resolve) => { + const identityFn = (key: string) => key; + resolve(identityFn); + }); + }); +} + +export function mockCalendarToHaveNoBusySlots(metadataLookupKey: keyof typeof appStoreMetadata) { + const appStoreLookupKey = metadataLookupKey; + appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + CalendarService: function MockCalendarService() { + return { + createEvent: () => { + return Promise.resolve({ + type: "daily_video", + id: "dailyEventName", + password: "dailyvideopass", + url: "http://dailyvideo.example.com", + }); + }, + getAvailability: (): Promise => { + return new Promise((resolve) => { + resolve([]); + }); + }, + }; + }, + }, + }); +} + +export function mockSuccessfulVideoMeetingCreation({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => { + return new Promise((resolve) => { + resolve({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + VideoApiAdapter: () => ({ + createMeeting: () => { + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-${metadataLookupKey}.example.com`, + }); + }, + }), + }, + }); + }); + }); +} + +export function mockErrorOnVideoMeetingCreation({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + appStoreMock.default[appStoreLookupKey].mockImplementation(() => { + return new Promise((resolve) => { + resolve({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + VideoApiAdapter: () => ({ + createMeeting: () => { + throw new MockError("Error creating Video meeting"); + }, + }), + }, + }); + }); + }); +} + +export function expectWebhookToHaveBeenCalledWith( + subscriberUrl: string, + data: { + triggerEvent: WebhookTriggerEvents; + payload: { metadata: Record; responses: Record }; + } +) { + const fetchCalls = fetchMock.mock.calls; + const webhookFetchCall = fetchCalls.find((call) => call[0] === subscriberUrl); + if (!webhookFetchCall) { + throw new Error(`Webhook not called with ${subscriberUrl}`); + } + expect(webhookFetchCall[0]).toBe(subscriberUrl); + const body = webhookFetchCall[1]?.body; + const parsedBody = JSON.parse((body as string) || "{}"); + console.log({ payload: parsedBody.payload }); + expect(parsedBody.triggerEvent).toBe(data.triggerEvent); + parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl + ? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID") + : parsedBody.payload.metadata.videoCallUrl; + expect(parsedBody.payload.metadata).toContain(data.payload.metadata); + expect(parsedBody.payload.responses).toEqual(data.payload.responses); +} + +export function expectWorkflowToBeTriggered() { + // TODO: Implement this. +} + +export function expectBookingToBeInDatabase(booking: Partial) { + const createBookingCalledWithArgs = prismaMock.booking.create.mock.calls[0]; + expect(createBookingCalledWithArgs[0].data).toEqual(expect.objectContaining(booking)); +} + +export function getBooker({ name, email }: { name: string; email: string }) { + return { + name, + email, + }; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toHaveEmail(expectedEmail: { htmlToContain?: string; to: string }): R; + } + } +} + +expect.extend({ + toHaveEmail( + testEmail: ReturnType[number], + expectedEmail: { + //TODO: Support email HTML parsing to target specific elements + htmlToContain?: string; + to: string; + } + ) { + let isHtmlContained = true; + let isToAddressExpected = true; + if (expectedEmail.htmlToContain) { + isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain); + } + isToAddressExpected = expectedEmail.to === testEmail.to; + + return { + pass: isHtmlContained && isToAddressExpected, + message: () => { + if (!isHtmlContained) { + return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`; + } + + return `Email To address is not as expected. Expected:${expectedEmail.to} isn't contained in ${testEmail.to}`; + }, + }; + }, +}); diff --git a/package.json b/package.json index ebf7d7bb55..badd51a1ea 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,8 @@ "prettier": "^2.8.6", "tsc-absolute": "^1.0.0", "typescript": "^4.9.4", - "vitest": "^0.31.1", + "vitest": "^0.34.3", + "vitest-fetch-mock": "^0.2.2", "vitest-mock-extended": "^1.1.3" }, "dependencies": { diff --git a/packages/app-store/_pages/setup/_getStaticProps.tsx b/packages/app-store/_pages/setup/_getStaticProps.tsx index 5e1f963ef6..3a10131c9d 100644 --- a/packages/app-store/_pages/setup/_getStaticProps.tsx +++ b/packages/app-store/_pages/setup/_getStaticProps.tsx @@ -2,6 +2,7 @@ import type { GetStaticPropsContext } from "next"; export const AppSetupPageMap = { zapier: import("../../zapier/pages/setup/_getStaticProps"), + make: import("../../make/pages/setup/_getStaticProps"), }; export const getStaticProps = async (ctx: GetStaticPropsContext) => { diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index 22331872a6..6438b04a29 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -9,6 +9,7 @@ export const AppSetupMap = { "exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")), "caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")), zapier: dynamic(() => import("../../zapier/pages/setup")), + make: dynamic(() => import("../../make/pages/setup")), closecom: dynamic(() => import("../../closecom/pages/setup")), sendgrid: dynamic(() => import("../../sendgrid/pages/setup")), paypal: dynamic(() => import("../../paypal/pages/setup")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 019da1d5b9..b3b679d8ed 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -12,6 +12,7 @@ import { appKeysSchema as gtm_zod_ts } from "./gtm/zod"; import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod"; import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; +import { appKeysSchema as make_zod_ts } from "./make/zod"; import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appKeysSchema as office365video_zod_ts } from "./office365video/zod"; @@ -43,6 +44,7 @@ export const appKeysSchemas = { hubspot: hubspot_zod_ts, jitsivideo: jitsivideo_zod_ts, larkcalendar: larkcalendar_zod_ts, + make: make_zod_ts, metapixel: metapixel_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 3b7308219e..c9c05fa170 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -29,6 +29,7 @@ import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata"; import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; +import make_config_json from "./make/config.json"; import metapixel_config_json from "./metapixel/config.json"; import mirotalk_config_json from "./mirotalk/config.json"; import n8n_config_json from "./n8n/config.json"; @@ -99,6 +100,7 @@ export const appStoreMetadata = { huddle01video: huddle01video__metadata_ts, jitsivideo: jitsivideo__metadata_ts, larkcalendar: larkcalendar__metadata_ts, + make: make_config_json, metapixel: metapixel_config_json, mirotalk: mirotalk_config_json, n8n: n8n_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 49ff7731ec..1801493d85 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -12,6 +12,7 @@ import { appDataSchema as gtm_zod_ts } from "./gtm/zod"; import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod"; import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; +import { appDataSchema as make_zod_ts } from "./make/zod"; import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appDataSchema as office365video_zod_ts } from "./office365video/zod"; @@ -43,6 +44,7 @@ export const appDataSchemas = { hubspot: hubspot_zod_ts, jitsivideo: jitsivideo_zod_ts, larkcalendar: larkcalendar_zod_ts, + make: make_zod_ts, metapixel: metapixel_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 6eb15be4f0..e37b6e7113 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -29,6 +29,7 @@ export const apiHandlers = { huddle01video: import("./huddle01video/api"), jitsivideo: import("./jitsivideo/api"), larkcalendar: import("./larkcalendar/api"), + make: import("./make/api"), metapixel: import("./metapixel/api"), mirotalk: import("./mirotalk/api"), n8n: import("./n8n/api"), diff --git a/packages/app-store/basecamp3/config.json b/packages/app-store/basecamp3/config.json index 2e92c82e5b..a0cad118dc 100644 --- a/packages/app-store/basecamp3/config.json +++ b/packages/app-store/basecamp3/config.json @@ -2,7 +2,7 @@ "name": "Basecamp3", "slug": "basecamp3", "type": "basecamp3_other_calendar", - "logo": "logo.svg", + "logo": "icon-dark.svg", "url": "https://basecamp.com/", "variant": "other", "categories": ["other"], diff --git a/packages/app-store/basecamp3/static/logo.svg b/packages/app-store/basecamp3/static/icon-dark.svg similarity index 100% rename from packages/app-store/basecamp3/static/logo.svg rename to packages/app-store/basecamp3/static/icon-dark.svg diff --git a/packages/app-store/element-call/static/icon.svg b/packages/app-store/element-call/static/icon.svg index b85f1ccc43..854a65b3dd 100644 --- a/packages/app-store/element-call/static/icon.svg +++ b/packages/app-store/element-call/static/icon.svg @@ -1,26 +1,38 @@ - - - - - + + + - - - + + + + - - - + + + + + - - - + + + + + - + + + + + + + + + + - + diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 755c7bcc40..23d10c8dbe 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -97,10 +97,9 @@ export default class GoogleCalendarService implements Calendar { responseStatus: "accepted", })) || []; return new Promise(async (resolve, reject) => { - const [mainHostDestinationCalendar] = - calEventRaw?.destinationCalendar && calEventRaw?.destinationCalendar.length > 0 - ? calEventRaw.destinationCalendar - : []; + const selectedHostDestinationCalendar = calEventRaw.destinationCalendar?.find( + (cal) => cal.credentialId === credentialId + ); const myGoogleAuth = await this.auth.getToken(); const payload: calendar_v3.Schema$Event = { summary: calEventRaw.title, @@ -119,8 +118,8 @@ export default class GoogleCalendarService implements Calendar { id: String(calEventRaw.organizer.id), responseStatus: "accepted", organizer: true, - email: mainHostDestinationCalendar?.externalId - ? mainHostDestinationCalendar.externalId + email: selectedHostDestinationCalendar?.externalId + ? selectedHostDestinationCalendar.externalId : calEventRaw.organizer.email, }, ...eventAttendees, @@ -144,14 +143,14 @@ export default class GoogleCalendarService implements Calendar { }); // Find in calEventRaw.destinationCalendar the one with the same credentialId - const selectedCalendar = calEventRaw.destinationCalendar?.find( - (cal) => cal.credentialId === credentialId - )?.externalId; + const selectedCalendar = + calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId || + "primary"; calendar.events.insert( { auth: myGoogleAuth, - calendarId: selectedCalendar || "primary", + calendarId: selectedCalendar, requestBody: payload, conferenceDataVersion: 1, sendUpdates: "none", diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 8982003f00..10046d5393 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -26,6 +26,7 @@ const appStore = { webexvideo: () => import("./webex"), giphy: () => import("./giphy"), zapier: () => import("./zapier"), + make: () => import("./make"), exchange2013calendar: () => import("./exchange2013calendar"), exchange2016calendar: () => import("./exchange2016calendar"), exchangecalendar: () => import("./exchangecalendar"), diff --git a/packages/app-store/make/DESCRIPTION.md b/packages/app-store/make/DESCRIPTION.md new file mode 100644 index 0000000000..ff99243ef5 --- /dev/null +++ b/packages/app-store/make/DESCRIPTION.md @@ -0,0 +1,10 @@ +--- +items: + - 1.jpeg + - 2.jpeg + - 3.jpeg + - 4.jpeg + - 5.jpeg +--- + +Workflow automation for everyone. Use the Cal.com app in Make to automate your workflows when a booking is created, rescheduled, cancelled or when a meeting has ended. You can also get all your booking with the 'List Bookings' module.

**After Installation:** Have you lost your API key? You can always generate a new key on the **Make Setup Page** \ No newline at end of file diff --git a/packages/app-store/make/README.md b/packages/app-store/make/README.md new file mode 100644 index 0000000000..e3de1d11c7 --- /dev/null +++ b/packages/app-store/make/README.md @@ -0,0 +1,22 @@ +# Setting up Make Integration + + +1. Install the app from the Cal app store and generate an API key. Copy the API key. +2. Go to `/admin/apps/automation` in Cal and set the `invite_link` for Make to `https://www.make.com/en/hq/app-invitation/6cb2772b61966508dd8f414ba3b44510` to use the app. +3. Create a [Make account](https://www.make.com/en/login), if you don't have one. +4. Go to `Scenarios` in the sidebar and click on **Create a new scenario**. +5. Search for `Cal.com` in the apps list and select from the list of triggers - Booking Created, Booking Deleted, Booking Rescheduled, Meeting Ended +6. To create a **connection** you will need your Cal deployment url and the app API Key generated above. You only need to create a **connection** once, all webhooks can use that connection. +7. Setup the webhook for the desired event in Make. +8. To delete a webhook, go to `Webhooks` in the left sidebar in Make, pick the webhook you want to delete and click **delete**. + +## Localhost or Self-hosting + +Localhost urls can not be used as the base URL for api endpoints + +Possible solution: using [https://ngrok.com/](https://ngrok.com/) + +1. Create Account +2. [Download](https://ngrok.com/download) ngrok and start a tunnel to your running localhost + - Use forwarding url as your baseUrl for the URL endpoints +3. Use the ngrok url as your Cal deployment url when creating the **Connection** in Make. \ No newline at end of file diff --git a/packages/app-store/make/api/add.ts b/packages/app-store/make/api/add.ts new file mode 100644 index 0000000000..ac5e8078c3 --- /dev/null +++ b/packages/app-store/make/api/add.ts @@ -0,0 +1,20 @@ +import { createDefaultInstallation } from "@calcom/app-store/_utils/installation"; +import type { AppDeclarativeHandler } from "@calcom/types/AppHandler"; + +import appConfig from "../config.json"; + +const handler: AppDeclarativeHandler = { + appType: appConfig.type, + variant: appConfig.variant, + slug: appConfig.slug, + supportsMultipleInstalls: false, + handlerType: "add", + redirect: { + newTab: true, + url: "/apps/make/setup", + }, + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), +}; + +export default handler; diff --git a/packages/app-store/make/api/index.ts b/packages/app-store/make/api/index.ts new file mode 100644 index 0000000000..d32a6d07b0 --- /dev/null +++ b/packages/app-store/make/api/index.ts @@ -0,0 +1,5 @@ +export { default as add } from "./add"; +export { default as listBookings } from "./subscriptions/listBookings"; +export { default as deleteSubscription } from "./subscriptions/deleteSubscription"; +export { default as addSubscription } from "./subscriptions/addSubscription"; +export { default as me } from "./subscriptions/me"; diff --git a/packages/app-store/make/api/subscriptions/addSubscription.ts b/packages/app-store/make/api/subscriptions/addSubscription.ts new file mode 100644 index 0000000000..3bfd90da16 --- /dev/null +++ b/packages/app-store/make/api/subscriptions/addSubscription.ts @@ -0,0 +1,38 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { addSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "make"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + const { subscriberUrl, triggerEvent } = req.body; + + const createAppSubscription = await addSubscription({ + appApiKey: validKey, + triggerEvent: triggerEvent, + subscriberUrl: subscriberUrl, + appId: "make", + }); + + if (!createAppSubscription) { + return res.status(500).json({ message: "Could not create subscription." }); + } + + res.status(200).json(createAppSubscription); +} + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/make/api/subscriptions/deleteSubscription.ts b/packages/app-store/make/api/subscriptions/deleteSubscription.ts new file mode 100644 index 0000000000..20b55e7714 --- /dev/null +++ b/packages/app-store/make/api/subscriptions/deleteSubscription.ts @@ -0,0 +1,40 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import z from "zod"; + +import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { deleteSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +const querySchema = z.object({ + apiKey: z.string(), + id: z.string(), +}); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { apiKey, id } = querySchema.parse(req.query); + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "make"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + const deleteEventSubscription = await deleteSubscription({ + appApiKey: validKey, + webhookId: id, + appId: "make", + }); + + if (!deleteEventSubscription) { + return res.status(500).json({ message: "Could not delete subscription." }); + } + res.status(204).json({ message: "Subscription is deleted." }); +} + +export default defaultHandler({ + DELETE: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/make/api/subscriptions/listBookings.ts b/packages/app-store/make/api/subscriptions/listBookings.ts new file mode 100644 index 0000000000..b4204fbe94 --- /dev/null +++ b/packages/app-store/make/api/subscriptions/listBookings.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { listBookings } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "make"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + const bookings = await listBookings(validKey); + + if (!bookings) { + return res.status(500).json({ message: "Unable to get bookings." }); + } + if (bookings.length === 0) { + const requested = validKey.teamId ? "teamId: " + validKey.teamId : "userId: " + validKey.userId; + return res.status(404).json({ + message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``, + }); + } + res.status(201).json(bookings); +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/make/api/subscriptions/me.ts b/packages/app-store/make/api/subscriptions/me.ts new file mode 100644 index 0000000000..f02a84c9d2 --- /dev/null +++ b/packages/app-store/make/api/subscriptions/me.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import prisma from "@calcom/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey as string; + + if (!apiKey) { + return res.status(401).json({ message: "No API key provided" }); + } + + const validKey = await findValidApiKey(apiKey, "make"); + + if (!validKey) { + return res.status(401).json({ message: "API key not valid" }); + } + + if (req.method === "GET") { + try { + const user = await prisma.user.findFirst({ + where: { + id: validKey.userId, + }, + select: { + username: true, + }, + }); + res.status(201).json(user); + } catch (error) { + console.error(error); + return res.status(500).json({ message: "Unable to get User." }); + } + } +} diff --git a/packages/app-store/make/config.json b/packages/app-store/make/config.json new file mode 100644 index 0000000000..bc2a1349da --- /dev/null +++ b/packages/app-store/make/config.json @@ -0,0 +1,18 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Make", + "slug": "make", + "type": "make_automation", + "logo": "icon.svg", + "url": "https://github.com/aar2dee2", + "variant": "automation", + "categories": ["automation"], + "publisher": "aar2dee2", + "email": "support@cal.com", + "description": "From tasks and workflows to apps and systems, build and automate anything in one powerful visual platform.", + "isTemplate": false, + "__createdUsingCli": true, + "__template": "basic", + "imageSrc": "icon.svg", + "dirName": "make" +} diff --git a/packages/app-store/make/index.ts b/packages/app-store/make/index.ts new file mode 100644 index 0000000000..d7f3602204 --- /dev/null +++ b/packages/app-store/make/index.ts @@ -0,0 +1 @@ +export * as api from "./api"; diff --git a/packages/app-store/make/package.json b/packages/app-store/make/package.json new file mode 100644 index 0000000000..ce3fc5fa9b --- /dev/null +++ b/packages/app-store/make/package.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/make", + "version": "0.0.0", + "main": "./index.ts", + "description": "Workflow automation for everyone. Use the Cal.com Make app to trigger your workflows when a booking is created, rescheduled, or cancelled, or after a meeting ends.", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*", + "@types/node-schedule": "^2.1.0" + } +} diff --git a/packages/app-store/make/pages/setup/_getStaticProps.tsx b/packages/app-store/make/pages/setup/_getStaticProps.tsx new file mode 100644 index 0000000000..1c6cac0efe --- /dev/null +++ b/packages/app-store/make/pages/setup/_getStaticProps.tsx @@ -0,0 +1,20 @@ +import type { GetStaticPropsContext } from "next"; + +import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug"; + +export interface IMakeSetupProps { + inviteLink: string; +} + +export const getStaticProps = async (ctx: GetStaticPropsContext) => { + if (typeof ctx.params?.slug !== "string") return { notFound: true } as const; + let inviteLink = ""; + const appKeys = await getAppKeysFromSlug("make"); + if (typeof appKeys.invite_link === "string") inviteLink = appKeys.invite_link; + + return { + props: { + inviteLink, + }, + }; +}; diff --git a/packages/app-store/make/pages/setup/index.tsx b/packages/app-store/make/pages/setup/index.tsx new file mode 100644 index 0000000000..73ccd84c6f --- /dev/null +++ b/packages/app-store/make/pages/setup/index.tsx @@ -0,0 +1,175 @@ +import type { InferGetStaticPropsType } from "next"; +import { Trans } from "next-i18next"; +import Link from "next/link"; +import { useState } from "react"; +import { Toaster } from "react-hot-toast"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button, Tooltip, showToast } from "@calcom/ui"; +import { Clipboard } from "@calcom/ui/components/icon"; + +import type { getStaticProps } from "./_getStaticProps"; + +const MAKE = "make"; + +export default function MakeSetup({ inviteLink }: InferGetStaticPropsType) { + const [newApiKeys, setNewApiKeys] = useState>({}); + + const { t } = useLocale(); + const utils = trpc.useContext(); + const integrations = trpc.viewer.integrations.useQuery({ variant: "automation" }); + const oldApiKey = trpc.viewer.apiKeys.findKeyOfType.useQuery({ appId: MAKE }); + const teamsList = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const teams = teamsList.data?.map((team) => ({ id: team.id, name: team.name })); + const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({ + onSuccess: () => { + utils.viewer.apiKeys.findKeyOfType.invalidate(); + }, + }); + const makeCredentials: { userCredentialIds: number[] } | undefined = integrations.data?.items.find( + (item: { type: string }) => item.type === "make_automation" + ); + const [credentialId] = makeCredentials?.userCredentialIds || [false]; + const showContent = integrations.data && integrations.isSuccess && credentialId; + + async function createApiKey(teamId?: number) { + const event = { note: "Make", expiresAt: null, appId: MAKE, teamId }; + const apiKey = await utils.client.viewer.apiKeys.create.mutate(event); + + if (oldApiKey.data) { + const oldKey = teamId + ? oldApiKey.data.find((key) => key.teamId === teamId) + : oldApiKey.data.find((key) => !key.teamId); + + if (oldKey) { + deleteApiKey.mutate({ + id: oldKey.id, + }); + } + } + + return apiKey; + } + + async function generateApiKey(teamId?: number) { + const apiKey = await createApiKey(teamId); + setNewApiKeys({ ...newApiKeys, [teamId || ""]: apiKey }); + } + + if (integrations.isLoading) { + return
; + } + + return ( +
+ {showContent ? ( +
+
+
+ Make Logo +
+
+
{t("setting_up_make")}
+ + <> +
{t("generate_api_key")}:
+ {!teams ? ( + + ) : ( + <> +
Your event types:
+ {!newApiKeys[""] ? ( + + ) : ( + + )} + {teams.map((team) => { + return ( +
+
{team.name}:
+ {!newApiKeys[team.id] ? ( + + ) : ( + + )} +
+ ); + })} + + )} + + +
    + +
  1. + Go to + + Make Invite Link + + and install the Cal.com app. +
  2. +
  3. Log into your Make account and create a new Scenario.
  4. +
  5. Select Cal.com as your Trigger app. Also choose a Trigger event.
  6. +
  7. Choose your account and then enter your Unique API Key.
  8. +
  9. Test your Trigger.
  10. +
  11. You're set!
  12. +
    +
+ + + +
+
+
+ ) : ( +
+
{t("install_make_app")}
+
+ + + +
+
+ )} + +
+ ); +} + +const CopyApiKey = ({ apiKey }: { apiKey: string }) => { + const { t } = useLocale(); + return ( +
+
+ + {apiKey} + + + + +
+
{t("copy_somewhere_safe")}
+
+ ); +}; diff --git a/packages/app-store/make/static/1.jpeg b/packages/app-store/make/static/1.jpeg new file mode 100644 index 0000000000..54f811f918 Binary files /dev/null and b/packages/app-store/make/static/1.jpeg differ diff --git a/packages/app-store/make/static/2.jpeg b/packages/app-store/make/static/2.jpeg new file mode 100644 index 0000000000..c6637a01c8 Binary files /dev/null and b/packages/app-store/make/static/2.jpeg differ diff --git a/packages/app-store/make/static/3.jpeg b/packages/app-store/make/static/3.jpeg new file mode 100644 index 0000000000..15c25d425f Binary files /dev/null and b/packages/app-store/make/static/3.jpeg differ diff --git a/packages/app-store/make/static/4.jpeg b/packages/app-store/make/static/4.jpeg new file mode 100644 index 0000000000..6ae8a3cabc Binary files /dev/null and b/packages/app-store/make/static/4.jpeg differ diff --git a/packages/app-store/make/static/5.jpeg b/packages/app-store/make/static/5.jpeg new file mode 100644 index 0000000000..29667aff9a Binary files /dev/null and b/packages/app-store/make/static/5.jpeg differ diff --git a/packages/app-store/make/static/icon.svg b/packages/app-store/make/static/icon.svg new file mode 100644 index 0000000000..3332f2c81f --- /dev/null +++ b/packages/app-store/make/static/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/app-store/make/zod.ts b/packages/app-store/make/zod.ts new file mode 100644 index 0000000000..4a98d79116 --- /dev/null +++ b/packages/app-store/make/zod.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const appDataSchema = z.object({}); + +export const appKeysSchema = z.object({ + invite_link: z.string().min(1), +}); diff --git a/packages/app-store/metapixel/static/icon.svg b/packages/app-store/metapixel/static/icon.svg index 444ccdf4f7..6b007fb8ad 100644 --- a/packages/app-store/metapixel/static/icon.svg +++ b/packages/app-store/metapixel/static/icon.svg @@ -1,951 +1,16 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index 5878906607..00b32083f6 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -5,6 +5,13 @@ import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import { test } from "@calcom/web/playwright/lib/fixtures"; import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils"; +import { + addForm, + saveCurrentForm, + verifySelectOptions, + addOneFieldAndDescriptionAndSaveForm, +} from "./testUtils"; + function todo(title: string) { // eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function test.skip(title, () => {}); @@ -407,22 +414,6 @@ async function fillSeededForm(page: Page, routingFormId: string) { await expect(page.locator("text=Custom Page Result")).toBeVisible(); } -export async function addForm(page: Page, { name = "Test Form Name" } = {}) { - await page.goto("/routing-forms/forms"); - await page.click('[data-testid="new-routing-form"]'); - // Choose to create the Form for the user(which is the first option) and not the team - await page.click('[data-testid="option-0"]'); - await page.fill("input[name]", name); - await page.click('[data-testid="add-form"]'); - await page.waitForSelector('[data-testid="add-field"]'); - const url = page.url(); - const formId = new URL(url).pathname.split("/").at(-1); - if (!formId) { - throw new Error("Form ID couldn't be determined from url"); - } - return formId; -} - async function addAllTypesOfFieldsAndSaveForm( formId: string, page: Page, @@ -480,46 +471,6 @@ async function addAllTypesOfFieldsAndSaveForm( }; } -export async function addOneFieldAndDescriptionAndSaveForm( - formId: string, - page: Page, - form: { description?: string; field?: { typeIndex: number; label: string } } -) { - await page.goto(`apps/routing-forms/form-edit/${formId}`); - await page.click('[data-testid="add-field"]'); - if (form.description) { - await page.fill('[data-testid="description"]', form.description); - } - - // Verify all Options of SelectBox - const { optionsInUi: types } = await verifySelectOptions( - { selector: ".data-testid-field-type", nth: 0 }, - ["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"], - page - ); - - const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1; - - if (form.field) { - await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label); - await page - .locator('[data-testid="field"]') - .nth(nextFieldIndex) - .locator(".data-testid-field-type") - .click(); - await page - .locator('[data-testid="field"]') - .nth(nextFieldIndex) - .locator('[id*="react-select-"][aria-disabled]') - .nth(form.field.typeIndex) - .click(); - } - await saveCurrentForm(page); - return { - types, - }; -} - async function selectOption({ page, selector, @@ -551,26 +502,6 @@ async function verifyFieldOptionsInRule(options: string[], page: Page) { ); } -async function verifySelectOptions( - selector: { selector: string; nth: number }, - expectedOptions: string[], - page: Page -) { - await page.locator(selector.selector).nth(selector.nth).click(); - const selectOptions = await page - .locator(selector.selector) - .nth(selector.nth) - .locator('[id*="react-select-"][aria-disabled]') - .allInnerTexts(); - - const sortedSelectOptions = [...selectOptions].sort(); - const sortedExpectedOptions = [...expectedOptions].sort(); - expect(sortedSelectOptions).toEqual(sortedExpectedOptions); - return { - optionsInUi: selectOptions, - }; -} - async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) { await selectOption({ selector: { @@ -581,8 +512,3 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) { page, }); } - -async function saveCurrentForm(page: Page) { - await page.click('[data-testid="update-form"]'); - await page.waitForSelector(".data-testid-toast-success"); -} diff --git a/packages/app-store/routing-forms/playwright/tests/testUtils.ts b/packages/app-store/routing-forms/playwright/tests/testUtils.ts new file mode 100644 index 0000000000..03220258d1 --- /dev/null +++ b/packages/app-store/routing-forms/playwright/tests/testUtils.ts @@ -0,0 +1,83 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +export async function addForm(page: Page, { name = "Test Form Name" } = {}) { + await page.goto("/routing-forms/forms"); + await page.click('[data-testid="new-routing-form"]'); + // Choose to create the Form for the user(which is the first option) and not the team + await page.click('[data-testid="option-0"]'); + await page.fill("input[name]", name); + await page.click('[data-testid="add-form"]'); + await page.waitForSelector('[data-testid="add-field"]'); + const url = page.url(); + const formId = new URL(url).pathname.split("/").at(-1); + if (!formId) { + throw new Error("Form ID couldn't be determined from url"); + } + return formId; +} + +export async function addOneFieldAndDescriptionAndSaveForm( + formId: string, + page: Page, + form: { description?: string; field?: { typeIndex: number; label: string } } +) { + await page.goto(`apps/routing-forms/form-edit/${formId}`); + await page.click('[data-testid="add-field"]'); + if (form.description) { + await page.fill('[data-testid="description"]', form.description); + } + + // Verify all Options of SelectBox + const { optionsInUi: types } = await verifySelectOptions( + { selector: ".data-testid-field-type", nth: 0 }, + ["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"], + page + ); + + const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1; + + if (form.field) { + await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label); + await page + .locator('[data-testid="field"]') + .nth(nextFieldIndex) + .locator(".data-testid-field-type") + .click(); + await page + .locator('[data-testid="field"]') + .nth(nextFieldIndex) + .locator('[id*="react-select-"][aria-disabled]') + .nth(form.field.typeIndex) + .click(); + } + await saveCurrentForm(page); + return { + types, + }; +} + +export async function saveCurrentForm(page: Page) { + await page.click('[data-testid="update-form"]'); + await page.waitForSelector(".data-testid-toast-success"); +} + +export async function verifySelectOptions( + selector: { selector: string; nth: number }, + expectedOptions: string[], + page: Page +) { + await page.locator(selector.selector).nth(selector.nth).click(); + const selectOptions = await page + .locator(selector.selector) + .nth(selector.nth) + .locator('[id*="react-select-"][aria-disabled]') + .allInnerTexts(); + + const sortedSelectOptions = [...selectOptions].sort(); + const sortedExpectedOptions = [...expectedOptions].sort(); + expect(sortedSelectOptions).toEqual(sortedExpectedOptions); + return { + optionsInUi: selectOptions, + }; +} diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index 16741cb299..4aebde0c7f 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -12,32 +12,50 @@ export async function onFormSubmission( form: Ensure & { user: User }, "fields">, response: Response ) { - const fieldResponsesByName: Record = {}; + const fieldResponsesByName: Record< + string, + { + value: Response[keyof Response]["value"]; + } + > = {}; for (const [fieldId, fieldResponse] of Object.entries(response)) { // Use the label lowercased as the key to identify a field. const key = form.fields.find((f) => f.id === fieldId)?.identifier || (fieldResponse.label as keyof typeof fieldResponsesByName); - fieldResponsesByName[key] = fieldResponse.value; + fieldResponsesByName[key] = { + value: fieldResponse.value, + }; } const subscriberOptions = { - userId: form.user.id, triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, - // When team routing forms are implemented, we need to make sure to add the teamId here - teamId: null, + ...getWebhookTargetEntity(form), }; const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => { - sendGenericWebhookPayload( - webhook.secret, - "FORM_SUBMITTED", - new Date().toISOString(), + sendGenericWebhookPayload({ + secretKey: webhook.secret, + triggerEvent: "FORM_SUBMITTED", + createdAt: new Date().toISOString(), webhook, - fieldResponsesByName - ).catch((e) => { + data: { + formId: form.id, + formName: form.name, + teamId: form.teamId, + responses: fieldResponsesByName, + }, + rootData: { + // Send responses unwrapped at root level for backwards compatibility + ...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => { + acc[key] = value.value; + return acc; + }, {} as Record), + }, + }).catch((e) => { console.error(`Error executing routing form webhook`, webhook, e); }); }); @@ -66,3 +84,10 @@ export const sendResponseEmail = async ( logger.error("Error sending response email", e); } }; + +function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) { + // If it's a team form, the target must be team webhook + // If it's a user form, the target must be user webhook + const isTeamForm = form.teamId; + return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null }; +} diff --git a/packages/app-store/typeform/playwright/tests/basic.e2e.ts b/packages/app-store/typeform/playwright/tests/basic.e2e.ts index 406d58e19d..3d04e0df12 100644 --- a/packages/app-store/typeform/playwright/tests/basic.e2e.ts +++ b/packages/app-store/typeform/playwright/tests/basic.e2e.ts @@ -4,7 +4,7 @@ import { expect } from "@playwright/test"; import { addForm as addRoutingForm, addOneFieldAndDescriptionAndSaveForm, -} from "@calcom/app-store/routing-forms/playwright/tests/basic.e2e"; +} from "@calcom/app-store/routing-forms/playwright/tests/testUtils"; import { CAL_URL } from "@calcom/lib/constants"; import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import { test } from "@calcom/web/playwright/lib/fixtures"; diff --git a/packages/app-store/vital/DESCRIPTION.md b/packages/app-store/vital/DESCRIPTION.md index 6762f80b43..9262c3026b 100644 --- a/packages/app-store/vital/DESCRIPTION.md +++ b/packages/app-store/vital/DESCRIPTION.md @@ -1,3 +1,7 @@ +--- +items: + - 1.jpg +--- Vital App is an app that can can help you combine your health peripherals with your calendar. #### Supported Actions: diff --git a/packages/app-store/vital/_metadata.ts b/packages/app-store/vital/_metadata.ts index 512ea0d193..f0359e0b04 100644 --- a/packages/app-store/vital/_metadata.ts +++ b/packages/app-store/vital/_metadata.ts @@ -8,7 +8,7 @@ export const metadata = { installed: true, category: "automation", categories: ["automation"], - logo: "icon.svg", + logo: "icon-dark.svg", label: "Vital", publisher: "Vital", slug: "vital-automation", diff --git a/packages/app-store/vital/static/1.jpg b/packages/app-store/vital/static/1.jpg new file mode 100644 index 0000000000..392d6fd8bb Binary files /dev/null and b/packages/app-store/vital/static/1.jpg differ diff --git a/packages/app-store/vital/static/icon.svg b/packages/app-store/vital/static/icon-dark.svg similarity index 100% rename from packages/app-store/vital/static/icon.svg rename to packages/app-store/vital/static/icon-dark.svg diff --git a/packages/app-store/zapier/api/subscriptions/addSubscription.ts b/packages/app-store/zapier/api/subscriptions/addSubscription.ts index 3bc3ecc2f4..675f161be1 100644 --- a/packages/app-store/zapier/api/subscriptions/addSubscription.ts +++ b/packages/app-store/zapier/api/subscriptions/addSubscription.ts @@ -1,12 +1,8 @@ -import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; -import { v4 } from "uuid"; import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; -import { scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { addSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import prisma from "@calcom/prisma"; -import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; async function handler(req: NextApiRequest, res: NextApiResponse) { const apiKey = req.query.apiKey as string; @@ -23,45 +19,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const { subscriberUrl, triggerEvent } = req.body; - try { - const createSubscription = await prisma.webhook.create({ - data: { - id: v4(), - userId: validKey.userId, - teamId: validKey.teamId, - eventTriggers: [triggerEvent], - subscriberUrl, - active: true, - appId: "zapier", - }, - }); + const createAppSubscription = await addSubscription({ + appApiKey: validKey, + triggerEvent: triggerEvent, + subscriberUrl: subscriberUrl, + appId: "zapier", + }); - if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) { - //schedule job for already existing bookings - const where: Prisma.BookingWhereInput = {}; - if (validKey.teamId) where.eventType = { teamId: validKey.teamId }; - else where.userId = validKey.userId; - const bookings = await prisma.booking.findMany({ - where: { - ...where, - startTime: { - gte: new Date(), - }, - status: BookingStatus.ACCEPTED, - }, - }); - - for (const booking of bookings) { - scheduleTrigger(booking, createSubscription.subscriberUrl, { - id: createSubscription.id, - appId: createSubscription.appId, - }); - } - } - res.status(200).json(createSubscription); - } catch (error) { + if (!createAppSubscription) { return res.status(500).json({ message: "Could not create subscription." }); } + + res.status(200).json(createAppSubscription); } export default defaultHandler({ diff --git a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts index b0887ac21b..81469a30ff 100644 --- a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts +++ b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts @@ -1,11 +1,9 @@ -import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import z from "zod"; import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; +import { deleteSubscription } from "@calcom/features/webhooks/lib/scheduleTrigger"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import prisma from "@calcom/prisma"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; const querySchema = z.object({ apiKey: z.string(), @@ -24,49 +22,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!validKey) { return res.status(401).json({ message: "API key not valid" }); } - const webhook = await prisma.webhook.findFirst({ - where: { - id, - userId: validKey.userId, - teamId: validKey.teamId, - }, + + const deleteEventSubscription = await deleteSubscription({ + appApiKey: validKey, + webhookId: id, + appId: "zapier", }); - if (!webhook) { - return res.status(401).json({ message: "Not authorized to delete this webhook" }); + if (!deleteEventSubscription) { + return res.status(500).json({ message: "Could not delete subscription." }); } - if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) { - const where: Prisma.BookingWhereInput = {}; - if (validKey.teamId) where.eventType = { teamId: validKey.teamId }; - else where.userId = validKey.userId; - const bookingsWithScheduledJobs = await prisma.booking.findMany({ - where: { - ...where, - scheduledJobs: { - isEmpty: false, - }, - }, - }); - for (const booking of bookingsWithScheduledJobs) { - const updatedScheduledJobs = booking.scheduledJobs.filter( - (scheduledJob) => scheduledJob !== `zapier_${webhook.id}` - ); - await prisma.booking.update({ - where: { - id: booking.id, - }, - data: { - scheduledJobs: updatedScheduledJobs, - }, - }); - } - } - - await prisma.webhook.delete({ - where: { - id, - }, - }); res.status(204).json({ message: "Subscription is deleted." }); } diff --git a/packages/app-store/zapier/api/subscriptions/listBookings.ts b/packages/app-store/zapier/api/subscriptions/listBookings.ts index 7cf0510339..f3a59c690a 100644 --- a/packages/app-store/zapier/api/subscriptions/listBookings.ts +++ b/packages/app-store/zapier/api/subscriptions/listBookings.ts @@ -1,11 +1,8 @@ -import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; -import { getHumanReadableLocationValue } from "@calcom/core/location"; -import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import findValidApiKey from "@calcom/features/ee/api-keys/lib/findValidApiKey"; -import { defaultHandler, defaultResponder, getTranslation } from "@calcom/lib/server"; -import prisma from "@calcom/prisma"; +import { listBookings } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; async function handler(req: NextApiRequest, res: NextApiResponse) { const apiKey = req.query.apiKey as string; @@ -20,88 +17,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(401).json({ message: "API key not valid" }); } - try { - const where: Prisma.BookingWhereInput = {}; - if (validKey.teamId) { - where.eventType = { - OR: [{ teamId: validKey.teamId }, { parent: { teamId: validKey.teamId } }], - }; - } else { - where.userId = validKey.userId; - } + const bookings = await listBookings(validKey); - const bookings = await prisma.booking.findMany({ - take: 3, - where, - orderBy: { - id: "desc", - }, - select: { - title: true, - description: true, - customInputs: true, - responses: true, - startTime: true, - endTime: true, - location: true, - cancellationReason: true, - status: true, - user: { - select: { - username: true, - name: true, - email: true, - timeZone: true, - locale: true, - }, - }, - eventType: { - select: { - title: true, - description: true, - requiresConfirmation: true, - price: true, - currency: true, - length: true, - bookingFields: true, - team: true, - }, - }, - attendees: { - select: { - name: true, - email: true, - timeZone: true, - }, - }, - }, - }); - - if (bookings.length === 0) { - const requested = validKey.teamId ? "teamId: " + validKey.teamId : "userId: " + validKey.userId; - return res.status(404).json({ - message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``, - }); - } - - const t = await getTranslation(bookings[0].user?.locale ?? "en", "common"); - - const updatedBookings = bookings.map((booking) => { - return { - ...booking, - ...getCalEventResponses({ - bookingFields: booking.eventType?.bookingFields ?? null, - booking, - }), - location: getHumanReadableLocationValue(booking.location || "", t), - }; - }); - - res.status(201).json(updatedBookings); - } catch (error) { - console.error(error); + if (!bookings) { return res.status(500).json({ message: "Unable to get bookings." }); } + if (bookings.length === 0) { + const requested = validKey.teamId ? "teamId: " + validKey.teamId : "userId: " + validKey.userId; + return res.status(404).json({ + message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``, + }); + } + res.status(201).json(bookings); } export default defaultHandler({ diff --git a/packages/app-store/zohocrm/api/_getAdd.ts b/packages/app-store/zohocrm/api/_getAdd.ts index edcc318885..cef586ac04 100644 --- a/packages/app-store/zohocrm/api/_getAdd.ts +++ b/packages/app-store/zohocrm/api/_getAdd.ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { stringify } from "querystring"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -13,7 +14,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!client_id) return res.status(400).json({ message: "zohocrm client id missing." }); const state = encodeOAuthState(req); - const redirectUri = WEBAPP_URL + "/api/integrations/zohocrm/callback"; - const url = `https://accounts.zoho.com/oauth/v2/auth?scope=ZohoCRM.modules.ALL,ZohoCRM.users.READ,AaaServer.profile.READ&client_id=${client_id}&response_type=code&access_type=offline&redirect_uri=${redirectUri}&state=${state}`; + const params = { + client_id, + response_type: "code", + redirect_uri: WEBAPP_URL + "/api/integrations/zohocrm/callback", + scope: ["ZohoCRM.modules.ALL", "ZohoCRM.users.READ", "AaaServer.profile.READ"], + access_type: "offline", + state, + prompt: "consent", + }; + + const query = stringify(params); + const url = `https://accounts.zoho.com/oauth/v2/auth?${query}`; + res.status(200).json({ url }); } diff --git a/packages/app-store/zohocrm/config.json b/packages/app-store/zohocrm/config.json index b0c9474f14..4166934341 100644 --- a/packages/app-store/zohocrm/config.json +++ b/packages/app-store/zohocrm/config.json @@ -3,7 +3,7 @@ "name": "ZohoCRM", "slug": "zohocrm", "type": "zohocrm_other_calendar", - "logo": "icon.png", + "logo": "icon.svg", "url": "https://github.com/jatinsandilya", "variant": "other", "categories": ["crm"], diff --git a/packages/app-store/zohocrm/static/icon.svg b/packages/app-store/zohocrm/static/icon.svg new file mode 100644 index 0000000000..78e064404e --- /dev/null +++ b/packages/app-store/zohocrm/static/icon.svg @@ -0,0 +1,2 @@ + + diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index cc8d5c55b9..a56648bcab 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -10,6 +10,7 @@ import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvide import { getEventLocationTypeFromApp } from "@calcom/app-store/locations"; import { MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import { createdEventSchema } from "@calcom/prisma/zod-utils"; import type { NewCalendarEventType } from "@calcom/types/Calendar"; @@ -436,7 +437,14 @@ export default class EventManager { * This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video. * @todo remove location from event types that has missing credentials * */ - if (!videoCredential) videoCredential = { ...FAKE_DAILY_CREDENTIAL, appName: "FAKE" }; + if (!videoCredential) { + logger.warn( + 'Falling back to "daily" video integration for event with location: ' + + event.location + + " because credential is missing for the app" + ); + videoCredential = { ...FAKE_DAILY_CREDENTIAL, appName: "FAKE" }; + } return videoCredential; } diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index 87474c51eb..a5e34ba235 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -27,9 +27,8 @@ export const TeamInviteEmail = ( })}>

<> - {props.language("email_no_user_invite_heading", { + {props.language(`email_no_user_invite_heading_${props.isOrg ? "org" : "team"}`, { appName: APP_NAME, - entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(), })}

@@ -57,11 +56,10 @@ export const TeamInviteEmail = ( lineHeightStep: "24px", }}> <> - {props.language("email_user_invite_subheading", { + {props.language(`email_user_invite_subheading_${props.isOrg ? "org" : "team"}`, { invitedBy: props.from, appName: APP_NAME, teamName: props.teamName, - entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(), })}

diff --git a/packages/emails/templates/_base-email.ts b/packages/emails/templates/_base-email.ts index b89149d75a..b3a7ff7bf8 100644 --- a/packages/emails/templates/_base-email.ts +++ b/packages/emails/templates/_base-email.ts @@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { serverConfig } from "@calcom/lib/serverConfig"; +import { setTestEmail } from "@calcom/lib/testEmails"; import prisma from "@calcom/prisma"; export default class BaseEmail { @@ -34,6 +35,16 @@ export default class BaseEmail { return new Promise((r) => r("Skipped Sending Email due to active Kill Switch")); } + if (process.env.INTEGRATION_TEST_MODE === "true") { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-expect-error + setTestEmail(this.getNodeMailerPayload()); + console.log( + "Skipped Sending Email as process.env.NEXT_PUBLIC_UNIT_TESTS is set. Emails are available in globalThis.testEmails" + ); + return new Promise((r) => r("Skipped sendEmail for Unit Tests")); + } + const payload = this.getNodeMailerPayload(); const parseSubject = z.string().safeParse(payload?.subject); const payloadWithUnEscapedSubject = { diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index d273d11fdf..7459b4d599 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -49,7 +49,7 @@ const BookerComponent = ({ * Prioritize dateSchedule load * Component will render but use data already fetched from here, and no duplicate requests will be made * */ - useScheduleForEvent({ + const schedule = useScheduleForEvent({ prefetchNextMonth: false, username, eventSlug, @@ -93,7 +93,7 @@ const BookerComponent = ({ ); const date = dayjs(selectedDate).format("YYYY-MM-DD"); - const schedule = useScheduleForEvent({ prefetchNextMonth: true }); + const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter( (slot) => dayjs(selectedDate).diff(slot, "day") <= 0 ); @@ -109,7 +109,7 @@ const BookerComponent = ({ ? totalWeekDays : 0; - //Taking one more avaliable slot(extraDays + 1) to claculate the no of days in between, that next and prev button need to shift. + // Taking one more available slot(extraDays + 1) to calculate the no of days in between, that next and prev button need to shift. const availableSlots = nonEmptyScheduleDays.slice(0, extraDays + 1); if (nonEmptyScheduleDays.length !== 0) columnViewExtraDays.current = diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index e40cf4725f..4b093855cc 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -34,6 +34,7 @@ import { Alert, Button, EmptyScreen, Form, showToast } from "@calcom/ui"; import { Calendar } from "@calcom/ui/components/icon"; import { useBookerStore } from "../../store"; +import { useSlotReservationId } from "../../useSlotReservationId"; import { useEvent } from "../../utils/event"; import { BookingFields } from "./BookingFields"; import { FormSkeleton } from "./Skeleton"; @@ -45,8 +46,16 @@ type BookEventFormProps = { type DefaultValues = Record; export const BookEventForm = ({ onCancel }: BookEventFormProps) => { + const [slotReservationId, setSlotReservationId] = useSlotReservationId(); const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({ - trpc: { context: { skipBatch: true } }, + trpc: { + context: { + skipBatch: true, + }, + }, + onSuccess: (data) => { + setSlotReservationId(data.uid); + }, }); const removeSelectedSlot = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation({ trpc: { context: { skipBatch: true } }, @@ -82,7 +91,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { return () => { if (eventType) { - removeSelectedSlot.mutate(); + removeSelectedSlot.mutate({ uid: slotReservationId }); } clearInterval(interval); }; diff --git a/packages/features/bookings/Booker/useSlotReservationId.ts b/packages/features/bookings/Booker/useSlotReservationId.ts new file mode 100644 index 0000000000..3639543dbf --- /dev/null +++ b/packages/features/bookings/Booker/useSlotReservationId.ts @@ -0,0 +1,15 @@ +// TODO: It would be lost on refresh, so we need to persist it. +// Though, we are persisting it in a cookie(`uid` cookie is set through reserveSlot call) +// but that becomes a third party cookie in context of embed and thus isn't accessible inside embed +// So, we need to persist it in top window as first party cookie in that case. +let slotReservationId: null | string = null; + +export const useSlotReservationId = () => { + function set(uid: string) { + slotReservationId = uid; + } + function get() { + return slotReservationId; + } + return [get(), set] as const; +}; diff --git a/packages/features/bookings/lib/getBookingFields.ts b/packages/features/bookings/lib/getBookingFields.ts index cab4a34ecd..b6441d3b77 100644 --- a/packages/features/bookings/lib/getBookingFields.ts +++ b/packages/features/bookings/lib/getBookingFields.ts @@ -251,6 +251,7 @@ export const ensureBookingInputsHaveSystemFields = ({ type: "multiemail", editable: "system-but-optional", name: "guests", + defaultPlaceholder: "email", required: false, hidden: disableGuests, sources: [ diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 6af8f7b5f5..8ffb3cba78 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -338,7 +338,67 @@ export async function handleConfirmation(args: { ); }) ); + await Promise.all(promises); + + if (paid) { + let paymentExternalId: string | undefined; + const subscriberMeetingPaid = await getWebhooks({ + userId: triggerForUser ? booking.userId : null, + eventTypeId: booking.eventTypeId, + triggerEvent: WebhookTriggerEvents.BOOKING_PAID, + teamId: booking.eventType?.teamId, + }); + const bookingWithPayment = await prisma.booking.findFirst({ + where: { + id: bookingId, + }, + select: { + payment: { + select: { + id: true, + success: true, + externalId: true, + }, + }, + }, + }); + const successPayment = bookingWithPayment?.payment?.find((item) => item.success); + if (successPayment) { + paymentExternalId = successPayment.externalId; + } + + const paymentMetadata = { + identifier: "cal.com", + bookingId, + eventTypeId: booking.eventType?.id, + bookerEmail: evt.attendees[0].email, + eventTitle: booking.eventType?.title, + externalId: paymentExternalId, + }; + const bookingPaidSubscribers = subscriberMeetingPaid.map((sub) => + sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_PAID, new Date().toISOString(), sub, { + ...evt, + ...eventTypeInfo, + bookingId, + eventTypeId: booking.eventType?.id, + status: "ACCEPTED", + smsReminderNumber: booking.smsReminderNumber || undefined, + paymentId: bookingWithPayment?.payment?.[0].id, + metadata: { + ...(paid ? paymentMetadata : {}), + }, + }).catch((e) => { + console.error( + `Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_PAID}, URL: ${sub.subscriberUrl}`, + e + ); + }) + ); + + // I don't need to await for this + Promise.all(bookingPaidSubscribers); + } } catch (error) { // Silently fail console.error(error); diff --git a/packages/features/bookings/lib/handleNewBooking.test.ts b/packages/features/bookings/lib/handleNewBooking.test.ts new file mode 100644 index 0000000000..a2fb67a0ea --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking.test.ts @@ -0,0 +1,511 @@ +/** + * How to ensure that unmocked prisma queries aren't called? + */ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, beforeEach } from "vitest"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + createBookingScenario, + getDate, + expectWorkflowToBeTriggered, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + expectBookingToBeInDatabase, + getZoomAppCredential, + mockEnableEmailFeature, + mockNoTranslations, + mockErrorOnVideoMeetingCreation, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + expectWebhookToHaveBeenCalledWith, + MockError, +} from "@calcom/web/test/utils/bookingScenario"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; + +describe.sequential("handleNewBooking", () => { + beforeEach(() => { + // Required to able to generate token in email in some cases + process.env.CALENDSO_ENCRYPTION_KEY="abcdefghjnmkljhjklmnhjklkmnbhjui" + mockNoTranslations(); + mockEnableEmailFeature(); + globalThis.testEmails = []; + fetchMock.resetMocks(); + }); + + describe.sequential("Frontend:", () => { + test( + `should create a successful booking with Cal Video(Daily Video) if no explicit location is provided + 1. Should create a booking in the database + 2. Should send emails to the booker as well as organizer + 3. Should trigger BOOKING_CREATED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + 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 mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const scenarioData = getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar"); + createBookingScenario(scenarioData); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + expectBookingToBeInDatabase({ + description: "", + eventType: { + connect: { + id: mockBookingData.eventTypeId, + }, + }, + status: BookingStatus.ACCEPTED, + }); + + expectWorkflowToBeTriggered(); + + const testEmails = emails.get(); + expect(testEmails[0]).toHaveEmail({ + htmlToContain: "confirmed_event_type_subject", + to: `${organizer.email}`, + }); + expect(testEmails[1]).toHaveEmail({ + htmlToContain: "confirmed_event_type_subject", + to: `${booker.name} <${booker.email}>`, + }); + expect(testEmails[1].html).toContain("confirmed_event_type_subject"); + expectWebhookToHaveBeenCalledWith("http://my-webhook.example.com", { + triggerEvent: "BOOKING_CREATED", + payload: { + metadata: { + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }, + responses: { + name: { label: "your_name", value: "Booker" }, + email: { label: "email_address", value: "booker@example.com" }, + location: { + label: "location", + value: { optionValue: "", value: "integrations:daily" }, + }, + title: { label: "what_is_this_meeting_about" }, + notes: { label: "additional_notes" }, + guests: { label: "additional_guests" }, + rescheduleReason: { label: "reason_for_reschedule" }, + }, + }, + }); + }, + timeout + ); + + test( + `should submit a booking request for event requiring confirmation + 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 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 mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const scenarioData = getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + 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"]], + }); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar"); + createBookingScenario(scenarioData); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + expectBookingToBeInDatabase({ + description: "", + eventType: { + connect: { + id: mockBookingData.eventTypeId, + }, + }, + status: BookingStatus.PENDING, + }); + + expectWorkflowToBeTriggered(); + + const testEmails = emails.get(); + expect(testEmails[0]).toHaveEmail({ + htmlToContain: "event_awaiting_approval_subject", + to: `${organizer.email}`, + }); + + expect(testEmails[1]).toHaveEmail({ + htmlToContain: "booking_submitted_subject", + to: `${booker.email}`, + }); + + expectWebhookToHaveBeenCalledWith("http://my-webhook.example.com", { + triggerEvent: "BOOKING_REQUESTED", + payload: { + metadata: { + // In a Pending Booking Request, we don't send the video call url + videoCallUrl: undefined, + }, + responses: { + name: { label: "your_name", value: "Booker" }, + email: { label: "email_address", value: "booker@example.com" }, + location: { + label: "location", + value: { optionValue: "", value: "integrations:daily" }, + }, + title: { label: "what_is_this_meeting_about" }, + notes: { label: "additional_notes" }, + guests: { label: "additional_guests" }, + rescheduleReason: { label: "reason_for_reschedule" }, + }, + }, + }); + }, + timeout + ); + + test( + `if booking with Cal Video(Daily Video) fails, booking creation fails with uncaught error`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.org", + name: "Booker", + }); + const organizer = TestData.users.example; + const { req } = createMockNextJsRequest({ + method: "POST", + body: getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }), + }); + + const scenarioData = { + hosts: [], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...organizer, + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }, + ], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }; + + mockErrorOnVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + mockCalendarToHaveNoBusySlots("googlecalendar"); + + createBookingScenario(scenarioData); + + try { + await handleNewBooking(req); + } catch (e) { + expect(e).toBeInstanceOf(MockError); + expect((e as { message: string }).message).toBe("Error creating Video meeting"); + } + }, + timeout + ); + + test( + `should create a successful booking with Zoom if used`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + 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: [getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:zoom" }, + }, + }, + }), + }); + + const bookingScenario = getScenarioData({ + organizer, + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + apps: [TestData.apps["daily-video"]], + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + }); + + createBookingScenario(bookingScenario); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + }); + await handleNewBooking(req); + + const testEmails = emails.get(); + + expect(testEmails[0]).toHaveEmail({ + htmlToContain: "confirmed_event_type_subject", + to: `${organizer.email}`, + }); + + expect(testEmails[1]).toHaveEmail({ + htmlToContain: "confirmed_event_type_subject", + to: `${booker.name} <${booker.email}>`, + }); + + expectWebhookToHaveBeenCalledWith("http://my-webhook.example.com", { + triggerEvent: "BOOKING_CREATED", + payload: { + metadata: { + videoCallUrl: "http://mock-zoomvideo.example.com", + }, + responses: { + name: { label: "your_name", value: "Booker" }, + email: { label: "email_address", value: "booker@example.com" }, + location: { + label: "location", + value: { optionValue: "", value: "integrations:zoom" }, + }, + title: { label: "what_is_this_meeting_about" }, + notes: { label: "additional_notes" }, + guests: { label: "additional_guests" }, + rescheduleReason: { label: "reason_for_reschedule" }, + }, + }, + }); + }, + timeout + ); + }); +}); + +function createMockNextJsRequest(...args: Parameters) { + return createMocks(...args); +} + +function getBasicMockRequestDataForBooking() { + return { + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`, + eventTypeSlug: "no-confirmation", + timeZone: "Asia/Calcutta", + language: "en", + bookingUid: "bvCmP5rSquAazGSA7hz7ZP", + user: "teampro", + metadata: {}, + hasHashedBookingLink: false, + hashedLink: null, + }; +} + +function getMockRequestDataForBooking({ + data, +}: { + data: Partial> & { + eventTypeId: number; + responses: { + email: string; + name: string; + location: { optionValue: ""; value: string }; + }; + }; +}) { + return { + ...getBasicMockRequestDataForBooking(), + ...data, + }; +} diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 74c92e46ec..0dbd8f4021 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1374,7 +1374,7 @@ async function handler( } } - if (noEmail !== true) { + if (noEmail !== true && (!requiresConfirmation || isOrganizerRescheduling)) { const copyEvent = cloneDeep(evt); await sendRescheduledEmails({ ...copyEvent, @@ -1494,12 +1494,14 @@ async function handler( ? calendarResult?.updatedEvent[0]?.iCalUID : calendarResult?.updatedEvent?.iCalUID || undefined; - // TODO send reschedule emails to attendees of the old booking - await sendRescheduledEmails({ - ...copyEvent, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: "$RCH$" + (rescheduleReason ? rescheduleReason : ""), // Removable code prefix to differentiate cancellation from rescheduling for email - }); + if (!requiresConfirmation || isOrganizerRescheduling) { + // TODO send reschedule emails to attendees of the old booking + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: "$RCH$" + (rescheduleReason ? rescheduleReason : ""), // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } // Update the old booking with the cancelled status await prisma.booking.update({ @@ -2097,7 +2099,7 @@ async function handler( videoCallUrl = metadata.hangoutLink || videoCallUrl || updatedEvent?.url; } } - if (noEmail !== true) { + if (noEmail !== true && (!requiresConfirmation || isOrganizerRescheduling)) { const copyEvent = cloneDeep(evt); await sendRescheduledEmails({ ...copyEvent, diff --git a/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx b/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx index a015f38b64..dcdd0e8809 100644 --- a/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx +++ b/packages/features/ee/organizations/components/TeamInviteFromOrg.tsx @@ -80,7 +80,6 @@ function UserToInviteItem({ alt="Users avatar" asChild imageSrc={`${bookerUrl}/${member.user.username}/avatar.png`} - gravatarFallbackMd5="hash" />
)} diff --git a/packages/features/ee/users/components/UserForm.tsx b/packages/features/ee/users/components/UserForm.tsx index 130682e2bc..1bc3101308 100644 --- a/packages/features/ee/users/components/UserForm.tsx +++ b/packages/features/ee/users/components/UserForm.tsx @@ -119,7 +119,7 @@ export const UserForm = ({ name="avatar" render={({ field: { value } }) => ( <> - +
diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts index e15103460d..fd4fec9115 100644 --- a/packages/features/ee/users/server/trpc-router.ts +++ b/packages/features/ee/users/server/trpc-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { WEBAPP_URL } from "@calcom/lib/constants"; -import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; import { _UserModel as User } from "@calcom/prisma/zod"; import type { inferRouterOutputs } from "@calcom/trpc"; import { TRPCError } from "@calcom/trpc"; @@ -39,7 +39,7 @@ export function getAvatarUrlFromUser(user: { username: string | null; email: string; }) { - if (!user.avatar || !user.username) return defaultAvatarSrc({ email: user.email }); + if (!user.avatar || !user.username) return AVATAR_FALLBACK; return `${WEBAPP_URL}/${user.username}/avatar.png`; } diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index a5950cc49c..6e2d487e17 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -61,7 +61,9 @@ export default function WorkflowDetailsPage(props: Props) { ...options, ...group.eventTypes.map((eventType) => ({ value: String(eventType.id), - label: `${eventType.title} ${eventType.children.length ? `(+${eventType.children.length})` : ``}`, + label: `${eventType.title} ${ + eventType.children && eventType.children.length ? `(+${eventType.children.length})` : `` + }`, })), ]; }, [] as Option[]) || [], diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index 7c72c114f8..0ce8732f29 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -135,7 +135,6 @@ const Filter = (props: { imageSrc={userAvatar || ""} size="sm" alt={`${user} Avatar`} - gravatarFallbackMd5="fallback" className="self-center" asChild /> @@ -172,7 +171,6 @@ const Filter = (props: { imageSrc={profile.image || ""} size="sm" alt={`${profile.slug} Avatar`} - gravatarFallbackMd5="fallback" className="self-center" asChild /> diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx index e0633410bd..1f9982c0f4 100644 --- a/packages/features/insights/components/TotalBookingUsersTable.tsx +++ b/packages/features/insights/components/TotalBookingUsersTable.tsx @@ -24,7 +24,6 @@ export const TotalBookingUsersTable = ({ imageSrc={item.user.avatar} title={item.user.name || ""} className="m-2" - gravatarFallbackMd5={item.emailMd5} />

{item.user.name} diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 4e8aeac4a6..8d6d28327c 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -31,7 +31,7 @@ export const useSchedule = ({ const nextMonthDayjs = monthDayjs.add(monthCount ? monthCount : 1, "month"); // Why the non-null assertions? All of these arguments are checked in the enabled condition, // and the query will not run if they are null. However, the check in `enabled` does - // no satisfy typscript. + // no satisfy typescript. return trpc.viewer.public.slots.getSchedule.useQuery( { isTeamEvent, diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 31456a01c9..1971d4e1f7 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -520,23 +520,9 @@ const SettingsSidebarContainer = ({ alt={otherTeam.name || "Team logo"} />

{otherTeam.name}

- {!otherTeam.accepted && otherTeam.userId === session.data?.user.id && ( - - Inv. - - )}
- {((otherTeam.accepted && otherTeam.userId === session.data?.user.id) || - isOrgAdminOrOwner) && ( - - )} - +
{username || "No username"} diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx index 805210ae37..8e5858241b 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx @@ -93,12 +93,7 @@ export function EditForm({ }); }}>
- +
{selectedUser?.name ?? "Nameless User"}

diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx index 5fba289e1b..898c884008 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx @@ -46,7 +46,6 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis className="h-[36px] w-[36px]" alt={`${loadedUser?.name} avatar`} imageSrc={avatarURL} - gravatarFallbackMd5="fallback" />

diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index ffc9c49aae..7a73106c16 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -163,12 +163,7 @@ export function UserListTable() { const { username, email } = row.original; return (
- +
{username || "No username"} diff --git a/packages/features/webhooks/lib/scheduleTrigger.ts b/packages/features/webhooks/lib/scheduleTrigger.ts index 8b610d55a7..aa5409101b 100644 --- a/packages/features/webhooks/lib/scheduleTrigger.ts +++ b/packages/features/webhooks/lib/scheduleTrigger.ts @@ -1,5 +1,205 @@ +import type { Prisma } from "@prisma/client"; +import { v4 } from "uuid"; + +import { getHumanReadableLocationValue } from "@calcom/core/location"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { ApiKey } from "@calcom/prisma/client"; +import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; + +const log = logger.getChildLogger({ prefix: ["[node-scheduler]"] }); + +export async function addSubscription({ + appApiKey, + triggerEvent, + subscriberUrl, + appId, +}: { + appApiKey: ApiKey; + triggerEvent: WebhookTriggerEvents; + subscriberUrl: string; + appId: string; +}) { + try { + const createSubscription = await prisma.webhook.create({ + data: { + id: v4(), + userId: appApiKey.userId, + teamId: appApiKey.teamId, + eventTriggers: [triggerEvent], + subscriberUrl, + active: true, + appId: appId, + }, + }); + + if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) { + //schedule job for already existing bookings + const where: Prisma.BookingWhereInput = {}; + if (appApiKey.teamId) where.eventType = { teamId: appApiKey.teamId }; + else where.userId = appApiKey.userId; + const bookings = await prisma.booking.findMany({ + where: { + ...where, + startTime: { + gte: new Date(), + }, + status: BookingStatus.ACCEPTED, + }, + }); + + for (const booking of bookings) { + scheduleTrigger(booking, createSubscription.subscriberUrl, { + id: createSubscription.id, + appId: createSubscription.appId, + }); + } + } + + return createSubscription; + } catch (error) { + log.error(`Error creating subscription for user ${appApiKey.userId} and appId ${appApiKey.appId}.`); + } +} + +export async function deleteSubscription({ + appApiKey, + webhookId, + appId, +}: { + appApiKey: ApiKey; + webhookId: string; + appId: string; +}) { + try { + const webhook = await prisma.webhook.findFirst({ + where: { + id: webhookId, + }, + }); + + if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) { + const where: Prisma.BookingWhereInput = {}; + if (appApiKey.teamId) where.eventType = { teamId: appApiKey.teamId }; + else where.userId = appApiKey.userId; + const bookingsWithScheduledJobs = await prisma.booking.findMany({ + where: { + ...where, + scheduledJobs: { + isEmpty: false, + }, + }, + }); + for (const booking of bookingsWithScheduledJobs) { + const updatedScheduledJobs = booking.scheduledJobs.filter( + (scheduledJob) => scheduledJob !== `${appId}_${webhook.id}` + ); + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + scheduledJobs: updatedScheduledJobs, + }, + }); + } + } + + const deleteWebhook = await prisma.webhook.delete({ + where: { + id: webhookId, + }, + }); + if (!deleteWebhook) { + throw new Error(`Unable to delete webhook ${webhookId}`); + } + return deleteWebhook; + } catch (err) { + log.error( + `Error deleting subscription for user ${appApiKey.userId}, webhookId ${webhookId}, appId ${appId}` + ); + } +} + +export async function listBookings(appApiKey: ApiKey) { + try { + const where: Prisma.BookingWhereInput = {}; + if (appApiKey.teamId) { + where.eventType = { + OR: [{ teamId: appApiKey.teamId }, { parent: { teamId: appApiKey.teamId } }], + }; + } else { + where.userId = appApiKey.userId; + } + const bookings = await prisma.booking.findMany({ + take: 3, + where: where, + orderBy: { + id: "desc", + }, + select: { + title: true, + description: true, + customInputs: true, + responses: true, + startTime: true, + endTime: true, + location: true, + cancellationReason: true, + status: true, + user: { + select: { + username: true, + name: true, + email: true, + timeZone: true, + locale: true, + }, + }, + eventType: { + select: { + title: true, + description: true, + requiresConfirmation: true, + price: true, + currency: true, + length: true, + bookingFields: true, + team: true, + }, + }, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + }, + }); + if (bookings.length === 0) { + return []; + } + const t = await getTranslation(bookings[0].user?.locale ?? "en", "common"); + + const updatedBookings = bookings.map((booking) => { + return { + ...booking, + ...getCalEventResponses({ + bookingFields: booking.eventType?.bookingFields ?? null, + booking, + }), + location: getHumanReadableLocationValue(booking.location || "", t), + }; + }); + + return updatedBookings; + } catch (err) { + log.error(`Error retrieving list of bookings for user ${appApiKey.userId} and appId ${appApiKey.appId}.`); + } +} export async function scheduleTrigger( booking: { id: number; endTime: Date; scheduledJobs: string[] }, diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index da7d355acb..e0211a6a71 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -18,7 +18,7 @@ export type EventTypeInfo = { export type WebhookDataType = CalendarEvent & EventTypeInfo & { - metadata?: { [key: string]: string }; + metadata?: { [key: string]: string | number | boolean | null }; bookingId?: number; status?: string; smsReminderNumber?: string; @@ -28,6 +28,7 @@ export type WebhookDataType = CalendarEvent & triggerEvent: string; createdAt: string; downloadLink?: string; + paymentId?: number; }; function getZapierPayload( @@ -77,11 +78,7 @@ function getZapierPayload( } function applyTemplate(template: string, data: WebhookDataType, contentType: ContentType) { - const organizer = JSON.stringify(data.organizer); - const attendees = JSON.stringify(data.attendees); - const formattedData = { ...data, metadata: JSON.stringify(data.metadata), organizer, attendees }; - - const compiled = compile(template)(formattedData).replace(/"/g, '"'); + const compiled = compile(template)(data).replace(/"/g, '"'); if (contentType === "application/json") { return JSON.stringify(jsonParse(compiled)); @@ -126,24 +123,37 @@ const sendPayload = async ( }); } - return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, contentType); + return _sendPayload(secretKey, webhook, body, contentType); }; -export const sendGenericWebhookPayload = async ( - secretKey: string | null, - triggerEvent: string, - createdAt: string, - webhook: Pick, - data: Record -) => { - const body = JSON.stringify(data); - return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, "application/json"); +export const sendGenericWebhookPayload = async ({ + secretKey, + triggerEvent, + createdAt, + webhook, + data, + rootData, +}: { + secretKey: string | null; + triggerEvent: string; + createdAt: string; + webhook: Pick; + data: Record; + rootData?: Record; +}) => { + const body = JSON.stringify({ + // Added rootData props first so that using the known(i.e. triggerEvent, createdAt, payload) properties in rootData doesn't override the known properties + ...rootData, + triggerEvent: triggerEvent, + createdAt: createdAt, + payload: data, + }); + + return _sendPayload(secretKey, webhook, body, "application/json"); }; const _sendPayload = async ( secretKey: string | null, - triggerEvent: string, - createdAt: string, webhook: Pick, body: string, contentType: "application/json" | "application/x-www-form-urlencoded" diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index 0bf8b35ba0..9429b5154a 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -63,8 +63,8 @@ function Component({ webhookId }: { webhookId: string }) { backButton /> { if ( subscriberUrlReserved({ diff --git a/packages/features/webhooks/pages/webhook-new-view.tsx b/packages/features/webhooks/pages/webhook-new-view.tsx index c7da3b97ad..bccddf531e 100644 --- a/packages/features/webhooks/pages/webhook-new-view.tsx +++ b/packages/features/webhooks/pages/webhook-new-view.tsx @@ -81,9 +81,9 @@ const NewWebhookView = () => { backButton /> app.slug)} - noRoutingFormTriggers={!!teamId} /> ); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 714626527b..6d302d7d03 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -58,6 +58,7 @@ export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES | export const NEXT_PUBLIC_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`; export const LOGO = "/calcom-logo-white-word.svg"; export const LOGO_ICON = "/cal-com-icon-white.svg"; +export const AVATAR_FALLBACK = "/avatar.svg"; export const FAVICON_16 = "/favicon-16x16.png"; export const FAVICON_32 = "/favicon-32x32.png"; export const APPLE_TOUCH_ICON = "/apple-touch-icon.png"; diff --git a/packages/lib/defaultAvatarImage.test.ts b/packages/lib/defaultAvatarImage.test.ts deleted file mode 100644 index 14d7ca2392..0000000000 --- a/packages/lib/defaultAvatarImage.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { defaultAvatarSrc, getPlaceholderAvatar } from "./defaultAvatarImage"; - -describe("Default Avatar Image tests", () => { - describe("fn: defaultAvatarSrc", () => { - it("should return a gravatar URL when an email is provided", () => { - const email = "john@example.com"; - const result = defaultAvatarSrc({ email }); - - expect(result).toEqual( - "https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=160&d=mp&r=PG" - ); - }); - - it("should return a gravatar URL when an MD5 hash is provided", () => { - const md5 = "my-md5-hash"; - const result = defaultAvatarSrc({ md5 }); - - expect(result).toEqual("https://www.gravatar.com/avatar/my-md5-hash?s=160&d=mp&r=PG"); - }); - - it("should return a gravatar URL using the MD5 hash when an email and MD5 hash are provided", () => { - const email = "john@example.com"; - const md5 = "my-md5-hash"; - - const result = defaultAvatarSrc({ email, md5 }); - - expect(result).toEqual("https://www.gravatar.com/avatar/my-md5-hash?s=160&d=mp&r=PG"); - }); - - it("should return an empty string when neither an email or MD5 hash is provided", () => { - const result = defaultAvatarSrc({}); - - expect(result).toEqual(""); - }); - }); - - describe("fn: getPlaceholderAvatar", () => { - it("should return the avatar URL when one is provided", () => { - const avatar = "https://example.com/avatar.png"; - const name = "John Doe"; - - const result = getPlaceholderAvatar(avatar, name); - - expect(result).toEqual(avatar); - }); - - it("should return a placeholder avatar URL when no avatar is provided", () => { - const name = "John Doe"; - - const result = getPlaceholderAvatar(null, name); - - expect(result).toEqual( - "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=John%20Doe" - ); - }); - - it("should return a placeholder avatar URL when no avatar is provided and no name is provided", () => { - const result = getPlaceholderAvatar(null, null); - - expect(result).toEqual( - "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=" - ); - }); - }); -}); diff --git a/packages/lib/defaultAvatarImage.ts b/packages/lib/defaultAvatarImage.ts index f9f3eb1864..2fee3bffee 100644 --- a/packages/lib/defaultAvatarImage.ts +++ b/packages/lib/defaultAvatarImage.ts @@ -1,19 +1,3 @@ -import md5Parser from "md5"; - -/** - * Provided either an email or an MD5 hash, return the URL for the Gravatar - * image aborting early if neither is provided. - */ -export const defaultAvatarSrc = function ({ email, md5 }: { md5?: string; email?: string }) { - if (!email && !md5) return ""; - - if (email && !md5) { - md5 = md5Parser(email); - } - - return `https://www.gravatar.com/avatar/${md5}?s=160&d=mp&r=PG`; -}; - /** * Given an avatar URL and a name, return the appropriate avatar URL. In the * event that no avatar URL is provided, return a placeholder avatar URL from diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 35e8cfe248..633ccffb84 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -136,7 +136,6 @@ export async function getTeamWithMembers(args: { // This should improve performance saving already app data found. const appDataMap = new Map(); - const members = team.members.map((obj) => { return { ...obj.user, @@ -144,7 +143,7 @@ export async function getTeamWithMembers(args: { accepted: obj.accepted, disableImpersonation: obj.disableImpersonation, avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`, - connectedApps: obj.user.credentials.map((cred) => { + connectedApps: obj?.user?.credentials?.map((cred) => { const appSlug = cred.app?.slug; let appData = appDataMap.get(appSlug); @@ -153,14 +152,13 @@ export async function getTeamWithMembers(args: { appDataMap.set(appSlug, appData); } - const isCalendar = cred?.app?.categories.includes("calendar"); - const externalId = isCalendar ? cred.destinationCalendars[0]?.externalId : undefined; - + const isCalendar = cred?.app?.categories?.includes("calendar") ?? false; + const externalId = isCalendar ? cred.destinationCalendars?.[0]?.externalId : null; return { - name: appData?.name, - logo: appData?.logo, + name: appData?.name ?? null, + logo: appData?.logo ?? null, app: cred.app, - externalId, + externalId: externalId ?? null, }; }), }; diff --git a/packages/lib/testEmails.ts b/packages/lib/testEmails.ts new file mode 100644 index 0000000000..fff8da1079 --- /dev/null +++ b/packages/lib/testEmails.ts @@ -0,0 +1,18 @@ +declare global { + // eslint-disable-next-line no-var + var testEmails: { + to: string; + from: string; + subject: string; + html: string; + }[]; +} + +export const setTestEmail = (email: (typeof globalThis.testEmails)[number]) => { + globalThis.testEmails = globalThis.testEmails || []; + globalThis.testEmails.push(email); +}; + +export const getTestEmails = () => { + return globalThis.testEmails; +}; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 80a4c25478..c6bf3208d4 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -56,6 +56,7 @@ model EventType { position Int @default(0) /// @zod.custom(imports.eventTypeLocations) locations Json? + /// @zod.min(1) length Int offsetStart Int @default(0) hidden Boolean @default(false) diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 56dd6e1133..d4bff45b70 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -337,7 +337,9 @@ export default async function main() { invite_link: process.env.ZAPIER_INVITE_LINK, }); } - + await createApp("make", "make", ["automation"], "make_automation", { + invite_link: "https://make.com/en/hq/app-invitation/6cb2772b61966508dd8f414ba3b44510", + }); await createApp("huddle01", "huddle01video", ["conferencing"], "huddle01_video"); // Payment apps diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index 13e5abd9ae..d68b8f0826 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -32,11 +32,10 @@ const userSelect = Prisma.validator()({ name: true, }); -const eventTypeSelect = Prisma.validator()({ +const userEventTypeSelect = Prisma.validator()({ // Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering position: true, hashedLink: true, - locations: true, destinationCalendar: true, userId: true, team: { @@ -53,13 +52,6 @@ const eventTypeSelect = Prisma.validator()({ users: { select: userSelect, }, - children: { - include: { - users: { - select: userSelect, - }, - }, - }, parentId: true, hosts: { select: { @@ -72,6 +64,17 @@ const eventTypeSelect = Prisma.validator()({ ...baseEventTypeSelect, }); +const teamEventTypeSelect = Prisma.validator()({ + ...userEventTypeSelect, + children: { + include: { + users: { + select: userSelect, + }, + }, + }, +}); + export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => { const mshipToNumber = (mship: MembershipRole) => Object.keys(MembershipRole).findIndex((mmship) => mmship === mship); @@ -118,7 +121,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }, }, eventTypes: { - select: eventTypeSelect, + select: teamEventTypeSelect, orderBy: [ { position: "desc", @@ -134,10 +137,12 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }, eventTypes: { where: { - team: null, + teamId: null, userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters), }, - select: eventTypeSelect, + select: { + ...userEventTypeSelect, + }, orderBy: [ { position: "desc", @@ -154,11 +159,15 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const mapEventType = (eventType: (typeof user.eventTypes)[number]) => ({ + type UserEventTypes = (typeof user.eventTypes)[number]; + type TeamEventTypeChildren = (typeof user.teams)[number]["team"]["eventTypes"][number]; + + const mapEventType = (eventType: UserEventTypes & Partial) => ({ ...eventType, - safeDescription: markdownToSafeHTML(eventType.description), - users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users, + safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined, + users: !!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users, metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined, + children: eventType.children, }); const userEventTypes = user.eventTypes.map(mapEventType); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index dc0f28781a..84bd5d76e5 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -241,13 +241,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } } - if (input?.price || input.metadata?.apps?.stripe?.price) { - data.price = input.price || input.metadata?.apps?.stripe?.price; + if (input.metadata?.apps?.stripe?.price) { + data.price = input.metadata?.apps?.stripe?.price; const paymentCredential = await ctx.prisma.credential.findFirst({ where: { userId: ctx.user.id, type: { - contains: "_payment", + equals: "stripe_payment", }, }, select: { @@ -260,6 +260,22 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const { default_currency } = stripeDataSchema.parse(paymentCredential.key); data.currency = default_currency; } + } + + if (input.metadata?.apps?.paypal?.price) { + data.price = input.metadata?.apps?.paypal?.price; + const paymentCredential = await ctx.prisma.credential.findFirst({ + where: { + userId: ctx.user.id, + type: { + equals: "paypal_payment", + }, + }, + select: { + type: true, + key: true, + }, + }); if (paymentCredential?.type === "paypal_payment" && input.metadata?.apps?.paypal?.currency) { data.currency = input.metadata?.apps?.paypal?.currency.toLowerCase(); } diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts index 8b82119d5f..3bb2a30d02 100644 --- a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts @@ -9,38 +9,20 @@ type ListOptions = { }; export const listOtherTeamHandler = async ({ ctx }: ListOptions) => { - const teamsInOrgIamNotPartOf = await prisma.membership.findMany({ + const teamsInOrgIamNotPartOf = await prisma.team.findMany({ where: { - userId: { - not: ctx.user.id, + parent: { + id: ctx.user?.organization?.id, }, - team: { - parent: { - is: { - id: ctx.user?.organization?.id, - }, - }, - members: { - none: { - userId: ctx.user.id, - }, + members: { + none: { + userId: ctx.user.id, }, }, }, - include: { - team: true, - }, - orderBy: { role: "desc" }, - distinct: ["teamId"], }); - return teamsInOrgIamNotPartOf.map(({ team, ...membership }) => ({ - role: membership.role, - accepted: membership.accepted, - userId: membership.userId, - isOrgAdmin: true, - ...team, - })); + return teamsInOrgIamNotPartOf; }; export default listOtherTeamHandler; diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx index c38b4e4441..2d37ad3eab 100644 --- a/packages/trpc/server/routers/viewer/slots/_router.tsx +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZGetScheduleInputSchema } from "./getSchedule.schema"; +import { ZRemoveSelectedSlotInputSchema } from "./removeSelectedSlot.schema"; import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; type SlotsRouterHandlerCache = { @@ -49,12 +50,14 @@ export const slotsRouter = router({ }); }), // This endpoint has no dependencies, it doesn't need its own file - removeSelectedSlotMark: publicProcedure.mutation(async ({ ctx }) => { - const { req, prisma } = ctx; - const uid = req?.cookies?.uid; - if (uid) { - await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); - } - return; - }), + removeSelectedSlotMark: publicProcedure + .input(ZRemoveSelectedSlotInputSchema) + .mutation(async ({ input, ctx }) => { + const { req, prisma } = ctx; + const uid = req?.cookies?.uid || input.uid; + if (uid) { + await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); + } + return; + }), }); diff --git a/packages/trpc/server/routers/viewer/slots/removeSelectedSlot.schema.ts b/packages/trpc/server/routers/viewer/slots/removeSelectedSlot.schema.ts new file mode 100644 index 0000000000..afdfab987c --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/removeSelectedSlot.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { removeSelectedSlotSchema } from "./types"; + +export const ZRemoveSelectedSlotInputSchema = removeSelectedSlotSchema; + +export type TRemoveSelectedSlotInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts index 330789f975..62e3a263ba 100644 --- a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -86,5 +86,7 @@ export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) => } } res?.setHeader("Set-Cookie", serialize("uid", uid, { path: "/", sameSite: "lax" })); - return; + return { + uid: uid, + }; }; diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index aed8403143..aca1834f62 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -57,3 +57,7 @@ export type Slot = { bookingUid?: string; users?: string[]; }; + +export const removeSelectedSlotSchema = z.object({ + uid: z.string().nullable(), +}); diff --git a/packages/ui/components/apps/AppCard.tsx b/packages/ui/components/apps/AppCard.tsx index 593d03f32d..1bbaca9f18 100644 --- a/packages/ui/components/apps/AppCard.tsx +++ b/packages/ui/components/apps/AppCard.tsx @@ -21,6 +21,7 @@ import { DropdownMenuLabel, DropdownItem, Avatar, + Badge, } from "@calcom/ui"; import { Button } from "../button"; @@ -154,15 +155,10 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar )}
- {appInstalled ? ( - - {t("installed", { count: appAdded })} - - ) : null} + {appInstalled ? {t("installed", { count: appAdded })} : null} {app.isTemplate && ( Template )} - {(app.isDefault || (!app.isDefault && app.isGlobal)) && ( {t("default")} diff --git a/packages/ui/components/avatar/Avatar.tsx b/packages/ui/components/avatar/Avatar.tsx index 4377f0fd22..2c3b3c4c6c 100644 --- a/packages/ui/components/avatar/Avatar.tsx +++ b/packages/ui/components/avatar/Avatar.tsx @@ -3,7 +3,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import Link from "next/link"; import classNames from "@calcom/lib/classNames"; -import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; import type { Maybe } from "@trpc/server"; @@ -17,7 +17,6 @@ export type AvatarProps = { title?: string; alt: string; href?: string; - gravatarFallbackMd5?: string; fallback?: React.ReactNode; accepted?: boolean; asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling @@ -35,7 +34,7 @@ const sizesPropsBySize = { } as const; export function Avatar(props: AvatarProps) { - const { imageSrc, gravatarFallbackMd5, size = "md", alt, title, href } = props; + const { imageSrc, size = "md", alt, title, href } = props; const rootClass = classNames("aspect-square rounded-full", sizesPropsBySize[size]); let avatar = ( <> - {props.fallback ? ( - props.fallback - ) : ( - {alt} - )} + {props.fallback ? props.fallback : {alt}} {props.accepted && ( diff --git a/packages/ui/components/avatar/avatar.stories.mdx b/packages/ui/components/avatar/avatar.stories.mdx index 94f650f13a..43e73630f5 100644 --- a/packages/ui/components/avatar/avatar.stories.mdx +++ b/packages/ui/components/avatar/avatar.stories.mdx @@ -29,10 +29,10 @@ Avatar group can be composed differently based on the number of user profile. - + - + @@ -146,7 +146,6 @@ Avatar group can be composed differently based on the number of user profile. args={{ size: "sm", alt: "Avatar Story", - gravatarFallbackMd5: "Ui@CAL.com", }} argTypes={{ size: { @@ -156,12 +155,11 @@ Avatar group can be composed differently based on the number of user profile. }, }, alt: { control: "text" }, - gravatarFallbackMd5: { control: "text" }, }}> - {({ size, alt, gravatarFallbackMd5 }) => ( + {({ size, alt }) => ( - + )} diff --git a/packages/ui/components/data-table/__storybook__/datatable.stories.mdx b/packages/ui/components/data-table/__storybook__/datatable.stories.mdx index 95807bd93a..c491d15f23 100644 --- a/packages/ui/components/data-table/__storybook__/datatable.stories.mdx +++ b/packages/ui/components/data-table/__storybook__/datatable.stories.mdx @@ -14,20 +14,54 @@ import { DataTable } from "../"; import { columns, filterableItems } from "./columns"; import { dataTableDemousers, dataTableSelectionActions } from "./data"; - + - +<Title title="DataTable" suffix="Brief" subtitle="Version 3.0 — Last Update: 28 Aug 2023" /> ## Definition +The `DataTable` component facilitates tabular data display with configurable columns, virtual scrolling, filtering, and interactive features for seamless dynamic table creation. + +## Structure + +The `DataTable` setup for tabular data, with columns, virtual scroll, sticky headers, and interactive features like filtering and row selection. + +<CustomArgsTable of={DataTable} /> + +## Dialog Story + <Canvas> - <Story name="Datatable"> - <DataTable - columns={columns} - data={dataTableDemousers} - filterableItems={filterableItems} - searchKey="username" - selectionOptions={dataTableSelectionActions} - /> + <Story + name="DataTable" + args={{ + columns: columns, + data: dataTableDemousers, + isLoading: false, + searchKey: "username", + filterableItems: filterableItems, + tableContainerRef: { current: null }, + selectionOptions: dataTableSelectionActions, + }} + argTypes={{ + tableContainerRef: { table: { disable: true } }, + searchKey: { + control: { + type: "select", + options: ["username", "email"], + }, + }, + selectionOptions: { control: { type: "object" } }, + onScroll: { table: { disable: true } }, + tableOverlay: { table: { disable: true } }, + CTA: { table: { disable: true } }, + tableCTA: { table: { disable: true } }, + }}> + {(args) => ( + <VariantsTable titles={["Default"]} columnMinWidth={1000}> + <VariantRow> + <DataTable {...args} /> + </VariantRow> + </VariantsTable> + )} </Story> </Canvas> diff --git a/packages/ui/components/form/wizard/wizard.stories.mdx b/packages/ui/components/form/wizard/wizard.stories.mdx new file mode 100644 index 0000000000..1b06fdc77b --- /dev/null +++ b/packages/ui/components/form/wizard/wizard.stories.mdx @@ -0,0 +1,59 @@ +import { Canvas, Meta, Story } from "@storybook/addon-docs"; +import { useRouter } from "next/router"; + +import { CustomArgsTable, Title, VariantsTable, VariantRow } from "@calcom/storybook/components"; + +import WizardForm from "./WizardForm"; + +<Meta title="UI/Form/WizardForm" component={WizardForm} /> + +<Title title="WizardForm" subtitle="Version 1.0 — Last Update: 5 Sep 2023" /> + +## Definition + +The `WizardForm` component provides a structure for creating multi-step forms or wizards. + +## Structure + +<CustomArgsTable of={WizardForm} /> + +## Note on Navigation + +Please be aware that the steps navigation is managed internally within the `WizardForm` component. As such, when viewing this component in Storybook, clicking the "Next" button will not showcase the transition to the subsequent step. + +To observe the actual step navigation behavior, please refer to the Storybook stories for the individual `Steps` component. + +## WizardForm Story + +<Canvas> + <Story + name="Basic" + args={{ + href: "/wizard", + steps: [ + { title: "Step 1", description: "Description for Step 1" }, + { title: "Step 2", description: "Description for Step 2" }, + { title: "Step 3", description: "Description for Step 3" }, + ], + }} + argTypes={{ + href: { + control: { + type: "text", + }, + }, + steps: { + control: { + type: "object", + }, + }, + }}> + {({ href, steps }) => ( + <VariantsTable titles={["Basic Wizard Form"]} columnMinWidth={150}> + <VariantRow> + <WizardForm href={href} steps={steps} /> + </VariantRow> + </VariantsTable> + )} + </Story> +</Canvas> diff --git a/setupVitest.ts b/setupVitest.ts new file mode 100644 index 0000000000..18104fa24c --- /dev/null +++ b/setupVitest.ts @@ -0,0 +1,7 @@ +import { vi } from "vitest"; +import createFetchMock from "vitest-fetch-mock"; + +const fetchMocker = createFetchMock(vi); + +// sets globalThis.fetch and globalThis.fetchMock to our mocked version +fetchMocker.enableMocks(); diff --git a/tests/libs/__mocks__/app-store.ts b/tests/libs/__mocks__/app-store.ts new file mode 100644 index 0000000000..2b74913d72 --- /dev/null +++ b/tests/libs/__mocks__/app-store.ts @@ -0,0 +1,17 @@ +import { beforeEach, vi } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as appStore from "@calcom/app-store"; + +vi.mock("@calcom/app-store", () => appStoreMock); + +beforeEach(() => { + mockReset(appStoreMock); +}); + +const appStoreMock = mockDeep<typeof appStore>({ + fallbackMockImplementation: () => { + throw new Error("Unimplemented"); + }, +}); +export default appStoreMock; diff --git a/tests/libs/__mocks__/libServerI18n.ts b/tests/libs/__mocks__/libServerI18n.ts new file mode 100644 index 0000000000..81d201d40d --- /dev/null +++ b/tests/libs/__mocks__/libServerI18n.ts @@ -0,0 +1,13 @@ +import { beforeEach, vi } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as i18n from "@calcom/lib/server/i18n"; + +vi.mock("@calcom/lib/server/i18n", () => i18nMock); + +beforeEach(() => { + mockReset(i18nMock); +}); + +const i18nMock = mockDeep<typeof i18n>(); +export default i18nMock; diff --git a/tests/libs/__mocks__/prisma.ts b/tests/libs/__mocks__/prisma.ts index d285b349a7..2b3153db07 100644 --- a/tests/libs/__mocks__/prisma.ts +++ b/tests/libs/__mocks__/prisma.ts @@ -5,6 +5,7 @@ import type { PrismaClient } from "@calcom/prisma"; vi.mock("@calcom/prisma", () => ({ default: prisma, + prisma, availabilityUserSelect: vi.fn(), userSelect: vi.fn(), })); diff --git a/tests/libs/__mocks__/reminderScheduler.ts b/tests/libs/__mocks__/reminderScheduler.ts new file mode 100644 index 0000000000..0881b17a68 --- /dev/null +++ b/tests/libs/__mocks__/reminderScheduler.ts @@ -0,0 +1,13 @@ +import { beforeEach, vi } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as reminderScheduler from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; + +vi.mock("@calcom/features/ee/workflows/lib/reminders/reminderScheduler", () => reminderSchedulerMock); + +beforeEach(() => { + mockReset(reminderSchedulerMock); +}); + +const reminderSchedulerMock = mockDeep<typeof reminderScheduler>(); +export default reminderSchedulerMock; diff --git a/tests/libs/__mocks__/videoClient.ts b/tests/libs/__mocks__/videoClient.ts new file mode 100644 index 0000000000..eb83c80ec3 --- /dev/null +++ b/tests/libs/__mocks__/videoClient.ts @@ -0,0 +1,13 @@ +import { beforeEach, vi } from "vitest"; +import { mockReset, mockDeep } from "vitest-mock-extended"; + +import type * as videoClient from "@calcom/core/videoClient"; + +vi.mock("@calcom/core/videoClient", () => videoClientMock); + +beforeEach(() => { + mockReset(videoClientMock); +}); + +const videoClientMock = mockDeep<typeof videoClient>(); +export default videoClientMock; diff --git a/vitest.config.ts b/vitest.config.ts index 6eea9436fc..d5aa8a132d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,12 @@ import { defineConfig } from "vitest/config"; +process.env.INTEGRATION_TEST_MODE = "true"; + export default defineConfig({ test: { coverage: { - provider: "c8", + provider: "v8", }, + testTimeout: 500000, }, }); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 6bda5c3c32..0940105554 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -17,6 +17,7 @@ const workspaces = packagedEmbedTestsOnly include: ["packages/**/*.{test,spec}.{ts,js}", "apps/**/*.{test,spec}.{ts,js}"], // TODO: Ignore the api until tests are fixed exclude: ["apps/api/**/*", "**/node_modules/**/*", "packages/embeds/**/*"], + setupFiles: ["setupVitest.ts"], }, }, { diff --git a/yarn.lock b/yarn.lock index 4b9938958b..cb2056aba6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3740,6 +3740,16 @@ __metadata: languageName: unknown linkType: soft +"@calcom/make@workspace:packages/app-store/make": + version: 0.0.0-use.local + resolution: "@calcom/make@workspace:packages/app-store/make" + dependencies: + "@calcom/lib": "*" + "@calcom/types": "*" + "@types/node-schedule": ^2.1.0 + languageName: unknown + linkType: soft + "@calcom/metapixel@workspace:packages/app-store/metapixel": version: 0.0.0-use.local resolution: "@calcom/metapixel@workspace:packages/app-store/metapixel" @@ -6962,7 +6972,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.13, @jridgewell/sourcemap-codec@npm:^1.4.14": +"@jridgewell/sourcemap-codec@npm:^1.4.13, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15": version: 1.4.15 resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8 @@ -13369,57 +13379,56 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:0.31.1": - version: 0.31.1 - resolution: "@vitest/expect@npm:0.31.1" +"@vitest/expect@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/expect@npm:0.34.3" dependencies: - "@vitest/spy": 0.31.1 - "@vitest/utils": 0.31.1 + "@vitest/spy": 0.34.3 + "@vitest/utils": 0.34.3 chai: ^4.3.7 - checksum: 0d1e135ae753d913231eae830da00ee42afca53d354898fb43f97e82398dcf17298c02e9989dd6b19b9b2909989248ef76d203d63f6af6f9159dc96959ea654b + checksum: 79afaa37d2efb7bb5503332caf389860b2261f198dbe61557e8061262b628d18658e59eb51d1808ecd35fc59f4bb4d04c0e0f97a27c7db02584ab5b424147b8d languageName: node linkType: hard -"@vitest/runner@npm:0.31.1": - version: 0.31.1 - resolution: "@vitest/runner@npm:0.31.1" +"@vitest/runner@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/runner@npm:0.34.3" dependencies: - "@vitest/utils": 0.31.1 - concordance: ^5.0.4 + "@vitest/utils": 0.34.3 p-limit: ^4.0.0 - pathe: ^1.1.0 - checksum: cc8702e21b799d5e941409cb2afe6d0e576b4f3ac99df4a1393a8cd11b57f6b0b06e756cc24e2739812d095fbfd0824e22e861dbd6a71769ca387d485ade6fb5 + pathe: ^1.1.1 + checksum: 945580eaa58e8edbe29a64059bc2a524a9e85117b6d600fdb457cfe84cbfb81bf6d7e98e1227e7cb4e7399992c8fe8d83d0791d0385ff005dc1a4d9da125443b languageName: node linkType: hard -"@vitest/snapshot@npm:0.31.1": - version: 0.31.1 - resolution: "@vitest/snapshot@npm:0.31.1" +"@vitest/snapshot@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/snapshot@npm:0.34.3" dependencies: - magic-string: ^0.30.0 - pathe: ^1.1.0 - pretty-format: ^27.5.1 - checksum: de05fa9136864f26f0804baf3ae8068f67de28015f29047329c84e67fb33be7305c9e52661b016e834d30f4081c136b3b6d8d4054c024a5d52b22a7f90fc4be0 + magic-string: ^0.30.1 + pathe: ^1.1.1 + pretty-format: ^29.5.0 + checksum: 234893e91a1efd4bdbbde047a68de40975e02ead8407724ce8ca4a24edf0fb2d725f8a3efceb104965388407b598faf22407aadfbf4164cc74b3cf1e0e9f4543 languageName: node linkType: hard -"@vitest/spy@npm:0.31.1": - version: 0.31.1 - resolution: "@vitest/spy@npm:0.31.1" +"@vitest/spy@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/spy@npm:0.34.3" dependencies: - tinyspy: ^2.1.0 - checksum: 8b06cf25fcc028c16106ec82f4ceb84d6dfa04d06f651bca4738ce2b99796d1fc4e0c10319767240755eff8ede2bff9d31d5a901fe92828d319c65001581137b + tinyspy: ^2.1.1 + checksum: a2b64b9c357a56ad2f2340ecd225ffe787e61afba4ffb24a6670aad3fc90ea2606ed48daa188ed62b3ef67d55c0259fda6b101143d6c91b58c9ac4298d8be4f9 languageName: node linkType: hard -"@vitest/utils@npm:0.31.1": - version: 0.31.1 - resolution: "@vitest/utils@npm:0.31.1" +"@vitest/utils@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/utils@npm:0.34.3" dependencies: - concordance: ^5.0.4 + diff-sequences: ^29.4.3 loupe: ^2.3.6 - pretty-format: ^27.5.1 - checksum: 58016c185455e3814632cb77e37368c846bde5e342f8b4a66fa229bde64f455ca39abebc9c12e2483696ee38bc17b3c4300379f7a3b18d1087f24f474448a8d8 + pretty-format: ^29.5.0 + checksum: aeb8ef7fd98b32cb6c403796880d0aa8f5411bbdb249bb23b3301a70e1b7d1ee025ddb204aae8c1db5756f6ac428c49ebbb8e2ed23ce185c8a659b67413efa85 languageName: node linkType: hard @@ -14018,6 +14027,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.10.0, acorn@npm:^8.9.0": + version: 8.10.0 + resolution: "acorn@npm:8.10.0" + bin: + acorn: bin/acorn + checksum: 538ba38af0cc9e5ef983aee196c4b8b4d87c0c94532334fa7e065b2c8a1f85863467bb774231aae91613fcda5e68740c15d97b1967ae3394d20faddddd8af61d + languageName: node + linkType: hard + "acorn@npm:^8.5.0": version: 8.7.1 resolution: "acorn@npm:8.7.1" @@ -15430,13 +15448,6 @@ __metadata: languageName: node linkType: hard -"blueimp-md5@npm:^2.10.0": - version: 2.19.0 - resolution: "blueimp-md5@npm:2.19.0" - checksum: 28095dcbd2c67152a2938006e8d7c74c3406ba6556071298f872505432feb2c13241b0476644160ee0a5220383ba94cb8ccdac0053b51f68d168728f9c382530 - languageName: node - linkType: hard - "bmp-js@npm:^0.1.0": version: 0.1.0 resolution: "bmp-js@npm:0.1.0" @@ -16126,7 +16137,8 @@ __metadata: tsc-absolute: ^1.0.0 turbo: ^1.10.1 typescript: ^4.9.4 - vitest: ^0.31.1 + vitest: ^0.34.3 + vitest-fetch-mock: ^0.2.2 vitest-mock-extended: ^1.1.3 languageName: unknown linkType: soft @@ -17232,22 +17244,6 @@ __metadata: languageName: node linkType: hard -"concordance@npm:^5.0.4": - version: 5.0.4 - resolution: "concordance@npm:5.0.4" - dependencies: - date-time: ^3.1.0 - esutils: ^2.0.3 - fast-diff: ^1.2.0 - js-string-escape: ^1.0.1 - lodash: ^4.17.15 - md5-hex: ^3.0.1 - semver: ^7.3.2 - well-known-symbols: ^2.0.0 - checksum: 749153ba711492feb7c3d2f5bb04c107157440b3e39509bd5dd19ee7b3ac751d1e4cd75796d9f702e0a713312dbc661421c68aa4a2c34d5f6d91f47e3a1c64a6 - languageName: node - linkType: hard - "concurrently@npm:^7.6.0": version: 7.6.0 resolution: "concurrently@npm:7.6.0" @@ -17668,6 +17664,15 @@ __metadata: languageName: node linkType: hard +"cross-fetch@npm:^3.0.6": + version: 3.1.8 + resolution: "cross-fetch@npm:3.1.8" + dependencies: + node-fetch: ^2.6.12 + checksum: 78f993fa099eaaa041122ab037fe9503ecbbcb9daef234d1d2e0b9230a983f64d645d088c464e21a247b825a08dc444a6e7064adfa93536d3a9454b4745b3632 + languageName: node + linkType: hard + "cross-spawn@npm:7.0.3, cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -18090,15 +18095,6 @@ __metadata: languageName: node linkType: hard -"date-time@npm:^3.1.0": - version: 3.1.0 - resolution: "date-time@npm:3.1.0" - dependencies: - time-zone: ^1.0.0 - checksum: f9cfcd1b15dfeabab15c0b9d18eb9e4e2d9d4371713564178d46a8f91ad577a290b5178b80050718d02d9c0cf646f8a875011e12d1ed05871e9f72c72c8a8fe6 - languageName: node - linkType: hard - "datocms-listen@npm:^0.1.9": version: 0.1.15 resolution: "datocms-listen@npm:0.1.15" @@ -20100,7 +20096,7 @@ __metadata: languageName: node linkType: hard -"esutils@npm:^2.0.2, esutils@npm:^2.0.3": +"esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" checksum: 22b5b08f74737379a840b8ed2036a5fb35826c709ab000683b092d9054e5c2a82c27818f12604bfc2a9a76b90b6834ef081edbc1c7ae30d1627012e067c6ec87 @@ -20555,13 +20551,6 @@ __metadata: languageName: node linkType: hard -"fast-diff@npm:^1.2.0": - version: 1.3.0 - resolution: "fast-diff@npm:1.3.0" - checksum: d22d371b994fdc8cce9ff510d7b8dc4da70ac327bcba20df607dd5b9cae9f908f4d1028f5fe467650f058d1e7270235ae0b8230809a262b4df587a3b3aa216c3 - languageName: node - linkType: hard - "fast-equals@npm:^1.6.3": version: 1.6.3 resolution: "fast-equals@npm:1.6.3" @@ -26730,12 +26719,12 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.0": - version: 0.30.0 - resolution: "magic-string@npm:0.30.0" +"magic-string@npm:^0.30.1": + version: 0.30.3 + resolution: "magic-string@npm:0.30.3" dependencies: - "@jridgewell/sourcemap-codec": ^1.4.13 - checksum: 7bdf22e27334d8a393858a16f5f840af63a7c05848c000fd714da5aa5eefa09a1bc01d8469362f25cc5c4a14ec01b46557b7fff8751365522acddf21e57c488d + "@jridgewell/sourcemap-codec": ^1.4.15 + checksum: a5a9ddf9bd3bf49a2de1048bf358464f1bda7b3cc1311550f4a0ba8f81a4070e25445d53a5ee28850161336f1bff3cf28aa3320c6b4aeff45ce3e689f300b2f3 languageName: node linkType: hard @@ -26929,15 +26918,6 @@ __metadata: languageName: node linkType: hard -"md5-hex@npm:^3.0.1": - version: 3.0.1 - resolution: "md5-hex@npm:3.0.1" - dependencies: - blueimp-md5: ^2.10.0 - checksum: 6799a19e8bdd3e0c2861b94c1d4d858a89220488d7885c1fa236797e367d0c2e5f2b789e05309307083503f85be3603a9686a5915568a473137d6b4117419cc2 - languageName: node - linkType: hard - "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -28000,6 +27980,18 @@ __metadata: languageName: node linkType: hard +"mlly@npm:^1.4.0": + version: 1.4.1 + resolution: "mlly@npm:1.4.1" + dependencies: + acorn: ^8.10.0 + pathe: ^1.1.1 + pkg-types: ^1.0.3 + ufo: ^1.3.0 + checksum: b2b59ab3d70196127be4e54609d2a442bd252345727138940fb245672a238b2fbdd431e8c75ec5c741ff90410ce488c5fd6446d5d3e6476d21dbf4c3fa35d4a0 + languageName: node + linkType: hard + "mock-fs@npm:^4.1.0": version: 4.14.0 resolution: "mock-fs@npm:4.14.0" @@ -28820,6 +28812,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + languageName: node + linkType: hard + "node-forge@npm:1.3.1, node-forge@npm:^1.0.0": version: 1.3.1 resolution: "node-forge@npm:1.3.1" @@ -30263,6 +30269,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.1": + version: 1.1.1 + resolution: "pathe@npm:1.1.1" + checksum: 34ab3da2e5aa832ebc6a330ffe3f73d7ba8aec6e899b53b8ec4f4018de08e40742802deb12cf5add9c73b7bf719b62c0778246bd376ca62b0fb23e0dde44b759 + languageName: node + linkType: hard + "pathval@npm:^1.1.1": version: 1.1.1 resolution: "pathval@npm:1.1.1" @@ -31145,7 +31158,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.0.2, pretty-format@npm:^27.5.1": +"pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" dependencies: @@ -34979,10 +34992,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.3.2": - version: 3.3.3 - resolution: "std-env@npm:3.3.3" - checksum: 6665f6d8bd63aae432d3eb9abbd7322847ad0d902603e6dce1e8051b4f42ceeb4f7f96a4faf70bb05ce65ceee2dc982502b701575c8a58b1bfad29f3dbb19f81 +"std-env@npm:^3.3.3": + version: 3.4.3 + resolution: "std-env@npm:3.4.3" + checksum: bef186fb2baddda31911234b1e58fa18f181eb6930616aaec3b54f6d5db65f2da5daaa5f3b326b98445a7d50ca81d6fe8809ab4ebab85ecbe4a802f1b40921bf languageName: node linkType: hard @@ -36333,13 +36346,6 @@ __metadata: languageName: node linkType: hard -"time-zone@npm:^1.0.0": - version: 1.0.0 - resolution: "time-zone@npm:1.0.0" - checksum: e46f5a69b8c236dcd8e91e29d40d4e7a3495ed4f59888c3f84ce1d9678e20461421a6ba41233509d47dd94bc18f1a4377764838b21b584663f942b3426dcbce8 - languageName: node - linkType: hard - "timed-out@npm:^4.0.0, timed-out@npm:^4.0.1": version: 4.0.1 resolution: "timed-out@npm:4.0.1" @@ -36425,17 +36431,17 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^0.5.0": - version: 0.5.0 - resolution: "tinypool@npm:0.5.0" - checksum: 4e0dfd8f28666d541c1d92304222edc4613f05d74fe2243c8520d466a2cc6596011a7072c1c41a7de7522351b82fda07e8038198e8f43673d8d69401c5903f8c +"tinypool@npm:^0.7.0": + version: 0.7.0 + resolution: "tinypool@npm:0.7.0" + checksum: fdcccd5c750574fce51f8801a877f8284e145d12b79cd5f2d72bfbddfe20c895e915555bc848e122bb6aa968098e7ac4fe1e8e88104904d518dc01cccd18a510 languageName: node linkType: hard -"tinyspy@npm:^2.1.0": - version: 2.1.0 - resolution: "tinyspy@npm:2.1.0" - checksum: cb83c1f74a79dd5934018bad94f60a304a29d98a2d909ea45fc367f7b80b21b0a7d8135a2ce588deb2b3ba56c7c607258b2a03e6001d89e4d564f9a95cc6a81f +"tinyspy@npm:^2.1.1": + version: 2.1.1 + resolution: "tinyspy@npm:2.1.1" + checksum: cfe669803a7f11ca912742b84c18dcc4ceecaa7661c69bc5eb608a8a802d541c48aba220df8929f6c8cd09892ad37cb5ba5958ddbbb57940e91d04681d3cee73 languageName: node linkType: hard @@ -37382,6 +37388,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.3.0": + version: 1.3.0 + resolution: "ufo@npm:1.3.0" + checksum: 01f0be86cd5c205ad1b49ebea985e000a4542c503ee75398302b0f5e4b9a6d9cd8e77af2dc614ab7bea08805fdfd9a85191fb3b5ee3df383cb936cf65e9db30d + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.15.3 resolution: "uglify-js@npm:3.15.3" @@ -38362,19 +38375,19 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:0.31.1": - version: 0.31.1 - resolution: "vite-node@npm:0.31.1" +"vite-node@npm:0.34.3": + version: 0.34.3 + resolution: "vite-node@npm:0.34.3" dependencies: cac: ^6.7.14 debug: ^4.3.4 - mlly: ^1.2.0 - pathe: ^1.1.0 + mlly: ^1.4.0 + pathe: ^1.1.1 picocolors: ^1.0.0 vite: ^3.0.0 || ^4.0.0 bin: vite-node: vite-node.mjs - checksum: f70ffa3f6dcb4937cdc99f59bf360d42de83c556ba9a19eb1c3504ef20db4c1d1afa644d9a8e63240e851c0c95773b64c526bdb3eb4794b5e941ddcd57124aa9 + checksum: 366c4f3fb7c038e2180abc6b18cfbac3b8684cd878eaf7ebf1ffb07d95d2ea325713fc575a7949a13bb00cfe264acbc28c02e2836b8647e1f443fe631c17805a languageName: node linkType: hard @@ -38452,6 +38465,17 @@ __metadata: languageName: node linkType: hard +"vitest-fetch-mock@npm:^0.2.2": + version: 0.2.2 + resolution: "vitest-fetch-mock@npm:0.2.2" + dependencies: + cross-fetch: ^3.0.6 + peerDependencies: + vitest: ">=0.16.0" + checksum: fa160f301171cd45dbf7d782880b6b6063fc74b9dd1965ef9206545e812ca8696e6be76662afbac822c6bf850fbb66cf8fb066af646e0e159f5a87ab25c97a02 + languageName: node + linkType: hard + "vitest-mock-extended@npm:^1.1.3": version: 1.1.3 resolution: "vitest-mock-extended@npm:1.1.3" @@ -38464,34 +38488,33 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^0.31.1": - version: 0.31.1 - resolution: "vitest@npm:0.31.1" +"vitest@npm:^0.34.3": + version: 0.34.3 + resolution: "vitest@npm:0.34.3" dependencies: "@types/chai": ^4.3.5 "@types/chai-subset": ^1.3.3 "@types/node": "*" - "@vitest/expect": 0.31.1 - "@vitest/runner": 0.31.1 - "@vitest/snapshot": 0.31.1 - "@vitest/spy": 0.31.1 - "@vitest/utils": 0.31.1 - acorn: ^8.8.2 + "@vitest/expect": 0.34.3 + "@vitest/runner": 0.34.3 + "@vitest/snapshot": 0.34.3 + "@vitest/spy": 0.34.3 + "@vitest/utils": 0.34.3 + acorn: ^8.9.0 acorn-walk: ^8.2.0 cac: ^6.7.14 chai: ^4.3.7 - concordance: ^5.0.4 debug: ^4.3.4 local-pkg: ^0.4.3 - magic-string: ^0.30.0 - pathe: ^1.1.0 + magic-string: ^0.30.1 + pathe: ^1.1.1 picocolors: ^1.0.0 - std-env: ^3.3.2 + std-env: ^3.3.3 strip-literal: ^1.0.1 tinybench: ^2.5.0 - tinypool: ^0.5.0 + tinypool: ^0.7.0 vite: ^3.0.0 || ^4.0.0 - vite-node: 0.31.1 + vite-node: 0.34.3 why-is-node-running: ^2.2.2 peerDependencies: "@edge-runtime/vm": "*" @@ -38521,7 +38544,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: b3f64a36102edc5b8594c085da648c838c0d275c620bd3b780624f936903b9c06579d6ef137fe9859e468f16deb8f154a50f009093119f9adb8b60ff1b7597ee + checksum: 4535d080feede94db5015eb60c6ed5f7b0d8cd67f12072de5ae1faded133cc640043c0c2646ef51ab9b61c2f885589da57458a65e82cf91a25cf954470018a40 languageName: node linkType: hard @@ -39225,13 +39248,6 @@ __metadata: languageName: node linkType: hard -"well-known-symbols@npm:^2.0.0": - version: 2.0.0 - resolution: "well-known-symbols@npm:2.0.0" - checksum: 4f54bbc3012371cb4d228f436891b8e7536d34ac61a57541890257e96788608e096231e0121ac24d08ef2f908b3eb2dc0adba35023eaeb2a7df655da91415402 - languageName: node - linkType: hard - "whatwg-encoding@npm:^2.0.0": version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0" @@ -40057,4 +40073,4 @@ __metadata: resolution: "zwitch@npm:2.0.4" checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6 languageName: node - linkType: hard \ No newline at end of file + linkType: hard