diff --git a/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec138..0000000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a933561ea8..7b477aef5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,22 @@ To develop locally: - Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file. - Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file. -6. Start developing and watch for code changes: +6. Setup Node + If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project: + + ```sh + nvm use + ``` + + You first might need to install the specific version and then use it: + + ```sh + nvm install && nvm use + ``` + + You can install nvm from [here](https://github.com/nvm-sh/nvm). + +7. Start developing and watch for code changes: ```sh yarn dev @@ -120,6 +135,16 @@ This will run and test all flows in multiple Chromium windows to verify that no yarn test-e2e ``` +#### Resolving issues + +##### E2E test browsers not installed + +Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`: + +``` +Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium +``` + ## Linting To check the formatting of your code: @@ -135,4 +160,4 @@ If you get errors, be sure to fix them before committing. - Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR. - If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). - Be sure to fill the PR Template accordingly. -- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations \ No newline at end of file +- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations diff --git a/README.md b/README.md index 944c4c6764..1f64390a7a 100644 --- a/README.md +++ b/README.md @@ -131,23 +131,39 @@ Here is what you need to be able to run Cal.com. > If you are on Windows, run the following command on `gitbash` with admin privileges:
> `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git`
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details. -1. Go to the project folder +2. Go to the project folder ```sh cd cal.com ``` -1. Install packages with yarn +3. Install packages with yarn ```sh yarn ``` -1. Set up your `.env` file +4. Set up your `.env` file + - Duplicate `.env.example` to `.env` - Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file. - Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file. +5. Setup Node + If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project: + + ```sh + nvm use + ``` + + You first might need to install the specific version and then use it: + + ```sh + nvm install && nvm use + ``` + + You can install nvm from [here](https://github.com/nvm-sh/nvm). + #### Quick start with `yarn dx` > - **Requires Docker and Docker Compose to be installed** @@ -221,6 +237,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env ``` 1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development + > **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1" ```sh @@ -259,6 +276,16 @@ yarn test-e2e yarn playwright show-report test-results/reports/playwright-html-report ``` +#### Resolving issues + +##### E2E test browsers not installed + +Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`: + +``` +Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium +``` + ### Upgrading from earlier versions 1. Pull the current version: @@ -470,9 +497,8 @@ following 4. Select Basecamp 4 as the product to integrate with. 5. Set the Redirect URL for OAuth `/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs. 6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields. -7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`. -For example, `Cal.com (support@cal.com)`. - +7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`. + For example, `Cal.com (support@cal.com)`. ### Obtaining HubSpot Client ID and Secret @@ -507,6 +533,7 @@ For example, `Cal.com (support@cal.com)`. ### Obtaining Zoho Calendar Client ID and Secret [Follow these steps](./packages/app-store/zohocalendar/) + ### Obtaining Zoho Bigin Client ID and Secret [Follow these steps](./packages/app-store/zoho-bigin/) diff --git a/apps/ai/README.md b/apps/ai/README.md index a504fcc19c..95316cd356 100644 --- a/apps/ai/README.md +++ b/apps/ai/README.md @@ -1,12 +1,12 @@ # Cal.com Email Assistant -Welcome to the first stage of Cal AI! +Welcome to the first stage of Cal.ai! This app lets you chat with your calendar via email: - - Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?" - - List and rearrange your bookings eg. "Cancel my next meeting" - - Answer basic questions about your busiest times eg. "How does my Tuesday look?" +- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?" +- List and rearrange your bookings eg. "Cancel my next meeting" +- Answer basic questions about your busiest times eg. "How does my Tuesday look?" The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings. @@ -24,10 +24,10 @@ If you haven't yet, please run the [root setup](/README.md) steps. Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need: - - An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4 - - A [SendGrid API key](https://app.sendgrid.com/settings/api_keys) - - A default sender email (for example, `ai@cal.dev`) - - The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts)) +- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4 +- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys) +- A default sender email (for example, `ai@cal.dev`) +- The Cal.ai's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts)) To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`. diff --git a/apps/ai/package.json b/apps/ai/package.json index a0dc4943ec..8eaf474ed1 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/ai", - "version": "1.1.0", + "version": "1.1.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 262a72e5e5..1697bdd4a5 100644 --- a/apps/ai/src/app/api/receive/route.ts +++ b/apps/ai/src/app/api/receive/route.ts @@ -61,9 +61,9 @@ export const POST = async (request: NextRequest) => { // 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.`, + 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: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`, + text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`, to: envelope.from, from: aiEmail, }); @@ -78,9 +78,9 @@ export const POST = async (request: NextRequest) => { const url = env.APP_URL; await sendEmail({ - html: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install it.`, + html: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install it.`, subject: `Re: ${body.subject}`, - text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`, + text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`, to: envelope.from, from: aiEmail, }); diff --git a/apps/ai/src/utils/agent.ts b/apps/ai/src/utils/agent.ts index 2164917d6f..1ef3319198 100644 --- a/apps/ai/src/utils/agent.ts +++ b/apps/ai/src/utils/agent.ts @@ -16,7 +16,7 @@ import now from "./now"; const gptModel = "gpt-4"; /** - * Core of the Cal AI booking agent: a LangChain Agent Executor. + * Core of the Cal.ai booking agent: a LangChain Agent Executor. * Uses a toolchain to book meetings, list available slots, etc. * Uses OpenAI functions to better enforce JSON-parsable output from the LLM. */ @@ -49,7 +49,7 @@ const agent = async ( */ const executor = await initializeAgentExecutorWithOptions(tools, model, { agentArgs: { - prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email. + prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email. Make sure your final answers are definitive, complete and well formatted. Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information. Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone. @@ -74,18 +74,19 @@ ${ ? `The email references the following @usernames and emails: ${users .map( (u) => - (u.id ? `, id: ${u.id}` : "id: (non user)") + - (u.username - ? u.type === "fromUsername" - ? `, username: @${u.username}` - : ", username: REDACTED" - : ", (no username)") + - (u.email - ? u.type === "fromEmail" - ? `, email: ${u.email}` - : ", email: REDACTED" - : ", (no email)") + - ";" + `${ + (u.id ? `, id: ${u.id}` : "id: (non user)") + + (u.username + ? u.type === "fromUsername" + ? `, username: @${u.username}` + : ", username: REDACTED" + : ", (no username)") + + (u.email + ? u.type === "fromEmail" + ? `, email: ${u.email}` + : ", email: REDACTED" + : ", (no email)") + };` ) .join("\n")}` : "" diff --git a/apps/ai/src/utils/extractUsers.ts b/apps/ai/src/utils/extractUsers.ts index b7c1345d59..0a5686bef1 100644 --- a/apps/ai/src/utils/extractUsers.ts +++ b/apps/ai/src/utils/extractUsers.ts @@ -1,8 +1,10 @@ +import prisma from "@calcom/prisma"; + +import type { UserList } from "../types/user"; + /* * Extracts usernames (@Example) and emails (hi@example.com) from a string */ -import type { UserList } from "../types/user"; - export const extractUsers = async (text: string) => { const usernames = text.match(/(? username.slice(1)); const emails = text.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g); diff --git a/apps/ai/src/utils/sendEmail.ts b/apps/ai/src/utils/sendEmail.ts index 799428091e..adbc9693e7 100644 --- a/apps/ai/src/utils/sendEmail.ts +++ b/apps/ai/src/utils/sendEmail.ts @@ -27,7 +27,7 @@ const send = async ({ cc, from: { email: from, - name: "Cal AI", + name: "Cal.ai", }, text, html, diff --git a/apps/api/test/lib/bookings/_post.test.ts b/apps/api/test/lib/bookings/_post.test.ts index 5e4b17f23d..a36b3b70fc 100644 --- a/apps/api/test/lib/bookings/_post.test.ts +++ b/apps/api/test/lib/bookings/_post.test.ts @@ -1,3 +1,5 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; + import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; import { createMocks } from "node-mocks-http"; @@ -8,7 +10,6 @@ import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder"; import prisma from "@calcom/prisma"; -import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; import handler from "../../../pages/api/bookings/_post"; type CustomNextApiRequest = NextApiRequest & Request; diff --git a/apps/web/components/AppListCard.tsx b/apps/web/components/AppListCard.tsx index b41cddd1be..7252a8ffc6 100644 --- a/apps/web/components/AppListCard.tsx +++ b/apps/web/components/AppListCard.tsx @@ -79,8 +79,8 @@ export default function AppListCard(props: AppListCardProps) { }, []); return ( -
-
+
+
{logo ? ( void; + listClassName?: string; } -export const AppList = ({ data, handleDisconnect, variant }: AppListProps) => { +export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppListProps) => { const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery(); const utils = trpc.useContext(); const [bulkUpdateModal, setBulkUpdateModal] = useState(false); @@ -155,7 +156,7 @@ export const AppList = ({ data, handleDisconnect, variant }: AppListProps) => { const { t } = useLocale(); return ( <> - + {cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))} {data.items .filter((item) => item.invalidCredentialIds) diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 992c29fef6..05c095caa1 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -89,6 +89,7 @@ export const AppPage = ({ const [existingCredentials, setExistingCredentials] = useState([]); const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false); + const appDbQuery = trpc.viewer.appCredentialsByType.useQuery( { appType: type }, { @@ -264,8 +265,8 @@ export const AppPage = ({ {price !== 0 && ( - {feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar} - {feeType === "monthly" && "/" + t("month")} + {feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar} + {feeType === "monthly" && `/${t("month")}`} )} @@ -285,7 +286,7 @@ export const AppPage = ({ currency: "USD", useGrouping: false, }).format(price)} - {feeType === "monthly" && "/" + t("month")} + {feeType === "monthly" && `/${t("month")}`} )} @@ -322,7 +323,7 @@ export const AppPage = ({ target="_blank" rel="noreferrer" className="text-emphasis font-normal no-underline hover:underline" - href={"mailto:" + email}> + href={`mailto:${email}`}> {email} diff --git a/apps/web/components/apps/CalendarListContainer.tsx b/apps/web/components/apps/CalendarListContainer.tsx index 87bce77cdc..b2c8135119 100644 --- a/apps/web/components/apps/CalendarListContainer.tsx +++ b/apps/web/components/apps/CalendarListContainer.tsx @@ -130,7 +130,7 @@ function ConnectedCalendarsList(props: Props) { title={t("something_went_wrong")} message={ - {item.integration.name}:{" "} + {item.integration.name}:{" "} {t("calendar_error")} } diff --git a/apps/web/components/apps/InstallAppButtonChild.tsx b/apps/web/components/apps/InstallAppButtonChild.tsx index 4cf2473bbe..b6ec80ddca 100644 --- a/apps/web/components/apps/InstallAppButtonChild.tsx +++ b/apps/web/components/apps/InstallAppButtonChild.tsx @@ -76,6 +76,7 @@ export const InstallAppButtonChild = ({ { if (mutation.isLoading) event.preventDefault(); }}> @@ -94,6 +95,7 @@ export const InstallAppButtonChild = ({ return ( (""); const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false); @@ -74,10 +73,8 @@ function BookingListItem(booking: BookingItemProps) { } utils.viewer.bookings.invalidate(); }, - onError: (e) => { - let message = t("booking_confirmation_failed"); - if ("message" in e) message = e.message; - showToast(message, "error"); + onError: () => { + showToast(t("booking_confirmation_failed"), "error"); utils.viewer.bookings.invalidate(); }, }); @@ -261,14 +258,16 @@ function BookingListItem(booking: BookingItemProps) { .concat(booking.recurringInfo?.bookings[BookingStatus.PENDING]) .sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime()); - const onClickTableData = () => { + const buildBookingLink = () => { const urlSearchParams = new URLSearchParams({ allRemainingBookings: isTabRecurring.toString(), }); if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email); - router.push(`/booking/${booking.uid}?${urlSearchParams.toString()}`); + return `/booking/${booking.uid}?${urlSearchParams.toString()}`; }; + const bookingLink = buildBookingLink(); + const title = booking.title; // To be used after we run query on legacy bookings // const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed; @@ -339,54 +338,11 @@ function BookingListItem(booking: BookingItemProps) { - -
-
{startTime}
-
- {formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} - {formatTime(booking.endTime, user?.timeFormat, user?.timeZone)} - -
- {isPending && ( - - {t("unconfirmed")} - - )} - {booking.eventType?.team && ( - - {booking.eventType.team.name} - - )} - {booking.paid && !booking.payment[0] ? ( - - {t("error_collecting_card")} - - ) : booking.paid ? ( - - {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} - - ) : null} - {recurringDates !== undefined && ( -
- -
- )} -
- - - {/* Time and Badges for mobile */} -
-
+ + +
{startTime}
-
+
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} {formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
+ {isPending && ( + + {t("unconfirmed")} + + )} + {booking.eventType?.team && ( + + {booking.eventType.team.name} + + )} + {booking.paid && !booking.payment[0] ? ( + + {t("error_collecting_card")} + + ) : booking.paid ? ( + + {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} + + ) : null} + {recurringDates !== undefined && ( +
+ +
+ )}
- - {isPending && ( - - {t("unconfirmed")} - - )} - {booking.eventType?.team && ( - - {booking.eventType.team.name} - - )} - {!!booking?.eventType?.price && !booking.paid && ( - - {t("pending_payment")} - - )} - {recurringDates !== undefined && ( -
- + + + + + {/* Time and Badges for mobile */} +
+
+
{startTime}
+
+ {formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} + {formatTime(booking.endTime, user?.timeFormat, user?.timeZone)} + +
- )} -
-
-
- {title} - - - {paymentAppData.enabled && !booking.paid && booking.payment.length && ( - + {isPending && ( + + {t("unconfirmed")} + + )} + {booking.eventType?.team && ( + + {booking.eventType.team.name} + + )} + {!!booking?.eventType?.price && !booking.paid && ( + {t("pending_payment")} )} + {recurringDates !== undefined && ( +
+ +
+ )}
- {booking.description && ( + +
- "{booking.description}" + title={title} + className={classNames( + "max-w-10/12 sm:max-w-56 text-emphasis text-sm font-medium leading-6 md:max-w-full", + isCancelled ? "line-through" : "" + )}> + {title} + + + {paymentAppData.enabled && !booking.paid && booking.payment.length && ( + + {t("pending_payment")} + + )}
- )} - {booking.attendees.length !== 0 && ( - - )} - {isCancelled && booking.rescheduled && ( -
- -
- )} -
+ {booking.description && ( +
+ "{booking.description}" +
+ )} + {booking.attendees.length !== 0 && ( + + )} + {isCancelled && booking.rescheduled && ( +
+ +
+ )} +
+ {isUpcoming && !isCancelled ? ( @@ -575,7 +576,7 @@ const FirstAttendee = ({ e.stopPropagation()}> {user.name} @@ -589,7 +590,7 @@ type AttendeeProps = { const Attendee = ({ email, name }: AttendeeProps) => { return ( - e.stopPropagation()}> + e.stopPropagation()}> {name || email} ); diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index c1be998af3..b891235c65 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -121,7 +121,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid URL for ${eventLocationType.label}. ${ - sampleUrl ? "Sample URL: " + sampleUrl : "" + sampleUrl ? `Sample URL: ${sampleUrl}` : "" }`, }); } diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index c95d28a4f9..11b1366ea0 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -34,7 +34,7 @@ import { TextField, Tooltip, } from "@calcom/ui"; -import { Copy, Edit } from "@calcom/ui/components/icon"; +import { Copy, Edit, Info } from "@calcom/ui/components/icon"; import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants"; import RequiresConfirmationController from "./RequiresConfirmationController"; @@ -67,7 +67,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick { - bookingFields[name] = name + " input"; + bookingFields[name] = `${name} input`; }); const eventNameObject: EventNameObjectType = { @@ -124,79 +124,81 @@ export const EventAdvancedTab = ({ eventType, team }: Pick formMethods.setValue("eventName", value); return ( -
+
{/** * Only display calendar selector if user has connected calendars AND if it's not * a team event. Since we don't have logic to handle each attendee calendar (for now). * This will fallback to each user selected destination calendar. */} - {!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && ( -
-
- - - {t("add_another_calendar")} - +
+ {!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && ( +
+
+ + + {t("add_another_calendar")} + +
+
+ ( + + )} + /> +
+

{t("select_which_cal")}

-
- ( - - )} - /> -
-

{t("select_which_cal")}

+ )} +
+ setShowEventNameTip((old) => !old)}> + + + } + />
- )} -
- setShowEventNameTip((old) => !old)}> - - - } +
+ + + +
+
-
-
- -
-
- -
+ -
+ ( )} /> -
+ ( )} /> -
+ ( <> - {/* Textfield has some margin by default we remove that so we can keep consistent alignment */} -
+
)} /> -
+ + + + } {...shouldLockDisableProps("hashedLinkCheck")} description={t("private_link_description", { appName: APP_NAME })} checked={hashedLinkVisible} @@ -285,8 +310,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick - {/* Textfield has some margin by default we remove that so we can keep consitant aligment */} -
+
{!IS_VISUAL_REGRESSION_TESTING && ( -
+ ( <> - ( -
- {t("seats")}} - onChange={(e) => { - onChange(Math.abs(Number(e.target.value))); - }} - /> -
- + ( +
+ formMethods.setValue("seatsShowAttendees", e.target.checked)} - defaultChecked={!!eventType.seatsShowAttendees} + defaultValue={value || 2} + min={1} + addOnSuffix={<>{t("seats")}} + onChange={(e) => { + onChange(Math.abs(Number(e.target.value))); + }} /> +
+ formMethods.setValue("seatsShowAttendees", e.target.checked)} + defaultChecked={!!eventType.seatsShowAttendees} + /> +
+
+ + formMethods.setValue("seatsShowAvailabilityCount", e.target.checked) + } + defaultChecked={!!eventType.seatsShowAvailabilityCount} + /> +
-
- formMethods.setValue("seatsShowAvailabilityCount", e.target.checked)} - defaultChecked={!!eventType.seatsShowAvailabilityCount} - /> -
-
- )} - /> + )} + /> +
{noShowFeeEnabled && } @@ -395,13 +429,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick {allowDisablingAttendeeConfirmationEmails(workflows) && ( <> -
( <> -
( <> {
{!shouldLockDisableProps("apps").disabled && ( -
+
{!isLoading && notInstalledApps?.length ? ( <>

@@ -166,7 +166,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {

- You have no apps installed. View popular apps below and explore more in our   + View popular apps below and explore more in our   App Store diff --git a/apps/web/components/eventtype/EventAvailabilityTab.tsx b/apps/web/components/eventtype/EventAvailabilityTab.tsx index 07ed8a9515..caf04147de 100644 --- a/apps/web/components/eventtype/EventAvailabilityTab.tsx +++ b/apps/web/components/eventtype/EventAvailabilityTab.tsx @@ -98,42 +98,43 @@ const EventTypeScheduleDetails = memo( schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || []; return ( -

-
    - {weekdayNames(i18n.language, 1, "long").map((day, index) => { - const isAvailable = !!filterDays(index).length; - return ( -
  1. - - {day} - - {isLoading ? ( - - ) : isAvailable ? ( -
    - {filterDays(index).map((dayRange, i) => ( -
    - - {format(dayRange.startTime, timeFormat === 12)} - - - -
    {format(dayRange.endTime, timeFormat === 12)}
    -
    - ))} -
    - ) : ( - {t("unavailable")} - )} -
  2. - ); - })} -
-
-
+
+
+
    + {weekdayNames(i18n.language, 1, "long").map((day, index) => { + const isAvailable = !!filterDays(index).length; + return ( +
  1. + + {day} + + {isLoading ? ( + + ) : isAvailable ? ( +
    + {filterDays(index).map((dayRange, i) => ( +
    + + {format(dayRange.startTime, timeFormat === 12)} + + - +
    {format(dayRange.endTime, timeFormat === 12)}
    +
    + ))} +
    + ) : ( + {t("unavailable")} + )} +
  2. + ); + })} +
+
+
{schedule?.timeZone || } @@ -234,8 +235,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => { }, [availabilityValue, setValue]); return ( -
-
+
+
{ - formMethods.setValue( - "periodCountCalendarDays", - opt?.value.toString() as "0" | "1" - ); - }} - defaultValue={ - optionsPeriod.find( - (opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0) - ) ?? optionsPeriod[0] - } - /> -
- )} - {period.type === "RANGE" && ( -
- ( - { + const isChecked = value && value !== "UNLIMITED"; + + return ( + formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}> +
+ formMethods.setValue("periodType", val as PeriodType)}> + {PERIOD_TYPES.filter((opt) => + periodTypeLocked.disabled ? watchPeriodType === opt.type : true + ).map((period) => { + if (period.type === "UNLIMITED") return null; + return ( +
+ {!periodTypeLocked.disabled && ( + + + + )} + {period.prefix ? {period.prefix}  : null} + {period.type === "ROLLING" && ( +
+ { - formMethods.setValue("periodDates", { - startDate, - endDate, - }); - }} + {...formMethods.register("periodDays", { valueAsNumber: true })} + defaultValue={eventType.periodDays || 30} /> - )} - /> + option.value === limitKey)} onChange={onIntervalSelect} + className="w-36" /> {hasDeleteButton && !disabled && ( -
); diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index 07a7d7d3b3..fbff6af59a 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -13,8 +13,10 @@ import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; +import { classNames } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import invertLogoOnDark from "@calcom/lib/invertLogoOnDark"; import { md } from "@calcom/lib/markdownIt"; import { slugify } from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; @@ -131,12 +133,12 @@ export const EventSetupTab = ( }; }); - const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180].map( - (mins) => ({ - value: mins, - label: t("multiple_duration_mins", { count: mins }), - }) - ); + const multipleDurationOptions = [ + 5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 480, + ].map((mins) => ({ + value: mins, + label: t("multiple_duration_mins", { count: mins }), + })); const [selectedMultipleDuration, setSelectedMultipleDuration] = useState< MultiValue<{ @@ -301,7 +303,10 @@ export const EventSetupTab = (
{`${eventLocationType.label} {`${eventLabel} ${ @@ -387,178 +392,185 @@ export const EventSetupTab = ( return (
-
- -
- - -
- - {urlPrefix}/ - {!isManagedEventType - ? team - ? (orgBranding ? "" : "team/") + team.slug - : eventType.users[0].username - : t("username_placeholder")} - / - - } - {...formMethods.register("slug", { - setValueAs: (v) => slugify(v), - })} - /> - {multipleDuration ? ( -
-
- - {t("available_durations")} - - t("default_duration_no_options")} - options={selectedMultipleDuration} - onChange={(option) => { - setDefaultDuration( - selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null - ); - if (option) formMethods.setValue("length", option.value); - }} - /> -
-
- ) : ( +
+
{t("minutes")}} - min={1} + label={t("title")} + {...shouldLockDisableProps("title")} + defaultValue={eventType.title} + {...formMethods.register("title")} /> - )} - {!lengthLockedProps.disabled && ( -
- { - if (multipleDuration !== undefined) { - setMultipleDuration(undefined); - formMethods.setValue("metadata.multipleDuration", undefined); - formMethods.setValue("length", eventType.length); - } else { - setMultipleDuration([]); - formMethods.setValue("metadata.multipleDuration", []); - formMethods.setValue("length", 0); - } - }} +
+ +
- )} -
- - {t("location")} - {shouldLockIndicator("locations")} - - - } + + {urlPrefix}/ + {!isManagedEventType + ? team + ? (orgBranding ? "" : "team/") + team.slug + : eventType.users[0].username + : t("username_placeholder")} + / + + } + {...formMethods.register("slug", { + setValueAs: (v) => slugify(v), + })} />
-
+
+ {multipleDuration ? ( +
+
+ + {t("available_durations")} + + t("default_duration_no_options")} + options={selectedMultipleDuration} + onChange={(option) => { + setDefaultDuration( + selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null + ); + if (option) formMethods.setValue("length", option.value); + }} + /> +
+
+ ) : ( + {t("minutes")}} + min={1} + /> + )} + {!lengthLockedProps.disabled && ( +
+ { + if (multipleDuration !== undefined) { + setMultipleDuration(undefined); + formMethods.setValue("metadata.multipleDuration", undefined); + formMethods.setValue("length", eventType.length); + } else { + setMultipleDuration([]); + formMethods.setValue("metadata.multipleDuration", []); + formMethods.setValue("length", 0); + } + }} + /> +
+ )} +
- {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */} - +
+
+ + {t("location")} + {shouldLockIndicator("locations")} + + + } + /> +
+
+ + {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */} + +
); }; diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index be3953160d..c0913422b9 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -247,7 +247,7 @@ function EventTypeSingleLayout({ return ( diff --git a/apps/web/components/eventtype/EventWebhooksTab.tsx b/apps/web/components/eventtype/EventWebhooksTab.tsx index 59f7bc7d36..b00bc86362 100644 --- a/apps/web/components/eventtype/EventWebhooksTab.tsx +++ b/apps/web/components/eventtype/EventWebhooksTab.tsx @@ -1,5 +1,7 @@ import type { Webhook } from "@prisma/client"; import { Webhook as TbWebhook } from "lucide-react"; +import { Trans } from "next-i18next"; +import Link from "next/link"; import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useState } from "react"; @@ -8,6 +10,7 @@ import { WebhookForm } from "@calcom/features/webhooks/components"; import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm"; import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem"; import { subscriberUrlReserved } from "@calcom/features/webhooks/lib/subscriberUrlReserved"; +import { APP_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui"; @@ -115,23 +118,40 @@ export const EventWebhooksTab = ({ eventType }: Pick -
- {webhooks.map((webhook, index) => { - return ( - { - setEditModalOpen(true); - setWebhookToEdit(webhook); - }} - /> - ); - })} +
+
{t("webhooks")}
+

+ {t("add_webhook_description", { appName: APP_NAME })} +

+ +
+ {webhooks.map((webhook, index) => { + return ( + { + setEditModalOpen(true); + setWebhookToEdit(webhook); + }} + /> + ); + })} +
+ +

+ + If you wish to edit or manage your web hooks, please head over to   + + webhooks settings + + +

- ) : ( - {recurringEventState && ( -
-
-

{t("repeats_every")}

- { - const newVal = { - ...recurringEventState, - interval: parseInt(event?.target.value), - }; - formMethods.setValue("recurringEvent", newVal); - setRecurringEventState(newVal); - }} - /> - { + const newVal = { + ...recurringEventState, + freq: parseInt(event?.value || `${Frequency.WEEKLY}`), + }; + formMethods.setValue("recurringEvent", newVal); + setRecurringEventState(newVal); + }} + /> +
+
+

{t("for_a_maximum_of")}

+ { + const newVal = { + ...recurringEventState, + count: parseInt(event?.target.value), + }; + formMethods.setValue("recurringEvent", newVal); + setRecurringEventState(newVal); + }} + /> +

+ {t("events", { + count: recurringEventState.count, + })} +

+
-
-

{t("for_a_maximum_of")}

- { - const newVal = { - ...recurringEventState, - count: parseInt(event?.target.value), - }; - formMethods.setValue("recurringEvent", newVal); - setRecurringEventState(newVal); - }} - /> -

- {t("events", { - count: recurringEventState.count, - })} -

-
-
- )} + )} +
)} diff --git a/apps/web/components/eventtype/RequiresConfirmationController.tsx b/apps/web/components/eventtype/RequiresConfirmationController.tsx index a9f94a28c0..e8789a2a48 100644 --- a/apps/web/components/eventtype/RequiresConfirmationController.tsx +++ b/apps/web/components/eventtype/RequiresConfirmationController.tsx @@ -67,6 +67,12 @@ export default function RequiresConfirmationController({ control={formMethods.control} render={() => ( - { - if (val === "always") { - formMethods.setValue("requiresConfirmation", true); - onRequiresConfirmation(true); - formMethods.setValue("metadata.requiresConfirmationThreshold", undefined); - setRequiresConfirmationSetup(undefined); - } else if (val === "notice") { - formMethods.setValue("requiresConfirmation", true); - onRequiresConfirmation(true); - formMethods.setValue( - "metadata.requiresConfirmationThreshold", - requiresConfirmationSetup || defaultRequiresConfirmationSetup - ); +
+ -
- {(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && ( - - )} - {(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && ( - - - { - const val = Number(evt.target?.value); - setRequiresConfirmationSetup({ - unit: - requiresConfirmationSetup?.unit ?? - defaultRequiresConfirmationSetup.unit, - time: val, - }); - formMethods.setValue( - "metadata.requiresConfirmationThreshold.time", - val - ); - }} - className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]" - defaultValue={metadata?.requiresConfirmationThreshold?.time || 30} - /> - -
- ), - }} - /> - - } - id="notice" - value="notice" - /> - )} -
-
+
+ ); +} + +Authorize.PageWrapper = PageWrapper; diff --git a/apps/web/pages/auth/sso/[provider].tsx b/apps/web/pages/auth/sso/[provider].tsx index a95be8c182..f83c5e455b 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -30,12 +30,12 @@ export default function Provider(props: SSOProviderPageProps) { const email = searchParams?.get("email"); if (!email) { - router.push("/auth/error?error=" + "Email not provided"); + router.push(`/auth/error?error=Email not provided`); return; } if (!props.isSAMLLoginEnabled) { - router.push("/auth/error?error=" + "SAML login not enabled"); + router.push(`/auth/error?error=SAML login not enabled`); return; } @@ -56,7 +56,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const providerParam = asStringOrNull(context.query.provider); const emailParam = asStringOrNull(context.query.email); const usernameParam = asStringOrNull(context.query.username); - const successDestination = "/getting-started" + (usernameParam ? `?username=${usernameParam}` : ""); + const successDestination = `/getting-started${usernameParam ? `?username=${usernameParam}` : ""}`; if (!providerParam) { throw new Error(`File is not named sso/[provider]`); } @@ -120,7 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => if (error) { return { redirect: { - destination: "/auth/error?error=" + error, + destination: `/auth/error?error=${error}`, permanent: false, }, }; diff --git a/apps/web/pages/auth/verify.tsx b/apps/web/pages/auth/verify.tsx index 12d2c4c4f5..8f6193d5ef 100644 --- a/apps/web/pages/auth/verify.tsx +++ b/apps/web/pages/auth/verify.tsx @@ -120,7 +120,7 @@ export default function Verify() { ? "Your payment failed" : sessionId ? "Payment successful!" - : "Verify your email" + " | " + APP_NAME} + : `Verify your email | ${APP_NAME}`}
diff --git a/apps/web/pages/availability/[schedule].tsx b/apps/web/pages/availability/[schedule].tsx index d4dd83c381..da393efe05 100644 --- a/apps/web/pages/availability/[schedule].tsx +++ b/apps/web/pages/availability/[schedule].tsx @@ -151,7 +151,7 @@ export default function Availability() { return (
- {new Intl.NumberFormat(i18n.language, { - style: "currency", - currency: props.paymentStatus.currency, - }).format(props.paymentStatus.amount / 100.0)} +
)} @@ -594,23 +592,24 @@ export default function Success(props: SuccessProps) {
+ download={`${props.eventType.title}.ics`}> { availability={availability} isUpdateMutationLoading={updateMutation.isLoading} formMethods={formMethods} - disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"} + // disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"} + disableBorder={true} currentUserMembership={currentUserMembership} isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
+ data-testid={`event-type-title-${type.id}`}> {type.title} {group.profile.slug ? ( + data-testid={`event-type-slug-${type.id}`}> {`/${ type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder") }/${type.slug}`} @@ -177,13 +177,13 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
+ data-testid={`event-type-title-${type.id}`}> {type.title} {group.profile.slug ? ( + data-testid={`event-type-slug-${type.id}`}> {`/${group.profile.slug}/${type.slug}`} ) : null} @@ -479,7 +479,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL )} - +
- + + + ) : ( +
+
{oAuthForm.getValues("name")}
+
Client Id
+
+ + {" "} + {clientId} + + + + +
+ {clientSecret ? ( + <> +
Client Secret
+
+ + {" "} + {clientSecret} + + + + +
+
{t("copy_client_secret_info")}
+ + ) : ( + <> + )} + +
+ )} +
+ ); +} diff --git a/apps/web/pages/settings/billing/index.tsx b/apps/web/pages/settings/billing/index.tsx index 2f3f2a9a6f..acf3b30578 100644 --- a/apps/web/pages/settings/billing/index.tsx +++ b/apps/web/pages/settings/billing/index.tsx @@ -22,12 +22,11 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => { <>
-

{title}

+

{title}

{description}

{children}
-
); }; @@ -45,14 +44,16 @@ const BillingView = () => { return ( <> - -
+ +
+
+ ); }; + if (isLoading || !data) { + return ( + + ); + } + return ( <> } + borderInShellHeader={true} /> - <> - {isLoading && } -
- {isLoading ? null : data?.length ? ( - <> -
- {data.map((apiKey, index) => ( - { - setApiKeyToEdit(apiKey); - setApiKeyModal(true); - }} - /> - ))} -
- - - ) : ( - } - /> - )} -
- +
+ {data?.length ? ( + <> +
+ {data.map((apiKey, index) => ( + { + setApiKeyToEdit(apiKey); + setApiKeyModal(true); + }} + /> + ))} +
+ + ) : ( + } + /> + )} +
diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index 1b2269824b..43ec340a97 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -3,8 +3,10 @@ import { Controller, useForm } from "react-hook-form"; import type { z } from "zod"; import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import ThemeLabel from "@calcom/features/settings/ThemeLabel"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { classNames } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan"; @@ -12,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { userMetadata } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { Alert, Button, @@ -22,7 +25,7 @@ import { SkeletonButton, SkeletonContainer, SkeletonText, - Switch, + SettingsToggle, UpgradeTeamsBadge, } from "@calcom/ui"; @@ -31,9 +34,9 @@ import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
-
+ +
+
@@ -44,49 +47,83 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
- - +
+
+ + +
); }; -const AppearanceView = () => { +const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; +const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; + +const AppearanceView = ({ + user, + hasPaidPlan, +}: { + user: RouterOutputs["viewer"]["me"]; + hasPaidPlan: boolean; +}) => { const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user, isLoading } = trpc.viewer.me.useQuery(); const [darkModeError, setDarkModeError] = useState(false); const [lightModeError, setLightModeError] = useState(false); + const [isCustomBrandColorChecked, setIsCustomBranColorChecked] = useState( + user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR + ); + const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false); - const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan(); - - const formMethods = useForm({ + const userThemeFormMethods = useForm({ defaultValues: { - theme: user?.theme, - brandColor: user?.brandColor || "#292929", - darkBrandColor: user?.darkBrandColor || "#fafafa", - hideBranding: user?.hideBranding, - metadata: user?.metadata as z.infer, + theme: user.theme, }, }); - const selectedTheme = formMethods.watch("theme"); + const { + formState: { isSubmitting: isUserThemeSubmitting, isDirty: isUserThemeDirty }, + reset: resetUserThemeReset, + } = userThemeFormMethods; + + const bookerLayoutFormMethods = useForm({ + defaultValues: { + metadata: user.metadata as z.infer, + }, + }); + + const { + formState: { isSubmitting: isBookerLayoutFormSubmitting, isDirty: isBookerLayoutFormDirty }, + reset: resetBookerLayoutThemeReset, + } = bookerLayoutFormMethods; + + const brandColorsFormMethods = useForm({ + defaultValues: { + brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR, + darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR, + }, + }); + + const { + formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty }, + reset: resetBrandColorsThemeReset, + } = brandColorsFormMethods; + + const selectedTheme = userThemeFormMethods.watch("theme"); const selectedThemeIsDark = selectedTheme === "dark" || (selectedTheme === "" && typeof document !== "undefined" && document.documentElement.classList.contains("dark")); - const { - formState: { isSubmitting, isDirty }, - reset, - } = formMethods; - const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (data) => { await utils.viewer.me.invalidate(); showToast(t("settings_updated_successfully"), "success"); - reset(data); + resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor }); + resetBookerLayoutThemeReset({ metadata: data.metadata }); + resetUserThemeReset({ theme: data.theme }); }, onError: (error) => { if (error.message) { @@ -97,136 +134,180 @@ const AppearanceView = () => { }, }); - if (isLoading || isTeamPlanStatusLoading) - return ; - - if (!user) return null; - - const isDisabled = isSubmitting || !isDirty; - return ( -
{ - const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null); - if (layoutError) throw new Error(t(layoutError)); - - mutation.mutate({ - ...values, - // Radio values don't support null as values, therefore we convert an empty string - // back to null here. - theme: values.theme || null, - }); - }}> - -
+
+ +
-

{t("theme")}

+

{t("theme")}

{t("theme_applies_note")}

-
- - - -
- -
- - -
-
-
-

{t("custom_brand_colors")}

-

{t("customize_your_brand_colors")}

+ { + mutation.mutate({ + // Radio values don't support null as values, therefore we convert an empty string + // back to null here. + theme: values.theme || null, + }); + }}> +
+ + +
-
+ + + + -
- ( -
-

{t("light_brand_color")}

- { + const layoutError = validateBookerLayouts(values?.metadata?.defaultBookerLayouts || null); + if (layoutError) { + showToast(t(layoutError), "error"); + return; + } else { + mutation.mutate(values); + } + }}> + + + +
{ + mutation.mutate(values); + }}> +
+ { + setIsCustomBranColorChecked(checked); + if (!checked) { + mutation.mutate({ + brandColor: DEFAULT_LIGHT_BRAND_COLOR, + darkBrandColor: DEFAULT_DARK_BRAND_COLOR, + }); + } + }} + childrenClassName="lg:ml-0" + switchContainerClassName={classNames( + "py-6 px-4 sm:px-6 border-subtle rounded-xl border", + isCustomBrandColorChecked && "rounded-b-none" + )}> +
+ { - if (!checkWCAGContrastColor("#ffffff", value)) { - setLightModeError(true); - } else { - setLightModeError(false); - } - formMethods.setValue("brandColor", value, { shouldDirty: true }); - }} + render={() => ( +
+

{t("light_brand_color")}

+ { + try { + checkWCAGContrastColor("#ffffff", value); + setLightModeError(false); + brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); + } catch (err) { + setLightModeError(false); + } + }} + /> + {lightModeError ? ( +
+ +
+ ) : null} +
+ )} /> -
- )} - /> - ( -
-

{t("dark_brand_color")}

- { - if (!checkWCAGContrastColor("#101010", value)) { - setDarkModeError(true); - } else { - setDarkModeError(false); - } - formMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - }} + render={() => ( +
+

{t("dark_brand_color")}

+ { + try { + checkWCAGContrastColor("#101010", value); + setDarkModeError(false); + brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); + } catch (err) { + setDarkModeError(true); + } + }} + /> + {darkModeError ? ( +
+ +
+ ) : null} +
+ )} />
- )} - /> -
- {darkModeError ? ( -
- + + + +
- ) : null} - {lightModeError ? ( -
- -
- ) : null} +
+ {/* TODO future PR to preview brandColors */} {/* */} -
- ( - <> -
-
-
-

- {t("disable_cal_branding", { appName: APP_NAME })} -

- -
-

{t("removes_cal_branding", { appName: APP_NAME })}

-
-
- - formMethods.setValue("hideBranding", checked, { shouldDirty: true }) - } - checked={hasPaidPlan ? value : false} - /> -
-
- - )} + + } + onCheckedChange={(checked) => { + setHideBrandingValue(checked); + mutation.mutate({ hideBranding: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" /> - - +
); }; -AppearanceView.getLayout = getLayout; -AppearanceView.PageWrapper = PageWrapper; +const AppearanceViewWrapper = () => { + const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { isLoading: isTeamPlanStatusLoading, hasPaidPlan } = useHasPaidPlan(); -export default AppearanceView; + const { t } = useLocale(); + + if (isLoading || isTeamPlanStatusLoading || !user) + return ; + + return ; +}; + +AppearanceViewWrapper.getLayout = getLayout; +AppearanceViewWrapper.PageWrapper = PageWrapper; + +export default AppearanceViewWrapper; diff --git a/apps/web/pages/settings/my-account/calendars.tsx b/apps/web/pages/settings/my-account/calendars.tsx index f9c630d473..2936f18535 100644 --- a/apps/web/pages/settings/my-account/calendars.tsx +++ b/apps/web/pages/settings/my-account/calendars.tsx @@ -1,11 +1,12 @@ import { Trans } from "next-i18next"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Fragment } from "react"; +import { Fragment, useState, useEffect } from "react"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch"; import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -34,13 +35,13 @@ import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = () => { return ( -
+
- +
); @@ -65,6 +66,21 @@ const CalendarsView = () => { const utils = trpc.useContext(); const query = trpc.viewer.connectedCalendars.useQuery(); + + const [selectedDestinationCalendarOption, setSelectedDestinationCalendar] = useState<{ + integration: string; + externalId: string; + } | null>(null); + + useEffect(() => { + if (query?.data?.destinationCalendar) { + setSelectedDestinationCalendar({ + integration: query.data.destinationCalendar.integration, + externalId: query.data.destinationCalendar.externalId, + }); + } + }, [query?.isLoading, query?.data?.destinationCalendar]); + const mutation = trpc.viewer.setDestinationCalendar.useMutation({ async onSettled() { await utils.viewer.connectedCalendars.invalidate(); @@ -79,43 +95,58 @@ const CalendarsView = () => { return ( <> - } /> + } + borderInShellHeader={false} + /> } success={({ data }) => { + const isDestinationUpdateBtnDisabled = + selectedDestinationCalendarOption?.externalId === query?.data?.destinationCalendar?.externalId; return data.connectedCalendars.length ? (
-
-
- -
- -
-
-

- {t("add_to_calendar")} -

-

- - Where to add events when you re booked. You can override this on a per-event basis in - advanced settings in the event type. - -

-
- -
+
+

+ {t("add_to_calendar")} +

+

{t("add_to_calendar_description")}

-

- {t("check_for_conflicts")} -

-

{t("select_calendars")}

- +
+ { + setSelectedDestinationCalendar(option); + }} + isLoading={mutation.isLoading} + /> +
+ + + + +
+

+ {t("check_for_conflicts")} +

+

{t("select_calendars")}

+
+ + {data.connectedCalendars.map((item) => ( {item.error && item.error.message && ( @@ -159,7 +190,7 @@ const CalendarsView = () => { }
- + {item.integration.name || item.integration.title} {data?.destinationCalendar?.credentialId === item.credentialId && ( @@ -207,6 +238,7 @@ const CalendarsView = () => { description={t("no_calendar_installed_description")} buttonText={t("add_a_calendar")} buttonOnClick={() => router.push("/apps/categories/calendar")} + className="mt-6" /> ); }} diff --git a/apps/web/pages/settings/my-account/conferencing.tsx b/apps/web/pages/settings/my-account/conferencing.tsx index be48afc6ca..9617369037 100644 --- a/apps/web/pages/settings/my-account/conferencing.tsx +++ b/apps/web/pages/settings/my-account/conferencing.tsx @@ -15,8 +15,8 @@ import { AppList } from "@components/apps/AppList"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
+ +
@@ -28,11 +28,9 @@ const AddConferencingButton = () => { const { t } = useLocale(); return ( - <> - - + ); }; @@ -72,6 +70,7 @@ const ConferencingLayout = () => { title={t("conferencing")} description={t("conferencing_description")} CTA={} + borderInShellHeader={true} /> { color="secondary" data-testid="connect-conferencing-apps" href="/apps/categories/conferencing"> - {t("connect_conferencing_apps")} + {t("connect_conference_apps")} } /> ); } - return ; + return ( + + ); }} />
diff --git a/apps/web/pages/settings/my-account/general.tsx b/apps/web/pages/settings/my-account/general.tsx index 688a79ff6a..131ce856b4 100644 --- a/apps/web/pages/settings/my-account/general.tsx +++ b/apps/web/pages/settings/my-account/general.tsx @@ -1,6 +1,8 @@ import { useSession } from "next-auth/react"; +import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localeOptions } from "@calcom/lib/i18n"; @@ -13,12 +15,12 @@ import { Label, Meta, Select, - SettingsToggle, showToast, SkeletonButton, SkeletonContainer, SkeletonText, TimezoneSelect, + SettingsToggle, } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; @@ -26,14 +28,14 @@ import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
+ +
- +
); @@ -59,6 +61,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { const utils = trpc.useContext(); const { t } = useLocale(); const { update } = useSession(); + const [isUpdateBtnLoading, setIsUpdateBtnLoading] = useState(false); const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { @@ -72,6 +75,7 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { }, onSettled: async () => { await utils.viewer.me.invalidate(); + setIsUpdateBtnLoading(false); }, }); @@ -105,9 +109,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { value: user.weekStart, label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1), }, - allowDynamicBooking: user.allowDynamicBooking ?? true, - allowSEOIndexing: user.allowSEOIndexing ?? true, - receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail ?? true, }, }); const { @@ -117,151 +118,150 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => { } = formMethods; const isDisabled = isSubmitting || !isDirty; + const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState( + !!user.allowDynamicBooking + ); + const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(!!user.allowSEOIndexing); + const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState( + !!user.receiveMonthlyDigestEmail + ); + return ( -
{ - mutation.mutate({ - ...values, - locale: values.locale.value, - timeFormat: values.timeFormat.value, - weekStart: values.weekStart.value, - }); - }}> - - ( - <> - - - className="capitalize" - options={localeOptions} - value={value} - onChange={onChange} - /> - - )} - /> - ( - <> - - { - if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true }); - }} - /> - - )} - /> - ( - <> - - { - if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true }); - }} - /> - - )} - /> -
- ( - { - formMethods.setValue("allowDynamicBooking", checked, { shouldDirty: true }); - }} - /> - )} - /> -
+
+ { + setIsUpdateBtnLoading(true); + mutation.mutate({ + ...values, + locale: values.locale.value, + timeFormat: values.timeFormat.value, + weekStart: values.weekStart.value, + }); + }}> + +
+ ( + <> + + + className="capitalize" + options={localeOptions} + value={value} + onChange={onChange} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true }); + }} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true }); + }} + /> + + )} + /> +
-
- ( - { - formMethods.setValue("allowSEOIndexing", checked, { shouldDirty: true }); - }} - /> - )} - /> -
+ + + + -
- ( - { - formMethods.setValue("receiveMonthlyDigestEmail", checked, { shouldDirty: true }); - }} - /> - )} - /> -
+ { + setIsAllowDynamicBookingChecked(checked); + mutation.mutate({ allowDynamicBooking: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + /> - - + { + setIsAllowSEOIndexingChecked(checked); + mutation.mutate({ allowSEOIndexing: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + /> + + { + setIsReceiveMonthlyDigestEmailChecked(checked); + mutation.mutate({ receiveMonthlyDigestEmail: checked }); + }} + switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + /> +
); }; diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 2b738408e5..33084f4369 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -7,8 +7,10 @@ import { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import turndown from "@calcom/lib/turndownService"; @@ -47,8 +49,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( - -
+ +
@@ -69,18 +71,37 @@ interface DeleteAccountValues { type FormValues = { username: string; - avatar: string; + avatar: string | null; name: string; email: string; bio: string; }; +const checkIfItFallbackImage = (fetchedImgSrc: string) => { + return fetchedImgSrc.endsWith(AVATAR_FALLBACK); +}; + const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useContext(); const { update } = useSession(); - const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const [fetchedImgSrc, setFetchedImgSrc] = useState(undefined); + + const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, { + onSuccess: async (userData) => { + try { + if (!userData.organization) { + const res = await fetch(userData.avatar); + if (res.url) setFetchedImgSrc(res.url); + } else { + setFetchedImgSrc(""); + } + } catch (err) { + setFetchedImgSrc(""); + } + }, + }); const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); @@ -204,7 +225,7 @@ const ProfileView = () => { [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), }; - if (isLoading || !user) + if (isLoading || !user || fetchedImgSrc === undefined) return ( ); @@ -219,11 +240,17 @@ const ProfileView = () => { return ( <> - + { if (values.email !== user.email && isCALIdentityProvider) { @@ -238,7 +265,7 @@ const ProfileView = () => { } }} extraField={ -
+
{ showToast(t("settings_updated_successfully"), "success"); @@ -252,16 +279,19 @@ const ProfileView = () => { } /> -
- - +
+ +

{t("account_deletion_cannot_be_undone")}

+
{/* Delete account Dialog */} - - - + + + + + void; extraField?: React.ReactNode; isLoading: boolean; + isFallbackImg: boolean; + userAvatar: string; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { const { t } = useLocale(); @@ -377,7 +411,7 @@ const ProfileForm = ({ const profileFormSchema = z.object({ username: z.string(), - avatar: z.string(), + avatar: z.string().nullable(), name: z .string() .trim() @@ -402,56 +436,77 @@ const ProfileForm = ({ return (
-
- ( - <> - -
- { - formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); - }} - imageSrc={value || undefined} - /> -
- - )} - /> +
+
+ { + const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value); + return ( + <> + +
+

{t("profile_picture")}

+
+ { + formMethods.setValue("avatar", newAvatar, { shouldDirty: true }); + }} + imageSrc={value || undefined} + triggerButtonColor={showRemoveAvatarButton ? "secondary" : "primary"} + /> + + {showRemoveAvatarButton && ( + + )} +
+
+ + ); + }} + /> +
+ {extraField} +
+ +
+
+ +
+
+ + md.render(formMethods.getValues("bio") || "")} + setText={(value: string) => { + formMethods.setValue("bio", turndown(value), { shouldDirty: true }); + }} + excludedToolbarItems={["blockType"]} + disableLists + firstRender={firstRender} + setFirstRender={setFirstRender} + /> +
- {extraField} -
- -
-
- -
-
- - md.render(formMethods.getValues("bio") || "")} - setText={(value: string) => { - formMethods.setValue("bio", turndown(value), { shouldDirty: true }); - }} - excludedToolbarItems={["blockType"]} - disableLists - firstRender={firstRender} - setFirstRender={setFirstRender} - /> -
- + + + ); }; diff --git a/apps/web/pages/settings/security/impersonation.tsx b/apps/web/pages/settings/security/impersonation.tsx index fbe31be02b..d3afab267e 100644 --- a/apps/web/pages/settings/security/impersonation.tsx +++ b/apps/web/pages/settings/security/impersonation.tsx @@ -1,23 +1,34 @@ -import type { GetServerSidePropsContext } from "next"; -import { useForm } from "react-hook-form"; +import { useState } from "react"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Button, Form, Label, Meta, showToast, Skeleton, Switch } from "@calcom/ui"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Meta, showToast, SettingsToggle, SkeletonContainer, SkeletonText } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -import { ssrInit } from "@server/lib/ssr"; +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { + return ( + + +
+ +
+
+ ); +}; -const ProfileImpersonationView = () => { +const ProfileImpersonationView = ({ user }: { user: RouterOutputs["viewer"]["me"] }) => { const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user } = trpc.viewer.me.useQuery(); + const [disableImpersonation, setDisableImpersonation] = useState( + user?.disableImpersonation + ); + const mutation = trpc.viewer.updateProfile.useMutation({ onSuccess: () => { showToast(t("profile_updated_successfully"), "success"); - reset(getValues()); }, onSettled: () => { utils.viewer.me.invalidate(); @@ -26,83 +37,54 @@ const ProfileImpersonationView = () => { await utils.viewer.me.cancel(); const previousValue = utils.viewer.me.getData(); - if (previousValue && disableImpersonation) { - utils.viewer.me.setData(undefined, { ...previousValue, disableImpersonation }); - } + setDisableImpersonation(disableImpersonation); + return { previousValue }; }, onError: (error, variables, context) => { if (context?.previousValue) { utils.viewer.me.setData(undefined, context.previousValue); + setDisableImpersonation(context.previousValue?.disableImpersonation); } showToast(`${t("error")}, ${error.message}`, "error"); }, }); - const formMethods = useForm<{ disableImpersonation: boolean }>({ - defaultValues: { - disableImpersonation: user?.disableImpersonation, - }, - }); - - const { - formState: { isSubmitting, isDirty }, - setValue, - reset, - getValues, - watch, - } = formMethods; - - const isDisabled = isSubmitting || !isDirty; return ( <> - -
{ - mutation.mutate({ disableImpersonation }); - }}> -
- { - setValue("disableImpersonation", !e, { shouldDirty: true }); - }} - fitToHeight={true} - checked={!watch("disableImpersonation")} - /> -
- - {t("user_impersonation_heading")} - - - {t("user_impersonation_description")} - -
-
- -
+ +
+ { + mutation.mutate({ disableImpersonation: !checked }); + }} + disabled={mutation.isLoading} + switchContainerClassName="py-6 px-4 sm:px-6 border-subtle rounded-b-xl border border-t-0" + /> +
); }; -ProfileImpersonationView.getLayout = getLayout; -ProfileImpersonationView.PageWrapper = PageWrapper; +const ProfileImpersonationViewWrapper = () => { + const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { t } = useLocale(); -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - const ssr = await ssrInit(context); - await ssr.viewer.me.prefetch(); - return { - props: { - trpcState: ssr.dehydrate(), - }, - }; + if (isLoading || !user) + return ; + + return ; }; -export default ProfileImpersonationView; +ProfileImpersonationViewWrapper.getLayout = getLayout; +ProfileImpersonationViewWrapper.PageWrapper = PageWrapper; + +export default ProfileImpersonationViewWrapper; diff --git a/apps/web/pages/settings/security/password.tsx b/apps/web/pages/settings/security/password.tsx index 6da897b430..71077c9447 100644 --- a/apps/web/pages/settings/security/password.tsx +++ b/apps/web/pages/settings/security/password.tsx @@ -1,13 +1,29 @@ import { signOut, useSession } from "next-auth/react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { identityProviderNameMap } from "@calcom/features/auth/lib/identityProviderNameMap"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { IdentityProvider } from "@calcom/prisma/enums"; -import { userMetadata } from "@calcom/prisma/zod-utils"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; -import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui"; +import { + Alert, + Button, + Form, + Meta, + PasswordField, + Select, + SettingsToggle, + showToast, + SkeletonButton, + SkeletonContainer, + SkeletonText, +} from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; @@ -18,34 +34,58 @@ type ChangePasswordSessionFormValues = { apiError: string; }; -const PasswordView = () => { +interface PasswordViewProps { + user: RouterOutputs["viewer"]["me"]; +} + +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { + return ( + + +
+ + + +
+
+ + + +
+
+ ); +}; + +const PasswordView = ({ user }: PasswordViewProps) => { const { data } = useSession(); const { t } = useLocale(); const utils = trpc.useContext(); - const { data: user } = trpc.viewer.me.useQuery(); - const metadata = userMetadata.safeParse(user?.metadata); - const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined; + const metadata = userMetadataSchema.safeParse(user?.metadata); + const initialSessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined; + + const [sessionTimeout, setSessionTimeout] = useState(initialSessionTimeout); const sessionMutation = trpc.viewer.updateProfile.useMutation({ - onSuccess: () => { + onSuccess: (data) => { showToast(t("session_timeout_changed"), "success"); formMethods.reset(formMethods.getValues()); + setSessionTimeout(data.metadata?.sessionTimeout); }, onSettled: () => { utils.viewer.me.invalidate(); }, onMutate: async () => { await utils.viewer.me.cancel(); - const previousValue = utils.viewer.me.getData(); - const previousMetadata = userMetadata.parse(previousValue?.metadata); + const previousValue = await utils.viewer.me.getData(); + const previousMetadata = userMetadataSchema.safeParse(previousValue?.metadata); - if (previousValue && sessionTimeout) { + if (previousValue && sessionTimeout && previousMetadata.success) { utils.viewer.me.setData(undefined, { ...previousValue, - metadata: { ...previousMetadata, sessionTimeout: sessionTimeout }, + metadata: { ...previousMetadata?.data, sessionTimeout: sessionTimeout }, }); + return { previousValue }; } - return { previousValue }; }, onError: (error, _, context) => { if (context?.previousValue) { @@ -84,20 +124,30 @@ const PasswordView = () => { defaultValues: { oldPassword: "", newPassword: "", - sessionTimeout, }, }); - const sessionTimeoutWatch = formMethods.watch("sessionTimeout"); - const handleSubmit = (values: ChangePasswordSessionFormValues) => { - const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values; + const { oldPassword, newPassword } = values; + + if (!oldPassword.length) { + formMethods.setError( + "oldPassword", + { type: "required", message: t("error_required_field") }, + { shouldFocus: true } + ); + } + if (!newPassword.length) { + formMethods.setError( + "newPassword", + { type: "required", message: t("error_required_field") }, + { shouldFocus: true } + ); + } + if (oldPassword && newPassword) { passwordMutation.mutate({ oldPassword, newPassword }); } - if (sessionTimeout !== newSessionTimeout) { - sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } }); - } }; const timeoutOptions = [5, 10, 15].map((mins) => ({ @@ -112,7 +162,7 @@ const PasswordView = () => { return ( <> - + {user && user.identityProvider !== IdentityProvider.CAL ? (
@@ -130,87 +180,127 @@ const PasswordView = () => {
) : (
- {formMethods.formState.errors.apiError && ( -
- -
- )} - -
-
- -
-
- +
+ {formMethods.formState.errors.apiError && ( +
+ +
+ )} +
+
+ +
+
+ +
+

+ {t("invalid_password_hint", { passwordLength: passwordMinLength })} +

-

- {t("invalid_password_hint", { passwordLength: passwordMinLength })} -

-
+ + + +
{ if (!e) { - formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true }); + setSessionTimeout(undefined); + + if (metadata.success) { + sessionMutation.mutate({ + metadata: { ...metadata.data, sessionTimeout: undefined }, + }); + } } else { - formMethods.setValue("sessionTimeout", 10, { shouldDirty: true }); + setSessionTimeout(10); } }} - /> - {sessionTimeoutWatch && ( -
-
-

{t("session_timeout_after")}

- tmo.value === sessionTimeout) + : timeoutOptions[1] + } + isSearchable={false} + className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm" + onChange={(event) => { + setSessionTimeout(event?.value); + }} + /> +
-
- )} + + + + +
- {/* TODO: Why is this Form not submitting? Hacky fix but works */} - )} ); }; -PasswordView.getLayout = getLayout; -PasswordView.PageWrapper = PageWrapper; +const PasswordViewWrapper = () => { + const { data: user, isLoading } = trpc.viewer.me.useQuery(); + const { t } = useLocale(); + if (isLoading || !user) + return ; -export default PasswordView; + return ; +}; + +PasswordViewWrapper.getLayout = getLayout; +PasswordViewWrapper.PageWrapper = PageWrapper; + +export default PasswordViewWrapper; diff --git a/apps/web/pages/settings/security/two-factor-auth.tsx b/apps/web/pages/settings/security/two-factor-auth.tsx index fd5c1a7e20..7cd47f3317 100644 --- a/apps/web/pages/settings/security/two-factor-auth.tsx +++ b/apps/web/pages/settings/security/two-factor-auth.tsx @@ -3,15 +3,24 @@ import { useState } from "react"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Badge, Meta, Switch, SkeletonButton, SkeletonContainer, SkeletonText, Alert } from "@calcom/ui"; +import { + Badge, + Meta, + SkeletonButton, + SkeletonContainer, + SkeletonText, + Alert, + SettingsToggle, +} from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; import DisableTwoFactorModal from "@components/settings/DisableTwoFactorModal"; import EnableTwoFactorModal from "@components/settings/EnableTwoFactorModal"; -const SkeletonLoader = () => { +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( +
@@ -28,36 +37,34 @@ const TwoFactorAuthView = () => { const { t } = useLocale(); const { data: user, isLoading } = trpc.viewer.me.useQuery(); - const [enableModalOpen, setEnableModalOpen] = useState(false); - const [disableModalOpen, setDisableModalOpen] = useState(false); + const [enableModalOpen, setEnableModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); - if (isLoading) return ; + if (isLoading) + return ; const isCalProvider = user?.identityProvider === "CAL"; const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled; return ( <> - + {canSetupTwoFactor && } -
- - user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true) - } - /> -
-
-

{t("two_factor_auth")}

- - {user?.twoFactorEnabled ? t("enabled") : t("disabled")} - -
-

{t("add_an_extra_layer_of_security")}

-
-
+ + user?.twoFactorEnabled ? setDisableModalOpen(true) : setEnableModalOpen(true) + } + Badge={ + + {user?.twoFactorEnabled ? t("enabled") : t("disabled")} + + } + switchContainerClassName="border-subtle rounded-b-xl border border-t-0 px-5 py-6 sm:px-6" + /> { return { redirect: { permanent: false, - destination: "/auth/login?callbackUrl=" + `${WEBAPP_URL}/${ctx.query.callbackUrl}`, + destination: `/auth/login?callbackUrl=${WEBAPP_URL}/${ctx.query.callbackUrl}`, }, }; } diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index b557bf41e8..2adbfe768a 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -102,7 +102,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain } items={type.users.map((user) => ({ alt: user.name || "", title: user.name || "", - image: "/" + user.username + "/avatar.png" || "", + image: `/${user.username}/avatar.png` || "", }))} />
@@ -160,7 +160,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
-

{" " + t("org_no_teams_yet")}

+

{` ${t("org_no_teams_yet")}`}

{t("org_no_teams_yet_description")}

@@ -324,7 +324,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ...type, users: type.users.map((user) => ({ ...user, - avatar: "/" + user.username + "/avatar.png", + avatar: `/${user.username}/avatar.png`, })), descriptionAsSafeHTML: markdownToSafeHTML(type.description), })) ?? null; diff --git a/apps/web/pages/video/[uid].tsx b/apps/web/pages/video/[uid].tsx index f08568e7a8..f14fed2213 100644 --- a/apps/web/pages/video/[uid].tsx +++ b/apps/web/pages/video/[uid].tsx @@ -95,12 +95,12 @@ export default function JoinCall(props: JoinCallPageProps) { - + - +
diff --git a/apps/web/pages/video/meeting-ended/[uid].tsx b/apps/web/pages/video/meeting-ended/[uid].tsx index 82e78f7895..b511de96bb 100644 --- a/apps/web/pages/video/meeting-ended/[uid].tsx +++ b/apps/web/pages/video/meeting-ended/[uid].tsx @@ -44,7 +44,7 @@ export default function MeetingUnavailable(props: inferSSRProps

- {dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")} + {dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}

diff --git a/apps/web/pages/video/meeting-not-started/[uid].tsx b/apps/web/pages/video/meeting-not-started/[uid].tsx index ac8d777a69..144312ac38 100644 --- a/apps/web/pages/video/meeting-not-started/[uid].tsx +++ b/apps/web/pages/video/meeting-not-started/[uid].tsx @@ -24,7 +24,7 @@ export default function MeetingNotStarted(props: inferSSRProps{props.booking.title}

- {dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")} + {dayjs(props.booking.startTime).format(`${detectBrowserTimeFormat}, dddd DD MMMM YYYY`)}

} diff --git a/apps/web/pagesAndRewritePaths.js b/apps/web/pagesAndRewritePaths.js index 1ad9c00727..784e638e63 100644 --- a/apps/web/pagesAndRewritePaths.js +++ b/apps/web/pagesAndRewritePaths.js @@ -23,7 +23,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"]; // book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp( - process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL + process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}` )); exports.orgHostPath = `^(?${subdomainRegExp})\\.(?!vercel\.app).*`; diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 8ca687e82e..87ac1dcf51 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -137,8 +137,6 @@ test.describe("pro user", () => { page.click('[data-testid="confirm"]'), page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")), ]); - - await page.goto("/bookings/unconfirmed"); // This is the only booking in there that needed confirmation and now it should be empty screen await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); }); @@ -229,45 +227,6 @@ test.describe("pro user", () => { const firstSlotAvailableText = await firstSlotAvailable.innerText(); expect(firstSlotAvailableText).toContain("9:00"); }); - - test("Cannot confirm booking for a slot, if another confirmed booking already exists for same slot.", async ({ - page, - users, - }) => { - // First booking done for first available time slot in next month - await bookOptinEvent(page); - - const [pro] = users.get(); - await page.goto(`/${pro.username}`); - - // Second booking done for same time slot - await bookOptinEvent(page); - - await pro.apiLogin(); - - await page.goto("/bookings/unconfirmed"); - - // Confirm first booking - await Promise.all([ - page.click('[data-testid="confirm"]'), - page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")), - ]); - - await Promise.all([ - page.goto("/bookings/unconfirmed"), - page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/get")), - ]); - - // Confirm second booking - await page.click('[data-testid="confirm"]'); - const response = await page.waitForResponse( - (response) => response.url().includes("/api/trpc/bookings/confirm") && response.status() !== 200 - ); - const responseObj = await response.json(); - - expect(responseObj[0]?.error?.json?.data?.code).toEqual("BAD_REQUEST"); - expect(responseObj[0]?.error?.json?.message).toEqual("Slot already confirmed for other booking"); - }); }); test.describe("prefill", () => { diff --git a/apps/web/playwright/fixtures/payments.ts b/apps/web/playwright/fixtures/payments.ts index 7d85e38582..52a801cb09 100644 --- a/apps/web/playwright/fixtures/payments.ts +++ b/apps/web/playwright/fixtures/payments.ts @@ -28,7 +28,7 @@ export const createPaymentsFixture = (page: Page) => { }, }, data: {}, - externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(), + externalId: `DEMO_PAYMENT_FROM_DB_${Date.now()}`, booking: { connect: { id: bookingId, diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts new file mode 100644 index 0000000000..bcf0a71b53 --- /dev/null +++ b/apps/web/playwright/insights.e2e.ts @@ -0,0 +1,239 @@ +import { expect } from "@playwright/test"; + +import { randomString } from "@calcom/lib/random"; +import prisma from "@calcom/prisma"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +const createTeamsAndMembership = async (userIdOne: number, userIdTwo: number) => { + const teamOne = await prisma.team.create({ + data: { + name: "test-insights", + slug: `test-insights-${Date.now()}-${randomString(5)}}`, + }, + }); + + const teamTwo = await prisma.team.create({ + data: { + name: "test-insights-2", + slug: `test-insights-2-${Date.now()}-${randomString(5)}}`, + }, + }); + if (!userIdOne || !userIdTwo || !teamOne || !teamTwo) { + throw new Error("Failed to create test data"); + } + + // create memberships + await prisma.membership.create({ + data: { + userId: userIdOne, + teamId: teamOne.id, + accepted: true, + role: "ADMIN", + }, + }); + await prisma.membership.create({ + data: { + teamId: teamTwo.id, + userId: userIdOne, + accepted: true, + role: "ADMIN", + }, + }); + await prisma.membership.create({ + data: { + teamId: teamOne.id, + userId: userIdTwo, + accepted: true, + role: "MEMBER", + }, + }); + await prisma.membership.create({ + data: { + teamId: teamTwo.id, + userId: userIdTwo, + accepted: true, + role: "MEMBER", + }, + }); + return { teamOne, teamTwo }; +}; + +test.afterAll(async ({ users }) => { + await users.deleteAll(); +}); + +test.describe("Insights", async () => { + test("should be able to go to insights as admins", async ({ page, users }) => { + const user = await users.create(); + const userTwo = await users.create(); + await createTeamsAndMembership(user.id, userTwo.id); + + await user.apiLogin(); + + // go to insights page + await page.goto("/insights"); + await page.waitForLoadState("networkidle"); + + // expect url to have isAll and TeamId in query params + expect(page.url()).toContain("isAll=false"); + expect(page.url()).toContain("teamId="); + }); + + test("should be able to go to insights as members", async ({ page, users }) => { + const user = await users.create(); + const userTwo = await users.create(); + + await userTwo.apiLogin(); + + await createTeamsAndMembership(user.id, userTwo.id); + // go to insights page + await page.goto("/insights"); + + await page.waitForLoadState("networkidle"); + + // expect url to have isAll and TeamId in query params + + expect(page.url()).toContain("isAll=false"); + expect(page.url()).not.toContain("teamId="); + }); + + test("team select filter should have 2 teams and your account option only as member", async ({ + page, + users, + }) => { + const user = await users.create(); + const userTwo = await users.create(); + + await user.apiLogin(); + + await createTeamsAndMembership(user.id, userTwo.id); + // go to insights page + await page.goto("/insights"); + + await page.waitForLoadState("networkidle"); + + // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 + await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); + await page + .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') + .click(); + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(3); + }); + + test("Insights Organization should have isAll option true", async ({ users, page }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + await page.goto("/insights"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("dashboard-shell").getByText("All").nth(1).click(); + + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(4); + }); + + test("should have all option in team-and-self filter as admin", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + + // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 + await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); + await page + .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') + .click(); + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(3); + }); + + test("should be able to switch between teams and self profile for insights", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + + // get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1 + await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click(); + await page + .locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]') + .click(); + const teamSelectFilter = await page.locator( + 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]' + ); + + await expect(teamSelectFilter).toHaveCount(3); + + // switch to self profile + await page.getByTestId("dashboard-shell").getByText("Your Account").click(); + + // switch to team 1 + await page.getByTestId("dashboard-shell").getByText("test-insights").nth(0).click(); + + // switch to team 2 + await page.getByTestId("dashboard-shell").getByText("test-insights-2").click(); + }); + + test("should be able to switch between memberUsers", async ({ page, users }) => { + const owner = await users.create(); + const member = await users.create(); + + await createTeamsAndMembership(owner.id, member.id); + + await owner.apiLogin(); + + await page.goto("/insights"); + + await page.getByText("Add filter").click(); + + await page.getByRole("button", { name: "User" }).click(); + //
People
+ await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click(); + + await page + .locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]') + .nth(0) + .click(); + await page.waitForLoadState("networkidle"); + + await page + .locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]') + .nth(1) + .click(); + await page.waitForLoadState("networkidle"); + // press escape button to close the filter + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: "Clear" }).click(); + + // expect for "Team: test-insight" text in page + expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy(); + }); +}); diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index edf81d0bf4..cd67112bc7 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -174,7 +174,7 @@ test.describe("Stripe integration", () => { await page.getByTestId("price-input-stripe").fill("200"); // Select currency in dropdown - await page.locator("div").filter({ hasText: "United States dollar (USD)" }).nth(1).click(); + await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); await page.locator("#react-select-2-input").fill("mexi"); await page.locator("#react-select-2-option-81").click(); diff --git a/apps/web/playwright/lib/next-server.ts b/apps/web/playwright/lib/next-server.ts index c7433f62a6..99d7314354 100644 --- a/apps/web/playwright/lib/next-server.ts +++ b/apps/web/playwright/lib/next-server.ts @@ -23,7 +23,7 @@ export const nextServer = async ({ port = 3000 } = { port: 3000 }) => { process.env.PLAYWRIGHT_TEST_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL = process.env.NEXT_PUBLIC_WEBSITE_URL = - "http://localhost:" + port; + `http://localhost:${port}`; const app = next({ dev: dev, port, @@ -46,7 +46,7 @@ export const nextServer = async ({ port = 3000 } = { port: 3000 }) => { resolve(server); }); server.on("error", (error) => { - if (error) throw new Error("Could not start Next.js server -" + error.message); + if (error) throw new Error(`Could not start Next.js server - ${error.message}`); }); }); return server; diff --git a/apps/web/playwright/oauth-provider.e2e.ts b/apps/web/playwright/oauth-provider.e2e.ts new file mode 100644 index 0000000000..31f1fb20e8 --- /dev/null +++ b/apps/web/playwright/oauth-provider.e2e.ts @@ -0,0 +1,227 @@ +import { expect } from "@playwright/test"; +import { randomBytes } from "crypto"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; + +import { test } from "./lib/fixtures"; + +test.afterEach(async ({ users }) => { + await users.deleteAll(); +}); + +let client: { + clientId: string; + redirectUri: string; + orginalSecret: string; + name: string; + clientSecret: string; + logo: string | null; +}; + +test.describe("OAuth Provider", () => { + test.beforeAll(async () => { + client = await createTestCLient(); + }); + test("should create valid access toke & refresh token for user", async ({ page, users }) => { + const user = await users.create({ username: "test user", name: "test user" }); + await user.apiLogin(); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234` + ); + + await page.waitForLoadState("networkidle"); + await page.getByTestId("allow-button").click(); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + const url = new URL(page.url()); + + // authorization code that is returned to client with redirect uri + const code = url.searchParams.get("code"); + + // request token with authorization code + const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, { + body: JSON.stringify({ + code, + client_id: client.clientId, + client_secret: client.orginalSecret, + grant_type: "authorization_code", + redirect_uri: client.redirectUri, + }), + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + const tokenData = await tokenResponse.json(); + + // test if token is valid + const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + const meData = await meResponse.json(); + + // check if user access token is valid + expect(meData.username.startsWith("test user")).toBe(true); + + // request new token with refresh token + const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, { + body: JSON.stringify({ + refresh_token: tokenData.refresh_token, + client_id: client.clientId, + client_secret: client.orginalSecret, + grant_type: "refresh_token", + }), + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + const refreshTokenData = await refreshTokenResponse.json(); + + expect(refreshTokenData.access_token).not.toBe(tokenData.access_token); + + const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + expect(meData.username.startsWith("test user")).toBe(true); + }); + + test("should create valid access toke & refresh token for team", async ({ page, users }) => { + const user = await users.create({ username: "test user", name: "test user" }, { hasTeam: true }); + await user.apiLogin(); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234` + ); + + await page.waitForLoadState("networkidle"); + + await page.locator("#account-select").click(); + + await page.locator("#react-select-2-option-1").click(); + + await page.getByTestId("allow-button").click(); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + const url = new URL(page.url()); + + // authorization code that is returned to client with redirect uri + const code = url.searchParams.get("code"); + + // request token with authorization code + const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, { + body: JSON.stringify({ + code, + client_id: client.clientId, + client_secret: client.orginalSecret, + grant_type: "authorization_code", + redirect_uri: client.redirectUri, + }), + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + const tokenData = await tokenResponse.json(); + + // test if token is valid + const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + const meData = await meResponse.json(); + + // check if team access token is valid + expect(meData.username.endsWith("Team Team")).toBe(true); + + // request new token with refresh token + const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, { + body: JSON.stringify({ + refresh_token: tokenData.refresh_token, + client_id: client.clientId, + client_secret: client.orginalSecret, + grant_type: "refresh_token", + }), + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + const refreshTokenData = await refreshTokenResponse.json(); + + expect(refreshTokenData.access_token).not.toBe(tokenData.access_token); + + const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + expect(meData.username.endsWith("Team Team")).toBe(true); + }); + + test("redirect not logged-in users to login page and after forward to authorization page", async ({ + page, + users, + }) => { + const user = await users.create({ username: "test-user", name: "test user" }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234` + ); + + // check if user is redirected to login page + await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + await page.locator("#email").fill(user.email); + await page.locator("#password").fill(user.username || ""); + await page.locator('[type="submit"]').click(); + + await page.waitForSelector("#account-select"); + + await expect(page.getByText("test user")).toBeVisible(); + }); +}); + +const createTestCLient = async () => { + const [hashedSecret, secret] = generateSecret(); + const clientId = randomBytes(32).toString("hex"); + + const client = await prisma.oAuthClient.create({ + data: { + name: "Test Client", + clientId, + clientSecret: hashedSecret, + redirectUri: "https://example.com", + }, + }); + + return { ...client, orginalSecret: secret }; +}; diff --git a/apps/web/playwright/payment-apps.e2e.ts b/apps/web/playwright/payment-apps.e2e.ts new file mode 100644 index 0000000000..c01bc10ba2 --- /dev/null +++ b/apps/web/playwright/payment-apps.e2e.ts @@ -0,0 +1,237 @@ +import { expect } from "@playwright/test"; + +import prisma from "@calcom/prisma"; + +import { test } from "./lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; + +test.describe.configure({ mode: "parallel" }); +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("Payment app", () => { + test("Should be able to edit alby price, currency", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); + if (!paymentEvent) { + throw new Error("No payment event found"); + } + await prisma.credential.create({ + data: { + type: "alby_payment", + userId: user.id, + key: { + account_id: "random", + account_email: "random@example.com", + webhook_endpoint_id: "ep_randomString", + webhook_endpoint_secret: "whsec_randomString", + account_lightning_address: "random@getalby.com", + }, + }, + }); + + await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + + await page.locator("#event-type-form").getByRole("switch").click(); + await page.getByPlaceholder("Price").click(); + await page.getByPlaceholder("Price").fill("200"); + await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click(); + await page.getByTestId("update-eventtype").click(); + + await page.goto(`${user.username}/${paymentEvent.slug}`); + + // expect 200 sats to be displayed in page + expect(await page.locator("text=200 sats").first()).toBeTruthy(); + + await selectFirstAvailableTimeSlotNextMonth(page); + expect(await page.locator("text=200 sats").first()).toBeTruthy(); + + // go to /event-types and check if the price is 200 sats + await page.goto(`event-types/`); + expect(await page.locator("text=200 sats").first()).toBeTruthy(); + }); + + test("Should be able to edit stripe price, currency", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); + if (!paymentEvent) { + throw new Error("No payment event found"); + } + await prisma.credential.create({ + data: { + type: "stripe_payment", + userId: user.id, + key: { + scope: "read_write", + livemode: false, + token_type: "bearer", + access_token: "sk_test_randomString", + refresh_token: "rt_randomString", + stripe_user_id: "acct_randomString", + default_currency: "usd", + stripe_publishable_key: "pk_test_randomString", + }, + }, + }); + + await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.locator("#event-type-form").getByRole("switch").click(); + await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("select-option-usd").click(); + + await page.getByTestId("price-input-stripe").click(); + await page.getByTestId("price-input-stripe").fill("350"); + await page.getByTestId("update-eventtype").click(); + + await page.goto(`${user.username}/${paymentEvent.slug}`); + + // expect 200 sats to be displayed in page + expect(await page.locator("text=350").first()).toBeTruthy(); + + await selectFirstAvailableTimeSlotNextMonth(page); + expect(await page.locator("text=350").first()).toBeTruthy(); + + // go to /event-types and check if the price is 200 sats + await page.goto(`event-types/`); + expect(await page.locator("text=350").first()).toBeTruthy(); + }); + + test("Should be able to edit paypal price, currency", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); + if (!paymentEvent) { + throw new Error("No payment event found"); + } + await prisma.credential.create({ + data: { + type: "paypal_payment", + userId: user.id, + key: { + client_id: "randomString", + secret_key: "randomString", + webhook_id: "randomString", + }, + }, + }); + + await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + + await page.locator("#event-type-form").getByRole("switch").click(); + + await page.getByPlaceholder("Price").click(); + await page.getByPlaceholder("Price").fill("150"); + + await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.locator("#react-select-2-option-13").click(); + + await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click(); + + await page.getByText("$MXNCurrencyMexican pesoPayment option").click(); + await page.getByTestId("update-eventtype").click(); + + await page.goto(`${user.username}/${paymentEvent.slug}`); + + // expect 150 to be displayed in page + expect(await page.locator("text=MX$150.00").first()).toBeTruthy(); + + await selectFirstAvailableTimeSlotNextMonth(page); + // expect 150 to be displayed in page + expect(await page.locator("text=MX$150.00").first()).toBeTruthy(); + + // go to /event-types and check if the price is 150 + await page.goto(`event-types/`); + expect(await page.locator("text=MX$150.00").first()).toBeTruthy(); + }); + + test("Should display App is not setup already for alby", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); + if (!paymentEvent) { + throw new Error("No payment event found"); + } + await prisma.credential.create({ + data: { + type: "alby_payment", + userId: user.id, + key: {}, + }, + }); + + await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + + await page.locator("#event-type-form").getByRole("switch").click(); + + // expect text "This app has not been setup yet" to be displayed + expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy(); + + await page.getByRole("button", { name: "Setup" }).click(); + + // Expect "Connect with Alby" to be displayed + expect(await page.locator("text=Connect with Alby").first()).toBeTruthy(); + }); + + test("Should display App is not setup already for paypal", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); + if (!paymentEvent) { + throw new Error("No payment event found"); + } + await prisma.credential.create({ + data: { + type: "paypal_payment", + userId: user.id, + key: {}, + }, + }); + + await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + + await page.locator("#event-type-form").getByRole("switch").click(); + + // expect text "This app has not been setup yet" to be displayed + expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy(); + + await page.getByRole("button", { name: "Setup" }).click(); + + // Expect "Getting started with Paypal APP" to be displayed + expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy(); + }); + + /** + * For now almost all the payment apps show display "This app has not been setup yet" + * this can change in the future + */ + test("Should not display App is not setup already for non payment app", async ({ page, users }) => { + // We will use google analytics app for this test + const user = await users.create(); + await user.apiLogin(); + // Any event should work here + const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); + if (!paymentEvent) { + throw new Error("No payment event found"); + } + + await prisma.credential.create({ + data: { + type: "ga4_analytics", + userId: user.id, + appId: "ga4", + invalid: false, + key: {}, + }, + }); + + await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + + await page.locator("#event-type-form").getByRole("switch").click(); + // make sure Tracking ID is displayed + expect(await page.locator("text=Tracking ID").first()).toBeTruthy(); + await page.getByLabel("Tracking ID").click(); + await page.getByLabel("Tracking ID").fill("demo"); + await page.getByTestId("update-eventtype").click(); + }); +}); diff --git a/apps/web/playwright/profile.e2e.ts b/apps/web/playwright/profile.e2e.ts new file mode 100644 index 0000000000..76692c9bd0 --- /dev/null +++ b/apps/web/playwright/profile.e2e.ts @@ -0,0 +1,24 @@ +import { expect } from "@playwright/test"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("Teams", () => { + test("Profile page is loaded for users in Organization", async ({ page, users }) => { + const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }]; + const owner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + hasSubteam: true, + teammates: teamMatesObj, + }); + await owner.apiLogin(); + await page.goto("/settings/my-account/profile"); + + // check if user avatar is loaded + await expect(page.locator('[data-testid="organization-avatar"]')).toBeVisible(); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d25b59d80c..2491c787c4 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -255,7 +255,7 @@ "yours": "Your account", "available_apps": "Available Apps", "available_apps_lower_case": "Available apps", - "available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store", + "available_apps_desc": "View popular apps below and explore more in our <1>App Store", "fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more", "round_robin_helper":"People in the group take turns and only one person will show up for the event.", "check_email_reset_password": "Check your email. We sent you a link to reset your password.", @@ -288,6 +288,7 @@ "when": "When", "where": "Where", "add_to_calendar": "Add to calendar", + "add_to_calendar_description":"Select where to add events when you’re booked.", "add_another_calendar": "Add another calendar", "other": "Other", "email_sign_in_subject": "Your sign-in link for {{appName}}", @@ -422,6 +423,7 @@ "booking_created": "Booking Created", "booking_rejected": "Booking Rejected", "booking_requested": "Booking Requested", + "booking_payment_initiated": "Booking Payment Initiated", "meeting_ended": "Meeting Ended", "form_submitted": "Form Submitted", "booking_paid": "Booking Paid", @@ -599,6 +601,7 @@ "hide_book_a_team_member": "Hide Book a Team Member Button", "hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.", "danger_zone": "Danger zone", + "account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.", "back": "Back", "cancel": "Cancel", "cancel_all_remaining": "Cancel all remaining", @@ -688,6 +691,7 @@ "people": "People", "your_email": "Your Email", "change_avatar": "Change Avatar", + "upload_avatar": "Upload Avatar", "language": "Language", "timezone": "Timezone", "first_day_of_week": "First Day of Week", @@ -1293,7 +1297,7 @@ "customize_your_brand_colors": "Customize your own brand colour into your booking page.", "pro": "Pro", "removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'", - "profile_picture": "Profile picture", + "profile_picture": "Profile Picture", "upload": "Upload", "add_profile_photo": "Add profile photo", "web3": "Web3", @@ -1654,7 +1658,7 @@ "no_recordings_found": "No recordings found", "new_workflow_subtitle": "New workflow for...", "reporting": "Reporting", - "reporting_feature": "See all incoming from data and download it as a CSV", + "reporting_feature": "See all incoming form data and download it as a CSV", "teams_plan_required": "Teams plan required", "routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.", "choose_a_license": "Choose a license", @@ -1880,6 +1884,7 @@ "edit_invite_link": "Edit link settings", "invite_link_copied": "Invite link copied", "invite_link_deleted": "Invite link deleted", + "api_key_deleted":"API Key deleted", "invite_link_updated": "Invite link settings saved", "link_expires_after": "Links set to expire after...", "one_day": "1 day", @@ -2050,14 +2055,33 @@ "team_no_event_types": "This team has no event types", "seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations", "include_calendar_event": "Include calendar event", + "oAuth": "OAuth", "recently_added":"Recently added", "no_members_found": "No members found", "event_setup_length_error":"Event Setup: The duration must be at least 1 minute.", "availability_schedules":"Availability Schedules", + "unauthorized":"Unauthorized", + "access_cal_account": "{{clientName}} would like access to your {{appName}} account", + "select_account_team": "Select account or team", + "allow_client_to": "This will allow {{clientName}} to", + "associate_with_cal_account":"Associate you with your personal info from {{clientName}}", + "see_personal_info":"See your personal info, including any personal info you've made publicly available", + "see_primary_email_address":"See your primary email address", + "connect_installed_apps":"Connect to your installed apps", + "access_event_type": "Read, edit, delete your event-types", + "access_availability": "Read, edit, delete your availability", + "access_bookings": "Read, edit, delete your bookings", + "allow_client_to_do": "Allow {{clientName}} to do this?", + "oauth_access_information": "By clicking allow, you allow this app to use your information in accordance with their terms of service and privacy policy. You can remove access in the {{appName}} App Store.", + "allow": "Allow", "view_only_edit_availability_not_onboarded":"This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.", "view_only_edit_availability":"You are viewing this user's availability. You can only edit your own availability.", "edit_users_availability":"Edit user's availability: {{username}}", "resend_invitation": "Resend invitation", "invitation_resent": "The invitation was resent.", + "add_client": "Add client", + "copy_client_secret_info": "After copying the secret you won't be able to view it anymore", + "add_new_client": "Add new Client", + "this_app_is_not_setup_already": "This app has not been setup yet", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/eu/common.json b/apps/web/public/static/locales/eu/common.json index 736535c4e6..01e610bfd4 100644 --- a/apps/web/public/static/locales/eu/common.json +++ b/apps/web/public/static/locales/eu/common.json @@ -7,17 +7,32 @@ "second_other": "{{count}} segundo", "upgrade_now": "Eguneratu orain", "accept_invitation": "Onartu gonbidapena", + "calcom_explained": "{{appName}}-ek bilerak programatzeko azpiegitura eskaintzen du guztiontzat.", + "calcom_explained_new_user": "Bukatu zure {{appName}} kontua konfiguratzen! Bileren programazio-arazo guztiak konpontzeko urrats gutxi batzuk besterik ez zaizkizu geratzen.", "have_any_questions": "Galderarik? Laguntzeko gaude.", "reset_password_subject": "{{appName}}: Pasahitza berrezartzeko argibideak", "verify_email_subject": "{{appName}}: egiaztatu zure kontua", "check_your_email": "Begiratu zure emaila", + "verify_email_page_body": "Email bat bidali dugu {{email}} helbidera. Garrantzitsua da zure email helbidea egiaztatzea, {{appName}}-tik mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko.", + "verify_email_banner_body": "Egiaztatu zure email helbidea mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko", "verify_email_email_header": "Egiaztatu zure email helbidea", "verify_email_email_button": "Egiaztatu emaila", + "verify_email_email_body": "Mesedez, egiaztatu zure email helbidea beheko botoia sakatuz.", + "verify_email_by_code_email_body": "Mesedez, egiaztatu zure email helbidea beheko kodea erabiliz.", + "verify_email_email_link_text": "Hemen duzu esteka, botoiak sakatzea gustuko ez baduzu:", + "email_verification_code": "Sartu egiaztatze-kodea", + "email_verification_code_placeholder": "Sartu zure email helbidera bidalitako egiaztatze-kodea", + "incorrect_email_verification_code": "Egiaztatze-kodea ez da zuzena.", + "email_sent": "Email mezua zuzen bidali da", + "email_not_sent": "Errore bat gertatu da email mezua bidaltzerakoan", "event_declined_subject": "Baztertua: {{title}} {{date}}(e)an", + "event_cancelled_subject": "Bertan behera: {{title}} {{date}}(e)an", "event_request_declined": "Zure gertaera-eskaera baztertua izan da", "event_request_declined_recurring": "Zure gertaera errepikari-eskaera baztertua izan da", + "event_request_cancelled": "Zure programatutako gertaera bertan behera utzi da", "organizer": "Antolatzailea", "need_to_reschedule_or_cancel": "Programazioa aldatu edo bertan behera utzi behar duzu?", + "no_options_available": "Ez dago aukerarik eskuragarri", "cancellation_reason": "Bertan behera uztearen arrazoia (aukerakoa)", "cancellation_reason_placeholder": "Zergatik utzi duzu bertan behera?", "rejection_reason": "Errefusatzeko arrazoia", @@ -25,7 +40,11 @@ "rejection_reason_description": "Ziur zaude erreserba errefusatu nahi duzula? Erreserba-eskaera egin duen pertsonari jakinaraziko zaio. Arrazoi bat adieraz dezakezu behean.", "rejection_confirmation": "Errefusatu erreserba", "manage_this_event": "Kudeatu gertaera hau", + "invite_team_member": "Gonbidatu taldekidea", + "invite_team_individual_segment": "Gonbidatu norbanakoa", + "invite_team_notifcation_badge": "Gon.", "your_event_has_been_scheduled": "Zure gertaera programatu da", + "your_event_has_been_scheduled_recurring": "Zure gertaera errepikaria programatu da", "error_message": "Errore-mezua honakoa ian da: '{{errorMessage}}'", "refund_failed_subject": "Itzulketak huts egin du: {{name}} - {{date}} - {{eventType}}", "refund_failed": "Huts egin du itzulketak {{eventType}} gertaerarako, {{userName}}(r)ekin {{date}}(e)an.", @@ -37,26 +56,79 @@ "refunded": "Itzulita", "payment": "Ordainketa", "pay_now": "Ordaindu orain", + "still_waiting_for_approval": "Gertaera bat onarpenaren zain dago", + "event_is_still_waiting": "Gertaera-eskaera oraindik zain dago: {{attendeeName}} - {{date}} - {{eventType}}", "no_more_results": "Emaitza gehiagorik ez", "no_results": "Emaitzarik ez", "load_more_results": "Kargatu emaitza gehiago", + "integration_meeting_id": "{{integrationName}} bileraren IDa: {{meetingId}}", "confirmed_event_type_subject": "Baieztatua: {{eventType}} {{name}}(r)ekin {{date}}(e)an", + "new_event_request": "Gertaera berriaren eskaera: {{attendeeName}} - {{date}} - {{eventType}}", "confirm_or_reject_request": "Baieztatu edo errefusatu eskaera", "check_bookings_page_to_confirm_or_reject": "Begiratu zure erreserba-orrialdea erreserba baieztatu edo errefusatzeko.", "event_awaiting_approval": "Gertaera bat zure onarpenaren zain dago", + "event_awaiting_approval_recurring": "Gertaera errepikari bat zure onarpenaren zain dago", + "someone_requested_an_event": "Norbaitek zure egutegian gertaera bat programatzeko eskaera egin du.", + "someone_requested_password_reset": "Norbaitek zure pasahitza aldatzeko esteka bat eskatu du.", + "password_reset_email_sent": "Email helbide hau gure sisteman baldin badago, berrezartzeko email mezu bat jaso behar zenuke.", + "password_reset_instructions": "Ez baduzu eskaera hau egin, segurua da email mezu honi kasurik ez egitea, eta zure pasahitza ez da aldatuko.", + "event_awaiting_approval_subject": "Onarpenaren zain: {{title}} {{date}}(e)an", + "event_still_awaiting_approval": "Gertaera bat zure onarpenaren zain dago oraindik", "booking_submitted_subject": "Erreserba bidalita: {{title}} {{date}}(e)an", + "download_recording_subject": "Deskargatu grabaketa: {{title}} {{date}}(e)an", + "download_your_recording": "Deskargatu zure grabaketa", + "your_meeting_has_been_booked": "Zure bileraren erreserba egin da", "event_type_has_been_rescheduled_on_time_date": "Zure {{title}} getaeraren programazioa aldatu egin da {{date}}(e)ra.", + "event_has_been_rescheduled": "Eguneratuta - Zure gertaeraren programazioa aldatu egin da", + "request_reschedule_subtitle": "{{organizer}}(e)k erreserba bertan behera utzi du eta beste denbora-tarte bat hautatzeko eskatu dizu.", + "request_reschedule_title_organizer": "Beste denbora-tarte bat hautatzeko eskatu diozu {{attendee}}(r)i", "hi_user_name": "Kaixo {{name}}", "ics_event_title": "{{eventType}} {{name}}(r)ekin", "notes": "Oharrak", "manage_my_bookings": "Kudeatu nire erreserbak", "rejected_event_type_with_organizer": "Errefusatua: {{eventType}} {{organizer}}(r)ekin {{date}}(e)an", "hi": "Kaixo", + "use_link_to_reset_password": "Erabili beheko esteka pasahitza berrezartzeko", + "hey_there": "Kaixo,", + "forgot_your_password_calcom": "Pasahitza ahaztu duzu? - {{appName}}", + "dismiss": "Alde batera utzi", + "no_data_yet": "Ez dago daturik", + "ping_test": "Ping testa", + "upcoming": "Laster", + "recurring": "Errepikariak", + "past": "Iraganekoak", + "choose_a_file": "Hautatu fitxategi bat...", + "upload_image": "Igo irudia", + "upload_target": "Igo {{target}}", + "no_target": "Ez dago {{target}}(r)ik", + "view_notifications": "Ikusi jakinarazpenak", + "view_public_page": "Ikusi orrialde publikoa", + "copy_public_page_link": "Kopiatu orrialde publikoaren esteka", + "sign_out": "Saioa itxi", + "add_another": "Gehitu beste bat", + "install_another": "Instalatu beste bat", + "unavailable": "Ez eskuragarri", + "set_work_schedule": "Ezarri zure laneko ordutegia", "change_bookings_availability": "Aldatu noiz zauden prest erreserbak jasotzeko", + "select": "Hautatu...", + "text": "Testua", + "multiline_text": "Lerro ugaritako testua", + "number": "Zenbakia", + "checkbox": "Kontrol-laukia", + "is_required": "Derrigorrezkoa da", + "required": "Derrigorrezkoa", + "optional": "Hautazkoa", + "input_type": "Sarrera-mota", + "rejected": "Baztertua", + "unconfirmed": "Baieztatu gabea", + "guests": "Gonbidatuak", + "create_account": "Sortu kontua", + "confirm_password": "Baieztatu pasahitza", "create_booking_link_with_calcom": "Sor ezazu zeure erreserba-esteka {{appName}}(e)kin", "user_needs_to_confirm_or_reject_booking": "{{user}}(e)k erreserba baieztatu edo errefusatu behar du oraindik.", "booking_submitted": "Zure erreserba bidali da", "booking_confirmed": "Zure erreserba baieztatu da", + "bookerlayout_column_view": "Zutabea", "back_to_bookings": "Itzuli erreserbatara", "really_cancel_booking": "Benetan bertan behera utzi nahi duzu zure erreserba?", "cannot_cancel_booking": "Ezin duzu erreserba hau bertan behera utzi", diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index 070e7b73e9..d922c1b600 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -1063,6 +1063,7 @@ "your_unique_api_key": "מפתח ה-API הייחודי שלך", "copy_safe_api_key": "העתק/י את מפתח ה-API הזה ושמור/י אותו במקום בטוח. אם תאבד/י אותו, יהיה עליך ליצור מפתח חדש.", "zapier_setup_instructions": "<0>התחבר/י לחשבון Zapier שלך וצור/י Zap חדש.<1>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.<2>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.<3>בדוק/י את ה-Trigger.<4>וזהו, הכל מוכן!", + "make_setup_instructions": "<0>עבור/י אל <1><0>יצירת קישור Invite והתקן/י את אפליקציית Cal.com.<1>התחבר/י לחשבון Make שלך וצור/י Scenario חדש.<2>בחר/י את Cal.com כאפליקציית ה-Trigger. בנוסף, בחר/י אירוע Trigger.<3>בחר/י את החשבון שלך ולאחר מכן הזן/י את מפתח ה-API הייחודי שלך.<4>בדוק/י את ה-Trigger.<5>וזהו, הכל מוכן!", "install_zapier_app": "תחילה עליך להוריד את אפליקציית Zapier מה-App Store ולהתקין אותה.", "install_make_app": "תחילה עליך להוריד את אפליקציית Make מה-App Store ולהתקין אותה.", "connect_apple_server": "חיבור לשרת Apple", @@ -1695,6 +1696,7 @@ "email_no_user_invite_heading_org": "הוזמנת להצטרף לארגון ב-{{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.", "email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.", + "email_user_invite_subheading_org": "{{invitedBy}} הזמין/ה אותך להצטרף לארגון שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולארגון שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.", "email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.", "email_no_user_step_one": "בחר שם משתמש", "email_no_user_step_two": "קשר את לוח השנה שלך", @@ -1865,6 +1867,8 @@ "insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.", "acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.", "card_details": "פרטי כרטיס", + "something_went_wrong_on_our_end": "משהו השתבש בצד שלנו. פנה/י למחלקת התמיכה שלנו, ואנחנו נפתור זאת מיד עבורך.", + "please_provide_following_text_to_suppport": "כשפונים לתמיכה, יש לספק את הטקסט הבא כדי שנוכל לסייע לך בצורה יעילה יותר", "seats_and_no_show_fee_error": "נכון לעכשיו, אי אפשר להפעיל מקומות ולחייב דמי אי-הגעה", "complete_your_booking": "יש להשלים את ההזמנה", "complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}", @@ -1993,14 +1997,46 @@ "add_to_team": "הוספה לצוות", "remove_users_from_org": "הסרת משתמשים מהארגון", "remove_users_from_org_confirm": "בטוח שברצונך להסיר {{userCount}} משתמשים מהארגון הזה?", + "user_has_no_schedules": "משתמש זה עדיין לא הגדיר לוחות זמנים", + "user_isnt_in_any_teams": "משתמש זה לא שייך לאף צוות", + "requires_booker_email_verification": "מחייב אימות של כתובת הדוא\"ל של המזמין", + "description_requires_booker_email_verification": "כדי להבטיח אימות של כתובת הדוא\"ל של המזמין לפני תזמון אירועים", + "requires_confirmation_mandatory": "ניתן לשלוח הודעות טקסט למשתתפים רק כאשר סוג האירוע מחייב אישור.", "organizations": "ארגונים", "org_admin_other_teams": "צוותים אחרים", + "org_admin_other_teams_description": "כאן תוכל/י לראות צוותים בארגון שאינך שייך/ת אליהם. יש לך אפשרות להוסיף את עצמך, במקרה הצורך.", + "no_other_teams_found": "לא נמצא אף צוות אחר", + "no_other_teams_found_description": "אין צוותים אחרים בארגון הזה.", "attendee_first_name_variable": "השם הפרטי של המשתתף", "attendee_last_name_variable": "שם המשפחה של המשתתף", + "attendee_first_name_info": "השם הפרטי של האדם שביצע את ההזמנה", + "attendee_last_name_info": "שם המשפחה של האדם שביצע את ההזמנה", "me": "אני", + "verify_team_tooltip": "אמת/י את הצוות שלך כדי לאפשר שליחת הודעות למשתתפים", "member_removed": "החבר הוסר", "my_availability": "הזמינות שלי", "team_availability": "הזמינות של הצוות", + "backup_code": "קוד גיבוי", + "backup_codes": "קודי גיבוי", + "backup_code_instructions": "כל קוד גיבוי יכול לשמש פעם אחת בלבד להענקת גישה בלי היישום המאמת.", + "backup_codes_copied": "קודי הגיבוי הועתקו!", + "incorrect_backup_code": "קוד הגיבוי שגוי.", + "lost_access": "הגישה אבדה", + "missing_backup_codes": "לא נמצאו קודי גיבוי. צור/י אותם בהגדרות.", + "admin_org_notification_email_subject": "נוצר ארגון חדש: בהמתנה לפעולה", + "hi_admin": "שלום, מנהל/ת מערכת", + "admin_org_notification_email_title": "ארגון מחייב הגדרת DNS", + "admin_org_notification_email_body_part1": "נוצר ארגון עם רכיב ה-slug ‏\"{{orgSlug}}\".

