diff --git a/.env.example b/.env.example index 0a75c7390a..d1023ca7c0 100644 --- a/.env.example +++ b/.env.example @@ -138,12 +138,16 @@ API_KEY_PREFIX=cal_ EMAIL_FROM='notifications@yourselfhostedcal.com' # Configure SMTP settings (@see https://nodemailer.com/smtp/). +# Configuration to receive emails locally (mailhog) +EMAIL_SERVER_HOST='localhost' +EMAIL_SERVER_PORT=1025 + # Note: The below configuration for Office 365 has been verified to work. -EMAIL_SERVER_HOST='smtp.office365.com' -EMAIL_SERVER_PORT=587 -EMAIL_SERVER_USER='' +# EMAIL_SERVER_HOST='smtp.office365.com' +# EMAIL_SERVER_PORT=587 +# EMAIL_SERVER_USER='' # Keep in mind that if you have 2FA enabled, you will need to provision an App Password. -EMAIL_SERVER_PASSWORD='' +# EMAIL_SERVER_PASSWORD='' # The following configuration for Gmail has been verified to work. # EMAIL_SERVER_HOST='smtp.gmail.com' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 16f119312b..f8961b1755 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,7 @@ on: paths-ignore: - "**.md" - ".github/CODEOWNERS" + merge_group: workflow_dispatch: concurrency: diff --git a/README.md b/README.md index 3ef368a738..dd0fd9a1b5 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ Here is what you need to be able to run Cal.com. ```sh git clone https://github.com/calcom/cal.com.git ``` + > 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 @@ -197,6 +200,12 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env ```sh yarn workspace @calcom/prisma db-deploy ``` +1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development + + ```sh + docker pull mailhog/mailhog + docker run -d -p 8025:8025 -p 1025:1025 mailhog/mailhog + ``` 1. Run (in development mode) @@ -500,7 +509,7 @@ following ## License -Distributed under the AGPLv3 License. See `LICENSE` for more information. +Distributed under the [AGPLv3 License](https://github.com/calcom/cal.com/blob/main/LICENSE). See `LICENSE` for more information. diff --git a/apps/web/components/apps/CalendarListContainer.tsx b/apps/web/components/apps/CalendarListContainer.tsx index c66596f138..8afd92845e 100644 --- a/apps/web/components/apps/CalendarListContainer.tsx +++ b/apps/web/components/apps/CalendarListContainer.tsx @@ -1,9 +1,9 @@ -import { useMutation } from "@tanstack/react-query"; import Link from "next/link"; import { Fragment } from "react"; import { InstallAppButton } from "@calcom/app-store/components"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; +import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch"; import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; @@ -12,12 +12,10 @@ import { Button, EmptyScreen, List, - showToast, AppSkeletonLoader as SkeletonLoader, - Switch, ShellSubHeading, } from "@calcom/ui"; -import { ArrowLeft, Calendar, Plus } from "@calcom/ui/components/icon"; +import { Calendar, Plus } from "@calcom/ui/components/icon"; import { QueryCell } from "@lib/QueryCell"; @@ -31,83 +29,6 @@ type Props = { destinationCalendarId?: string; }; -function CalendarSwitch(props: { - type: string; - externalId: string; - title: string; - defaultSelected: boolean; - destination?: boolean; -}) { - const { t } = useLocale(); - const utils = trpc.useContext(); - - const mutation = useMutation< - unknown, - unknown, - { - isOn: boolean; - } - >( - async ({ isOn }) => { - const body = { - integration: props.type, - externalId: props.externalId, - }; - if (isOn) { - const res = await fetch("/api/availability/calendar", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - throw new Error("Something went wrong"); - } - } else { - const res = await fetch("/api/availability/calendar", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - throw new Error("Something went wrong"); - } - } - }, - { - async onSettled() { - await utils.viewer.integrations.invalidate(); - }, - onError() { - showToast(`Something went wrong when toggling "${props.title}""`, "error"); - }, - } - ); - return ( -
- { - mutation.mutate({ isOn }); - }} - /> - {!!props.destination && ( - - - {t("adding_events_to")} - - )} -
- ); -} - function CalendarList(props: Props) { const { t } = useLocale(); const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false }); @@ -185,14 +106,15 @@ function ConnectedCalendarsList(props: Props) { {!fromOnboarding && ( <>

{t("toggle_calendars_conflict")}

-
    +
      {item.calendars.map((cal) => ( ))} diff --git a/apps/web/components/booking/AvailableEventLocations.tsx b/apps/web/components/booking/AvailableEventLocations.tsx index c0825cd53f..e03daa0da5 100644 --- a/apps/web/components/booking/AvailableEventLocations.tsx +++ b/apps/web/components/booking/AvailableEventLocations.tsx @@ -47,7 +47,7 @@ export function AvailableEventLocations({ locations }: { locations: Props["event src={eventLocationType.iconUrl} className={classNames( "ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ", - !eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : "" + !eventLocationType.iconUrl?.startsWith("/app-store") ? "dark:invert-[.65]" : "" )} alt={`${eventLocationType.label} icon`} /> diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 394225ba3a..a48b802d98 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -72,6 +72,7 @@ const AvailableTimes: FC = ({ slotUtcStartDate: slot.time, eventTypeId, slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(), + bookingAttendees: bookingAttendees || undefined, }); }; diff --git a/apps/web/components/booking/TimezoneDropdown.tsx b/apps/web/components/booking/TimezoneDropdown.tsx index 8766c3530d..239ca6c673 100644 --- a/apps/web/components/booking/TimezoneDropdown.tsx +++ b/apps/web/components/booking/TimezoneDropdown.tsx @@ -17,7 +17,7 @@ export function TimezoneDropdown({ return ( <> -
      +
      diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index da214f7b85..4b208c5951 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -230,12 +230,14 @@ const BookingPage = ({ }), {} ); + const reserveSlot = () => { if (queryDuration) { reserveSlotMutation.mutate({ eventTypeId: eventType.id, slotUtcStartDate: dayjs(queryDate).utc().format(), slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(), + bookingAttendees: currentSlotBooking ? currentSlotBooking.attendees.length : undefined, }); } }; @@ -550,7 +552,7 @@ const BookingPage = ({ )}>
      {showEventTypeDetails && ( -
      +
      {!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && ( diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 0de520f5d5..caae6dc9d8 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -11,6 +11,10 @@ import { getEventName } from "@calcom/core/event"; import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; +import { + allowDisablingAttendeeConfirmationEmails, + allowDisablingHostConfirmationEmails, +} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { FormBuilder } from "@calcom/features/form-builder/FormBuilder"; import { classNames } from "@calcom/lib"; import { APP_NAME, CAL_URL } from "@calcom/lib/constants"; @@ -43,6 +47,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick workflowOnEventType.workflow); + eventType.bookingFields.forEach(({ name }) => { bookingFields[name] = name + " input"; }); @@ -338,7 +344,51 @@ export const EventAdvancedTab = ({ eventType, team }: Pick )} /> - + {allowDisablingAttendeeConfirmationEmails(workflows) && ( + <> +
      + ( + <> + { + formMethods.setValue("metadata.disableStandardEmails.confirmation.attendee", e); + onChange(e); + }} + /> + + )} + /> + + )} + {allowDisablingHostConfirmationEmails(workflows) && ( + <> +
      + ( + <> + { + formMethods.setValue("metadata.disableStandardEmails.confirmation.host", e); + onChange(e); + }} + /> + + )} + /> + + )} {showEventNameTip && ( { const { t } = useLocale(); - const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = - useLockedFieldsManager( - eventType, - t("locked_fields_admin_description"), - t("locked_fields_member_description") - ); + const { shouldLockIndicator, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager( + eventType, + t("locked_fields_admin_description"), + t("locked_fields_member_description") + ); const { watch } = useFormContext(); const watchSchedule = watch("schedule"); const formMethods = useFormContext(); @@ -205,6 +204,8 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => { const value = options.find((option) => scheduleId ? option.value === scheduleId + : isManagedEventType + ? option.value === 0 : option.value === schedules.find((schedule) => schedule.isDefault)?.id ); diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index c3949449fc..222a8312d5 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -285,7 +285,7 @@ export const EventSetupTab = ( "h-4 w-4", // invert all the icons except app icons eventLocationType.iconUrl && - !eventLocationType.iconUrl.startsWith("/api") && + !eventLocationType.iconUrl.startsWith("/app-store") && "dark:invert" )} alt={`${eventLocationType.label} logo`} diff --git a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx index b2e6d905e1..f859274a5e 100644 --- a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx +++ b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx @@ -1,4 +1,4 @@ -import { CalendarSwitch } from "./CalendarSwitch"; +import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch"; interface IConnectedCalendarItem { name: string; diff --git a/apps/web/components/settings/CalendarSwitch.tsx b/apps/web/components/settings/CalendarSwitch.tsx deleted file mode 100644 index 8d58bf72dd..0000000000 --- a/apps/web/components/settings/CalendarSwitch.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; - -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc/react"; -import { Badge, showToast, Switch } from "@calcom/ui"; -import { ArrowLeft } from "@calcom/ui/components/icon"; - -export function CalendarSwitch(props: { - type: string; - externalId: string; - title: string; - defaultSelected: boolean; - isSelected: boolean; -}) { - const { t } = useLocale(); - - const utils = trpc.useContext(); - - const mutation = useMutation< - unknown, - unknown, - { - isOn: boolean; - } - >( - async ({ isOn }) => { - const body = { - integration: props.type, - externalId: props.externalId, - }; - if (isOn) { - const res = await fetch("/api/availability/calendar", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - throw new Error("Something went wrong"); - } - } else { - const res = await fetch("/api/availability/calendar", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - throw new Error("Something went wrong"); - } - } - }, - { - async onSettled() { - await utils.viewer.integrations.invalidate(); - }, - onError() { - showToast(`Something went wrong when toggling "${props.title}""`, "error"); - }, - } - ); - return ( -
      - { - mutation.mutate({ isOn }); - }} - /> - {props.defaultSelected && ( - -
      - {t("adding_events_to")} -
      -
      - )} -
      - ); -} diff --git a/apps/web/components/ui/form/LocationSelect.tsx b/apps/web/components/ui/form/LocationSelect.tsx index f81e11d225..846082b26d 100644 --- a/apps/web/components/ui/form/LocationSelect.tsx +++ b/apps/web/components/ui/form/LocationSelect.tsx @@ -25,7 +25,7 @@ const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => { src={icon} alt="cover" // invert all the icons except app icons - className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/api") && "dark:invert")} + className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")} /> )} {label} diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index f7b51e9352..9efa2cedd2 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -23,6 +23,7 @@ class MyDocument extends Document { // If x-csp not set by gSSP, then it's initialPropsOnly setHeader(ctx, "x-csp", "initialPropsOnly"); } + const asPath = ctx.asPath || ""; // Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only const parsedUrl = new URL(asPath, "https://dummyurl"); diff --git a/apps/web/pages/api/link.ts b/apps/web/pages/api/link.ts index de9e045480..efae23bb33 100644 --- a/apps/web/pages/api/link.ts +++ b/apps/web/pages/api/link.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; -import { UserPermissionRole } from "@calcom/prisma/enums"; import { TRPCError } from "@calcom/trpc/server"; import { createContext } from "@calcom/trpc/server/createContext"; import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router"; @@ -27,32 +26,18 @@ const decryptedSchema = z.object({ async function handler(req: NextApiRequest, res: NextApiResponse) { const { action, token, reason } = querySchema.parse(req.query); - const { bookingUid, userId } = decryptedSchema.parse( + const { bookingUid } = decryptedSchema.parse( JSON.parse(symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")) ); - /** We shape the session as required by tRPC router */ - async function sessionGetter() { - return { - user: { - id: userId, - username: "" /* Not used in this context */, - role: UserPermissionRole.USER, - }, - hasValidLicense: true, - expires: "" /* Not used in this context */, - }; - } - const booking = await prisma.booking.findUniqueOrThrow({ where: { uid: bookingUid }, }); try { /** @see https://trpc.io/docs/server-side-calls */ - const ctx = await createContext({ req, res }, sessionGetter); + const ctx = await createContext({ req, res }); const caller = viewerRouter.createCaller({ ...ctx, req, res }); - await caller.bookings.confirm({ bookingId: booking.id, recurringEventId: booking.recurringEventId || undefined, diff --git a/apps/web/pages/api/trpc/[trpc].ts b/apps/web/pages/api/trpc/[trpc].ts deleted file mode 100644 index 4dd7642397..0000000000 --- a/apps/web/pages/api/trpc/[trpc].ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * This file contains tRPC's HTTP response handler - */ -import { z } from "zod"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import * as trpcNext from "@calcom/trpc/server/adapters/next"; -import { createContext as createTrpcContext } from "@calcom/trpc/server/createContext"; -import { appRouter } from "@calcom/trpc/server/routers/_app"; - -export default trpcNext.createNextApiHandler({ - router: appRouter, - /** - * @link https://trpc.io/docs/context - */ - createContext: ({ req, res }) => { - const sessionGetter = () => getServerSession({ req, res }); - - return createTrpcContext({ req, res }, sessionGetter); - }, - /** - * @link https://trpc.io/docs/error-handling - */ - onError({ error }) { - if (error.code === "INTERNAL_SERVER_ERROR") { - // send to bug reporting - console.error("Something went wrong", error); - } - }, - /** - * Enable query batching - */ - batching: { - enabled: true, - }, - /** - * @link https://trpc.io/docs/caching#api-response-caching - */ - responseMeta({ ctx, paths, type, errors }) { - // Some helpers relevant to this function only - const ONE_DAY_IN_SECONDS = 60 * 60 * 24; - // assuming you have all your public routes with the keyword `public` in them - const allPublic = paths && paths.every((path) => path.startsWith("viewer.public.")); - // checking that no procedures errored - const allOk = errors.length === 0; - // checking we're doing a query request - const isQuery = type === "query"; - const noHeaders = {}; - - // We cannot set headers on SSG queries - if (!ctx?.res) return noHeaders; - - const defaultHeaders: Record<"headers", Record> = { - headers: {}, - }; - - const timezone = z.string().safeParse(ctx.req?.headers["x-vercel-ip-timezone"]); - if (timezone.success) defaultHeaders.headers["x-cal-timezone"] = timezone.data; - - // We need all these conditions to be true to set cache headers - if (!(allPublic && allOk && isQuery)) return defaultHeaders; - - // No cache by default - defaultHeaders.headers["cache-control"] = `no-cache`; - - // Our cache can change depending on our current paths value. Since paths is an array, - // we want to create a map that can match potential paths with their desired cache value - const cacheRules = { - "viewer.public.session": `no-cache`, - "viewer.public.i18n": `no-cache`, - // Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321 - "viewer.public.slots.getSchedule": `no-cache`, // FIXME - "viewer.public.cityTimezones": `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate`, - } as const; - - // Find which element above is an exact match for this group of paths - const matchedPath = paths.find((v) => v in cacheRules) as keyof typeof cacheRules; - - if (matchedPath) defaultHeaders.headers["cache-control"] = cacheRules[matchedPath]; - - return defaultHeaders; - }, -}); diff --git a/apps/web/pages/api/trpc/apiKeys/[trpc].ts b/apps/web/pages/api/trpc/apiKeys/[trpc].ts new file mode 100644 index 0000000000..60b65ea0a1 --- /dev/null +++ b/apps/web/pages/api/trpc/apiKeys/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router"; + +export default createNextApiHandler(apiKeysRouter); diff --git a/apps/web/pages/api/trpc/appRoutingForms/[trpc].ts b/apps/web/pages/api/trpc/appRoutingForms/[trpc].ts new file mode 100644 index 0000000000..63037301d8 --- /dev/null +++ b/apps/web/pages/api/trpc/appRoutingForms/[trpc].ts @@ -0,0 +1,4 @@ +import appRoutingForms from "@calcom/app-store/routing-forms/trpc-router"; +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; + +export default createNextApiHandler(appRoutingForms); diff --git a/apps/web/pages/api/trpc/apps/[trpc].ts b/apps/web/pages/api/trpc/apps/[trpc].ts new file mode 100644 index 0000000000..e7e03ae67f --- /dev/null +++ b/apps/web/pages/api/trpc/apps/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router"; + +export default createNextApiHandler(appsRouter); diff --git a/apps/web/pages/api/trpc/appsRouter/[trpc].ts b/apps/web/pages/api/trpc/appsRouter/[trpc].ts new file mode 100644 index 0000000000..e7e03ae67f --- /dev/null +++ b/apps/web/pages/api/trpc/appsRouter/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router"; + +export default createNextApiHandler(appsRouter); diff --git a/apps/web/pages/api/trpc/auth/[trpc].ts b/apps/web/pages/api/trpc/auth/[trpc].ts new file mode 100644 index 0000000000..28363de1d2 --- /dev/null +++ b/apps/web/pages/api/trpc/auth/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { authRouter } from "@calcom/trpc/server/routers/viewer/auth/_router"; + +export default createNextApiHandler(authRouter); diff --git a/apps/web/pages/api/trpc/availability/[trpc].ts b/apps/web/pages/api/trpc/availability/[trpc].ts new file mode 100644 index 0000000000..b8ba4c7cb8 --- /dev/null +++ b/apps/web/pages/api/trpc/availability/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router"; + +export default createNextApiHandler(availabilityRouter); diff --git a/apps/web/pages/api/trpc/bookings/[trpc].ts b/apps/web/pages/api/trpc/bookings/[trpc].ts new file mode 100644 index 0000000000..908a82a82c --- /dev/null +++ b/apps/web/pages/api/trpc/bookings/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router"; + +export default createNextApiHandler(bookingsRouter); diff --git a/apps/web/pages/api/trpc/deploymentSetup/[trpc].ts b/apps/web/pages/api/trpc/deploymentSetup/[trpc].ts new file mode 100644 index 0000000000..c7dc57b734 --- /dev/null +++ b/apps/web/pages/api/trpc/deploymentSetup/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { deploymentSetupRouter } from "@calcom/trpc/server/routers/viewer/deploymentSetup/_router"; + +export default createNextApiHandler(deploymentSetupRouter); diff --git a/apps/web/pages/api/trpc/eth/[trpc].ts b/apps/web/pages/api/trpc/eth/[trpc].ts new file mode 100644 index 0000000000..9015a59cdc --- /dev/null +++ b/apps/web/pages/api/trpc/eth/[trpc].ts @@ -0,0 +1,4 @@ +import ethRouter from "@calcom/app-store/rainbow/trpc/router"; +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; + +export default createNextApiHandler(ethRouter); diff --git a/apps/web/pages/api/trpc/eventTypes/[trpc].ts b/apps/web/pages/api/trpc/eventTypes/[trpc].ts new file mode 100644 index 0000000000..90affa56f6 --- /dev/null +++ b/apps/web/pages/api/trpc/eventTypes/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router"; + +export default createNextApiHandler(eventTypesRouter); diff --git a/apps/web/pages/api/trpc/features/[trpc].ts b/apps/web/pages/api/trpc/features/[trpc].ts new file mode 100644 index 0000000000..314293a648 --- /dev/null +++ b/apps/web/pages/api/trpc/features/[trpc].ts @@ -0,0 +1,4 @@ +import { featureFlagRouter } from "@calcom/features/flags/server/router"; +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; + +export default createNextApiHandler(featureFlagRouter); diff --git a/apps/web/pages/api/trpc/insights/[trpc].ts b/apps/web/pages/api/trpc/insights/[trpc].ts new file mode 100644 index 0000000000..f0eb3490ac --- /dev/null +++ b/apps/web/pages/api/trpc/insights/[trpc].ts @@ -0,0 +1,4 @@ +import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; + +export default createNextApiHandler(insightsRouter); diff --git a/apps/web/pages/api/trpc/payments/[trpc].ts b/apps/web/pages/api/trpc/payments/[trpc].ts new file mode 100644 index 0000000000..673a2b1e57 --- /dev/null +++ b/apps/web/pages/api/trpc/payments/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { paymentsRouter } from "@calcom/trpc/server/routers/viewer/payments/_router"; + +export default createNextApiHandler(paymentsRouter); diff --git a/apps/web/pages/api/trpc/public/[trpc].ts b/apps/web/pages/api/trpc/public/[trpc].ts new file mode 100644 index 0000000000..da8470cdfc --- /dev/null +++ b/apps/web/pages/api/trpc/public/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router"; + +export default createNextApiHandler(publicViewerRouter, true); diff --git a/apps/web/pages/api/trpc/saml/[trpc].ts b/apps/web/pages/api/trpc/saml/[trpc].ts new file mode 100644 index 0000000000..d149507757 --- /dev/null +++ b/apps/web/pages/api/trpc/saml/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { ssoRouter } from "@calcom/trpc/server/routers/viewer/sso/_router"; + +export default createNextApiHandler(ssoRouter); diff --git a/apps/web/pages/api/trpc/slots/[trpc].ts b/apps/web/pages/api/trpc/slots/[trpc].ts new file mode 100644 index 0000000000..c220bf20f7 --- /dev/null +++ b/apps/web/pages/api/trpc/slots/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router"; + +export default createNextApiHandler(slotsRouter); diff --git a/apps/web/pages/api/trpc/teams/[trpc].ts b/apps/web/pages/api/trpc/teams/[trpc].ts new file mode 100644 index 0000000000..504b2692ea --- /dev/null +++ b/apps/web/pages/api/trpc/teams/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; + +export default createNextApiHandler(viewerTeamsRouter); diff --git a/apps/web/pages/api/trpc/users/[trpc].ts b/apps/web/pages/api/trpc/users/[trpc].ts new file mode 100644 index 0000000000..9829c4a3bb --- /dev/null +++ b/apps/web/pages/api/trpc/users/[trpc].ts @@ -0,0 +1,4 @@ +import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; + +export default createNextApiHandler(userAdminRouter); diff --git a/apps/web/pages/api/trpc/viewer/[trpc].ts b/apps/web/pages/api/trpc/viewer/[trpc].ts new file mode 100644 index 0000000000..2e44c1f966 --- /dev/null +++ b/apps/web/pages/api/trpc/viewer/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { loggedInViewerRouter } from "@calcom/trpc/server/routers/loggedInViewer/_router"; + +export default createNextApiHandler(loggedInViewerRouter); diff --git a/apps/web/pages/api/trpc/webhook/[trpc].ts b/apps/web/pages/api/trpc/webhook/[trpc].ts new file mode 100644 index 0000000000..c19093f285 --- /dev/null +++ b/apps/web/pages/api/trpc/webhook/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router"; + +export default createNextApiHandler(webhookRouter); diff --git a/apps/web/pages/api/trpc/workflows/[trpc].ts b/apps/web/pages/api/trpc/workflows/[trpc].ts new file mode 100644 index 0000000000..b086ebab6f --- /dev/null +++ b/apps/web/pages/api/trpc/workflows/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { workflowsRouter } from "@calcom/trpc/server/routers/viewer/workflows/_router"; + +export default createNextApiHandler(workflowsRouter); diff --git a/apps/web/pages/apps/index.tsx b/apps/web/pages/apps/index.tsx index 9b67bb9aa0..3253ce61d4 100644 --- a/apps/web/pages/apps/index.tsx +++ b/apps/web/pages/apps/index.tsx @@ -40,7 +40,7 @@ function AppsSearch({ className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0" addOnLeading={} addOnClassname="!border-muted" - containerClassName={classNames("focus:!ring-offset-0", className)} + containerClassName={classNames("focus:!ring-offset-0 m-1", className)} type="search" autoComplete="false" onChange={onChange} diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 5ad100ae18..d5ef9e2ca5 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -352,6 +352,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL const firstItem = types[0]; const lastItem = types[types.length - 1]; + const isManagedEventPrefix = () => { + return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : ""; + }; return (
        @@ -501,7 +504,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL )} {/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */} {(group.metadata?.readOnly === false || group.metadata.readOnly === null) && - isManagedEventType && ( + !isChildrenManagedEventType && ( <> @@ -533,27 +536,31 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL - - - {t("preview")} - - - - { - navigator.clipboard.writeText(calLink); - showToast(t("link_copied"), "success"); - }} - StartIcon={Clipboard} - className="w-full rounded-none text-left"> - {t("copy_link")} - - + {!isManagedEventType && ( + <> + + + {t("preview")} + + + + { + navigator.clipboard.writeText(calLink); + showToast(t("link_copied"), "success"); + }} + StartIcon={Clipboard} + className="w-full rounded-none text-left"> + {t("copy_link")} + + + + )} {isNativeShare ? ( ) : null} - - router.push("/event-types/" + type.id)} - StartIcon={Edit} - className="w-full rounded-none"> - {t("edit")} - - - - openDuplicateModal(type, group)} - StartIcon={Copy} - data-testid={"event-type-duplicate-" + type.id}> - {t("duplicate")} - - - - - { - setDeleteDialogOpen(true); - setDeleteDialogTypeId(type.id); - setDeleteDialogSchedulingType(type.schedulingType); - }} - StartIcon={Trash} - className="w-full rounded-none"> - {t("delete")} - - + {!readOnly && ( + + router.push("/event-types/" + type.id)} + StartIcon={Edit} + className="w-full rounded-none"> + {t("edit")} + + + )} + {!isManagedEventType && !isChildrenManagedEventType && ( + + openDuplicateModal(type, group)} + StartIcon={Copy} + data-testid={"event-type-duplicate-" + type.id}> + {t("duplicate")} + + + )} + {/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */} + {(group.metadata?.readOnly === false || group.metadata.readOnly === null) && + !isChildrenManagedEventType && ( + <> + + + { + setDeleteDialogOpen(true); + setDeleteDialogTypeId(type.id); + setDeleteDialogSchedulingType(type.schedulingType); + }} + StartIcon={Trash} + className="w-full rounded-none"> + {t("delete")} + + + + )} @@ -616,36 +633,24 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL { e.preventDefault(); deleteEventTypeHandler(deleteDialogTypeId); }}> -

        - {t( - `delete_${ - deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed" - }_event_type_description` - )} -

        + i18nKey={`delete${isManagedEventPrefix()}_event_type_description`} + components={{ li:

      • , ul:
          }}> +
            +
          • Members assigned to this event type will also have their event types deleted.
          • +
          • + Anyone who they've shared their link with will no longer be able to book using it. +
          • +
          +

      • diff --git a/apps/web/pages/new-booker/[user]/[type].tsx b/apps/web/pages/new-booker/[user]/[type].tsx index 6a6db3e0e8..5151f13f50 100644 --- a/apps/web/pages/new-booker/[user]/[type].tsx +++ b/apps/web/pages/new-booker/[user]/[type].tsx @@ -15,7 +15,7 @@ type PageProps = inferSSRProps; export default function Type({ slug, user, booking, away }: PageProps) { return ( -
        +
        ); diff --git a/apps/web/pages/new-booker/team/[slug]/[type].tsx b/apps/web/pages/new-booker/team/[slug]/[type].tsx index 46a91e6890..6267178e9c 100644 --- a/apps/web/pages/new-booker/team/[slug]/[type].tsx +++ b/apps/web/pages/new-booker/team/[slug]/[type].tsx @@ -14,7 +14,7 @@ type PageProps = inferSSRProps; export default function Type({ slug, user, booking, away }: PageProps) { return ( -
        +
        ); diff --git a/apps/web/pages/settings/my-account/calendars.tsx b/apps/web/pages/settings/my-account/calendars.tsx index 97afcdddda..063db71aa0 100644 --- a/apps/web/pages/settings/my-account/calendars.tsx +++ b/apps/web/pages/settings/my-account/calendars.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { Fragment } 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 { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { classNames } from "@calcom/lib"; @@ -29,7 +30,6 @@ import { Plus, Calendar } from "@calcom/ui/components/icon"; import { QueryCell } from "@lib/QueryCell"; import PageWrapper from "@components/PageWrapper"; -import { CalendarSwitch } from "@components/settings/CalendarSwitch"; const SkeletonLoader = () => { return ( @@ -178,15 +178,17 @@ const CalendarsView = () => {

      {t("toggle_calendars_conflict")}

      -
        +
          {item.calendars.map((cal) => ( ))}
        diff --git a/apps/web/pages/settings/my-account/conferencing.tsx b/apps/web/pages/settings/my-account/conferencing.tsx index 48c1a94758..9ff8dbf39c 100644 --- a/apps/web/pages/settings/my-account/conferencing.tsx +++ b/apps/web/pages/settings/my-account/conferencing.tsx @@ -24,7 +24,7 @@ import { SkeletonContainer, SkeletonText, } from "@calcom/ui"; -import { AlertCircle, MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon"; +import { AlertCircle, MoreHorizontal, Trash, Video, Plus } from "@calcom/ui/components/icon"; import AppListCard from "@components/AppListCard"; import PageWrapper from "@components/PageWrapper"; @@ -41,6 +41,18 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st ); }; +const AddConferencingButton = () => { + const { t } = useLocale(); + + return ( + <> + + + ); +}; + const ConferencingLayout = () => { const { t } = useLocale(); const utils = trpc.useContext(); @@ -91,7 +103,11 @@ const ConferencingLayout = () => { return (
        - + } + /> {apps?.items && apps.items diff --git a/apps/web/playwright/availability.e2e.ts b/apps/web/playwright/availability.e2e.ts index abee0f3a49..723da4c766 100644 --- a/apps/web/playwright/availability.e2e.ts +++ b/apps/web/playwright/availability.e2e.ts @@ -33,7 +33,7 @@ test.describe("Availablity tests", () => { }); await test.step("Date override is displayed in troubleshooter", async () => { - const response = await page.waitForResponse("**/api/trpc/viewer.availability.schedule.update?batch=1"); + const response = await page.waitForResponse("**/api/trpc/availability/schedule.update?batch=1"); const json = await response.json(); // @ts-expect-error trust me bro const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date); diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 59a811e7a6..d5ef9c0ef3 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -131,7 +131,7 @@ testBothBookers.describe("pro user", () => { await page.goto("/bookings/unconfirmed"); await Promise.all([ page.click('[data-testid="confirm"]'), - page.waitForResponse((response) => response.url().includes("/api/trpc/viewer.bookings.confirm")), + page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")), ]); // 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(); diff --git a/apps/web/public/static/locales/ar/common.json b/apps/web/public/static/locales/ar/common.json index cb9fe945bd..963961bd37 100644 --- a/apps/web/public/static/locales/ar/common.json +++ b/apps/web/public/static/locales/ar/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "متى تم إلغاء هذا الحدث", "new_event_trigger": "متى تم حجز الحدث الجديد", "email_host_action": "إرسال رسالة إلكترونية إلى المضيف", - "email_attendee_action": "إرسال رسالة إلكترونية إلى واحد من الحضور", "sms_attendee_action": "إرسال رسالة نصية قصيرة إلى واحد من الحضور", "sms_number_action": "إرسال رسالة نصية قصيرة إلى رقم محدد", "workflows": "سير العمل", diff --git a/apps/web/public/static/locales/cs/common.json b/apps/web/public/static/locales/cs/common.json index 6013b44a2f..1ffe4544f5 100644 --- a/apps/web/public/static/locales/cs/common.json +++ b/apps/web/public/static/locales/cs/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "při zrušení události", "new_event_trigger": "při rezervaci nové události", "email_host_action": "odeslání e-mailu hostiteli", - "email_attendee_action": "odeslání e-mailu účastníkovi", "sms_attendee_action": "odeslání SMS účastníkovi", "sms_number_action": "odeslání SMS na konkrétní číslo", "workflows": "Pracovní postupy", diff --git a/apps/web/public/static/locales/da/common.json b/apps/web/public/static/locales/da/common.json index a350252427..60ec96fe87 100644 --- a/apps/web/public/static/locales/da/common.json +++ b/apps/web/public/static/locales/da/common.json @@ -1010,7 +1010,6 @@ "event_cancelled_trigger": "når begivenheden er aflyst", "new_event_trigger": "når ny begivenhed er booket", "email_host_action": "send e-mail til vært", - "email_attendee_action": "send e-mail til deltager", "sms_attendee_action": "Send SMS til deltager", "sms_number_action": "send SMS til et specifikt nummer", "workflows": "Workflows", diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 6a37639d4d..53cfea5d8e 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "wenn das Ereignis abgesagt wird", "new_event_trigger": "wenn ein neer Termin gebucht wird", "email_host_action": "E-Mail an den Veranstalter senden", - "email_attendee_action": "E-Mail an Teilnehmende senden", "sms_attendee_action": "SMS an Teilnehmende senden", "sms_number_action": "SMS an eine bestimmte Nummer senden", "workflows": "Workflows", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a56327020f..9190aa6da3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1039,7 +1039,7 @@ "event_cancelled_trigger": "when event is cancelled", "new_event_trigger": "when new event is booked", "email_host_action": "send email to host", - "email_attendee_action": "send email to attendee", + "email_attendee_action": "send email to attendees", "sms_attendee_action": "send SMS to attendee", "sms_number_action": "send SMS to a specific number", "workflows": "Workflows", @@ -1248,6 +1248,7 @@ "calendars_description": "Configure how your event types interact with your calendars", "appearance_description": "Manage settings for your booking appearance", "conferencing_description": "Add your favourite video conferencing apps for your meetings", + "add_conferencing_app": "Add Conferencing App", "password_description": "Manage settings for your account passwords", "2fa_description": "Manage settings for your account passwords", "we_just_need_basic_info": "We just need some basic info to get your profile setup.", @@ -1808,5 +1809,9 @@ "pop_up_element_click": "Pop up via element click", "open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.", "need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.", - "book_my_cal": "Book my Cal" + "book_my_cal": "Book my Cal", + "disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees", + "disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.", + "disable_host_confirmation_emails": "Disable default confirmation emails for host", + "disable_host_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the host when the event is booked." } diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 6de616c589..e47ab44879 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "cuando se cancela el evento", "new_event_trigger": "cuando se reserva un nuevo evento", "email_host_action": "enviar correo electrónico al anfitrión", - "email_attendee_action": "enviar correo electrónico al asistente", "sms_attendee_action": "enviar SMS al asistente", "sms_number_action": "enviar SMS a un número específico", "workflows": "Flujos de trabajo", diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index 27e2df620f..8196453bf5 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -1039,7 +1039,7 @@ "event_cancelled_trigger": "lorsque l'événement est annulé", "new_event_trigger": "lorsqu'un nouvel événement est réservé", "email_host_action": "envoyer un e-mail à l'hôte", - "email_attendee_action": "envoyer un e-mail au participant", + "email_attendee_action": "envoyer un e-mail aux participants", "sms_attendee_action": "envoyer un SMS au participant", "sms_number_action": "envoyer un SMS à un numéro spécifique", "workflows": "Workflows", @@ -1248,6 +1248,7 @@ "calendars_description": "Configurez la manière dont vos types d'événements interagissent avec vos calendriers.", "appearance_description": "Gérez les paramètres d'apparence de vos réservations.", "conferencing_description": "Ajoutez vos applications de visioconférence préférées pour vos rendez-vous.", + "add_conferencing_app": "Ajouter une application de conférence", "password_description": "Gérez les paramètres pour les mots de passe de votre compte.", "2fa_description": "Gérez les paramètres pour les mots de passe de votre compte.", "we_just_need_basic_info": "Nous avons juste besoin de quelques informations de base pour configurer votre profil.", @@ -1800,5 +1801,12 @@ "payment_app_commission": "Exiger un paiement ({{paymentFeePercentage}} % + {{fee, currency}} de commission par transaction)", "email_invite_team": "{{email}} a été invité", "error_collecting_card": "Erreur lors de la collecte de la carte", - "image_size_limit_exceed": "L'image téléchargée ne doit pas dépasser 5 Mo" + "image_size_limit_exceed": "L'image téléchargée ne doit pas dépasser 5 Mo", + "inline_embed": "Intégration en ligne", + "load_inline_content": "Charge votre type d'événement directement en ligne avec le contenu de votre site web.", + "floating_pop_up_button": "Bouton pop-up flottant", + "disable_attendees_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour les participants", + "disable_attendees_confirmation_emails_description": "Au moins un workflow est actif sur ce type d'événement qui envoie un e-mail aux participants lorsque l'événement est réservé.", + "disable_host_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour l'hôte", + "disable_host_confirmation_emails_description": "Au moins un workflow est actif sur ce type d'événement qui envoie un e-mail à l'hôte lorsque l'événement est réservé." } diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index ab7893257a..99d199a292 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -1014,7 +1014,6 @@ "event_cancelled_trigger": "כאשר אירוע מבוטל", "new_event_trigger": "כאשר אירוע חדש מוזמן", "email_host_action": "שלח מייל למארח", - "email_attendee_action": "שלח מייל למשתתף", "sms_attendee_action": "שלח SMS למשתתף", "sms_number_action": "שלח SMS למספר מסוים", "workflows": "תהליכים", diff --git a/apps/web/public/static/locales/it/common.json b/apps/web/public/static/locales/it/common.json index 3b41e78f5e..2320b55c54 100644 --- a/apps/web/public/static/locales/it/common.json +++ b/apps/web/public/static/locales/it/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "quando l'evento viene annullato", "new_event_trigger": "quando viene prenotato un nuovo evento", "email_host_action": "invia e-mail all'organizzatore", - "email_attendee_action": "invia e-mail al partecipante", "sms_attendee_action": "invia SMS al partecipante", "sms_number_action": "invia SMS a un numero specifico", "workflows": "Flussi di lavoro", diff --git a/apps/web/public/static/locales/ja/common.json b/apps/web/public/static/locales/ja/common.json index 7b6d78164d..634a63d7eb 100644 --- a/apps/web/public/static/locales/ja/common.json +++ b/apps/web/public/static/locales/ja/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "イベントキャンセル時に", "new_event_trigger": "新しいイベント予約時に", "email_host_action": "ホストにメールを送信", - "email_attendee_action": "出席者にメールを送信", "sms_attendee_action": "出席者に SMS を送信", "sms_number_action": "特定の番号に SMS を送信", "workflows": "ワークフロー", diff --git a/apps/web/public/static/locales/ko/common.json b/apps/web/public/static/locales/ko/common.json index 11041ce161..baad64ccd6 100644 --- a/apps/web/public/static/locales/ko/common.json +++ b/apps/web/public/static/locales/ko/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "이벤트 취소 시", "new_event_trigger": "새 이벤트 예약 시", "email_host_action": "호스트에게 이메일 발송", - "email_attendee_action": "참석자에게 이메일 발송", "sms_attendee_action": "참석자에게 SMS 발송", "sms_number_action": "특정 번호로 SMS 발송", "workflows": "워크플로", diff --git a/apps/web/public/static/locales/nl/common.json b/apps/web/public/static/locales/nl/common.json index 1ed87b97fa..f94c2bd5a2 100644 --- a/apps/web/public/static/locales/nl/common.json +++ b/apps/web/public/static/locales/nl/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "wanneer de gebeurtenis is geannuleerd", "new_event_trigger": "wanneer een nieuwe gebeurtenis is geboekt", "email_host_action": "e-mail naar organisator versturen", - "email_attendee_action": "e-mail naar deelnemer versturen", "sms_attendee_action": "sms naar deelnemer versturen", "sms_number_action": "sms naar een specifiek nummer sturen", "workflows": "Werkstromen", diff --git a/apps/web/public/static/locales/no/common.json b/apps/web/public/static/locales/no/common.json index fc12c0fc14..eb36121620 100644 --- a/apps/web/public/static/locales/no/common.json +++ b/apps/web/public/static/locales/no/common.json @@ -990,7 +990,6 @@ "event_cancelled_trigger": "når hendelsen er avlyst", "new_event_trigger": "når en ny hendelse er booket", "email_host_action": "send e-post til vert", - "email_attendee_action": "send e-post til deltakeren", "sms_attendee_action": "send SMS til deltakeren", "sms_number_action": "send SMS til et bestemt nummer", "workflows": "Arbeidsflyter", diff --git a/apps/web/public/static/locales/pl/common.json b/apps/web/public/static/locales/pl/common.json index ac9ebb5f2e..4a6ebb6f1c 100644 --- a/apps/web/public/static/locales/pl/common.json +++ b/apps/web/public/static/locales/pl/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "gdy wydarzenie zostanie odwołane", "new_event_trigger": "gdy nowe wydarzenie zostanie zarezerwowane", "email_host_action": "wyślij wiadomość e-mail do hosta", - "email_attendee_action": "wyślij wiadomość e-mail do uczestnika", "sms_attendee_action": "wyślij SMS do uczestnika", "sms_number_action": "wyślij wiadomość SMS na określony numer", "workflows": "Przepływy pracy", diff --git a/apps/web/public/static/locales/pt-BR/common.json b/apps/web/public/static/locales/pt-BR/common.json index be40472ffe..c082e22892 100644 --- a/apps/web/public/static/locales/pt-BR/common.json +++ b/apps/web/public/static/locales/pt-BR/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "quando o evento é cancelado", "new_event_trigger": "quando um novo evento for reservado", "email_host_action": "enviar e-mail para o host", - "email_attendee_action": "enviar e-mail para o participante", "sms_attendee_action": "enviar SMS para participante", "sms_number_action": "enviar SMS para um número específico", "workflows": "Fluxos de trabalho", diff --git a/apps/web/public/static/locales/pt/common.json b/apps/web/public/static/locales/pt/common.json index 2e4d957fa3..a09ad159db 100644 --- a/apps/web/public/static/locales/pt/common.json +++ b/apps/web/public/static/locales/pt/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "quando o evento for cancelado", "new_event_trigger": "quando um novo evento for reservado", "email_host_action": "enviar email ao anfitrião", - "email_attendee_action": "enviar email ao participante", "sms_attendee_action": "enviar SMS ao participante", "sms_number_action": "enviar SMS para um número específico", "workflows": "Fluxos de trabalho", diff --git a/apps/web/public/static/locales/ro/common.json b/apps/web/public/static/locales/ro/common.json index 199d5b4c77..bd35210afc 100644 --- a/apps/web/public/static/locales/ro/common.json +++ b/apps/web/public/static/locales/ro/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "când se anulează evenimentul", "new_event_trigger": "când este rezervat noul eveniment", "email_host_action": "trimiteți e-mail la gazdă", - "email_attendee_action": "trimiteți e-mail la participant", "sms_attendee_action": "trimiteți SMS la participant", "sms_number_action": "trimiteți SMS la un anumit număr", "workflows": "Fluxuri de lucru", diff --git a/apps/web/public/static/locales/ru/common.json b/apps/web/public/static/locales/ru/common.json index 71596041c2..68376a4264 100644 --- a/apps/web/public/static/locales/ru/common.json +++ b/apps/web/public/static/locales/ru/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "когда событие отменено", "new_event_trigger": "когда будет забронировано новое событие", "email_host_action": "отправить письмо организатору", - "email_attendee_action": "отправить письмо участнику", "sms_attendee_action": "отправить SMS-сообщение участнику", "sms_number_action": "отправка SMS-сообщение на указанный номер", "workflows": "Рабочие процессы", diff --git a/apps/web/public/static/locales/sr/common.json b/apps/web/public/static/locales/sr/common.json index d2a32c772a..716b625429 100644 --- a/apps/web/public/static/locales/sr/common.json +++ b/apps/web/public/static/locales/sr/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "kada je događaj otkazan", "new_event_trigger": "kada je zakazan novi događaj", "email_host_action": "pošalji imejl domaćinu", - "email_attendee_action": "pošalji imejl polazniku", "sms_attendee_action": "pošalji SMS polazniku", "sms_number_action": "pošalji SMS na određeni broj", "workflows": "Radni tokovi", diff --git a/apps/web/public/static/locales/sv/common.json b/apps/web/public/static/locales/sv/common.json index 2227e4b0d6..516b8cd887 100644 --- a/apps/web/public/static/locales/sv/common.json +++ b/apps/web/public/static/locales/sv/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "när händelsen ställs in", "new_event_trigger": "när ny händelse bokas", "email_host_action": "skicka e-post till värd", - "email_attendee_action": "skicka e-post till deltagare", "sms_attendee_action": "skicka SMS till deltagare", "sms_number_action": "skicka SMS till ett specifikt nummer", "workflows": "Arbetsflöden", diff --git a/apps/web/public/static/locales/tr/common.json b/apps/web/public/static/locales/tr/common.json index 7924302c35..525a10c8fe 100644 --- a/apps/web/public/static/locales/tr/common.json +++ b/apps/web/public/static/locales/tr/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "etkinlik iptal edildiğinde", "new_event_trigger": "yeni bir etkinlik için rezervasyon yapıldığında", "email_host_action": "organizatöre e-posta gönder", - "email_attendee_action": "katılımcıya e-posta gönder", "sms_attendee_action": "katılımcıya SMS gönder", "sms_number_action": "belirli bir numaraya SMS gönder", "workflows": "İş akışları", diff --git a/apps/web/public/static/locales/uk/common.json b/apps/web/public/static/locales/uk/common.json index 4f3d3a1a63..dea6b56fb4 100644 --- a/apps/web/public/static/locales/uk/common.json +++ b/apps/web/public/static/locales/uk/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "у момент скасування заходу", "new_event_trigger": "у момент бронювання нового заходу", "email_host_action": "надсилати електронний лист ведучому", - "email_attendee_action": "надсилати електронний лист учаснику", "sms_attendee_action": "надсилати SMS учаснику", "sms_number_action": "надсилати SMS на певний номер", "workflows": "Робочі процеси", diff --git a/apps/web/public/static/locales/vi/common.json b/apps/web/public/static/locales/vi/common.json index b687744502..9490052fd0 100644 --- a/apps/web/public/static/locales/vi/common.json +++ b/apps/web/public/static/locales/vi/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "khi sự kiện bị huỷ", "new_event_trigger": "khi sự kiện mới được đặt lịch hẹn", "email_host_action": "gửi email đến chủ sự kiện", - "email_attendee_action": "gửi email đến người tham gia", "sms_attendee_action": "gửi SMS đến người tham gia", "sms_number_action": "gửi SMS đến một số cụ thể", "workflows": "Tiến độ công việc", diff --git a/apps/web/public/static/locales/zh-CN/common.json b/apps/web/public/static/locales/zh-CN/common.json index 0d9846be4e..a4acd53bce 100644 --- a/apps/web/public/static/locales/zh-CN/common.json +++ b/apps/web/public/static/locales/zh-CN/common.json @@ -1247,6 +1247,7 @@ "calendars_description": "配置您的活动类型与日历的交互方式", "appearance_description": "管理预约页面外观的设置", "conferencing_description": "为您的会议添加最喜欢的视频会议应用", + "add_conferencing_app": "添加会议应用", "password_description": "管理账户密码的设置", "2fa_description": "管理账户密码的设置", "we_just_need_basic_info": "我们只需要一些基本信息来设置您的个人资料。", @@ -1783,5 +1784,9 @@ "seats_and_no_show_fee_error": "当前无法启用位置和收取失约费", "complete_your_booking": "完成您的预约", "complete_your_booking_subject": "完成您的预约: {{date}} 的 {{title}}", - "email_invite_team": "{{email}} 已被邀请" + "email_invite_team": "{{email}} 已被邀请", + "disable_attendees_confirmation_emails": "禁用参与者的默认确认电子邮件", + "disable_attendees_confirmation_emails_description": "该活动类型中至少有一个工作流程处于活动状态,当该活动被预约时,将向参与者发送电子邮件。", + "disable_host_confirmation_emails": "禁用主持人的默认确认电子邮件", + "disable_host_confirmation_emails_description": "该活动类型中至少有一个工作流程处于活动状态,当该活动被预约时,将向主持人发送电子邮件。" } diff --git a/apps/web/public/static/locales/zh-TW/common.json b/apps/web/public/static/locales/zh-TW/common.json index 0a170f6ea7..30642a56bd 100644 --- a/apps/web/public/static/locales/zh-TW/common.json +++ b/apps/web/public/static/locales/zh-TW/common.json @@ -1038,7 +1038,6 @@ "event_cancelled_trigger": "活動取消時", "new_event_trigger": "預約新活動時", "email_host_action": "傳送電子郵件給主辦者", - "email_attendee_action": "傳送電子郵件給與會者", "sms_attendee_action": "傳送簡訊給與會者", "sms_number_action": "傳送簡訊至特定號碼", "workflows": "工作流程", diff --git a/apps/web/server/lib/ssr.ts b/apps/web/server/lib/ssr.ts index 0d0c4177d4..2c0d53f2e0 100644 --- a/apps/web/server/lib/ssr.ts +++ b/apps/web/server/lib/ssr.ts @@ -1,7 +1,8 @@ import type { GetServerSidePropsContext } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import superjson from "superjson"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getLocaleFromHeaders } from "@calcom/lib/i18n"; import { createProxySSGHelpers } from "@calcom/trpc/react/ssg"; import { createContext } from "@calcom/trpc/server/createContext"; import { appRouter } from "@calcom/trpc/server/routers/_app"; @@ -13,16 +14,14 @@ import { appRouter } from "@calcom/trpc/server/routers/_app"; * Make sure to `return { props: { trpcState: ssr.dehydrate() } }` at the end. */ export async function ssrInit(context: GetServerSidePropsContext) { - const { req, res } = context; - - const sessionGetter = () => getServerSession({ req, res }); - - const ctx = await createContext(context, sessionGetter); + const ctx = await createContext(context); + const locale = getLocaleFromHeaders(context.req); + const i18n = await serverSideTranslations(getLocaleFromHeaders(context.req), ["common", "vital"]); const ssr = createProxySSGHelpers({ router: appRouter, transformer: superjson, - ctx, + ctx: { ...ctx, locale, i18n }, }); // always preload "viewer.public.i18n" diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 2a97f45713..005edb5e91 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -84,8 +84,8 @@ /* Brand shenanigans -> These will be computed for the users theme at runtime. */ - --cal-brand: #111827; - --cal-brand-emphasis: #101010; + --cal-brand: white; + --cal-brand-emphasis: #e1e1e1; --cal-brand-text: black; } diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index dfd173e4e8..e88e89cc9c 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -95,7 +95,8 @@ describe("handleChildrenEventTypes", () => { it("Adds new users", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const { schedulingType, id, teamId, timeZone, scheduleId, users, ...evType } = mockFindFirstEventType({ + // eslint-disable-next-line + const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({ id: 123, metadata: { managedEventConfig: {} }, locations: [], @@ -130,6 +131,7 @@ describe("handleChildrenEventTypes", () => { it("Updates old users", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore + // eslint-disable-next-line const { schedulingType, id, teamId, timeZone, locations, parentId, userId, scheduleId, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -214,7 +216,8 @@ describe("handleChildrenEventTypes", () => { it("Deletes existent event types for new users added", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const { schedulingType, id, teamId, timeZone, scheduleId, users, ...evType } = mockFindFirstEventType({ + // eslint-disable-next-line + const { schedulingType, id, teamId, timeZone, users, ...evType } = mockFindFirstEventType({ id: 123, metadata: { managedEventConfig: {} }, locations: [], @@ -250,6 +253,7 @@ describe("handleChildrenEventTypes", () => { it("Deletes existent event types for old users updated", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore + // eslint-disable-next-line const { schedulingType, id, teamId, timeZone, users, locations, parentId, userId, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 10c60ed1c3..5fddb8b419 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -12,7 +12,7 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise if (calendarType?.endsWith("_other_calendar")) { calendarType = calendarType.split("_other_calendar")[0]; } - const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore]; + const calendarApp = await appStore[calendarType.split("_").join("") as keyof typeof appStore](); if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) { log.warn(`calendar of type ${calendarType} is not implemented`); return null; diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 6e7dbe2d63..a7076b832f 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { handleErrorsJson } from "@calcom/lib/errors"; +import { prisma } from "@calcom/prisma"; import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils"; import { getRecordingsResponseSchema, getAccessLinkResponseSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -108,8 +109,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { // added a 1 hour buffer for room expiration const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60; const { scale_plan: scalePlan } = await getDailyAppKeys(); - - if (scalePlan === "true") { + const hasTeamPlan = await prisma.membership.findFirst({ + where: { + userId: event.organizer.id, + team: { + slug: { + not: null, + }, + }, + }, + }); + if (scalePlan === "true" && !!hasTeamPlan === true) { return { privacy: "public", properties: { diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index b7d5393644..1453334b4c 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -1,34 +1,33 @@ const appStore = { - // example: import("./example"), - applecalendar: import("./applecalendar"), - caldavcalendar: import("./caldavcalendar"), - closecom: import("./closecom"), - dailyvideo: import("./dailyvideo"), - googlecalendar: import("./googlecalendar"), - googlevideo: import("./googlevideo"), - hubspot: import("./hubspot"), - huddle01video: import("./huddle01video"), - jitsivideo: import("./jitsivideo"), - larkcalendar: import("./larkcalendar"), - office365calendar: import("./office365calendar"), - office365video: import("./office365video"), - plausible: import("./plausible"), - salesforce: import("./salesforce"), - zohocrm: import("./zohocrm"), - "zoho-bigin": import("./zoho-bigin"), - sendgrid: import("./sendgrid"), - stripepayment: import("./stripepayment"), - tandemvideo: import("./tandemvideo"), - vital: import("./vital"), - zoomvideo: import("./zoomvideo"), - wipemycalother: import("./wipemycalother"), - giphy: import("./giphy"), - zapier: import("./zapier"), - exchange2013calendar: import("./exchange2013calendar"), - exchange2016calendar: import("./exchange2016calendar"), - exchangecalendar: import("./exchangecalendar"), - facetime: import("./facetime"), - sylapsvideo: import("./sylapsvideo"), + // example: () => import("./example"), + applecalendar: () => import("./applecalendar"), + caldavcalendar: () => import("./caldavcalendar"), + closecom: () => import("./closecom"), + dailyvideo: () => import("./dailyvideo"), + googlecalendar: () => import("./googlecalendar"), + googlevideo: () => import("./googlevideo"), + hubspot: () => import("./hubspot"), + huddle01video: () => import("./huddle01video"), + jitsivideo: () => import("./jitsivideo"), + larkcalendar: () => import("./larkcalendar"), + office365calendar: () => import("./office365calendar"), + office365video: () => import("./office365video"), + plausible: () => import("./plausible"), + salesforce: () => import("./salesforce"), + zohocrm: () => import("./zohocrm"), + sendgrid: () => import("./sendgrid"), + stripepayment: () => import("./stripepayment"), + tandemvideo: () => import("./tandemvideo"), + vital: () => import("./vital"), + zoomvideo: () => import("./zoomvideo"), + wipemycalother: () => import("./wipemycalother"), + giphy: () => import("./giphy"), + zapier: () => import("./zapier"), + exchange2013calendar: () => import("./exchange2013calendar"), + exchange2016calendar: () => import("./exchange2016calendar"), + exchangecalendar: () => import("./exchangecalendar"), + facetime: () => import("./facetime"), + sylapsvideo: () => import("./sylapsvideo"), }; export default appStore; diff --git a/packages/app-store/rainbow/trpc/router.ts b/packages/app-store/rainbow/trpc/router.ts index 81f4c49b4f..9bc9f40c80 100644 --- a/packages/app-store/rainbow/trpc/router.ts +++ b/packages/app-store/rainbow/trpc/router.ts @@ -1,4 +1,5 @@ -import { router, publicProcedure } from "@calcom/trpc/server/trpc"; +import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; +import { router } from "@calcom/trpc/server/trpc"; import { ZBalanceInputSchema, ZBalanceOutputSchema } from "./balance.schema"; import { ZContractInputSchema, ZContractOutputSchema } from "./contract.schema"; diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index 3e47c36c98..6d2d4924b1 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -179,7 +179,7 @@ test.describe("Routing Forms", () => { await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`); // Can't keep waiting forever. So, added a timeout of 5000ms - await page.waitForResponse((response) => response.url().includes("viewer.appRoutingForms.report"), { + await page.waitForResponse((response) => response.url().includes("appRoutingForms/report"), { timeout: 5000, }); const headerEls = page.locator("[data-testid='reporting-header'] th"); diff --git a/packages/app-store/routing-forms/trpc-router.ts b/packages/app-store/routing-forms/trpc-router.ts index 6e1ba9506c..2adeb77930 100644 --- a/packages/app-store/routing-forms/trpc-router.ts +++ b/packages/app-store/routing-forms/trpc-router.ts @@ -1,665 +1 @@ -import type { App_RoutingForms_Form, User } from "@prisma/client"; -import { Prisma } from "@prisma/client"; -import { z } from "zod"; - -import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; -import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; -import logger from "@calcom/lib/logger"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; -import { TRPCError } from "@calcom/trpc/server"; -import { authedProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc"; -import type { Ensure } from "@calcom/types/utils"; - -import ResponseEmail from "./emails/templates/response-email"; -import { jsonLogicToPrisma } from "./jsonLogicToPrisma"; -import { createFallbackRoute } from "./lib/createFallbackRoute"; -import getConnectedForms from "./lib/getConnectedForms"; -import { getSerializableForm } from "./lib/getSerializableForm"; -import { isFallbackRoute } from "./lib/isFallbackRoute"; -import { isFormEditAllowed } from "./lib/isFormEditAllowed"; -import isRouter from "./lib/isRouter"; -import isRouterLinkedField from "./lib/isRouterLinkedField"; -import type { Response, SerializableForm } from "./types/types"; -import { zodFields, zodRouterRoute, zodRoutes } from "./zod"; - -async function onFormSubmission( - form: Ensure & { user: User }, "fields">, - response: Response -) { - const fieldResponsesByName: Record = {}; - - for (const [fieldId, fieldResponse] of Object.entries(response)) { - // Use the label lowercased as the key to identify a field. - const key = - form.fields.find((f) => f.id === fieldId)?.identifier || - (fieldResponse.label as keyof typeof fieldResponsesByName); - fieldResponsesByName[key] = fieldResponse.value; - } - - const subscriberOptions = { - userId: form.user.id, - // It isn't an eventType webhook - eventTypeId: -1, - triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, - }; - - const webhooks = await getWebhooks(subscriberOptions); - const promises = webhooks.map((webhook) => { - sendGenericWebhookPayload( - webhook.secret, - "FORM_SUBMITTED", - new Date().toISOString(), - webhook, - fieldResponsesByName - ).catch((e) => { - console.error(`Error executing routing form webhook`, webhook, e); - }); - }); - - await Promise.all(promises); - if (form.settings?.emailOwnerOnSubmission) { - logger.debug( - `Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}` - ); - await sendResponseEmail(form, response, form.user.email); - } -} - -const sendResponseEmail = async ( - form: Pick, - response: Response, - ownerEmail: string -) => { - try { - const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response }); - await email.sendEmail(); - } catch (e) { - logger.error("Error sending response email", e); - } -}; - -const appRoutingForms = router({ - public: router({ - response: publicProcedure - .input( - z.object({ - formId: z.string(), - formFillerId: z.string(), - response: z.record( - z.object({ - label: z.string(), - value: z.union([z.string(), z.array(z.string())]), - }) - ), - }) - ) - .mutation(async ({ ctx, input }) => { - const { prisma } = ctx; - try { - const { response, formId } = input; - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: formId, - }, - include: { - user: true, - }, - }); - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - }); - } - - const serializableForm = await getSerializableForm(form); - if (!serializableForm.fields) { - // There is no point in submitting a form that doesn't have fields defined - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - - const serializableFormWithFields = { - ...serializableForm, - fields: serializableForm.fields, - }; - - const missingFields = serializableFormWithFields.fields - .filter((field) => !(field.required ? response[field.id]?.value : true)) - .map((f) => f.label); - - if (missingFields.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Missing required fields ${missingFields.join(", ")}`, - }); - } - const invalidFields = serializableFormWithFields.fields - .filter((field) => { - const fieldValue = response[field.id]?.value; - // The field isn't required at this point. Validate only if it's set - if (!fieldValue) { - return false; - } - let schema; - if (field.type === "email") { - schema = z.string().email(); - } else if (field.type === "phone") { - schema = z.any(); - } else { - schema = z.any(); - } - return !schema.safeParse(fieldValue).success; - }) - .map((f) => ({ label: f.label, type: f.type })); - - if (invalidFields.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`, - }); - } - - const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ - data: input, - }); - - await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response); - return dbFormResponse; - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - throw new TRPCError({ - code: "CONFLICT", - }); - } - } - throw e; - } - }), - }), - forms: authedProcedure.query(async ({ ctx }) => { - const { prisma, user } = ctx; - const forms = await prisma.app_RoutingForms_Form.findMany({ - where: { - userId: user.id, - }, - orderBy: { - createdAt: "desc", - }, - include: { - _count: { - select: { - responses: true, - }, - }, - }, - }); - - const serializableForms = []; - for (let i = 0; i < forms.length; i++) { - serializableForms.push(await getSerializableForm(forms[i])); - } - return serializableForms; - }), - formQuery: authedProcedure - .input( - z.object({ - id: z.string(), - }) - ) - .query(async ({ ctx, input }) => { - const { prisma, user } = ctx; - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - userId: user.id, - id: input.id, - }, - include: { - _count: { - select: { - responses: true, - }, - }, - }, - }); - - if (!form) { - return null; - } - - return await getSerializableForm(form); - }), - formMutation: authedProcedure - .input( - z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable().optional(), - disabled: z.boolean().optional(), - fields: zodFields, - routes: zodRoutes, - addFallback: z.boolean().optional(), - duplicateFrom: z.string().nullable().optional(), - shouldConnect: z.boolean().optional(), - settings: RoutingFormSettings.optional(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input; - if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - let { routes: inputRoutes } = input; - let { fields: inputFields } = input; - inputFields = inputFields || []; - inputRoutes = inputRoutes || []; - type InputFields = typeof inputFields; - type InputRoutes = typeof inputRoutes; - let routes: InputRoutes; - let fields: InputFields; - type DuplicateFrom = NonNullable; - - const dbForm = await prisma.app_RoutingForms_Form.findUnique({ - where: { - id: id, - }, - select: { - id: true, - user: true, - name: true, - description: true, - userId: true, - disabled: true, - createdAt: true, - updatedAt: true, - routes: true, - fields: true, - settings: true, - }, - }); - - const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null; - - if (duplicateFrom) { - ({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom)); - } else { - [fields, routes] = [inputFields, inputRoutes]; - if (dbSerializedForm) { - fields = markMissingFieldsDeleted(dbSerializedForm, fields); - } - } - - if (dbSerializedForm) { - // If it's an existing form being mutated, update fields in the connected forms(if any). - await updateFieldsInConnectedForms(dbSerializedForm, inputFields); - } - - fields = await getUpdatedRouterLinkedFields(fields, routes); - - if (addFallback) { - // Add a fallback route if there is none - if (!routes.find(isFallbackRoute)) { - routes.push(createFallbackRoute()); - } - } - - return await prisma.app_RoutingForms_Form.upsert({ - where: { - id: id, - }, - create: { - user: { - connect: { - id: user.id, - }, - }, - fields, - name: name, - description, - // Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case. - routes: routes === null ? Prisma.JsonNull : routes, - id: id, - }, - update: { - disabled: disabled, - fields, - name: name, - description, - settings: settings === null ? Prisma.JsonNull : settings, - routes: routes === null ? Prisma.JsonNull : routes, - }, - }); - - /** - * If Form has Router Linked fields, enrich them with the latest info from the Router - * If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router - */ - async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) { - const routerLinkedFields: Record = {}; - for (const [, field] of Object.entries(fields)) { - if (!isRouterLinkedField(field)) { - continue; - } - routerLinkedFields[field.routerId] = true; - - if (!routes.some((route) => route.id === field.routerId)) { - // If the field is from a router that is not available anymore, mark it as deleted - field.deleted = true; - continue; - } - // Get back deleted field as now the Router is there for it. - if (field.deleted) field.deleted = false; - const router = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: field.routerId, - userId: user.id, - }, - }); - if (router) { - assertIfInvalidRouter(router); - const parsedRouterFields = zodFields.parse(router.fields); - - // There is a field from some router available, make sure that the field has up-to-date info from the router - const routerField = parsedRouterFields?.find((f) => f.id === field.id); - // Update local field(cache) with router field on every mutation - Object.assign(field, routerField); - } - } - - for (const [, route] of Object.entries(routes)) { - if (!isRouter(route)) { - continue; - } - - // If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields - if (routerLinkedFields[route.id]) { - continue; - } - - const router = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: route.id, - userId: user.id, - }, - }); - if (router) { - assertIfInvalidRouter(router); - const parsedRouterFields = zodFields.parse(router.fields); - const fieldsFromRouter = parsedRouterFields - ?.filter((f) => !f.deleted) - .map((f) => { - return { - ...f, - routerId: route.id, - }; - }); - - if (fieldsFromRouter) { - fields = fields.concat(fieldsFromRouter); - } - } - } - return fields; - } - - function findFieldWithId(id: string, fields: InputFields) { - return fields.find((field) => field.id === id); - } - - /** - * Update fields in connected forms as per the inputFields - */ - async function updateFieldsInConnectedForms( - serializedForm: SerializableForm, - inputFields: InputFields - ) { - for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) { - const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: connectedForm.id, - }, - }); - if (!connectedFormDb) { - continue; - } - const connectedFormFields = zodFields.parse(connectedFormDb.fields); - - const fieldsThatAreNotInConnectedForm = ( - inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || [] - ).map((f) => ({ - ...f, - routerId: serializedForm.id, - })); - - const updatedConnectedFormFields = connectedFormFields - // Update fields that are already in connected form - ?.map((field) => { - if (isRouterLinkedField(field) && field.routerId === serializedForm.id) { - return { - ...field, - ...findFieldWithId(field.id, inputFields || []), - }; - } - return field; - }) - // Add fields that are not there - .concat(fieldsThatAreNotInConnectedForm); - - await prisma.app_RoutingForms_Form.update({ - where: { - id: connectedForm.id, - }, - data: { - fields: updatedConnectedFormFields, - }, - }); - } - } - - async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) { - const sourceForm = await prisma.app_RoutingForms_Form.findFirst({ - where: { - userId: user.id, - id: duplicateFrom, - }, - select: { - id: true, - fields: true, - routes: true, - }, - }); - if (!sourceForm) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Form to duplicate: ${duplicateFrom} not found`, - }); - } - //TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well. - const fieldsParsed = zodFields.safeParse(sourceForm.fields); - const routesParsed = zodRoutes.safeParse(sourceForm.routes); - if (!fieldsParsed.success || !routesParsed.success) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Could not parse source form's fields or routes", - }); - } - - let fields, routes; - if (shouldConnect) { - routes = [ - // This connected route would automatically link the fields - zodRouterRoute.parse({ - id: sourceForm.id, - isRouter: true, - }), - ]; - fields = - fieldsParsed.data - // Deleted fields in the form shouldn't be added to the new form - ?.filter((f) => !f.deleted) - .map((f) => { - return { - id: f.id, - routerId: sourceForm.id, - label: "", - type: "", - }; - }) || []; - } else { - // Duplicate just routes and fields - // We don't want name, description and responses to be copied - routes = routesParsed.data || []; - // FIXME: Deleted fields shouldn't come in duplicate - fields = fieldsParsed.data || []; - } - return { routes, fields }; - } - - function markMissingFieldsDeleted( - serializedForm: SerializableForm, - fields: InputFields - ) { - // Find all fields that are in DB(including deleted) but not in the mutation - // e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted - const deletedFields = - serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || []; - - // Add back deleted fields in the end and mark them deleted. - // Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation - fields = fields.concat( - deletedFields.map((f) => { - f.deleted = true; - return f; - }) - ); - return fields; - } - function assertIfInvalidRouter(router: App_RoutingForms_Form) { - const routesOfRouter = zodRoutes.parse(router.routes); - if (routesOfRouter) { - if (routesOfRouter.find(isRouter)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "A form being used as a Router must be a Origin form. It must not be using any other Router.", - }); - } - } - } - }), - deleteForm: authedProcedure - .input( - z.object({ - id: z.string(), - }) - ) - .mutation(async ({ ctx, input }) => { - const { user, prisma } = ctx; - if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - - const areFormsUsingIt = ( - await getConnectedForms(prisma, { - id: input.id, - userId: user.id, - }) - ).length; - if (areFormsUsingIt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This form is being used by other forms. Please remove it's usage from there first.", - }); - } - return await prisma.app_RoutingForms_Form.deleteMany({ - where: { - id: input.id, - userId: user.id, - }, - }); - }), - - report: authedProcedure - .input( - z.object({ - formId: z.string(), - jsonLogicQuery: z.object({ - logic: z.union([z.record(z.any()), z.null()]), - }), - cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type - }) - ) - .query(async ({ ctx: { prisma }, input }) => { - // Can be any prisma `where` clause - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prismaWhere: Record = input.jsonLogicQuery - ? jsonLogicToPrisma(input.jsonLogicQuery) - : {}; - const skip = input.cursor ?? 0; - const take = 50; - logger.debug( - `Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify( - input.jsonLogicQuery - )}` - ); - const form = await prisma.app_RoutingForms_Form.findUnique({ - where: { - id: input.formId, - }, - }); - - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Form not found", - }); - } - // TODO: Second argument is required to return deleted operators. - const serializedForm = await getSerializableForm(form, true); - - const rows = await prisma.app_RoutingForms_FormResponse.findMany({ - where: { - formId: input.formId, - ...prismaWhere, - }, - take, - skip, - }); - const fields = serializedForm?.fields || []; - const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); - const responses: string[][] = []; - rows.forEach((r) => { - const rowResponses: string[] = []; - responses.push(rowResponses); - fields.forEach((field) => { - if (!r.response) { - return; - } - const response = r.response as Response; - const value = response[field.id]?.value || ""; - let stringValue = ""; - if (value instanceof Array) { - stringValue = value.join(", "); - } else { - stringValue = value; - } - rowResponses.push(stringValue); - }); - }); - const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; - return { - headers, - responses, - nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length, - }; - }), -}); - -export default appRoutingForms; +export { default } from "./trpc/_router"; diff --git a/packages/app-store/routing-forms/trpc/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts new file mode 100644 index 0000000000..1830454f06 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -0,0 +1,667 @@ +import type { App_RoutingForms_Form, User } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import logger from "@calcom/lib/logger"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; +import { TRPCError } from "@calcom/trpc/server"; +import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; +import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; +import { router } from "@calcom/trpc/server/trpc"; +import type { Ensure } from "@calcom/types/utils"; + +import ResponseEmail from "../emails/templates/response-email"; +import { jsonLogicToPrisma } from "../jsonLogicToPrisma"; +import { createFallbackRoute } from "../lib/createFallbackRoute"; +import getConnectedForms from "../lib/getConnectedForms"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import { isFallbackRoute } from "../lib/isFallbackRoute"; +import { isFormEditAllowed } from "../lib/isFormEditAllowed"; +import isRouter from "../lib/isRouter"; +import isRouterLinkedField from "../lib/isRouterLinkedField"; +import type { Response, SerializableForm } from "../types/types"; +import { zodFields, zodRouterRoute, zodRoutes } from "../zod"; + +async function onFormSubmission( + form: Ensure & { user: User }, "fields">, + response: Response +) { + const fieldResponsesByName: Record = {}; + + for (const [fieldId, fieldResponse] of Object.entries(response)) { + // Use the label lowercased as the key to identify a field. + const key = + form.fields.find((f) => f.id === fieldId)?.identifier || + (fieldResponse.label as keyof typeof fieldResponsesByName); + fieldResponsesByName[key] = fieldResponse.value; + } + + const subscriberOptions = { + userId: form.user.id, + // It isn't an eventType webhook + eventTypeId: -1, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + }; + + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => { + sendGenericWebhookPayload( + webhook.secret, + "FORM_SUBMITTED", + new Date().toISOString(), + webhook, + fieldResponsesByName + ).catch((e) => { + console.error(`Error executing routing form webhook`, webhook, e); + }); + }); + + await Promise.all(promises); + if (form.settings?.emailOwnerOnSubmission) { + logger.debug( + `Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}` + ); + await sendResponseEmail(form, response, form.user.email); + } +} + +const sendResponseEmail = async ( + form: Pick, + response: Response, + ownerEmail: string +) => { + try { + const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response }); + await email.sendEmail(); + } catch (e) { + logger.error("Error sending response email", e); + } +}; + +const appRoutingForms = router({ + public: router({ + response: publicProcedure + .input( + z.object({ + formId: z.string(), + formFillerId: z.string(), + response: z.record( + z.object({ + label: z.string(), + value: z.union([z.string(), z.array(z.string())]), + }) + ), + }) + ) + .mutation(async ({ ctx, input }) => { + const { prisma } = ctx; + try { + const { response, formId } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + user: true, + }, + }); + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + }); + } + + const serializableForm = await getSerializableForm(form); + if (!serializableForm.fields) { + // There is no point in submitting a form that doesn't have fields defined + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + + const serializableFormWithFields = { + ...serializableForm, + fields: serializableForm.fields, + }; + + const missingFields = serializableFormWithFields.fields + .filter((field) => !(field.required ? response[field.id]?.value : true)) + .map((f) => f.label); + + if (missingFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Missing required fields ${missingFields.join(", ")}`, + }); + } + const invalidFields = serializableFormWithFields.fields + .filter((field) => { + const fieldValue = response[field.id]?.value; + // The field isn't required at this point. Validate only if it's set + if (!fieldValue) { + return false; + } + let schema; + if (field.type === "email") { + schema = z.string().email(); + } else if (field.type === "phone") { + schema = z.any(); + } else { + schema = z.any(); + } + return !schema.safeParse(fieldValue).success; + }) + .map((f) => ({ label: f.label, type: f.type })); + + if (invalidFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`, + }); + } + + const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: input, + }); + + await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response); + return dbFormResponse; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + }); + } + } + throw e; + } + }), + }), + forms: authedProcedure.query(async ({ ctx }) => { + const { prisma, user } = ctx; + const forms = await prisma.app_RoutingForms_Form.findMany({ + where: { + userId: user.id, + }, + orderBy: { + createdAt: "desc", + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + const serializableForms = []; + for (let i = 0; i < forms.length; i++) { + serializableForms.push(await getSerializableForm(forms[i])); + } + return serializableForms; + }), + formQuery: authedProcedure + .input( + z.object({ + id: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const { prisma, user } = ctx; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: input.id, + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + if (!form) { + return null; + } + + return await getSerializableForm(form); + }), + formMutation: authedProcedure + .input( + z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + disabled: z.boolean().optional(), + fields: zodFields, + routes: zodRoutes, + addFallback: z.boolean().optional(), + duplicateFrom: z.string().nullable().optional(), + shouldConnect: z.boolean().optional(), + settings: RoutingFormSettings.optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { user, prisma } = ctx; + const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input; + if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + let { routes: inputRoutes } = input; + let { fields: inputFields } = input; + inputFields = inputFields || []; + inputRoutes = inputRoutes || []; + type InputFields = typeof inputFields; + type InputRoutes = typeof inputRoutes; + let routes: InputRoutes; + let fields: InputFields; + type DuplicateFrom = NonNullable; + + const dbForm = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: id, + }, + select: { + id: true, + user: true, + name: true, + description: true, + userId: true, + disabled: true, + createdAt: true, + updatedAt: true, + routes: true, + fields: true, + settings: true, + }, + }); + + const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null; + + if (duplicateFrom) { + ({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom)); + } else { + [fields, routes] = [inputFields, inputRoutes]; + if (dbSerializedForm) { + fields = markMissingFieldsDeleted(dbSerializedForm, fields); + } + } + + if (dbSerializedForm) { + // If it's an existing form being mutated, update fields in the connected forms(if any). + await updateFieldsInConnectedForms(dbSerializedForm, inputFields); + } + + fields = await getUpdatedRouterLinkedFields(fields, routes); + + if (addFallback) { + // Add a fallback route if there is none + if (!routes.find(isFallbackRoute)) { + routes.push(createFallbackRoute()); + } + } + + return await prisma.app_RoutingForms_Form.upsert({ + where: { + id: id, + }, + create: { + user: { + connect: { + id: user.id, + }, + }, + fields, + name: name, + description, + // Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case. + routes: routes === null ? Prisma.JsonNull : routes, + id: id, + }, + update: { + disabled: disabled, + fields, + name: name, + description, + settings: settings === null ? Prisma.JsonNull : settings, + routes: routes === null ? Prisma.JsonNull : routes, + }, + }); + + /** + * If Form has Router Linked fields, enrich them with the latest info from the Router + * If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router + */ + async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) { + const routerLinkedFields: Record = {}; + for (const [, field] of Object.entries(fields)) { + if (!isRouterLinkedField(field)) { + continue; + } + routerLinkedFields[field.routerId] = true; + + if (!routes.some((route) => route.id === field.routerId)) { + // If the field is from a router that is not available anymore, mark it as deleted + field.deleted = true; + continue; + } + // Get back deleted field as now the Router is there for it. + if (field.deleted) field.deleted = false; + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: field.routerId, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + + // There is a field from some router available, make sure that the field has up-to-date info from the router + const routerField = parsedRouterFields?.find((f) => f.id === field.id); + // Update local field(cache) with router field on every mutation + Object.assign(field, routerField); + } + } + + for (const [, route] of Object.entries(routes)) { + if (!isRouter(route)) { + continue; + } + + // If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields + if (routerLinkedFields[route.id]) { + continue; + } + + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: route.id, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + const fieldsFromRouter = parsedRouterFields + ?.filter((f) => !f.deleted) + .map((f) => { + return { + ...f, + routerId: route.id, + }; + }); + + if (fieldsFromRouter) { + fields = fields.concat(fieldsFromRouter); + } + } + } + return fields; + } + + function findFieldWithId(id: string, fields: InputFields) { + return fields.find((field) => field.id === id); + } + + /** + * Update fields in connected forms as per the inputFields + */ + async function updateFieldsInConnectedForms( + serializedForm: SerializableForm, + inputFields: InputFields + ) { + for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) { + const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: connectedForm.id, + }, + }); + if (!connectedFormDb) { + continue; + } + const connectedFormFields = zodFields.parse(connectedFormDb.fields); + + const fieldsThatAreNotInConnectedForm = ( + inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || [] + ).map((f) => ({ + ...f, + routerId: serializedForm.id, + })); + + const updatedConnectedFormFields = connectedFormFields + // Update fields that are already in connected form + ?.map((field) => { + if (isRouterLinkedField(field) && field.routerId === serializedForm.id) { + return { + ...field, + ...findFieldWithId(field.id, inputFields || []), + }; + } + return field; + }) + // Add fields that are not there + .concat(fieldsThatAreNotInConnectedForm); + + await prisma.app_RoutingForms_Form.update({ + where: { + id: connectedForm.id, + }, + data: { + fields: updatedConnectedFormFields, + }, + }); + } + } + + async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) { + const sourceForm = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: duplicateFrom, + }, + select: { + id: true, + fields: true, + routes: true, + }, + }); + if (!sourceForm) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Form to duplicate: ${duplicateFrom} not found`, + }); + } + //TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well. + const fieldsParsed = zodFields.safeParse(sourceForm.fields); + const routesParsed = zodRoutes.safeParse(sourceForm.routes); + if (!fieldsParsed.success || !routesParsed.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not parse source form's fields or routes", + }); + } + + let fields, routes; + if (shouldConnect) { + routes = [ + // This connected route would automatically link the fields + zodRouterRoute.parse({ + id: sourceForm.id, + isRouter: true, + }), + ]; + fields = + fieldsParsed.data + // Deleted fields in the form shouldn't be added to the new form + ?.filter((f) => !f.deleted) + .map((f) => { + return { + id: f.id, + routerId: sourceForm.id, + label: "", + type: "", + }; + }) || []; + } else { + // Duplicate just routes and fields + // We don't want name, description and responses to be copied + routes = routesParsed.data || []; + // FIXME: Deleted fields shouldn't come in duplicate + fields = fieldsParsed.data || []; + } + return { routes, fields }; + } + + function markMissingFieldsDeleted( + serializedForm: SerializableForm, + fields: InputFields + ) { + // Find all fields that are in DB(including deleted) but not in the mutation + // e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted + const deletedFields = + serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || []; + + // Add back deleted fields in the end and mark them deleted. + // Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation + fields = fields.concat( + deletedFields.map((f) => { + f.deleted = true; + return f; + }) + ); + return fields; + } + function assertIfInvalidRouter(router: App_RoutingForms_Form) { + const routesOfRouter = zodRoutes.parse(router.routes); + if (routesOfRouter) { + if (routesOfRouter.find(isRouter)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "A form being used as a Router must be a Origin form. It must not be using any other Router.", + }); + } + } + } + }), + deleteForm: authedProcedure + .input( + z.object({ + id: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { user, prisma } = ctx; + if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + const areFormsUsingIt = ( + await getConnectedForms(prisma, { + id: input.id, + userId: user.id, + }) + ).length; + if (areFormsUsingIt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This form is being used by other forms. Please remove it's usage from there first.", + }); + } + return await prisma.app_RoutingForms_Form.deleteMany({ + where: { + id: input.id, + userId: user.id, + }, + }); + }), + + report: authedProcedure + .input( + z.object({ + formId: z.string(), + jsonLogicQuery: z.object({ + logic: z.union([z.record(z.any()), z.null()]), + }), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type + }) + ) + .query(async ({ ctx: { prisma }, input }) => { + // Can be any prisma `where` clause + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prismaWhere: Record = input.jsonLogicQuery + ? jsonLogicToPrisma(input.jsonLogicQuery) + : {}; + const skip = input.cursor ?? 0; + const take = 50; + logger.debug( + `Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify( + input.jsonLogicQuery + )}` + ); + const form = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: input.formId, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + // TODO: Second argument is required to return deleted operators. + const serializedForm = await getSerializableForm(form, true); + + const rows = await prisma.app_RoutingForms_FormResponse.findMany({ + where: { + formId: input.formId, + ...prismaWhere, + }, + take, + skip, + }); + const fields = serializedForm?.fields || []; + const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); + const responses: string[][] = []; + rows.forEach((r) => { + const rowResponses: string[] = []; + responses.push(rowResponses); + fields.forEach((field) => { + if (!r.response) { + return; + } + const response = r.response as Response; + const value = response[field.id]?.value || ""; + let stringValue = ""; + if (value instanceof Array) { + stringValue = value.join(", "); + } else { + stringValue = value; + } + rowResponses.push(stringValue); + }); + }); + const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; + return { + headers, + responses, + nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length, + }; + }), +}); + +export default appRoutingForms; diff --git a/packages/app-store/routing-forms/trpc/deleteForm.handler.ts b/packages/app-store/routing-forms/trpc/deleteForm.handler.ts new file mode 100644 index 0000000000..cc5e936e3c --- /dev/null +++ b/packages/app-store/routing-forms/trpc/deleteForm.handler.ts @@ -0,0 +1,43 @@ +import type { PrismaClient } from "@prisma/client"; + +import { TRPCError } from "@calcom/trpc/server"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import getConnectedForms from "../lib/getConnectedForms"; +import { isFormEditAllowed } from "../lib/isFormEditAllowed"; +import type { TDeleteFormInputSchema } from "./deleteForm.schema"; + +interface DeleteFormHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; + input: TDeleteFormInputSchema; +} +export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => { + const { user, prisma } = ctx; + if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + const areFormsUsingIt = ( + await getConnectedForms(prisma, { + id: input.id, + userId: user.id, + }) + ).length; + if (areFormsUsingIt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This form is being used by other forms. Please remove it's usage from there first.", + }); + } + return await prisma.app_RoutingForms_Form.deleteMany({ + where: { + id: input.id, + userId: user.id, + }, + }); +}; diff --git a/packages/app-store/routing-forms/trpc/deleteForm.schema.ts b/packages/app-store/routing-forms/trpc/deleteForm.schema.ts new file mode 100644 index 0000000000..8e3bab6a0e --- /dev/null +++ b/packages/app-store/routing-forms/trpc/deleteForm.schema.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const ZDeleteFormInputSchema = z.object({ + id: z.string(), +}); + +export type TDeleteFormInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts new file mode 100644 index 0000000000..1455613d49 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts @@ -0,0 +1,330 @@ +import type { PrismaClient } from "@prisma/client"; +import type { App_RoutingForms_Form } from "@prisma/client"; +import { Prisma } from "@prisma/client"; + +import { TRPCError } from "@calcom/trpc/server"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { createFallbackRoute } from "../lib/createFallbackRoute"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import { isFallbackRoute } from "../lib/isFallbackRoute"; +import { isFormEditAllowed } from "../lib/isFormEditAllowed"; +import isRouter from "../lib/isRouter"; +import isRouterLinkedField from "../lib/isRouterLinkedField"; +import type { SerializableForm } from "../types/types"; +import { zodFields, zodRouterRoute, zodRoutes } from "../zod"; +import type { TFormMutationInputSchema } from "./formMutation.schema"; + +interface FormMutationHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; + input: TFormMutationInputSchema; +} +export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => { + const { user, prisma } = ctx; + const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input; + if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + let { routes: inputRoutes } = input; + let { fields: inputFields } = input; + inputFields = inputFields || []; + inputRoutes = inputRoutes || []; + type InputFields = typeof inputFields; + type InputRoutes = typeof inputRoutes; + let routes: InputRoutes; + let fields: InputFields; + type DuplicateFrom = NonNullable; + + const dbForm = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: id, + }, + select: { + id: true, + user: true, + name: true, + description: true, + userId: true, + disabled: true, + createdAt: true, + updatedAt: true, + routes: true, + fields: true, + settings: true, + }, + }); + + const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null; + + if (duplicateFrom) { + ({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom)); + } else { + [fields, routes] = [inputFields, inputRoutes]; + if (dbSerializedForm) { + fields = markMissingFieldsDeleted(dbSerializedForm, fields); + } + } + + if (dbSerializedForm) { + // If it's an existing form being mutated, update fields in the connected forms(if any). + await updateFieldsInConnectedForms(dbSerializedForm, inputFields); + } + + fields = await getUpdatedRouterLinkedFields(fields, routes); + + if (addFallback) { + // Add a fallback route if there is none + if (!routes.find(isFallbackRoute)) { + routes.push(createFallbackRoute()); + } + } + + return await prisma.app_RoutingForms_Form.upsert({ + where: { + id: id, + }, + create: { + user: { + connect: { + id: user.id, + }, + }, + fields, + name: name, + description, + // Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case. + routes: routes === null ? Prisma.JsonNull : routes, + id: id, + }, + update: { + disabled: disabled, + fields, + name: name, + description, + settings: settings === null ? Prisma.JsonNull : settings, + routes: routes === null ? Prisma.JsonNull : routes, + }, + }); + + /** + * If Form has Router Linked fields, enrich them with the latest info from the Router + * If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router + */ + async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) { + const routerLinkedFields: Record = {}; + for (const [, field] of Object.entries(fields)) { + if (!isRouterLinkedField(field)) { + continue; + } + routerLinkedFields[field.routerId] = true; + + if (!routes.some((route) => route.id === field.routerId)) { + // If the field is from a router that is not available anymore, mark it as deleted + field.deleted = true; + continue; + } + // Get back deleted field as now the Router is there for it. + if (field.deleted) field.deleted = false; + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: field.routerId, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + + // There is a field from some router available, make sure that the field has up-to-date info from the router + const routerField = parsedRouterFields?.find((f) => f.id === field.id); + // Update local field(cache) with router field on every mutation + Object.assign(field, routerField); + } + } + + for (const [, route] of Object.entries(routes)) { + if (!isRouter(route)) { + continue; + } + + // If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields + if (routerLinkedFields[route.id]) { + continue; + } + + const router = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: route.id, + userId: user.id, + }, + }); + if (router) { + assertIfInvalidRouter(router); + const parsedRouterFields = zodFields.parse(router.fields); + const fieldsFromRouter = parsedRouterFields + ?.filter((f) => !f.deleted) + .map((f) => { + return { + ...f, + routerId: route.id, + }; + }); + + if (fieldsFromRouter) { + fields = fields.concat(fieldsFromRouter); + } + } + } + return fields; + } + + function findFieldWithId(id: string, fields: InputFields) { + return fields.find((field) => field.id === id); + } + + /** + * Update fields in connected forms as per the inputFields + */ + async function updateFieldsInConnectedForms( + serializedForm: SerializableForm, + inputFields: InputFields + ) { + for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) { + const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: connectedForm.id, + }, + }); + if (!connectedFormDb) { + continue; + } + const connectedFormFields = zodFields.parse(connectedFormDb.fields); + + const fieldsThatAreNotInConnectedForm = ( + inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || [] + ).map((f) => ({ + ...f, + routerId: serializedForm.id, + })); + + const updatedConnectedFormFields = connectedFormFields + // Update fields that are already in connected form + ?.map((field) => { + if (isRouterLinkedField(field) && field.routerId === serializedForm.id) { + return { + ...field, + ...findFieldWithId(field.id, inputFields || []), + }; + } + return field; + }) + // Add fields that are not there + .concat(fieldsThatAreNotInConnectedForm); + + await prisma.app_RoutingForms_Form.update({ + where: { + id: connectedForm.id, + }, + data: { + fields: updatedConnectedFormFields, + }, + }); + } + } + + async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) { + const sourceForm = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: duplicateFrom, + }, + select: { + id: true, + fields: true, + routes: true, + }, + }); + if (!sourceForm) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Form to duplicate: ${duplicateFrom} not found`, + }); + } + //TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well. + const fieldsParsed = zodFields.safeParse(sourceForm.fields); + const routesParsed = zodRoutes.safeParse(sourceForm.routes); + if (!fieldsParsed.success || !routesParsed.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not parse source form's fields or routes", + }); + } + + let fields, routes; + if (shouldConnect) { + routes = [ + // This connected route would automatically link the fields + zodRouterRoute.parse({ + id: sourceForm.id, + isRouter: true, + }), + ]; + fields = + fieldsParsed.data + // Deleted fields in the form shouldn't be added to the new form + ?.filter((f) => !f.deleted) + .map((f) => { + return { + id: f.id, + routerId: sourceForm.id, + label: "", + type: "", + }; + }) || []; + } else { + // Duplicate just routes and fields + // We don't want name, description and responses to be copied + routes = routesParsed.data || []; + // FIXME: Deleted fields shouldn't come in duplicate + fields = fieldsParsed.data || []; + } + return { routes, fields }; + } + + function markMissingFieldsDeleted( + serializedForm: SerializableForm, + fields: InputFields + ) { + // Find all fields that are in DB(including deleted) but not in the mutation + // e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted + const deletedFields = + serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || []; + + // Add back deleted fields in the end and mark them deleted. + // Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation + fields = fields.concat( + deletedFields.map((f) => { + f.deleted = true; + return f; + }) + ); + return fields; + } + function assertIfInvalidRouter(router: App_RoutingForms_Form) { + const routesOfRouter = zodRoutes.parse(router.routes); + if (routesOfRouter) { + if (routesOfRouter.find(isRouter)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "A form being used as a Router must be a Origin form. It must not be using any other Router.", + }); + } + } + } +}; diff --git a/packages/app-store/routing-forms/trpc/formMutation.schema.ts b/packages/app-store/routing-forms/trpc/formMutation.schema.ts new file mode 100644 index 0000000000..802e88a5fa --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formMutation.schema.ts @@ -0,0 +1,20 @@ +import z from "zod"; + +import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; + +import { zodFields, zodRoutes } from "../zod"; + +export const ZFormMutationInputSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + disabled: z.boolean().optional(), + fields: zodFields, + routes: zodRoutes, + addFallback: z.boolean().optional(), + duplicateFrom: z.string().nullable().optional(), + shouldConnect: z.boolean().optional(), + settings: RoutingFormSettings.optional(), +}); + +export type TFormMutationInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/formQuery.handler.ts b/packages/app-store/routing-forms/trpc/formQuery.handler.ts new file mode 100644 index 0000000000..61b8a30e54 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formQuery.handler.ts @@ -0,0 +1,36 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { TFormQueryInputSchema } from "./formQuery.schema"; + +interface FormsHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; + input: TFormQueryInputSchema; +} +export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => { + const { prisma, user } = ctx; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + userId: user.id, + id: input.id, + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + if (!form) { + return null; + } + + return await getSerializableForm(form); +}; diff --git a/packages/app-store/routing-forms/trpc/formQuery.schema.ts b/packages/app-store/routing-forms/trpc/formQuery.schema.ts new file mode 100644 index 0000000000..704fe59689 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/formQuery.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZFormQueryInputSchema = z.object({ + id: z.string(), +}); + +export type TFormQueryInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/forms.handler.ts b/packages/app-store/routing-forms/trpc/forms.handler.ts new file mode 100644 index 0000000000..b992b6ab1e --- /dev/null +++ b/packages/app-store/routing-forms/trpc/forms.handler.ts @@ -0,0 +1,37 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { getSerializableForm } from "../lib/getSerializableForm"; + +interface FormsHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + }; +} + +export const formsHandler = async ({ ctx }: FormsHandlerOptions) => { + const { prisma, user } = ctx; + const forms = await prisma.app_RoutingForms_Form.findMany({ + where: { + userId: user.id, + }, + orderBy: { + createdAt: "desc", + }, + include: { + _count: { + select: { + responses: true, + }, + }, + }, + }); + + const serializableForms = []; + for (let i = 0; i < forms.length; i++) { + serializableForms.push(await getSerializableForm(forms[i])); + } + return serializableForms; +}; diff --git a/packages/app-store/routing-forms/trpc/report.handler.ts b/packages/app-store/routing-forms/trpc/report.handler.ts new file mode 100644 index 0000000000..b2b80b2518 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/report.handler.ts @@ -0,0 +1,80 @@ +import type { PrismaClient } from "@prisma/client"; + +import logger from "@calcom/lib/logger"; +import { TRPCError } from "@calcom/trpc/server"; + +import { jsonLogicToPrisma } from "../jsonLogicToPrisma"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { Response } from "../types/types"; +import type { TReportInputSchema } from "./report.schema"; + +interface ReportHandlerOptions { + ctx: { + prisma: PrismaClient; + }; + input: TReportInputSchema; +} +export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => { + // Can be any prisma `where` clause + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prismaWhere: Record = input.jsonLogicQuery + ? jsonLogicToPrisma(input.jsonLogicQuery) + : {}; + const skip = input.cursor ?? 0; + const take = 50; + logger.debug( + `Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify( + input.jsonLogicQuery + )}` + ); + const form = await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: input.formId, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + // TODO: Second argument is required to return deleted operators. + const serializedForm = await getSerializableForm(form, true); + + const rows = await prisma.app_RoutingForms_FormResponse.findMany({ + where: { + formId: input.formId, + ...prismaWhere, + }, + take, + skip, + }); + const fields = serializedForm?.fields || []; + const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : "")); + const responses: string[][] = []; + rows.forEach((r) => { + const rowResponses: string[] = []; + responses.push(rowResponses); + fields.forEach((field) => { + if (!r.response) { + return; + } + const response = r.response as Response; + const value = response[field.id]?.value || ""; + let stringValue = ""; + if (value instanceof Array) { + stringValue = value.join(", "); + } else { + stringValue = value; + } + rowResponses.push(stringValue); + }); + }); + const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take; + return { + headers, + responses, + nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length, + }; +}; diff --git a/packages/app-store/routing-forms/trpc/report.schema.ts b/packages/app-store/routing-forms/trpc/report.schema.ts new file mode 100644 index 0000000000..2b7799f14b --- /dev/null +++ b/packages/app-store/routing-forms/trpc/report.schema.ts @@ -0,0 +1,11 @@ +import z from "zod"; + +export const ZReportInputSchema = z.object({ + formId: z.string(), + jsonLogicQuery: z.object({ + logic: z.union([z.record(z.any()), z.null()]), + }), + cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type +}); + +export type TReportInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts new file mode 100644 index 0000000000..7a8c1d4ef5 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -0,0 +1,101 @@ +import type { PrismaClient } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import { TRPCError } from "@calcom/trpc/server"; + +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { Response } from "../types/types"; +import type { TResponseInputSchema } from "./response.schema"; +import { onFormSubmission } from "./utils"; + +interface ResponseHandlerOptions { + ctx: { + prisma: PrismaClient; + }; + input: TResponseInputSchema; +} +export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => { + const { prisma } = ctx; + try { + const { response, formId } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + user: true, + }, + }); + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + }); + } + + const serializableForm = await getSerializableForm(form); + if (!serializableForm.fields) { + // There is no point in submitting a form that doesn't have fields defined + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + + const serializableFormWithFields = { + ...serializableForm, + fields: serializableForm.fields, + }; + + const missingFields = serializableFormWithFields.fields + .filter((field) => !(field.required ? response[field.id]?.value : true)) + .map((f) => f.label); + + if (missingFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Missing required fields ${missingFields.join(", ")}`, + }); + } + const invalidFields = serializableFormWithFields.fields + .filter((field) => { + const fieldValue = response[field.id]?.value; + // The field isn't required at this point. Validate only if it's set + if (!fieldValue) { + return false; + } + let schema; + if (field.type === "email") { + schema = z.string().email(); + } else if (field.type === "phone") { + schema = z.any(); + } else { + schema = z.any(); + } + return !schema.safeParse(fieldValue).success; + }) + .map((f) => ({ label: f.label, type: f.type })); + + if (invalidFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`, + }); + } + + const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: input, + }); + + await onFormSubmission(serializableFormWithFields, dbFormResponse.response as Response); + return dbFormResponse; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + }); + } + } + throw e; + } +}; diff --git a/packages/app-store/routing-forms/trpc/response.schema.ts b/packages/app-store/routing-forms/trpc/response.schema.ts new file mode 100644 index 0000000000..9020de630a --- /dev/null +++ b/packages/app-store/routing-forms/trpc/response.schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ZResponseInputSchema = z.object({ + formId: z.string(), + formFillerId: z.string(), + response: z.record( + z.object({ + label: z.string(), + value: z.union([z.string(), z.array(z.string())]), + }) + ), +}); + +export type TResponseInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts new file mode 100644 index 0000000000..1cb4cb65a0 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -0,0 +1,66 @@ +import type { App_RoutingForms_Form, User } from "@prisma/client"; + +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import logger from "@calcom/lib/logger"; +import { WebhookTriggerEvents } from "@calcom/prisma/client"; +import type { Ensure } from "@calcom/types/utils"; + +import ResponseEmail from "../emails/templates/response-email"; +import type { Response, SerializableForm } from "../types/types"; + +export async function onFormSubmission( + form: Ensure & { user: User }, "fields">, + response: Response +) { + const fieldResponsesByName: Record = {}; + + for (const [fieldId, fieldResponse] of Object.entries(response)) { + // Use the label lowercased as the key to identify a field. + const key = + form.fields.find((f) => f.id === fieldId)?.identifier || + (fieldResponse.label as keyof typeof fieldResponsesByName); + fieldResponsesByName[key] = fieldResponse.value; + } + + const subscriberOptions = { + userId: form.user.id, + // It isn't an eventType webhook + eventTypeId: -1, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + }; + + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => { + sendGenericWebhookPayload( + webhook.secret, + "FORM_SUBMITTED", + new Date().toISOString(), + webhook, + fieldResponsesByName + ).catch((e) => { + console.error(`Error executing routing form webhook`, webhook, e); + }); + }); + + await Promise.all(promises); + if (form.settings?.emailOwnerOnSubmission) { + logger.debug( + `Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}` + ); + await sendResponseEmail(form, response, form.user.email); + } +} + +export const sendResponseEmail = async ( + form: Pick, + response: Response, + ownerEmail: string +) => { + try { + const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response }); + await email.sendEmail(); + } catch (e) { + logger.error("Error sending response email", e); + } +}; diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index e7155b1a79..386e5ef028 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -4,10 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import z from "zod"; import { sendAwaitingPaymentEmail } from "@calcom/emails"; -import type { IAbstractPaymentService } from "@calcom/lib/PaymentService"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { paymentOptionEnum } from "../zod"; import { createPaymentLink } from "./client"; diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 06887ce96d..91c48dff89 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -23,7 +23,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise BaseEmail) => { }); }; -export const sendScheduledEmails = async (calEvent: CalendarEvent, eventNameObject?: EventNameObjectType) => { +export const sendScheduledEmails = async ( + calEvent: CalendarEvent, + eventNameObject?: EventNameObjectType, + hostEmailDisabled?: boolean, + attendeeEmailDisabled?: boolean +) => { const emailsToSend: Promise[] = []; - emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent }))); + if (!hostEmailDisabled) { + emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent }))); - if (calEvent.team) { - for (const teamMember of calEvent.team.members) { - emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, teamMember }))); + if (calEvent.team) { + for (const teamMember of calEvent.team.members) { + emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, teamMember }))); + } } } - emailsToSend.push( - ...calEvent.attendees.map((attendee) => { - return sendEmail( - () => - new AttendeeScheduledEmail( - { - ...calEvent, - ...(eventNameObject && { - title: getEventName({ ...eventNameObject, t: attendee.language.translate }), - }), - }, - attendee - ) - ); - }) - ); + if (!attendeeEmailDisabled) { + emailsToSend.push( + ...calEvent.attendees.map((attendee) => { + return sendEmail( + () => + new AttendeeScheduledEmail( + { + ...calEvent, + ...(eventNameObject && { + title: getEventName({ ...eventNameObject, t: attendee.language.translate }), + }), + }, + attendee + ) + ); + }) + ); + } await Promise.all(emailsToSend); }; diff --git a/packages/emails/package.json b/packages/emails/package.json index 9454e7db25..786d6ab25e 100644 --- a/packages/emails/package.json +++ b/packages/emails/package.json @@ -3,6 +3,9 @@ "sideEffects": false, "version": "0.0.0", "private": true, + "scripts": { + "dx": "docker compose up -d" + }, "dependencies": { "@calcom/dayjs": "*", "@calcom/lib": "*", diff --git a/packages/emails/templates/_base-email.ts b/packages/emails/templates/_base-email.ts index 51509bd58d..53dadcbb24 100644 --- a/packages/emails/templates/_base-email.ts +++ b/packages/emails/templates/_base-email.ts @@ -52,9 +52,10 @@ export default class BaseEmail { ...(parseSubject.success && { subject: decodeHTML(parseSubject.data) }), }; - new Promise((resolve, reject) => - createTransport(this.getMailerOptions().transport) - .sendMail(payloadWithUnEscapedSubject, (_err, info) => { + new Promise((resolve, reject) => + createTransport(this.getMailerOptions().transport).sendMail( + payloadWithUnEscapedSubject, + (_err, info) => { if (_err) { const err = getErrorFromUnknown(_err); this.printNodeMailerError(err); @@ -62,7 +63,8 @@ export default class BaseEmail { } else { resolve(info); } - }) + } + ) ).catch((e) => console.error("sendEmail", e)); return new Promise((resolve) => resolve("send mail async")); } diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index aec279eaf4..3a88bff122 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -19,7 +19,7 @@ import { EventMeta } from "./components/EventMeta"; import { LargeCalendar } from "./components/LargeCalendar"; import { LargeViewHeader } from "./components/LargeViewHeader"; import { BookerSection } from "./components/Section"; -import { fadeInLeft, resizeAnimationConfig } from "./config"; +import { fadeInLeft, useBookerResizeAnimation } from "./config"; import { useBookerStore, useInitializeBookerStore } from "./store"; import type { BookerLayout, BookerProps } from "./types"; import { useEvent } from "./utils/event"; @@ -56,6 +56,8 @@ const BookerComponent = ({ username, eventSlug, month, rescheduleBooking }: Book const extraDays = layout === "large_timeslots" ? (isTablet ? 2 : 4) : 0; const onLayoutToggle = useCallback((newLayout: BookerLayout) => setLayout(newLayout), [setLayout]); + const animationScope = useBookerResizeAnimation(layout, bookerState); + useBrandColors({ brandColor: event.data?.profile.brandColor, darkBrandColor: event.data?.profile.darkBrandColor, @@ -124,24 +126,21 @@ const BookerComponent = ({ username, eventSlug, month, rescheduleBooking }: Book )}
        - - + + {layout !== "small_calendar" && !(layout === "mobile" && bookerState === "booking") && (
        diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 59b9dd35e3..4b896df51e 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -9,7 +9,7 @@ import { Calendar, Globe } from "@calcom/ui/components/icon"; import { fadeInUp } from "../config"; import { useBookerStore } from "../store"; -import { formatEventFromToTime } from "../utils/dates"; +import { FromToTime } from "../utils/dates"; import { useEvent } from "../utils/event"; const TimezoneSelect = dynamic(() => import("@calcom/ui").then((mod) => mod.TimezoneSelect), { @@ -42,25 +42,25 @@ export const EventMeta = () => { {t("former_time")}
        - {formatEventFromToTime( - rescheduleBooking.startTime.toString(), - null, - timeFormat, - timezone, - i18n.language - )} + )} {selectedTimeslot && ( - {formatEventFromToTime( - selectedTimeslot, - selectedDuration, - timeFormat, - timezone, - i18n.language - )} + )} diff --git a/packages/features/bookings/Booker/config.ts b/packages/features/bookings/Booker/config.ts index f77fd0ecbe..36ba429ffd 100644 --- a/packages/features/bookings/Booker/config.ts +++ b/packages/features/bookings/Booker/config.ts @@ -1,3 +1,7 @@ +import { cubicBezier, useAnimate } from "framer-motion"; +import { useReducedMotion } from "framer-motion"; +import { useEffect } from "react"; + import type { BookerLayout, BookerState } from "./types"; // Framer motion fade in animation configs. @@ -43,6 +47,7 @@ export const resizeAnimationConfig: ResizeAnimationConfig = { mobile: { default: { width: "100%", + minHeight: "0px", gridTemplateAreas: ` "meta" "main" @@ -54,28 +59,86 @@ export const resizeAnimationConfig: ResizeAnimationConfig = { small_calendar: { default: { width: "calc(var(--booker-meta-width) + var(--booker-main-width))", + minHeight: "450px", + height: "auto", gridTemplateAreas: `"meta main"`, gridTemplateColumns: "var(--booker-meta-width) var(--booker-main-width)", }, selecting_time: { - width: "100%", - maxWidth: "calc(var(--booker-meta-width) + var(--booker-main-width) + var(--booker-timeslots-width))", + width: "calc(var(--booker-meta-width) + var(--booker-main-width) + var(--booker-timeslots-width))", + minHeight: "450px", + height: "auto", gridTemplateAreas: `"meta main timeslots"`, gridTemplateColumns: "var(--booker-meta-width) 1fr var(--booker-timeslots-width)", }, }, large_calendar: { default: { - width: "100%", + width: "100vw", + height: "100vh", gridTemplateAreas: `"meta main"`, gridTemplateColumns: "var(--booker-meta-width) 1fr", }, }, large_timeslots: { default: { - width: "100%", + width: "100vw", + height: "100vh", gridTemplateAreas: `"meta main"`, gridTemplateColumns: "var(--booker-meta-width) 1fr", }, }, }; + +/** + * This hook returns a ref that should be set on the booker element. + * Based on that ref this hook animates the size of the booker element with framer motion. + * It also takes into account the prefers-reduced-motion setting, to not animate when that's set. + */ +export const useBookerResizeAnimation = (layout: BookerLayout, state: BookerState) => { + const prefersReducedMotion = useReducedMotion(); + const [animationScope, animate] = useAnimate(); + + useEffect(() => { + const animationConfig = resizeAnimationConfig[layout][state] || resizeAnimationConfig[layout].default; + + const animatedProperties = { + height: animationConfig?.height || "auto", + }; + + const nonAnimatedProperties = { + // Width is animated by the css class instead of via framer motion, + // because css is better at animating the calcs, framer motion might + // make some mistakes in that. + width: animationConfig?.width || "auto", + gridTemplateAreas: animationConfig?.gridTemplateAreas, + gridTemplateColumns: animationConfig?.gridTemplateColumns, + minHeight: animationConfig?.minHeight, + }; + + // We don't animate if users has set prefers-reduced-motion, + // or when the layout is mobile. + if (prefersReducedMotion || layout === "mobile") { + animate( + animationScope.current, + { + ...animatedProperties, + ...nonAnimatedProperties, + }, + { + duration: 0, + } + ); + } else { + animate(animationScope.current, nonAnimatedProperties, { + duration: 0, + }); + animate(animationScope.current, animatedProperties, { + duration: 0.5, + ease: cubicBezier(0.4, 0, 0.2, 1), + }); + } + }, [animate, animationScope, layout, prefersReducedMotion, state]); + + return animationScope; +}; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 359d84d426..ba84ced3ca 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -101,7 +101,7 @@ export const useBookerStore = create((set, get) => ({ setLayout: (layout: BookerLayout) => { // If we switch to a large layout and don't have a date selected yet, // we selected it here, so week title is rendered properly. - if (["large_calendar", "large_timeslots"].includes(get().layout) && !get().selectedDate) { + if (["large_calendar", "large_timeslots"].includes(layout) && !get().selectedDate) { set({ selectedDate: dayjs().format("YYYY-MM-DD") }); } return set({ layout }); diff --git a/packages/features/bookings/Booker/utils/dates.ts b/packages/features/bookings/Booker/utils/dates.ts deleted file mode 100644 index 7306119b08..0000000000 --- a/packages/features/bookings/Booker/utils/dates.ts +++ /dev/null @@ -1,18 +0,0 @@ -import dayjs from "@calcom/dayjs"; -import type { TimeFormat } from "@calcom/lib/timeFormat"; - -export const formatEventFromToTime = ( - date: string, - duration: number | null, - timeFormat: TimeFormat, - timeZone: string, - language: string -) => { - const start = dayjs(date).tz(timeZone); - const end = duration ? start.add(duration, "minute") : null; - return `${start.format("dddd")}, ${start - .toDate() - .toLocaleDateString(language, { dateStyle: "long" })} ${start.format(timeFormat)} ${ - end ? `– ${end.format(timeFormat)}` : `` - }`; -}; diff --git a/packages/features/bookings/Booker/utils/dates.tsx b/packages/features/bookings/Booker/utils/dates.tsx new file mode 100644 index 0000000000..7584c1add8 --- /dev/null +++ b/packages/features/bookings/Booker/utils/dates.tsx @@ -0,0 +1,38 @@ +import dayjs from "@calcom/dayjs"; +import type { TimeFormat } from "@calcom/lib/timeFormat"; + +interface EventFromToTime { + date: string; + duration: number | null; + timeFormat: TimeFormat; + timeZone: string; + language: string; +} + +export const formatEventFromToTime = ({ + date, + duration, + timeFormat, + timeZone, + language, +}: EventFromToTime) => { + const start = dayjs(date).tz(timeZone); + const end = duration ? start.add(duration, "minute") : null; + const formattedDate = `${start.format("dddd")}, ${start + .toDate() + .toLocaleDateString(language, { dateStyle: "long" })}`; + const formattedTime = `${start.format(timeFormat)} ${end ? `– ${end.format(timeFormat)}` : ``}`; + + return { date: formattedDate, time: formattedTime }; +}; + +export const FromToTime = (props: EventFromToTime) => { + const formatted = formatEventFromToTime(props); + return ( + <> + {formatted.date} +
        + {formatted.time} + + ); +}; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index f9287e4421..6532493c48 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -78,7 +78,7 @@ export const AvailableTimes = ({ data-testid="time" data-time={slot.time} onClick={() => onTimeSelect(slot.time)} - className="mb-2 flex h-auto min-h-9 w-full flex-col justify-center py-2" + className="min-h-9 mb-2 flex h-auto w-full flex-col justify-center py-2" color="secondary"> {dayjs.utc(slot.time).tz(timezone).format(timeFormat)} {bookingFull &&

        {t("booking_full")}

        } diff --git a/packages/features/bookings/components/event-meta/Members.tsx b/packages/features/bookings/components/event-meta/Members.tsx index df4773e927..3076270f6d 100644 --- a/packages/features/bookings/components/event-meta/Members.tsx +++ b/packages/features/bookings/components/event-meta/Members.tsx @@ -1,4 +1,4 @@ -import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL } from "@calcom/lib/constants"; import { SchedulingType } from "@calcom/prisma/enums"; import { AvatarGroup } from "@calcom/ui"; @@ -14,28 +14,54 @@ export interface EventMembersProps { profile: PublicEvent["profile"]; } +type Avatar = { + title: string; + image: string | undefined; + alt: string | undefined; + href: string | undefined; +}; + +type AvatarWithRequiredImage = Avatar & { image: string }; + export const EventMembers = ({ schedulingType, users, profile }: EventMembersProps) => { const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN; - const shownUsers = showMembers ? [...users, profile] : [profile]; + const shownUsers = showMembers ? users : []; - const avatars = shownUsers - .map((user) => ({ - title: `${user.name}`, - image: "image" in user ? `${user.image}` : `${WEBAPP_URL}/${user.username}/avatar.png`, - alt: user.name || undefined, - href: user.username ? `${CAL_URL}/${user.username}` : undefined, - })) - .filter((item) => !!item.image) + // In some cases we don't show the user's names, but only show the profile name. + const showOnlyProfileName = + (profile.name && schedulingType === SchedulingType.ROUND_ROBIN) || + !users.length || + (profile.name !== users[0].name && schedulingType === SchedulingType.COLLECTIVE); + + const avatars: Avatar[] = shownUsers.map((user) => ({ + title: `${user.name}`, + image: "image" in user ? `${user.image}` : `${CAL_URL}/${user.username}/avatar.png`, + alt: user.name || undefined, + href: user.username ? `${CAL_URL}/${user.username}` : undefined, + })); + + // Add profile later since we don't want to force creating an avatar for this if it doesn't exist. + avatars.unshift({ + title: `${profile.name}`, + image: "logo" in profile && profile.logo ? `${profile.logo}` : undefined, + alt: profile.name || undefined, + href: profile.username ? `${CAL_URL}/${profile.username}` : undefined, + }); + + const uniqueAvatars = avatars + .filter((item): item is AvatarWithRequiredImage => !!item.image) .filter((item, index, self) => self.findIndex((t) => t.image === item.image) === index); return ( <> - +

        - {users - .map((user) => user.name) - .filter((name) => name) - .join(", ")} + {showOnlyProfileName + ? profile.name + : shownUsers + .map((user) => user.name) + .filter((name) => name) + .join(", ")}

        ); diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index a4a68ecd51..adfe196842 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -17,6 +17,8 @@ export const bookingResponse = z.union([ optionValue: z.string(), value: z.string(), }), + // For variantsConfig case + z.record(z.string()), ]); export const bookingResponsesDbSchema = z.record(bookingResponse); diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index bee4040357..cc345d433f 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -24,6 +24,7 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { BookingStatus, MembershipRole, WorkflowMethods } from "@calcom/prisma/enums"; import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; async function getBookingToDelete(id: number | undefined, uid: string | undefined) { return await prisma.booking.findUnique({ @@ -591,14 +592,16 @@ async function handler(req: CustomRequest) { } // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore]; + const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore](); if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { console.warn(`payment App service of type ${paymentApp} is not implemented`); return null; } - const PaymentService = paymentApp.lib.PaymentService; - const paymentInstance = new PaymentService(paymentAppCredential); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const PaymentService = paymentApp.lib.PaymentService as unknown as any; + const paymentInstance = new PaymentService(paymentAppCredential) as IAbstractPaymentService; + try { await paymentInstance.refund(successPayment.id); } catch (error) { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 338ecda022..2a55ab7c3a 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -33,6 +33,10 @@ import { } from "@calcom/emails"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; +import { + allowDisablingAttendeeConfirmationEmails, + allowDisablingHostConfirmationEmails, +} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; @@ -1014,9 +1018,17 @@ async function handler( message?: string; }) | null = null; - const booking = await prisma.booking.findUniqueOrThrow({ + const booking = await prisma.booking.findFirst({ where: { - uid: rescheduleUid || reqBody.bookingUid, + OR: [ + { + uid: rescheduleUid || reqBody.bookingUid, + }, + { + eventTypeId: eventType.id, + startTime: evt.startTime, + }, + ], }, select: { uid: true, @@ -1029,6 +1041,11 @@ async function handler( status: true, }, }); + + if (!booking) { + throw new HttpError({ statusCode: 404, message: "Could not find booking" }); + } + // See if attendee is already signed up for timeslot if ( booking.attendees.find((attendee) => attendee.email === invitee[0].email) && @@ -1953,6 +1970,26 @@ async function handler( videoCallUrl = metadata.hangoutLink || defaultLocationUrl || videoCallUrl; } if (noEmail !== true) { + let isHostConfirmationEmailsDisabled = false; + let isAttendeeConfirmationEmailDisabled = false; + + const workflows = eventType.workflows.map((workflow) => workflow.workflow); + + if (eventType.workflows) { + isHostConfirmationEmailsDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.host || false; + isAttendeeConfirmationEmailDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; + + if (isHostConfirmationEmailsDisabled) { + isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); + } + + if (isAttendeeConfirmationEmailDisabled) { + isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); + } + } + await sendScheduledEmails( { ...evt, @@ -1960,7 +1997,9 @@ async function handler( additionalNotes, customInputs, }, - eventNameObject + eventNameObject, + isHostConfirmationEmailsDisabled, + isAttendeeConfirmationEmailDisabled ); } } diff --git a/apps/web/components/getting-started/components/CalendarSwitch.tsx b/packages/features/calendars/CalendarSwitch.tsx similarity index 67% rename from apps/web/components/getting-started/components/CalendarSwitch.tsx rename to packages/features/calendars/CalendarSwitch.tsx index e067c5f44d..75ec99fa5e 100644 --- a/apps/web/components/getting-started/components/CalendarSwitch.tsx +++ b/packages/features/calendars/CalendarSwitch.tsx @@ -1,9 +1,11 @@ import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { showToast, Switch } from "@calcom/ui"; - -import classNames from "@lib/classNames"; +import { ArrowLeft, RotateCcw } from "@calcom/ui/components/icon"; interface ICalendarSwitchProps { title: string; @@ -12,10 +14,13 @@ interface ICalendarSwitchProps { isChecked: boolean; name: string; isLastItemInList?: boolean; + destination?: boolean; } const CalendarSwitch = (props: ICalendarSwitchProps) => { const { title, externalId, type, isChecked, name, isLastItemInList = false } = props; + const [checkedInternal, setCheckedInternal] = useState(isChecked); const utils = trpc.useContext(); + const { t } = useLocale(); const mutation = useMutation< unknown, unknown, @@ -58,19 +63,22 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { { async onSettled() { await utils.viewer.integrations.invalidate(); + await utils.viewer.connectedCalendars.invalidate(); }, onError() { + setCheckedInternal(false); showToast(`Something went wrong when toggling "${title}""`, "error"); }, } ); return ( -
        +
        { + setCheckedInternal(isOn); mutation.mutate({ isOn }); }} /> @@ -78,6 +86,13 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { + {!!props.destination && ( + + + {t("adding_events_to")} + + )} + {mutation.isLoading && }
        ); }; diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 24f3d31fc2..2b9f123a52 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -96,7 +96,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) name="inviteUser" placeholder="email@example.com" required - onChange={onChange} + onChange={(e) => onChange(e.target.value.trim().toLowerCase())} /> {error && {error.message}} diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 8c047c7ddd..2314fe1001 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -54,7 +54,7 @@ const ProfileView = () => { const utils = trpc.useContext(); const session = useSession(); const [firstRender, setFirstRender] = useState(true); - + useLayoutEffect(() => { document.body.focus(); }, []); diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts index 3c4d761738..2273e6cd2c 100644 --- a/packages/features/ee/users/server/trpc-router.ts +++ b/packages/features/ee/users/server/trpc-router.ts @@ -5,7 +5,8 @@ import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; import { _UserModel as User } from "@calcom/prisma/zod"; import type { inferRouterOutputs } from "@calcom/trpc"; import { TRPCError } from "@calcom/trpc"; -import { authedAdminProcedure, middleware, router } from "@calcom/trpc/server/trpc"; +import { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure"; +import { router } from "@calcom/trpc/server/trpc"; export type UserAdminRouter = typeof userAdminRouter; export type UserAdminRouterOutputs = inferRouterOutputs; @@ -51,7 +52,9 @@ function exclude(user: UserType, keys: Key } /** Reusable logic that checks for admin permissions and if the requested user exists */ -const authedAdminWithUserMiddleware = middleware(async ({ ctx, next, rawInput }) => { +//const authedAdminWithUserMiddleware = middleware(); + +const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(async ({ ctx, next, rawInput }) => { const { prisma } = ctx; const parsed = userIdSchema.safeParse(rawInput); if (!parsed.success) throw new TRPCError({ code: "BAD_REQUEST", message: "User id is required" }); @@ -68,8 +71,6 @@ const authedAdminWithUserMiddleware = middleware(async ({ ctx, next, rawInput }) }); }); -const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(authedAdminWithUserMiddleware); - export const userAdminRouter = router({ get: authedAdminProcedureWithRequestedUser.input(userIdSchema).query(async ({ ctx }) => { const { requestedUser } = ctx; diff --git a/packages/features/ee/video/ViewRecordingsDialog.tsx b/packages/features/ee/video/ViewRecordingsDialog.tsx index 1fa86ccbb1..15f14556f1 100644 --- a/packages/features/ee/video/ViewRecordingsDialog.tsx +++ b/packages/features/ee/video/ViewRecordingsDialog.tsx @@ -2,7 +2,7 @@ import { useState, Suspense } from "react"; import dayjs from "@calcom/dayjs"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; -import useHasPaidPlan from "@calcom/lib/hooks/useHasPaidPlan"; +import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RecordingItemSchema } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; @@ -98,7 +98,7 @@ const useRecordingDownload = () => { }; }; -const ViewRecordingsList = ({ roomName, hasPaidPlan }: { roomName: string; hasPaidPlan: boolean }) => { +const ViewRecordingsList = ({ roomName, hasTeamPlan }: { roomName: string; hasTeamPlan: boolean }) => { const { t } = useLocale(); const { setRecordingId, isFetching, recordingId } = useRecordingDownload(); @@ -129,7 +129,7 @@ const ViewRecordingsList = ({ roomName, hasPaidPlan }: { roomName: string; hasPa

        {convertSecondsToMs(recording.duration)}

        - {hasPaidPlan ? ( + {hasTeamPlan ? ( ) : null diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 3e7d8ad1d8..15671e2e35 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -3,7 +3,8 @@ import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { authedProcedure, isAuthed, router } from "@calcom/trpc/server/trpc"; +import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; +import { router } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; @@ -13,7 +14,9 @@ const UserBelongsToTeamInput = z.object({ teamId: z.coerce.number().optional().nullable(), }); -const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, rawInput }) => { +//const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(); + +const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, rawInput }) => { const parse = UserBelongsToTeamInput.safeParse(rawInput); if (!parse.success) { throw new TRPCError({ code: "BAD_REQUEST" }); @@ -41,8 +44,6 @@ const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, r return next(); }); -const userBelongsToTeamProcedure = authedProcedure.use(userBelongsToTeamMiddleware); - const UserSelect = { id: true, name: true, diff --git a/packages/features/package.json b/packages/features/package.json index 7582de6295..2e74a919af 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -12,7 +12,7 @@ "@calcom/ui": "*", "@lexical/react": "^0.5.0", "dompurify": "^2.4.1", - "framer-motion": "^10.12.3", + "framer-motion": "^10.12.8", "lexical": "^0.5.0", "react-sticky-box": "^2.0.4", "zustand": "^4.3.2" diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 7fc489a248..c9e1080bb5 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -299,19 +299,19 @@ function UserDropdown({ small }: { small?: boolean }) { /> } {!user.away && ( -
        +
        )} {user.away && ( -
        +
        )} {!small && ( - + {user.name || "Nameless User"} - + {user.username ? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? `cal.com/${user.username}` @@ -746,7 +746,7 @@ function SideBarContainer() { function SideBar() { return (
        -