This commit is contained in:
Peer Richelsen committed 2023-05-10 11:13:21 +01:00
commit 8290ca0106
179 files changed
+2867 -1722

No files matched your search

+8 -4
View File
@@ -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='<office365_emailAddress>'
# EMAIL_SERVER_HOST='smtp.office365.com'
# EMAIL_SERVER_PORT=587
# EMAIL_SERVER_USER='<office365_emailAddress>'
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
EMAIL_SERVER_PASSWORD='<office365_password>'
# EMAIL_SERVER_PASSWORD='<office365_password>'
# The following configuration for Gmail has been verified to work.
# EMAIL_SERVER_HOST='smtp.gmail.com'
+1
View File
@@ -7,6 +7,7 @@ on:
paths-ignore:
- "**.md"
- ".github/CODEOWNERS"
merge_group:
workflow_dispatch:
concurrency:
+10 -1
View File
@@ -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: <br>
```git clone -c core.symlinks=true https://github.com/calcom/cal.com.git``` <br>
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.
<!-- ACKNOWLEDGEMENTS -->
@@ -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 (
<div className="flex flex-col py-1 sm:flex-row">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.defaultSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
{!!props.destination && (
<span className="bg-subtle text-default ml-8 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm font-normal sm:ml-4">
<ArrowLeft className="h-4 w-4" />
{t("adding_events_to")}
</span>
)}
</div>
);
}
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 && (
<>
<p className="text-subtle px-5 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-2 px-5 py-4">
<ul className="space-y-4 px-5 py-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={item.integration.type}
defaultSelected={cal.isSelected}
isChecked={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
/>
))}
@@ -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`}
/>
@@ -72,6 +72,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
slotUtcStartDate: slot.time,
eventTypeId,
slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(),
bookingAttendees: bookingAttendees || undefined,
});
};
@@ -17,7 +17,7 @@ export function TimezoneDropdown({
return (
<>
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<div className="dark:focus-within:bg-darkgray-200 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
<Globe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div>
@@ -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 = ({
)}>
<div className="sm:flex">
{showEventTypeDetails && (
<div className="sm:border-subtle text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
<div className="sm:border-subtle text-default flex flex-col px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6">
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
<BookingDescriptionPayment eventType={eventType} t={t} i18n={i18n} />
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
@@ -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<EventTypeSetupProps,
const bookingFields: Prisma.JsonObject = {};
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name + " input";
});
@@ -338,7 +344,51 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</>
)}
/>
{allowDisablingAttendeeConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.attendee"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
title={t("disable_attendees_confirmation_emails")}
description={t("disable_attendees_confirmation_emails_description")}
checked={value || false}
onCheckedChange={(e) => {
formMethods.setValue("metadata.disableStandardEmails.confirmation.attendee", e);
onChange(e);
}}
/>
</>
)}
/>
</>
)}
{allowDisablingHostConfirmationEmails(workflows) && (
<>
<hr className="border-subtle" />
<Controller
name="metadata.disableStandardEmails.confirmation.host"
control={formMethods.control}
defaultValue={!!eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<>
<SettingsToggle
title={t("disable_host_confirmation_emails")}
description={t("disable_host_confirmation_emails_description")}
checked={value || false}
onCheckedChange={(e) => {
formMethods.setValue("metadata.disableStandardEmails.confirmation.host", e);
onChange(e);
}}
/>
</>
)}
/>
</>
)}
{showEventNameTip && (
<CustomEventTypeModal
close={closeEventNameTip}
@@ -154,12 +154,11 @@ const EventTypeScheduleDetails = ({
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
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<FormValues>();
const watchSchedule = watch("schedule");
const formMethods = useFormContext<FormValues>();
@@ -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
);
@@ -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`}
@@ -1,4 +1,4 @@
import { CalendarSwitch } from "./CalendarSwitch";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
interface IConnectedCalendarItem {
name: string;
@@ -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 (
<div className="flex space-x-2 py-1 rtl:space-x-reverse">
<Switch
key={props.externalId}
name="enabled"
label={props.title}
defaultChecked={props.isSelected}
onCheckedChange={(isOn: boolean) => {
mutation.mutate({ isOn });
}}
/>
{props.defaultSelected && (
<Badge variant="gray">
<div className="flex items-center">
<ArrowLeft className="text-default mr-1 h-4 w-4" /> {t("adding_events_to")}
</div>
</Badge>
)}
</div>
);
}
@@ -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")}
/>
)}
<span className={classNames("text-sm font-medium")}>{label}</span>
+1
View File
@@ -23,6 +23,7 @@ class MyDocument extends Document<Props> {
// 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");
+2 -17
View File
@@ -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<Response>) {
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,
-83
View File
@@ -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<string, string>> = {
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;
},
});
@@ -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);
@@ -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);
+4
View File
@@ -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);
@@ -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);
+4
View File
@@ -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);
@@ -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);
@@ -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);
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(ethRouter);
@@ -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);
@@ -0,0 +1,4 @@
import { featureFlagRouter } from "@calcom/features/flags/server/router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(featureFlagRouter);
@@ -0,0 +1,4 @@
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(insightsRouter);
@@ -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);
+4
View File
@@ -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);
+4
View File
@@ -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);
+4
View File
@@ -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);
+4
View File
@@ -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);
+4
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { loggedInViewerRouter } from "@calcom/trpc/server/routers/loggedInViewer/_router";
export default createNextApiHandler(loggedInViewerRouter);
@@ -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);
@@ -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);
+1 -1
View File
@@ -40,7 +40,7 @@ function AppsSearch({
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
addOnLeading={<Search className="text-subtle h-4 w-4" />}
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}
+81 -76
View File
@@ -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 (
<div className="bg-default border-subtle mb-16 flex overflow-hidden rounded-md border">
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
@@ -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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem>
@@ -533,27 +536,31 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem
href={calLink}
target="_blank"
StartIcon={ExternalLink}
className="w-full rounded-none">
{t("preview")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={"event-type-duplicate-" + type.id}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}
StartIcon={Clipboard}
className="w-full rounded-none text-left">
{t("copy_link")}
</DropdownItem>
</DropdownMenuItem>
{!isManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
href={calLink}
target="_blank"
StartIcon={ExternalLink}
className="w-full rounded-none">
{t("preview")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
data-testid={"event-type-duplicate-" + type.id}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}
StartIcon={Clipboard}
className="w-full rounded-none text-left">
{t("copy_link")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<DropdownItem
@@ -574,36 +581,46 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
</DropdownItem>
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => router.push("/event-types/" + type.id)}
StartIcon={Edit}
className="w-full rounded-none">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
StartIcon={Copy}
data-testid={"event-type-duplicate-" + type.id}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="outline-none">
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon={Trash}
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
{!readOnly && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => router.push("/event-types/" + type.id)}
StartIcon={Edit}
className="w-full rounded-none">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
StartIcon={Copy}
data-testid={"event-type-duplicate-" + type.id}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
)}
{/* 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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="outline-none">
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon={Trash}
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
@@ -616,36 +633,24 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t(
`delete_${deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"}_event_type`
)}
confirmBtnText={t(
`confirm_delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type`
)}
loadingText={t(
`confirm_delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type`
)}
title={t(`delete${isManagedEventPrefix()}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
<p className="mt-5">
{t(
`delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type_description`
)}
</p>
<p className="mt-5">
<Trans
i18nKey={`delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type_warning`}
/>
i18nKey={`delete${isManagedEventPrefix()}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>
Anyone who they&apos;ve shared their link with will no longer be able to book using it.
</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
+1 -1
View File
@@ -15,7 +15,7 @@ type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
return (
<main className="flex justify-center">
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
</main>
);
@@ -14,7 +14,7 @@ type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away }: PageProps) {
return (
<main className="flex justify-center">
<main className="flex h-full min-h-[100dvh] items-center justify-center">
<Booker username={user} eventSlug={slug} rescheduleBooking={booking} isAway={away} />
</main>
);
@@ -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 = () => {
</div>
<div className="border-subtle w-full border-t">
<p className="text-subtle px-2 pt-4 text-sm">{t("toggle_calendars_conflict")}</p>
<ul className="space-y-2 p-4">
<ul className="space-y-4 p-4">
{item.calendars.map((cal) => (
<CalendarSwitch
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
name={cal.name || "Nameless calendar"}
type={item.integration.type}
isSelected={cal.isSelected}
defaultSelected={cal.externalId === data?.destinationCalendar?.externalId}
isChecked={
cal.isSelected || cal.externalId === data?.destinationCalendar?.externalId
}
/>
))}
</ul>
@@ -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 (
<>
<Button color="secondary" StartIcon={Plus} href="/apps/categories/video">
{t("add_conferencing_app")}
</Button>
</>
);
};
const ConferencingLayout = () => {
const { t } = useLocale();
const utils = trpc.useContext();
@@ -91,7 +103,11 @@ const ConferencingLayout = () => {
return (
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<Meta title={t("conferencing")} description={t("conferencing_description")} />
<Meta
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
/>
<List>
{apps?.items &&
apps.items
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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();
@@ -1038,7 +1038,6 @@
"event_cancelled_trigger": "متى تم إلغاء هذا الحدث",
"new_event_trigger": "متى تم حجز الحدث الجديد",
"email_host_action": "إرسال رسالة إلكترونية إلى المضيف",
"email_attendee_action": "إرسال رسالة إلكترونية إلى واحد من الحضور",
"sms_attendee_action": "إرسال رسالة نصية قصيرة إلى واحد من الحضور",
"sms_number_action": "إرسال رسالة نصية قصيرة إلى رقم محدد",
"workflows": "سير العمل",
@@ -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",
@@ -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",
@@ -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",
@@ -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."
}
@@ -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",
+10 -2
View File
@@ -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é."
}
@@ -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": "תהליכים",
@@ -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",
@@ -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": "ワークフロー",
@@ -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": "워크플로",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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": "Рабочие процессы",
@@ -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",
@@ -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",
@@ -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ı",
@@ -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": "Робочі процеси",
@@ -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",
@@ -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": "该活动类型中至少有一个工作流程处于活动状态,当该活动被预约时,将向主持人发送电子邮件。"
}
@@ -1038,7 +1038,6 @@
"event_cancelled_trigger": "活動取消時",
"new_event_trigger": "預約新活動時",
"email_host_action": "傳送電子郵件給主辦者",
"email_attendee_action": "傳送電子郵件給與會者",
"sms_attendee_action": "傳送簡訊給與會者",
"sms_number_action": "傳送簡訊至特定號碼",
"workflows": "工作流程",
+6 -7
View File
@@ -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"
+2 -2
View File
@@ -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;
}
@@ -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: {} },
+1 -1
View File
@@ -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;
@@ -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: {
+29 -30
View File
@@ -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;
+2 -1
View File
@@ -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";
@@ -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");
+1 -665
View File
@@ -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<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = fieldResponse.value;
}
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<App_RoutingForms_Form, "id" | "name">,
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<typeof duplicateFrom>;
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<string, boolean> = {};
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<App_RoutingForms_Form>,
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<App_RoutingForms_Form>,
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<string, any> = 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";
@@ -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<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = fieldResponse.value;
}
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<App_RoutingForms_Form, "id" | "name">,
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<typeof duplicateFrom>;
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<string, boolean> = {};
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<App_RoutingForms_Form>,
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<App_RoutingForms_Form>,
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<string, any> = 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;
@@ -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<TrpcSessionUser>;
};
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,
},
});
};
@@ -0,0 +1,7 @@
import z from "zod";
export const ZDeleteFormInputSchema = z.object({
id: z.string(),
});
export type TDeleteFormInputSchema = z.infer<typeof ZDeleteFormInputSchema>;
@@ -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<TrpcSessionUser>;
};
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<typeof duplicateFrom>;
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<string, boolean> = {};
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<App_RoutingForms_Form>,
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<App_RoutingForms_Form>,
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.",
});
}
}
}
};
@@ -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<typeof ZFormMutationInputSchema>;
@@ -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<TrpcSessionUser>;
};
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);
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZFormQueryInputSchema = z.object({
id: z.string(),
});
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;
@@ -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<TrpcSessionUser>;
};
}
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;
};
@@ -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<string, any> = 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,
};
};
@@ -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<typeof ZReportInputSchema>;
@@ -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;
}
};
@@ -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<typeof ZResponseInputSchema>;
@@ -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<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
response: Response
) {
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
for (const [fieldId, fieldResponse] of Object.entries(response)) {
// Use the label lowercased as the key to identify a field.
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByName);
fieldResponsesByName[key] = fieldResponse.value;
}
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<App_RoutingForms_Form, "id" | "name">,
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);
}
};
@@ -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";
+1 -1
View File
@@ -23,7 +23,7 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
for (const cred of withCredentials) {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
const app = await appStore[appName as keyof typeof appStore];
const app = await appStore[appName as keyof typeof appStore]();
if (app && "lib" in app && "VideoApiAdapter" in app.lib) {
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
+8
View File
@@ -0,0 +1,8 @@
# Starts mailhog SMTP server on port 1025, web interface on port 8025
version: "3.6"
services:
mailhog:
image: 'mailhog/mailhog:latest'
ports:
- "1025:1025"
- "8025:8025"
+30 -21
View File
@@ -47,33 +47,42 @@ const sendEmail = (prepare: () => BaseEmail) => {
});
};
export const sendScheduledEmails = async (calEvent: CalendarEvent, eventNameObject?: EventNameObjectType) => {
export const sendScheduledEmails = async (
calEvent: CalendarEvent,
eventNameObject?: EventNameObjectType,
hostEmailDisabled?: boolean,
attendeeEmailDisabled?: boolean
) => {
const emailsToSend: Promise<unknown>[] = [];
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);
};
+3
View File
@@ -3,6 +3,9 @@
"sideEffects": false,
"version": "0.0.0",
"private": true,
"scripts": {
"dx": "docker compose up -d"
},
"dependencies": {
"@calcom/dayjs": "*",
"@calcom/lib": "*",
+6 -4
View File
@@ -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"));
}
+11 -12
View File
@@ -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
)}
<div className="flex h-full w-full flex-col items-center">
<div
style={resizeAnimationConfig[layout]?.[bookerState] || resizeAnimationConfig[layout].default}
ref={animationScope}
className={classNames(
// Size settings are abstracted on their own lines purely for readbility.
// General sizes:
"[--booker-main-width:480px] [--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px]",
"bg-muted grid max-w-full auto-rows-max items-start overflow-clip dark:[color-scheme:dark] md:flex-row",
layout === "small_calendar" &&
"w-[calc(var(--booker-meta-width)+var(--booker-main-width))] [--booker-meta-width:240px] lg:[--booker-meta-width:280px]",
// Sizes for fullscreen layouts:
layout === "small_calendar" && "[--booker-meta-width:240px] lg:[--booker-meta-width:280px]",
layout !== "small_calendar" && "[--booker-meta-width:340px] lg:[--booker-meta-width:424px]",
// Other styles
layout === "small_calendar" && "border-subtle mt-20 min-h-[450px] rounded-md border",
layout !== "small_calendar" && "h-auto min-h-screen w-screen",
"transition-[width,max-width] duration-300"
"bg-muted grid max-w-full auto-rows-fr items-start overflow-clip dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row",
layout === "small_calendar" && "border-subtle rounded-md border"
)}>
<AnimatePresence>
<StickyOnDesktop key="meta" className="relative z-10">
<BookerSection area="meta" className="max-w-screen w-full md:w-[var(--booker-meta-width)]">
<StickyOnDesktop key="meta" className="relative z-10 flex min-h-full">
<BookerSection
area="meta"
className="max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
<EventMeta />
{layout !== "small_calendar" && !(layout === "mobile" && bookerState === "booking") && (
<div className=" mt-auto p-5">
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more