חשוב להקפיד להגדיר את רשם ה-DNS כך שיפנה את התת-דומיין המקביל לארגון החדש למיקום שבו האפליקציה הראשית פועלת. אחרת, הארגון לא יוכל לפעול.

לפניך פירוט של האפשרויות הבסיסיות ממש להגדרת תת-דומיין כך שיפנה לאפליקציה שלו על מנת שדף הפרופיל של הארגון ייטען.

אפשר לעשות את זה עם רשומת A:", + "admin_org_notification_email_body_part2": "או רשומת CNAME:", + "admin_org_notification_email_body_part3": "לאחר שתגדיר/י את התת-דומיין, יש לסמן שתצורת DNS הושלמה בהגדרות מנהלי המערכת של הארגון.", + "admin_org_notification_email_cta": "עבור/י אל הגדרות מנהלי המערכת של הארגון", + "org_has_been_processed": "עיבוד הארגון הושלם", + "org_error_processing": "היתה שגיאה בעיבוד של ארגון זה", + "orgs_page_description": "רשימה של כל הארגונים. קבלת ארגון תאפשר לכל המשתמשים מאותו דומיין דוא\"ל להירשם בלי להצטרך לבצע אימות של כתובת הדוא\"ל.", + "unverified": "לא אומת", + "dns_missing": "DNS חסר", + "mark_dns_configured": "סימון כי DNS הוגדר", + "value": "ערך", "your_organization_updated_sucessfully": "עדכון הארגון שלך בוצע בהצלחה", "team_no_event_types": "אין לצוות זה אף סוג של אירוע", "seat_options_doesnt_multiple_durations": "האפשרויות של הושבה במקומות לא תומכות במשכי זמן שונים", diff --git a/apps/web/test/lib/checkBookingLimits.test.ts b/apps/web/test/lib/checkBookingLimits.test.ts index b80e9ef695..1dec09fc79 100644 --- a/apps/web/test/lib/checkBookingLimits.test.ts +++ b/apps/web/test/lib/checkBookingLimits.test.ts @@ -1,4 +1,4 @@ -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; import { describe, expect, it } from "vitest"; diff --git a/apps/web/test/lib/checkDurationLimits.test.ts b/apps/web/test/lib/checkDurationLimits.test.ts index d8c7daf827..14e79e869a 100644 --- a/apps/web/test/lib/checkDurationLimits.test.ts +++ b/apps/web/test/lib/checkDurationLimits.test.ts @@ -1,4 +1,4 @@ -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; import { describe, expect, it } from "vitest"; diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index b3d6d158c8..77fd0bcb32 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -1,20 +1,18 @@ import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager"; -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../tests/libs/__mocks__/prisma"; import { diff } from "jest-diff"; import { describe, expect, vi, beforeEach, afterEach, test } from "vitest"; -import prisma from "@calcom/prisma"; 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); -// @ts-expect-error Prisma v5 typings are not yet available -prismaMock.user.findMany.mockResolvedValue([]); +import { + getDate, + getGoogleCalendarCredential, + createBookingScenario, +} from "../utils/bookingScenario/bookingScenario"; vi.mock("@calcom/lib/constants", () => ({ IS_PRODUCTION: true, @@ -146,13 +144,13 @@ const TestData = { }; const cleanup = async () => { - await prisma.eventType.deleteMany(); - await prisma.user.deleteMany(); - await prisma.schedule.deleteMany(); - await prisma.selectedCalendar.deleteMany(); - await prisma.credential.deleteMany(); - await prisma.booking.deleteMany(); - await prisma.app.deleteMany(); + await prismock.eventType.deleteMany(); + await prismock.user.deleteMany(); + await prismock.schedule.deleteMany(); + await prismock.selectedCalendar.deleteMany(); + await prismock.credential.deleteMany(); + await prismock.booking.deleteMany(); + await prismock.app.deleteMany(); }; beforeEach(async () => { @@ -201,7 +199,7 @@ describe("getSchedule", () => { apps: [TestData.apps.googleCalendar], }; // An event with one accepted booking - createBookingScenario(scenarioData); + await createBookingScenario(scenarioData); const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({ input: { @@ -228,7 +226,7 @@ describe("getSchedule", () => { const { dateString: plus3DateString } = getDate({ dateIncrement: 3 }); // An event with one accepted booking - createBookingScenario({ + await createBookingScenario({ // An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours) eventTypes: [ { @@ -354,7 +352,7 @@ describe("getSchedule", () => { }); test("slots are available as per `length`, `slotInterval` of the event", async () => { - createBookingScenario({ + await createBookingScenario({ eventTypes: [ { id: 1, @@ -453,7 +451,7 @@ describe("getSchedule", () => { })() ); - createBookingScenario({ + await createBookingScenario({ eventTypes: [ { id: 1, @@ -569,7 +567,7 @@ describe("getSchedule", () => { apps: [TestData.apps.googleCalendar], }; - createBookingScenario(scenarioData); + await createBookingScenario(scenarioData); const scheduleForEventOnADayWithNonCalBooking = await getSchedule({ input: { @@ -643,7 +641,7 @@ describe("getSchedule", () => { apps: [TestData.apps.googleCalendar], }; - createBookingScenario(scenarioData); + await createBookingScenario(scenarioData); const scheduleForEventOnADayWithCalBooking = await getSchedule({ input: { @@ -701,7 +699,7 @@ describe("getSchedule", () => { apps: [TestData.apps.googleCalendar], }; - createBookingScenario(scenarioData); + await createBookingScenario(scenarioData); const schedule = await getSchedule({ input: { @@ -765,7 +763,7 @@ describe("getSchedule", () => { ], }; - createBookingScenario(scenarioData); + await createBookingScenario(scenarioData); const scheduleForEventOnADayWithDateOverride = await getSchedule({ input: { @@ -790,7 +788,7 @@ describe("getSchedule", () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); - createBookingScenario({ + await createBookingScenario({ eventTypes: [ // A Collective Event Type hosted by this user { @@ -885,7 +883,7 @@ describe("getSchedule", () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); - createBookingScenario({ + await createBookingScenario({ eventTypes: [ // An event having two users with one accepted booking { @@ -1010,7 +1008,7 @@ describe("getSchedule", () => { const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const { dateString: plus3DateString } = getDate({ dateIncrement: 3 }); - createBookingScenario({ + await createBookingScenario({ eventTypes: [ // An event having two users with one accepted booking { diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index d90cb2fb17..42000292f0 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -1,4 +1,4 @@ -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; import type { EventType } from "@prisma/client"; import { describe, expect, it, vi } from "vitest"; diff --git a/apps/web/test/lib/team-event-types.test.ts b/apps/web/test/lib/team-event-types.test.ts index 998eb4b7aa..dc0ed54b0d 100644 --- a/apps/web/test/lib/team-event-types.test.ts +++ b/apps/web/test/lib/team-event-types.test.ts @@ -1,4 +1,4 @@ -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; import { expect, it } from "vitest"; diff --git a/apps/web/test/utils/bookingScenario.ts b/apps/web/test/utils/bookingScenario.ts deleted file mode 100644 index dca048fac4..0000000000 --- a/apps/web/test/utils/bookingScenario.ts +++ /dev/null @@ -1,754 +0,0 @@ -import appStoreMock from "../../../../tests/libs/__mocks__/app-store"; -import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n"; -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; - -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"; - -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) => { - // @ts-expect-error Prisma v5 breaks this - const where = findManyArg?.where || {}; - return new Promise((resolve) => { - resolve( - // @ts-expect-error Prisma v5 breaks this - 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 || []; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const userIdIn = where.OR[0].userId?.in || []; - const firstConditionMatches = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - statusIn.includes(booking.status) && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - (booking.userId === where.OR[0].userId || userIdIn.includes(booking.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( - // @ts-expect-error Prisma v5 breaks this - 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) => { - // @ts-expect-error Prisma v5 breaks this - resolve({ - // @ts-expect-error Prisma v5 breaks this - email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`, - } as unknown as PrismaUser); - }); - }); - - prismaMock.user.findMany.mockResolvedValue( - // @ts-expect-error Prisma v5 breaks this - 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) { - // @ts-expect-error Prisma v5 breaks this - 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() { - // @ts-expect-error Prisma v5 breaks this - prismaMock.feature.findMany.mockResolvedValue([ - { - slug: "emails", - // It's a kill switch - enabled: false, - }, - ]); -} - -export function mockNoTranslations() { - // @ts-expect-error FIXME - 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/apps/web/test/utils/bookingScenario/MockPaymentService.ts b/apps/web/test/utils/bookingScenario/MockPaymentService.ts new file mode 100644 index 0000000000..2eef10444a --- /dev/null +++ b/apps/web/test/utils/bookingScenario/MockPaymentService.ts @@ -0,0 +1,88 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; + +import type { Payment, Prisma, PaymentOption, Booking } from "@prisma/client"; +import { v4 as uuidv4 } from "uuid"; +import "vitest-fetch-mock"; + +import { sendAwaitingPaymentEmail } from "@calcom/emails"; +import logger from "@calcom/lib/logger"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; + +export function getMockPaymentService() { + function createPaymentLink(/*{ paymentUid, name, email, date }*/) { + return "http://mock-payment.example.com/"; + } + const paymentUid = uuidv4(); + const externalId = uuidv4(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + class MockPaymentService implements IAbstractPaymentService { + // TODO: We shouldn't need to implement adding a row to Payment table but that's a requirement right now. + // We should actually delegate table creation to the core app. Here, only the payment app specific logic should come + async create( + payment: Pick, + bookingId: Booking["id"], + userId: Booking["userId"], + username: string | null, + bookerName: string | null, + bookerEmail: string, + paymentOption: PaymentOption + ) { + const paymentCreateData = { + id: 1, + uid: paymentUid, + appId: null, + bookingId, + // booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + fee: 10, + success: true, + refunded: false, + data: {}, + externalId, + paymentOption, + amount: payment.amount, + currency: payment.currency, + }; + + const paymentData = prismaMock.payment.create({ + data: paymentCreateData, + }); + logger.silly("Created mock payment", JSON.stringify({ paymentData })); + + return paymentData; + } + async afterPayment( + event: CalendarEvent, + booking: { + user: { email: string | null; name: string | null; timeZone: string } | null; + id: number; + startTime: { toISOString: () => string }; + uid: string; + }, + paymentData: Payment + ): Promise { + // TODO: App implementing PaymentService is supposed to send email by itself at the moment. + await sendAwaitingPaymentEmail({ + ...event, + paymentInfo: { + link: createPaymentLink(/*{ + paymentUid: paymentData.uid, + name: booking.user?.name, + email: booking.user?.email, + date: booking.startTime.toISOString(), + }*/), + paymentOption: paymentData.paymentOption || "ON_BOOKING", + amount: paymentData.amount, + currency: paymentData.currency, + }, + }); + } + } + return { + paymentUid, + externalId, + MockPaymentService, + }; +} diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts new file mode 100644 index 0000000000..ba6b393824 --- /dev/null +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -0,0 +1,1031 @@ +import appStoreMock from "../../../../../tests/libs/__mocks__/app-store"; +import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n"; +import prismock from "../../../../../tests/libs/__mocks__/prisma"; + +import type { Prisma } from "@prisma/client"; +import type { WebhookTriggerEvents } from "@prisma/client"; +import type Stripe from "stripe"; +import { v4 as uuidv4 } from "uuid"; +import "vitest-fetch-mock"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; +import type { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { SchedulingType } from "@calcom/prisma/enums"; +import type { BookingStatus } from "@calcom/prisma/enums"; +import type { NewCalendarEventType } from "@calcom/types/Calendar"; +import type { EventBusyDate } from "@calcom/types/Calendar"; + +import { getMockPaymentService } from "./MockPaymentService"; + +logger.setSettings({ minLevel: "silly" }); +const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] }); +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; + }[]; + destinationCalendar?: Prisma.DestinationCalendarCreateInput; +}; + +export 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; + destinationCalendar?: Prisma.DestinationCalendarCreateInput; +} & Partial>; + +type InputBooking = { + id?: number; + uid?: string; + userId?: number; + eventTypeId: number; + startTime: string; + endTime: string; + title?: string; + status: BookingStatus; + attendees?: { email: string }[]; + references?: { + type: string; + uid: string; + meetingId?: string; + meetingPassword?: string; + meetingUrl?: string; + bookingId?: number; + externalCalendarId?: string; + deleted?: boolean; + credentialId?: number; + }[]; +}; + +const Timezones = { + "+5:30": "Asia/Kolkata", + "+6:00": "Asia/Dhaka", +}; + +async function addEventTypesToDb( + eventTypes: (Omit & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + users?: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workflows?: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + destinationCalendar?: any; + })[] +) { + log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); + await prismock.eventType.createMany({ + data: eventTypes, + }); + log.silly( + "TestData: All EventTypes in DB are", + JSON.stringify({ + eventTypes: await prismock.eventType.findMany({ + include: { + users: true, + workflows: true, + destinationCalendar: true, + }, + }), + }) + ); +} + +async 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, + length: 15, + //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, + destinationCalendar: eventType.destinationCalendar + ? { + create: eventType.destinationCalendar, + } + : eventType.destinationCalendar, + }; + }); + log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); + await addEventTypesToDb(eventTypesWithUsers); +} + +function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) { + prismock.bookingReference.createMany({ + data: bookingReferences, + }); +} + +async function addBookingsToDb( + bookings: (Prisma.BookingCreateInput & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + references: any[]; + })[] +) { + await prismock.booking.createMany({ + data: bookings, + }); + log.silly( + "TestData: Booking as in DB", + JSON.stringify({ + bookings: await prismock.booking.findMany({ + include: { + references: true, + }, + }), + }) + ); +} + +async function addBookings(bookings: InputBooking[]) { + log.silly("TestData: Creating Bookings", JSON.stringify(bookings)); + const allBookings = [...bookings].map((booking) => { + if (booking.references) { + addBookingReferencesToDB( + booking.references.map((reference) => { + return { + ...reference, + bookingId: booking.id, + }; + }) + ); + } + return { + uid: uuidv4(), + workflowReminders: [], + references: [], + title: "Test Booking Title", + ...booking, + }; + }); + + await addBookingsToDb( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + allBookings.map((booking) => { + const bookingCreate = booking; + if (booking.references) { + bookingCreate.references = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + createMany: { + data: booking.references, + }, + }; + } + return bookingCreate; + }) + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function addWebhooksToDb(webhooks: any[]) { + await prismock.webhook.createMany({ + data: webhooks, + }); +} + +async function addWebhooks(webhooks: InputWebhook[]) { + log.silly("TestData: Creating Webhooks", safeStringify(webhooks)); + + await addWebhooksToDb(webhooks); +} + +async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[] })[]) { + log.silly("TestData: Creating Users", JSON.stringify(users)); + await prismock.user.createMany({ + data: users, + }); + log.silly( + "Added users to Db", + safeStringify({ + allUsers: await prismock.user.findMany(), + }) + ); +} + +async function addUsers(users: InputUser[]) { + const prismaUsersCreate = users.map((user) => { + const newUser = user; + if (user.schedules) { + newUser.schedules = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + createMany: { + data: user.schedules.map((schedule) => { + return { + ...schedule, + availability: { + createMany: { + data: schedule.availability, + }, + }, + }; + }), + }, + }; + } + if (user.credentials) { + newUser.credentials = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + createMany: { + data: user.credentials, + }, + }; + } + if (user.selectedCalendars) { + newUser.selectedCalendars = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + createMany: { + data: user.selectedCalendars, + }, + }; + } + return newUser; + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + await addUsersToDb(prismaUsersCreate); +} + +export async function createBookingScenario(data: ScenarioData) { + log.silly("TestData: Creating Scenario", JSON.stringify({ data })); + await addUsers(data.users); + + const eventType = await addEventTypes(data.eventTypes, data.users); + if (data.apps) { + prismock.app.createMany({ + data: data.apps, + }); + } + data.bookings = data.bookings || []; + // allowSuccessfulBookingCreation(); + await addBookings(data.bookings); + // mockBusyCalendarTimes([]); + await addWebhooks(data.webhooks || []); + // addPaymentMock(); + return { + eventType, + }; +} + +// async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) { +// await prismaMock.payment.createMany({ +// data: payments, +// }); +// } + +/** + * 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; + }; +}) { + const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; + return { + type: app.type, + appId: app.slug, + app: app, + 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:write", + }, + }); +} + +export function getStripeAppCredential() { + return getMockedCredential({ + metadataLookupKey: "stripepayment", + key: { + scope: "read_write", + }, + }); +} + +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", + enabled: true, + 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", + enabled: true, + // 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"], + }, + }, + zoomvideo: { + slug: "zoom", + enabled: true, + 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"], + }, + }, + "stripe-payment": { + //TODO: Read from appStoreMeta + slug: "stripe", + enabled: true, + dirName: "stripepayment", + // 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"], + }, + }, + }, +}; + +export class MockError extends Error { + constructor(message: string) { + super(message); + this.name = "MockError"; + } +} + +export function getOrganizer({ + name, + email, + id, + schedules, + credentials, + selectedCalendars, + destinationCalendar, +}: { + name: string; + email: string; + id: number; + schedules: InputUser["schedules"]; + credentials?: InputCredential[]; + selectedCalendars?: InputSelectedCalendar[]; + destinationCalendar?: Prisma.DestinationCalendarCreateInput; +}) { + return { + ...TestData.users.example, + name, + email, + id, + schedules, + credentials, + selectedCalendars, + destinationCalendar, + }; +} + +export function getScenarioData({ + organizer, + eventTypes, + usersApartFromOrganizer = [], + apps = [], + webhooks, + bookings, +}: // hosts = [], +{ + organizer: ReturnType; + eventTypes: ScenarioData["eventTypes"]; + apps?: ScenarioData["apps"]; + usersApartFromOrganizer?: ScenarioData["users"]; + webhooks?: ScenarioData["webhooks"]; + bookings?: ScenarioData["bookings"]; + // 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.map((eventType, index) => { + return { + ...eventType, + title: `Test Event Type - ${index + 1}`, + description: `It's a test event type - ${index + 1}`, + }; + }), + users: users.map((user) => { + const newUser = { + ...user, + }; + if (user.destinationCalendar) { + newUser.destinationCalendar = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + create: user.destinationCalendar, + }; + } + return newUser; + }), + apps: [...apps], + webhooks, + bookings: bookings || [], + }; +} + +export function enableEmailFeature() { + prismock.feature.create({ + data: { + slug: "emails", + enabled: false, + type: "KILL_SWITCH", + }, + }); +} + +export function mockNoTranslations() { + log.silly("Mocking i18n.getTranslation to return identity function"); + // @ts-expect-error FIXME + i18nMock.getTranslation.mockImplementation(() => { + return new Promise((resolve) => { + const identityFn = (key: string) => key; + resolve(identityFn); + }); + }); +} + +/** + * @param metadataLookupKey + * @param calendarData Specify uids and other data to be faked to be returned by createEvent and updateEvent + */ +export function mockCalendar( + metadataLookupKey: keyof typeof appStoreMetadata, + calendarData?: { + create?: { + id?: string; + uid?: string; + iCalUID?: string; + }; + update?: { + id?: string; + uid: string; + iCalUID?: string; + }; + busySlots?: { start: `${string}Z`; end: `${string}Z` }[]; + creationCrash?: boolean; + updationCrash?: boolean; + getAvailabilityCrash?: boolean; + } +) { + const appStoreLookupKey = metadataLookupKey; + const normalizedCalendarData = calendarData || { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + }, + }; + log.silly(`Mocking ${appStoreLookupKey} on appStoreMock`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createEventCalls: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateEventCalls: any[] = []; + const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; + 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 { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createEvent: async function (...rest: any[]): Promise { + if (calendarData?.creationCrash) { + throw new Error("MockCalendarService.createEvent fake error"); + } + const [calEvent, credentialId] = rest; + log.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId })); + createEventCalls.push(rest); + return Promise.resolve({ + type: app.type, + additionalInfo: {}, + uid: "PROBABLY_UNUSED_UID", + // A Calendar is always expected to return an id. + id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID", + iCalUID: normalizedCalendarData.create?.iCalUID, + // Password and URL seems useless for CalendarService, plan to remove them if that's the case + password: "MOCK_PASSWORD", + url: "https://UNUSED_URL", + }); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateEvent: async function (...rest: any[]): Promise { + if (calendarData?.updationCrash) { + throw new Error("MockCalendarService.updateEvent fake error"); + } + const [uid, event, externalCalendarId] = rest; + log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); + // eslint-disable-next-line prefer-rest-params + updateEventCalls.push(rest); + return Promise.resolve({ + type: app.type, + additionalInfo: {}, + uid: "PROBABLY_UNUSED_UID", + iCalUID: normalizedCalendarData.update?.iCalUID, + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", + // Password and URL seems useless for CalendarService, plan to remove them if that's the case + password: "MOCK_PASSWORD", + url: "https://UNUSED_URL", + }); + }, + getAvailability: async (): Promise => { + if (calendarData?.getAvailabilityCrash) { + throw new Error("MockCalendarService.getAvailability fake error"); + } + return new Promise((resolve) => { + resolve(calendarData?.busySlots || []); + }); + }, + }; + }, + }, + }); + return { + createEventCalls, + updateEventCalls, + }; +} + +export function mockCalendarToHaveNoBusySlots( + metadataLookupKey: keyof typeof appStoreMetadata, + calendarData?: Parameters[1] +) { + calendarData = calendarData || { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + }, + }; + return mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] }); +} + +export function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { + return mockCalendar(metadataLookupKey, { creationCrash: true }); +} + +export function mockCalendarToCrashOnUpdateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { + return mockCalendar(metadataLookupKey, { updationCrash: true }); +} + +export function mockVideoApp({ + metadataLookupKey, + appStoreLookupKey, + videoMeetingData, + creationCrash, + updationCrash, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; + videoMeetingData?: { + password: string; + id: string; + url: string; + }; + creationCrash?: boolean; + updationCrash?: boolean; +}) { + appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + videoMeetingData = videoMeetingData || { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-${metadataLookupKey}.example.com`, + }; + log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); + const createMeetingCalls: any[] = []; + const updateMeetingCalls: any[] = []; + // 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: () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMeeting: (...rest: any[]) => { + if (creationCrash) { + throw new Error("MockVideoApiAdapter.createMeeting fake error"); + } + createMeetingCalls.push(rest); + + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + ...videoMeetingData, + }); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateMeeting: async (...rest: any[]) => { + if (updationCrash) { + throw new Error("MockVideoApiAdapter.updateMeeting fake error"); + } + const [bookingRef, calEvent] = rest; + updateMeetingCalls.push(rest); + if (!bookingRef.type) { + throw new Error("bookingRef.type is not defined"); + } + if (!calEvent.organizer) { + throw new Error("calEvent.organizer is not defined"); + } + log.silly( + "mockSuccessfulVideoMeetingCreation.updateMeeting", + JSON.stringify({ bookingRef, calEvent }) + ); + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + ...videoMeetingData, + }); + }, + }), + }, + }); + }); + }); + return { + createMeetingCalls, + updateMeetingCalls, + }; +} + +export function mockSuccessfulVideoMeetingCreation({ + metadataLookupKey, + appStoreLookupKey, + videoMeetingData, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; + videoMeetingData?: { + password: string; + id: string; + url: string; + }; +}) { + return mockVideoApp({ + metadataLookupKey, + appStoreLookupKey, + videoMeetingData, + }); +} + +export function mockVideoAppToCrashOnCreateMeeting({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + return mockVideoApp({ + metadataLookupKey, + appStoreLookupKey, + creationCrash: true, + }); +} + +export function mockPaymentApp({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + const { paymentUid, externalId, MockPaymentService } = getMockPaymentService(); + // 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: { + PaymentService: MockPaymentService, + }, + }); + }); + }); + + return { + paymentUid, + externalId, + }; +} + +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 getBooker({ name, email }: { name: string; email: string }) { + return { + name, + email, + }; +} + +export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) { + return { + id: null, + data: { + object: { + id: paymentIntentId, + }, + }, + } as unknown as Stripe.Event; +} + +export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) { + let webhookResponse = null; + try { + await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId })); + } catch (e) { + log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e)); + + webhookResponse = e as HttpError; + } + return { webhookResponse }; +} diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts new file mode 100644 index 0000000000..e988017b9b --- /dev/null +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -0,0 +1,646 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; + +import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client"; +import ical from "node-ical"; +import { expect } from "vitest"; +import "vitest-fetch-mock"; + +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { Fixtures } from "@calcom/web/test/fixtures/fixtures"; + +import type { InputEventType } from "./bookingScenario"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toHaveEmail( + expectedEmail: { + //TODO: Support email HTML parsing to target specific elements + htmlToContain?: string; + to: string; + noIcs?: true; + ics?: { + filename: string; + iCalUID: string; + }; + }, + to: string + ): R; + } + } +} + +expect.extend({ + toHaveEmail( + emails: Fixtures["emails"], + expectedEmail: { + //TODO: Support email HTML parsing to target specific elements + htmlToContain?: string; + to: string; + ics: { + filename: string; + iCalUID: string; + }; + noIcs: true; + }, + to: string + ) { + const testEmail = emails.get().find((email) => email.to.includes(to)); + const emailsToLog = emails + .get() + .map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent })); + if (!testEmail) { + logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); + return { + pass: false, + message: () => `No email sent to ${to}`, + }; + } + const ics = testEmail.icalEvent; + const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null; + + let isHtmlContained = true; + let isToAddressExpected = true; + const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true; + const isIcsUIDExpected = expectedEmail.ics + ? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null) + : true; + + if (expectedEmail.htmlToContain) { + isHtmlContained = testEmail.html.includes(expectedEmail.htmlToContain); + } + + isToAddressExpected = expectedEmail.to === testEmail.to; + + if (!isHtmlContained || !isToAddressExpected) { + logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); + } + + return { + pass: + isHtmlContained && + isToAddressExpected && + (expectedEmail.noIcs ? true : isIcsFilenameExpected && isIcsUIDExpected), + message: () => { + if (!isHtmlContained) { + return `Email HTML is not as expected. Expected:"${expectedEmail.htmlToContain}" isn't contained in "${testEmail.html}"`; + } + + if (!isToAddressExpected) { + return `Email To address is not as expected. Expected:${expectedEmail.to} isn't equal to ${testEmail.to}`; + } + + if (!isIcsFilenameExpected) { + return `ICS Filename is not as expected. Expected:${expectedEmail.ics.filename} isn't equal to ${ics?.filename}`; + } + + if (!isIcsUIDExpected) { + return `ICS UID is not as expected. Expected:${ + expectedEmail.ics.iCalUID + } isn't present in ${JSON.stringify(icsObject)}`; + } + throw new Error("Unknown error"); + }, + }; + }, +}); + +export function expectWebhookToHaveBeenCalledWith( + subscriberUrl: string, + data: { + triggerEvent: WebhookTriggerEvents; + payload: Record | null; + } +) { + const fetchCalls = fetchMock.mock.calls; + const webhooksToSubscriberUrl = fetchCalls.filter((call) => { + return call[0] === subscriberUrl; + }); + logger.silly("Scanning fetchCalls for webhook", safeStringify(fetchCalls)); + const webhookFetchCall = webhooksToSubscriberUrl.find((call) => { + const body = call[1]?.body; + const parsedBody = JSON.parse((body as string) || "{}"); + return parsedBody.triggerEvent === data.triggerEvent; + }); + + if (!webhookFetchCall) { + throw new Error( + `Webhook not sent to ${subscriberUrl} for ${data.triggerEvent}. All webhooks: ${JSON.stringify( + webhooksToSubscriberUrl + )}` + ); + } + expect(webhookFetchCall[0]).toBe(subscriberUrl); + const body = webhookFetchCall[1]?.body; + const parsedBody = JSON.parse((body as string) || "{}"); + + expect(parsedBody.triggerEvent).toBe(data.triggerEvent); + if (parsedBody.payload.metadata?.videoCallUrl) { + parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl + ? parsedBody.payload.metadata.videoCallUrl.replace(/\/video\/[a-zA-Z0-9]{22}/, "/video/DYNAMIC_UID") + : parsedBody.payload.metadata.videoCallUrl; + } + if (data.payload) { + if (data.payload.metadata !== undefined) { + expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata)); + } + if (data.payload.responses !== undefined) + expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses)); + const { responses: _1, metadata: _2, ...remainingPayload } = data.payload; + expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload)); + } +} + +export function expectWorkflowToBeTriggered() { + // TODO: Implement this. +} + +export async function expectBookingToBeInDatabase( + booking: Partial & Pick & { references?: Partial[] } +) { + const actualBooking = await prismaMock.booking.findUnique({ + where: { + uid: booking.uid, + }, + include: { + references: true, + }, + }); + + const { references, ...remainingBooking } = booking; + expect(actualBooking).toEqual(expect.objectContaining(remainingBooking)); + expect(actualBooking?.references).toEqual( + expect.arrayContaining((references || []).map((reference) => expect.objectContaining(reference))) + ); +} + +export function expectSuccessfulBookingCreationEmails({ + emails, + organizer, + booker, + iCalUID, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + iCalUID: string; +}) { + expect(emails).toHaveEmail( + { + htmlToContain: "confirmed_event_type_subject", + to: `${organizer.email}`, + ics: { + filename: "event.ics", + iCalUID: iCalUID, + }, + }, + `${organizer.email}` + ); + + expect(emails).toHaveEmail( + { + htmlToContain: "confirmed_event_type_subject", + to: `${booker.name} <${booker.email}>`, + ics: { + filename: "event.ics", + iCalUID: iCalUID, + }, + }, + `${booker.name} <${booker.email}>` + ); +} + +export function expectBrokenIntegrationEmails({ + emails, + organizer, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; +}) { + // Broken Integration email is only sent to the Organizer + expect(emails).toHaveEmail( + { + htmlToContain: "broken_integration", + to: `${organizer.email}`, + // No ics goes in case of broken integration email it seems + // ics: { + // filename: "event.ics", + // iCalUID: iCalUID, + // }, + }, + `${organizer.email}` + ); + + // expect(emails).toHaveEmail( + // { + // htmlToContain: "confirmed_event_type_subject", + // to: `${booker.name} <${booker.email}>`, + // }, + // `${booker.name} <${booker.email}>` + // ); +} + +export function expectCalendarEventCreationFailureEmails({ + emails, + organizer, + booker, + iCalUID, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + iCalUID: string; +}) { + expect(emails).toHaveEmail( + { + htmlToContain: "broken_integration", + to: `${organizer.email}`, + ics: { + filename: "event.ics", + iCalUID, + }, + }, + `${organizer.email}` + ); + + expect(emails).toHaveEmail( + { + htmlToContain: "calendar_event_creation_failure_subject", + to: `${booker.name} <${booker.email}>`, + ics: { + filename: "event.ics", + iCalUID, + }, + }, + `${booker.name} <${booker.email}>` + ); +} + +export function expectSuccessfulBookingRescheduledEmails({ + emails, + organizer, + booker, + iCalUID, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + iCalUID: string; +}) { + expect(emails).toHaveEmail( + { + htmlToContain: "event_type_has_been_rescheduled_on_time_date", + to: `${organizer.email}`, + ics: { + filename: "event.ics", + iCalUID, + }, + }, + `${organizer.email}` + ); + + expect(emails).toHaveEmail( + { + htmlToContain: "event_type_has_been_rescheduled_on_time_date", + to: `${booker.name} <${booker.email}>`, + ics: { + filename: "event.ics", + iCalUID, + }, + }, + `${booker.name} <${booker.email}>` + ); +} +export function expectAwaitingPaymentEmails({ + emails, + booker, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; +}) { + expect(emails).toHaveEmail( + { + htmlToContain: "awaiting_payment_subject", + to: `${booker.name} <${booker.email}>`, + noIcs: true, + }, + `${booker.email}` + ); +} + +export function expectBookingRequestedEmails({ + emails, + organizer, + booker, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; +}) { + expect(emails).toHaveEmail( + { + htmlToContain: "event_awaiting_approval_subject", + to: `${organizer.email}`, + noIcs: true, + }, + `${organizer.email}` + ); + + expect(emails).toHaveEmail( + { + htmlToContain: "booking_submitted_subject", + to: `${booker.email}`, + noIcs: true, + }, + `${booker.email}` + ); +} + +export function expectBookingRequestedWebhookToHaveBeenFired({ + booker, + location, + subscriberUrl, + paidEvent, + eventType, +}: { + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + subscriberUrl: string; + location: string; + paidEvent?: boolean; + eventType: InputEventType; +}) { + // There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement. + if (!paidEvent) { + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: "BOOKING_REQUESTED", + payload: { + eventTitle: eventType.title, + eventDescription: eventType.description, + metadata: { + // In a Pending Booking Request, we don't send the video call url + }, + responses: { + name: { label: "your_name", value: booker.name }, + email: { label: "email_address", value: booker.email }, + location: { + label: "location", + value: { optionValue: "", value: location }, + }, + }, + }, + }); + } else { + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: "BOOKING_REQUESTED", + payload: { + eventTitle: eventType.title, + eventDescription: eventType.description, + metadata: { + // In a Pending Booking Request, we don't send the video call url + }, + responses: { + name: { label: "name", value: booker.name }, + email: { label: "email", value: booker.email }, + location: { + label: "location", + value: { optionValue: "", value: location }, + }, + }, + }, + }); + } +} + +export function expectBookingCreatedWebhookToHaveBeenFired({ + booker, + location, + subscriberUrl, + paidEvent, + videoCallUrl, +}: { + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + subscriberUrl: string; + location: string; + paidEvent?: boolean; + videoCallUrl?: string | null; +}) { + if (!paidEvent) { + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: "BOOKING_CREATED", + payload: { + metadata: { + ...(videoCallUrl ? { videoCallUrl } : null), + }, + responses: { + name: { label: "your_name", value: booker.name }, + email: { label: "email_address", value: booker.email }, + location: { + label: "location", + value: { optionValue: "", value: location }, + }, + }, + }, + }); + } else { + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: "BOOKING_CREATED", + payload: { + // FIXME: File this bug and link ticket here. This is a bug in the code. metadata must be sent here like other BOOKING_CREATED webhook + metadata: null, + responses: { + name: { label: "name", value: booker.name }, + email: { label: "email", value: booker.email }, + location: { + label: "location", + value: { optionValue: "", value: location }, + }, + }, + }, + }); + } +} + +export function expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + location, + subscriberUrl, + videoCallUrl, +}: { + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + subscriberUrl: string; + location: string; + paidEvent?: boolean; + videoCallUrl?: string; +}) { + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: "BOOKING_RESCHEDULED", + payload: { + metadata: { + ...(videoCallUrl ? { videoCallUrl } : null), + }, + responses: { + name: { label: "your_name", value: booker.name }, + email: { label: "email_address", value: booker.email }, + location: { + label: "location", + value: { optionValue: "", value: location }, + }, + }, + }, + }); +} + +export function expectBookingPaymentIntiatedWebhookToHaveBeenFired({ + booker, + location, + subscriberUrl, + paymentId, +}: { + organizer: { email: string; name: string }; + booker: { email: string; name: string }; + subscriberUrl: string; + location: string; + paymentId: number; +}) { + expectWebhookToHaveBeenCalledWith(subscriberUrl, { + triggerEvent: "BOOKING_PAYMENT_INITIATED", + payload: { + paymentId: paymentId, + metadata: { + // In a Pending Booking Request, we don't send the video call url + }, + responses: { + name: { label: "your_name", value: booker.name }, + email: { label: "email_address", value: booker.email }, + location: { + label: "location", + value: { optionValue: "", value: location }, + }, + }, + }, + }); +} + +export function expectSuccessfulCalendarEventCreationInCalendar( + calendarMock: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createEventCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateEventCalls: any[]; + }, + expected: { + calendarId: string | null; + videoCallUrl: string; + } +) { + expect(calendarMock.createEventCalls.length).toBe(1); + const call = calendarMock.createEventCalls[0]; + const calEvent = call[0]; + + expect(calEvent).toEqual( + expect.objectContaining({ + destinationCalendar: expected.calendarId + ? [ + expect.objectContaining({ + externalId: expected.calendarId, + }), + ] + : null, + videoCallData: expect.objectContaining({ + url: expected.videoCallUrl, + }), + }) + ); +} + +export function expectSuccessfulCalendarEventUpdationInCalendar( + calendarMock: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createEventCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateEventCalls: any[]; + }, + expected: { + externalCalendarId: string; + calEvent: Partial; + uid: string; + } +) { + expect(calendarMock.updateEventCalls.length).toBe(1); + const call = calendarMock.updateEventCalls[0]; + const uid = call[0]; + const calendarEvent = call[1]; + const externalId = call[2]; + expect(uid).toBe(expected.uid); + expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); + expect(externalId).toBe(expected.externalCalendarId); +} + +export function expectSuccessfulVideoMeetingCreationInCalendar( + videoMock: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMeetingCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateMeetingCalls: any[]; + }, + expected: { + externalCalendarId: string; + calEvent: Partial; + uid: string; + } +) { + expect(videoMock.createMeetingCalls.length).toBe(1); + const call = videoMock.createMeetingCalls[0]; + const uid = call[0]; + const calendarEvent = call[1]; + const externalId = call[2]; + expect(uid).toBe(expected.uid); + expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); + expect(externalId).toBe(expected.externalCalendarId); +} + +export function expectSuccessfulVideoMeetingUpdationInCalendar( + videoMock: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMeetingCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateMeetingCalls: any[]; + }, + expected: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingRef: any; + calEvent: Partial; + } +) { + expect(videoMock.updateMeetingCalls.length).toBe(1); + const call = videoMock.updateMeetingCalls[0]; + const bookingRef = call[0]; + const calendarEvent = call[1]; + expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef)); + expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) { + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...from, + status: BookingStatus.CANCELLED, + }); + + // Expect new booking to be created + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...to, + status: BookingStatus.ACCEPTED, + }); +} diff --git a/package.json b/package.json index badd51a1ea..effb13590d 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@playwright/test": "^1.31.2", "@snaplet/copycat": "^0.3.0", "@testing-library/jest-dom": "^5.16.5", + "@types/jsonwebtoken": "^9.0.3", "c8": "^7.13.0", "dotenv-checker": "^1.1.5", "husky": "^8.0.0", @@ -86,7 +87,9 @@ "jsdom": "^22.0.0", "lint-staged": "^12.5.0", "mailhog": "^4.16.0", + "node-ical": "^0.16.1", "prettier": "^2.8.6", + "prismock": "^1.21.1", "tsc-absolute": "^1.0.0", "typescript": "^4.9.4", "vitest": "^0.34.3", diff --git a/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx b/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx index 64aec72658..2085736a3d 100644 --- a/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx +++ b/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx @@ -208,7 +208,7 @@ export const AppForm = ({ Tip : Go and change the logo of your {isTemplate ? "template" : "app"} by replacing{" "} - {getAppDirPath(slug, isTemplate) + "/static/icon.svg"} + {`${getAppDirPath(slug, isTemplate)}/static/icon.svg`} diff --git a/packages/app-store-cli/src/components/Message.tsx b/packages/app-store-cli/src/components/Message.tsx index 949275a875..a06a871de1 100644 --- a/packages/app-store-cli/src/components/Message.tsx +++ b/packages/app-store-cli/src/components/Message.tsx @@ -12,7 +12,7 @@ export function Message({ if (message.showInProgressIndicator) { const interval = setInterval(() => { setProgressText((progressText) => { - return progressText.length > 3 ? "" : progressText + "."; + return progressText.length > 3 ? "" : `${progressText}.`; }); }, 1000); return () => { diff --git a/packages/app-store-cli/src/core.ts b/packages/app-store-cli/src/core.ts index e80d88630b..ed36d375aa 100644 --- a/packages/app-store-cli/src/core.ts +++ b/packages/app-store-cli/src/core.ts @@ -1,7 +1,7 @@ import fs from "fs"; import path from "path"; -import { APP_STORE_PATH, TEMPLATES_PATH, IS_WINDOWS_PLATFORM} from "./constants"; +import { APP_STORE_PATH, TEMPLATES_PATH, IS_WINDOWS_PLATFORM } from "./constants"; import execSync from "./utils/execSync"; const slugify = (str: string) => { @@ -70,7 +70,11 @@ export const BaseAppFork = { const appDirPath = getAppDirPath(slug, isTemplate); if (!editMode) { await execSync(IS_WINDOWS_PLATFORM ? `mkdir ${appDirPath}` : `mkdir -p ${appDirPath}`); - await execSync(IS_WINDOWS_PLATFORM ? `xcopy "${TEMPLATES_PATH}\\${template}\\*" "${appDirPath}" /e /i` : `cp -r ${TEMPLATES_PATH}/${template}/* ${appDirPath}`); + await execSync( + IS_WINDOWS_PLATFORM + ? `xcopy "${TEMPLATES_PATH}\\${template}\\*" "${appDirPath}" /e /i` + : `cp -r ${TEMPLATES_PATH}/${template}/* ${appDirPath}` + ); } else { if (!oldSlug) { throw new Error("oldSlug is required when editMode is true"); @@ -79,7 +83,9 @@ export const BaseAppFork = { // We need to rename only if they are different const oldAppDirPath = getAppDirPath(oldSlug, isTemplate); - await execSync(IS_WINDOWS_PLATFORM ? `move ${oldAppDirPath} ${appDirPath}` : `mv ${oldAppDirPath} ${appDirPath}`); + await execSync( + IS_WINDOWS_PLATFORM ? `move ${oldAppDirPath} ${appDirPath}` : `mv ${oldAppDirPath} ${appDirPath}` + ); } } updatePackageJson({ slug, appDirPath, appDescription: description }); diff --git a/packages/app-store/CONTRIBUTING.md b/packages/app-store/CONTRIBUTING.md index f3de617bc0..f716be057e 100644 --- a/packages/app-store/CONTRIBUTING.md +++ b/packages/app-store/CONTRIBUTING.md @@ -1,11 +1,13 @@ ## App Contribution Guidelines #### `DESCRIPTION.md` + 1. images - include atleast 4 images (do we have a recommended size here?). Can show app in use and/or installation steps 2. add only file name for images, path not required. i.e. `1.jpeg`, not `/app-store/zohocalendar/1.jpeg` 3. description should include what the integration with Cal allows the user to do e.g. `Allows you to sync Cal bookings with your Zoho Calendar` #### `README.md` + 1. Include installation instructions and links to the app's website. 2. For url use `/api/integrations`, rather than `/api/integrations` @@ -16,7 +18,8 @@ 2. description here should not exceed 10 words (this is arbitrary, but should not be long otherwise it's truncated in the app store) #### Others -1. Add API documentation links in comments for files `api`, `lib` and `types` + +1. Add API documentation links in comments for files `api`, `lib` and `types` 2. Use [`AppDeclarativeHandler`](../types/AppHandler.d.ts) across all apps. Whatever isn't supported in it, support that. 3. README should be added in the respective app and can be linked in main README [like this](https://github.com/calcom/cal.com/pull/10429/files/155ac84537d12026f595551fe3542e810b029714#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R509) -4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. \ No newline at end of file +4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts)) diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx index 5489c1ea95..cf06eeba10 100644 --- a/packages/app-store/_components/AppCard.tsx +++ b/packages/app-store/_components/AppCard.tsx @@ -1,10 +1,12 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import Link from "next/link"; +import { useTranslation } from "react-i18next"; import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import { classNames } from "@calcom/lib"; import type { RouterOutputs } from "@calcom/trpc/react"; -import { Switch, Badge, Avatar } from "@calcom/ui"; +import { Switch, Badge, Avatar, Button } from "@calcom/ui"; +import { Settings } from "@calcom/ui/components/icon"; import type { CredentialOwner } from "../types"; import OmniInstallAppButton from "./OmniInstallAppButton"; @@ -27,6 +29,7 @@ export default function AppCard({ teamId?: number; LockedIcon?: React.ReactNode; }) { + const { t } = useTranslation(); const [animationRef] = useAutoAnimate(); const { setAppData, LockedIcon, disabled } = useAppContextWithSchema(); @@ -41,7 +44,7 @@ export default function AppCard({
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
{app?.isInstalled && switchChecked &&
} + {app?.isInstalled && switchChecked ? ( -
{children}
+ app.isSetupAlready === undefined || app.isSetupAlready ? ( +
+ +
+ ) : ( +
+

{t("this_app_is_not_setup_already")}

+ + + +
+ ) ) : null}
diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx new file mode 100644 index 0000000000..39b20bf398 --- /dev/null +++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx @@ -0,0 +1,23 @@ +import type { GetServerSidePropsContext } from "next"; + +export const AppSetupPageMap = { + alby: import("../../alby/pages/setup/_getServerSideProps"), + make: import("../../make/pages/setup/_getServerSideProps"), + zapier: import("../../zapier/pages/setup/_getServerSideProps"), + stripe: import("../../stripepayment/pages/setup/_getServerSideProps"), +}; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const { slug } = ctx.params || {}; + if (typeof slug !== "string") return { notFound: true } as const; + + if (!(slug in AppSetupPageMap)) return { props: {} }; + + const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap]; + + if (!page.getServerSideProps) return { props: {} }; + + const props = await page.getServerSideProps(ctx); + + return props; +}; diff --git a/packages/app-store/_pages/setup/_getStaticProps.tsx b/packages/app-store/_pages/setup/_getStaticProps.tsx deleted file mode 100644 index 3a10131c9d..0000000000 --- a/packages/app-store/_pages/setup/_getStaticProps.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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) => { - const { slug } = ctx.params || {}; - if (typeof slug !== "string") return { notFound: true } as const; - - if (!(slug in AppSetupPageMap)) return { props: {} }; - - const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap]; - - if (!page.getStaticProps) return { props: {} }; - - const props = await page.getStaticProps(ctx); - - return props; -}; diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index 6438b04a29..e9345f8c90 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -3,6 +3,7 @@ import dynamic from "next/dynamic"; import { DynamicComponent } from "../../_components/DynamicComponent"; export const AppSetupMap = { + alby: dynamic(() => import("../../alby/pages/setup")), "apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")), exchange: dynamic(() => import("../../exchangecalendar/pages/setup")), "exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")), @@ -12,6 +13,7 @@ export const AppSetupMap = { make: dynamic(() => import("../../make/pages/setup")), closecom: dynamic(() => import("../../closecom/pages/setup")), sendgrid: dynamic(() => import("../../sendgrid/pages/setup")), + stripe: dynamic(() => import("../../stripepayment/pages/setup")), paypal: dynamic(() => import("../../paypal/pages/setup")), }; diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index dd864929be..2524fe5ba6 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -42,7 +42,7 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise log.warn(`calendar of type ${calendarType} is not implemented`); return null; } - log.info("calendarApp", calendarApp.lib.CalendarService); + log.info("Got calendarApp", calendarApp.lib.CalendarService); const CalendarService = calendarApp.lib.CalendarService; return new CalendarService(credential); }; diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index d8fd1683f3..76becbbf10 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -64,7 +64,7 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta const stateStr = encodeURIComponent(JSON.stringify(state)); const searchParams = `?state=${stateStr}${teamId ? `&teamId=${teamId}` : ""}`; - const res = await fetch(`/api/integrations/${type}/add` + searchParams); + const res = await fetch(`/api/integrations/${type}/add${searchParams}`); if (!res.ok) { const errorBody = await res.json(); diff --git a/packages/app-store/alby/api/add.ts b/packages/app-store/alby/api/add.ts index 6ab3106577..f0f47ff83b 100644 --- a/packages/app-store/alby/api/add.ts +++ b/packages/app-store/alby/api/add.ts @@ -1,16 +1,42 @@ -import { createDefaultInstallation } from "@calcom/app-store/_utils/installation"; -import type { AppDeclarativeHandler } from "@calcom/types/AppHandler"; +import type { NextApiRequest, NextApiResponse } from "next"; -import appConfig from "../config.json"; +import prisma from "@calcom/prisma"; -const handler: AppDeclarativeHandler = { - appType: appConfig.type, - variant: appConfig.variant, - slug: appConfig.slug, - supportsMultipleInstalls: false, - handlerType: "add", - createCredential: ({ appType, user, slug, teamId }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), -}; +import config from "../config.json"; -export default handler; +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + const appType = config.type; + try { + const alreadyInstalled = await prisma.credential.findFirst({ + where: { + type: appType, + userId: req.session.user.id, + }, + }); + if (alreadyInstalled) { + throw new Error("Already installed"); + } + const installation = await prisma.credential.create({ + data: { + type: appType, + key: {}, + userId: req.session.user.id, + appId: "alby", + }, + }); + + if (!installation) { + throw new Error("Unable to create user credential for Alby"); + } + } catch (error: unknown) { + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } + + return res.status(200).json({ url: "/apps/alby/setup" }); +} diff --git a/packages/app-store/alby/api/index.ts b/packages/app-store/alby/api/index.ts index 4c0d2ead01..f29c527245 100644 --- a/packages/app-store/alby/api/index.ts +++ b/packages/app-store/alby/api/index.ts @@ -1 +1,2 @@ export { default as add } from "./add"; +export { default as webhook, config } from "@calcom/web/pages/api/integrations/alby/webhook"; diff --git a/packages/app-store/alby/api/webhook.ts b/packages/app-store/alby/api/webhook.ts new file mode 100644 index 0000000000..4910d7749c --- /dev/null +++ b/packages/app-store/alby/api/webhook.ts @@ -0,0 +1,125 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import getRawBody from "raw-body"; +import * as z from "zod"; + +import { albyCredentialKeysSchema } from "@calcom/app-store/alby/lib"; +import parseInvoice from "@calcom/app-store/alby/lib/parseInvoice"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { HttpError as HttpCode } from "@calcom/lib/http-error"; +import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess"; +import prisma from "@calcom/prisma"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== "POST") { + throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); + } + + const bodyRaw = await getRawBody(req); + const headers = req.headers; + const bodyAsString = bodyRaw.toString(); + + const parseHeaders = webhookHeadersSchema.safeParse(headers); + if (!parseHeaders.success) { + console.error(parseHeaders.error); + throw new HttpCode({ statusCode: 400, message: "Bad Request" }); + } + + const { data: parsedHeaders } = parseHeaders; + + const parse = eventSchema.safeParse(JSON.parse(bodyAsString)); + if (!parse.success) { + console.error(parse.error); + throw new HttpCode({ statusCode: 400, message: "Bad Request" }); + } + + const { data: parsedPayload } = parse; + + if (parsedPayload.metadata?.payer_data?.appId !== "cal.com") { + throw new HttpCode({ statusCode: 204, message: "Payment not for cal.com" }); + } + + const payment = await prisma.payment.findFirst({ + where: { + uid: parsedPayload.metadata.payer_data.referenceId, + }, + select: { + id: true, + amount: true, + bookingId: true, + booking: { + select: { + user: { + select: { + credentials: { + where: { + type: "alby_payment", + }, + }, + }, + }, + }, + }, + }, + }); + + if (!payment) throw new HttpCode({ statusCode: 204, message: "Payment not found" }); + const key = payment.booking?.user?.credentials?.[0].key; + if (!key) throw new HttpCode({ statusCode: 204, message: "Credentials not found" }); + + const parseCredentials = albyCredentialKeysSchema.safeParse(key); + if (!parseCredentials.success) { + console.error(parseCredentials.error); + throw new HttpCode({ statusCode: 500, message: "Credentials not valid" }); + } + + const credentials = parseCredentials.data; + + const albyInvoice = await parseInvoice(bodyAsString, parsedHeaders, credentials.webhook_endpoint_secret); + if (!albyInvoice) throw new HttpCode({ statusCode: 204, message: "Invoice not found" }); + if (albyInvoice.amount !== payment.amount) { + throw new HttpCode({ statusCode: 400, message: "invoice amount does not match payment amount" }); + } + + return await handlePaymentSuccess(payment.id, payment.bookingId); + } catch (_err) { + const err = getErrorFromUnknown(_err); + console.error(`Webhook Error: ${err.message}`); + return res.status(err.statusCode || 500).send({ + message: err.message, + stack: IS_PRODUCTION ? undefined : err.stack, + }); + } +} + +const payerDataSchema = z + .object({ + appId: z.string().optional(), + referenceId: z.string().optional(), + }) + .optional(); + +const metadataSchema = z + .object({ + payer_data: payerDataSchema, + }) + .optional(); + +const eventSchema = z.object({ + metadata: metadataSchema, +}); + +const webhookHeadersSchema = z + .object({ + "svix-id": z.string(), + "svix-timestamp": z.string(), + "svix-signature": z.string(), + }) + .passthrough(); diff --git a/packages/app-store/alby/components/AlbyPaymentComponent.tsx b/packages/app-store/alby/components/AlbyPaymentComponent.tsx new file mode 100644 index 0000000000..a4977b64cd --- /dev/null +++ b/packages/app-store/alby/components/AlbyPaymentComponent.tsx @@ -0,0 +1,182 @@ +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import QRCode from "react-qr-code"; +import z from "zod"; + +import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment"; +import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { Button } from "@calcom/ui"; +import { showToast } from "@calcom/ui"; +import { ClipboardCheck, Clipboard } from "@calcom/ui/components/icon"; +import { Spinner } from "@calcom/ui/components/icon/Spinner"; + +interface IAlbyPaymentComponentProps { + payment: { + // Will be parsed on render + data: unknown; + }; + paymentPageProps: PaymentPageProps; +} + +// Create zod schema for data +const PaymentAlbyDataSchema = z.object({ + invoice: z + .object({ + paymentRequest: z.string(), + }) + .required(), +}); + +export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => { + const { payment } = props; + const { data } = payment; + const [showQRCode, setShowQRCode] = useState(window.webln === undefined); + const [isPaying, setPaying] = useState(false); + const { copyToClipboard, isCopied } = useCopy(); + const wrongUrl = ( + <> +

Couldn't obtain payment URL

+ + ); + + const parsedData = PaymentAlbyDataSchema.safeParse(data); + if (!parsedData.success || !parsedData.data?.invoice?.paymentRequest) { + return wrongUrl; + } + const paymentRequest = parsedData.data.invoice.paymentRequest; + + return ( +
+ + {isPaying && } + {!isPaying && ( + <> + {!showQRCode && ( +
+ + {window.webln && ( + + )} +
+ )} + {showQRCode && ( + <> +
+

Waiting for payment...

+ +
+

Click or scan the invoice below to pay

+ + + + + + + Don't have a lightning wallet? + + + )} + + )} + +
+ Powered by  + Alby + Alby +
+ +
+ ); +}; + +type PaymentCheckerProps = PaymentPageProps; + +function PaymentChecker(props: PaymentCheckerProps) { + // TODO: move booking success code to a common lib function + // TODO: subscribe rather than polling + const searchParams = useSearchParams(); + const bookingSuccessRedirect = useBookingSuccessRedirect(); + const utils = trpc.useContext(); + const { t } = useLocale(); + useEffect(() => { + const interval = setInterval(() => { + (async () => { + if (props.booking.status === "ACCEPTED") { + return; + } + const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({ + bookingUid: props.booking.uid, + }); + + if (bookingResult?.paid) { + showToast("Payment successful", "success"); + + const params: { + uid: string; + email: string | null; + location: string; + } = { + uid: props.booking.uid, + email: searchParams.get("email"), + location: t("web_conferencing_details_to_follow"), + }; + + bookingSuccessRedirect({ + successRedirectUrl: props.eventType.successRedirectUrl, + query: params, + booking: props.booking, + }); + } + })(); + }, 1000); + return () => clearInterval(interval); + }, [ + bookingSuccessRedirect, + props.booking, + props.booking.id, + props.booking.status, + props.eventType.id, + props.eventType.successRedirectUrl, + props.payment.success, + searchParams, + t, + utils.viewer.bookings, + ]); + return null; +} diff --git a/packages/app-store/alby/components/AlbyPriceComponent.tsx b/packages/app-store/alby/components/AlbyPriceComponent.tsx new file mode 100644 index 0000000000..d9e1d1081c --- /dev/null +++ b/packages/app-store/alby/components/AlbyPriceComponent.tsx @@ -0,0 +1,30 @@ +import { fiat } from "@getalby/lightning-tools"; +import React from "react"; + +import { Tooltip } from "@calcom/ui"; +import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol"; + +type AlbyPriceComponentProps = { + displaySymbol: boolean; + price: number; + formattedPrice: string; +}; + +export function AlbyPriceComponent({ displaySymbol, price, formattedPrice }: AlbyPriceComponentProps) { + const [fiatValue, setFiatValue] = React.useState("loading..."); + React.useEffect(() => { + (async () => { + const unformattedFiatValue = await fiat.getFiatValue({ satoshi: price, currency: "USD" }); + setFiatValue(`$${unformattedFiatValue.toFixed(2)}`); + })(); + }, [price]); + + return ( + +
+ {displaySymbol && } + {formattedPrice} +
+
+ ); +} diff --git a/packages/app-store/alby/components/EventTypeAppCardInterface.tsx b/packages/app-store/alby/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..0eb71ba136 --- /dev/null +++ b/packages/app-store/alby/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,127 @@ +import { useRouter } from "next/router"; +import { useState, useEffect } from "react"; + +import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; +import AppCard from "@calcom/app-store/_components/AppCard"; +import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert, Select, TextField } from "@calcom/ui"; +import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol"; + +import type { appDataSchema } from "../zod"; +import { PaypalPaymentOptions as paymentOptions } from "../zod"; + +type Option = { value: string; label: string }; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const { asPath } = useRouter(); + const { getAppData, setAppData } = useAppContextWithSchema(); + const price = getAppData("price"); + const currency = getAppData("currency"); + const [selectedCurrency, setSelectedCurrency] = useState( + currencyOptions.find((c) => c.value === currency) || currencyOptions[0] + ); + const paymentOption = getAppData("paymentOption"); + const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || { + label: paymentOptions[0].label, + value: paymentOptions[0].value, + }; + const seatsEnabled = !!eventType.seatsPerTimeSlot; + const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); + const { t } = useLocale(); + const recurringEventDefined = eventType.recurringEvent?.count !== undefined; + + // make sure a currency is selected + useEffect(() => { + if (!currency && requirePayment) { + setAppData("currency", selectedCurrency.value); + } + }, [currency, selectedCurrency, setAppData, requirePayment]); + + return ( + { + setRequirePayment(enabled); + }} + description={<>Add bitcoin lightning payments to your events}> + <> + {recurringEventDefined ? ( + + ) : ( + requirePayment && ( + <> +
+ } + addOnSuffix={selectedCurrency.unit || selectedCurrency.value} + type="number" + required + className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm" + placeholder="Price" + onChange={(e) => { + setAppData("price", Number(e.target.value)); + if (currency) { + setAppData("currency", currency); + } + }} + value={price && price > 0 ? price : undefined} + /> +
+
+ + { if (e) { setSelectedCurrency(e); + setCurrencySymbol(currencySymbols[e.value]); setAppData("currency", e.value); } }} />
-
-