Moved booking logic to calcom/features (#4960)

* Moved booking logic to calcom/features

Needed to reuse on public API

* Type fixes

* Cleanup

* Cleanup

* Cleanup

* Restored still needed V1 pages

This reverts commit 790a304a6aa3b9e16525ea3b015bef773f36ded1.

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Omar LópezandAlex van Andel authored and GitHub committed 2022-10-12 13:04:51 +00:00
1 parent c40ff2e6bb
commit e5af99f572
42 files changed
+939 -6392

No files matched your search

@@ -1,145 +0,0 @@
import type { FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Alert } from "@calcom/ui/Alert";
import Select from "@components/ui/form/Select";
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent | null;
formMethods: UseFormReturn<FormValues>;
paymentEnabled: boolean;
onRecurringEventDefined: (value: boolean) => void;
};
/**
* @deprecated
* use component from `/apps/web/components/v2/eventtype/RecurringEventController` instead
**/
export default function RecurringEventController({
recurringEvent,
formMethods,
paymentEnabled,
onRecurringEventDefined,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
/* Just yearly-0, monthly-1 and weekly-2 */
const recurringEventFreqOptions = Object.entries(Frequency)
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
.map(([key, value]) => ({
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
value: value.toString(),
}));
return (
<div className="block items-start sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<span className="flex text-sm font-medium text-neutral-700">{t("recurring_event")}</span>
</div>
<div className={!paymentEnabled ? "w-full" : ""}>
{paymentEnabled ? (
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
) : (
<>
<div className="flex items-start">
<div className="flex h-5 items-start">
<input
onChange={(event) => {
onRecurringEventDefined(event?.target.checked);
if (!event?.target.checked) {
formMethods.setValue("recurringEvent", null);
setRecurringEventState(null);
} else {
const newVal = recurringEvent || {
interval: 1,
count: 12,
freq: Frequency.WEEKLY,
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}
}}
type="checkbox"
className="text-primary-600 h-4 w-4 rounded border-gray-300"
defaultChecked={recurringEventState !== null}
data-testid="recurring-event-check"
id="recurringEvent"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label htmlFor="recurringEvent" className="text-neutral-900">
{t("recurring_event_description")}
</label>
</div>
</div>
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<Select
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 block min-w-0 rounded-sm text-sm"
onChange={(event) => {
const newVal = {
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("max")}</p>
<input
type="number"
min="1"
max="20"
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.count}
onChange={(event) => {
const newVal = {
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
setRecurringEventState(newVal);
}}
/>
<p className="mr-2 text-neutral-900">
{t("occurrence", {
count: recurringEventState.count,
})}
</p>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import type { FormValues } from "pages/event-types/[type]";
import type { FormValues } from "pages/v2/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
@@ -1,245 +0,0 @@
import { Webhook } from "@prisma/client";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/utils/integrationTemplate";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
import { WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP } from "@lib/webhooks/constants";
import { TWebhook } from "@components/webhook/WebhookListItem";
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookDialogForm(props: {
eventTypeId?: number;
defaultValues?: TWebhook;
app?: string;
handleClose: () => void;
webhooks: Webhook[];
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const appId = props.app;
const webhooks = props.webhooks;
const triggers = !appId
? WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["core"]
: WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP[appId as keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP];
const {
defaultValues = {
id: "",
eventTriggers: triggers,
subscriberUrl: "",
active: true,
payloadTemplate: null,
secret: null,
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const [changeSecret, setChangeSecret] = useState(false);
const [newSecret, setNewSecret] = useState("");
const hasSecretKey = !!defaultValues.secret;
const currentSecret = defaultValues.secret;
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
return !!webhooks.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
};
const form = useForm({
defaultValues,
});
const handleInput = (event: React.FormEvent<HTMLInputElement>) => {
setNewSecret(event.currentTarget.value);
};
useEffect(() => {
if (changeSecret) {
form.unregister("secret", { keepDefaultValue: false });
}
}, [changeSecret]);
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
if (subscriberUrlReserved(event.subscriberUrl, event.id)) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
const e = changeSecret
? { ...event, eventTypeId: props.eventTypeId, appId }
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId, appId };
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
if (event.id) {
await utils.client.mutation("viewer.webhook.edit", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
} else {
await utils.client.mutation("viewer.webhook.create", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_created_successfully"), "success");
}
props.handleClose();
}}
className="space-y-4">
<div>
<input type="hidden" {...form.register("id")} />
</div>
<fieldset className="space-y-2">
<InputGroupBox className="border-0 bg-gray-50">
<Controller
control={form.control}
name="active"
render={({ field }) => (
<Switch
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("active", isChecked);
}}
/>
)}
/>
</InputGroupBox>
</fieldset>
<div>
<TextField
label={t("subscriber_url")}
{...form.register("subscriberUrl")}
required
type="url"
onChange={(e) => {
form.setValue("subscriberUrl", e.target.value);
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomPayloadTemplate(true);
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
}
}}
/>
</div>
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{triggers.map((key) => (
<Controller
key={key}
control={form.control}
name="eventTriggers"
render={({ field }) => (
<Switch
label={t(key.toLowerCase())}
defaultChecked={field.value.includes(key)}
onCheckedChange={(isChecked) => {
const value = field.value;
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
form.setValue("eventTriggers", newValue, {
shouldDirty: true,
});
}}
/>
)}
/>
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
{!!hasSecretKey && !changeSecret && (
<>
<FieldsetLegend>{t("secret")}</FieldsetLegend>
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
{t("forgotten_secret_description")}
</div>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(true);
}}>
{t("change_secret")}
</Button>
</>
)}
{!!hasSecretKey && changeSecret && (
<>
<TextField
autoComplete="off"
label={t("secret")}
{...form.register("secret")}
value={newSecret}
onChange={handleInput}
type="text"
placeholder={t("leave_blank_to_remove_secret")}
/>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(false);
}}>
{t("cancel")}
</Button>
</>
)}
{!hasSecretKey && (
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
)}
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<TextArea
{...form.register("payloadTemplate")}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
);
}
@@ -1,103 +0,0 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { List } from "@calcom/ui/List";
import { ShellSubHeading } from "@calcom/ui/Shell";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { QueryCell } from "@lib/QueryCell";
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
export type WebhookListContainerType = {
title: string;
subtitle: string;
eventTypeId?: number;
appId?: string;
};
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookListContainer(props: WebhookListContainerType) {
const router = useRouter();
const query = trpc.useQuery(
["viewer.webhook.list", { eventTypeId: props.eventTypeId, appId: props.appId }],
{
suspense: true,
enabled: router.isReady,
}
);
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => (
<div className="border-b border-gray-200 py-8 pl-2 pr-1">
<ShellSubHeading
className="mt-2"
title={props.title}
subtitle={props.subtitle}
actions={
<Button
color="minimal"
size="icon"
StartIcon={Icon.FiPlus}
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook"
/>
}
/>
{data.length ? (
<List className="mt-6">
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm
app={props.appId}
webhooks={data}
eventTypeId={props.eventTypeId}
handleClose={() => setNewWebhookModal(false)}
/>
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
app={props.appId}
key={editing.id}
webhooks={data}
eventTypeId={props.eventTypeId || undefined}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</div>
)}
/>
);
}
@@ -1,94 +0,0 @@
import classNames from "@calcom/lib/classNames";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { ListItem } from "@calcom/ui/List";
import { Tooltip } from "@calcom/ui/Tooltip";
import { useLocale } from "@lib/hooks/useLocale";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
});
return (
<ListItem className={classNames("flex w-full p-4", props.webhook.active ? "bg-white" : "bg-gray-100")}>
<div className="flex w-full justify-between">
<div className="flex max-w-full flex-col truncate">
<div className="flex space-y-1">
<span
className={classNames(
"truncate text-sm",
props.webhook.active ? "text-neutral-700" : "text-neutral-400"
)}>
{props.webhook.subscriberUrl}
</span>
</div>
<div className="mt-2 flex">
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
className={classNames(
"w-max rounded-sm px-1 text-xs",
props.webhook.active ? "text-grey-200 bg-gray-200" : "bg-grey-50 text-neutral-400"
)}>
{t(`${eventTrigger.toLowerCase()}`)}
</span>
))}
</span>
</div>
</div>
<div className="flex">
<Tooltip side="top" content={t("edit_webhook")}>
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={Icon.FiEdit2}
className="ml-4 w-full self-center p-2"
/>
</Tooltip>
<Dialog>
<Tooltip side="top" content={t("delete_webhook")}>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
size="icon"
color="warn"
StartIcon={Icon.FiTrash}
className="ml-2 w-full self-center p-2"
/>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
cancelBtnText={t("cancel")}
onConfirm={() =>
deleteWebhook.mutate({
id: props.webhook.id,
eventTypeId: props.webhook.eventTypeId || undefined,
})
}>
{t("delete_webhook_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</ListItem>
);
}
@@ -1,63 +0,0 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import { InputGroupBox } from "@calcom/ui/form/fields";
import { useLocale } from "@lib/hooks/useLocale";
/** @deprecated Moved to `packages/features/webhooks` */
export default function WebhookTestDisclosure() {
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
onError(err) {
showToast(err.message, "error");
},
});
return (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className="flex w-full cursor-pointer">
<Icon.FiChevronRight className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="space-y-0 border-0 px-0">
<div className="flex justify-between bg-gray-50 p-2">
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
<Button
type="button"
color="minimal"
disabled={mutation.isLoading}
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
{t("ping_test")}
</Button>
</div>
<div className="border-8 border-gray-50 p-2 text-gray-500">
{!mutation.data && <em>{t("no_data_yet")}</em>}
{mutation.status === "success" && (
<>
<div
className={classNames(
"ml-auto w-max px-2 py-1 text-xs",
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
)}>
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
)}
</div>
</InputGroupBox>
</CollapsibleContent>
</Collapsible>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { WebhookTriggerEvents } from "@prisma/client";
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
core: [
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.MEETING_ENDED,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
};
export const WEBHOOK_TRIGGER_EVENTS = [
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
] as const;
-45
View File
@@ -1,45 +0,0 @@
import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@calcom/prisma";
export type GetSubscriberOptions = {
userId: number;
eventTypeId: number;
triggerEvent: WebhookTriggerEvents;
};
/** @deprecated use `packages/lib/webhooks/subscriptions.tsx` */
const getWebhooks = async (options: GetSubscriberOptions) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
OR: [
{
userId,
},
{
eventTypeId,
},
],
AND: {
eventTriggers: {
has: options.triggerEvent,
},
active: {
equals: true,
},
},
},
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
},
});
return allWebhooks;
};
export default getWebhooks;
+7 -915
View File
@@ -1,922 +1,14 @@
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import type { NextApiRequest } from "next";
import { RRule } from "rrule";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import dayjs from "@calcom/dayjs";
import {
sendAttendeeRequestEmail,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
sendScheduledSeatsEmails,
} from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks";
import { isPrismaObjOrUndefined, parseBookingLimit, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { defaultResponder, getLuckyUser, checkBookingLimits } from "@calcom/lib/server";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma, { userSelect } from "@calcom/prisma";
import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { getSession } from "@calcom/lib/auth";
import { defaultResponder } from "@calcom/lib/server";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import sendPayload, { EventTypeInfo } from "@lib/webhooks/sendPayload";
import { getTranslation } from "@server/lib/i18n";
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
/**
* Refreshes a Credential with fresh data from the database.
*
* @param credential
*/
async function refreshCredential(credential: Credential): Promise<Credential> {
const newCredential = await prisma.credential.findUnique({
where: {
id: credential.id,
},
});
if (!newCredential) {
return credential;
} else {
return newCredential;
}
}
/**
* Refreshes the given set of credentials.
*
* @param credentials
*/
async function refreshCredentials(credentials: Array<Credential>): Promise<Array<Credential>> {
return await async.mapLimit(credentials, 5, refreshCredential);
}
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
// Check for conflicts
let t = true;
// Early return
if (!Array.isArray(busyTimes) || busyTimes.length < 1) return t;
let i = 0;
while (t === true && i < busyTimes.length) {
const busyTime = busyTimes[i];
i++;
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
break;
}
// Check if slot end time is between start and end time
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
t = false;
break;
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
t = false;
break;
}
}
return t;
}
const getEventTypesFromDB = async (eventTypeId: number) => {
const eventType = await prisma.eventType.findUniqueOrThrow({
where: {
id: eventTypeId,
},
select: {
id: true,
customInputs: true,
users: userSelect,
team: {
select: {
id: true,
name: true,
},
},
title: true,
length: true,
eventName: true,
schedulingType: true,
description: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
price: true,
currency: true,
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
seatsPerTimeSlot: true,
bookingLimits: true,
recurringEvent: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
locations: true,
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
return {
...eventType,
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: (eventType.locations ?? []) as LocationObject[],
};
};
async function ensureAvailableUsers(
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
users: User[];
},
input: { dateFrom: string; dateTo: string }
) {
const availableUsers: typeof eventType.users = [];
/** Let's start checking for availability */
for (const user of eventType.users) {
const { busy: bufferedBusyTimes } = await getUserAvailability(
{
userId: user.id,
eventTypeId: eventType.id,
...input,
},
{ user, eventType }
);
console.log("calendarBusyTimes==>>>", bufferedBusyTimes);
let isAvailableToBeBooked = true;
try {
if (eventType.recurringEvent) {
const recurringEvent = parseRecurringEvent(eventType.recurringEvent);
const allBookingDates = new RRule({ dtstart: new Date(input.dateFrom), ...recurringEvent }).all();
// Go through each date for the recurring event and check if each one's availability
// DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop
// running at the first unavailable time.
let i = 0;
while (isAvailableToBeBooked === true && i < allBookingDates.length) {
const aDate = allBookingDates[i];
i++;
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length);
// We bail at the first false, we don't need to keep checking
if (!isAvailableToBeBooked) break;
}
} else {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, input.dateFrom, eventType.length);
}
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (isAvailableToBeBooked) {
availableUsers.push(user);
}
}
if (!availableUsers.length) {
throw new Error("No available users found.");
}
return availableUsers;
}
async function handler(req: NextApiRequest) {
async function handler(req: NextApiRequest & { userId?: number }) {
const session = await getSession({ req });
const currentUser = session?.user;
const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } =
extendedBookingCreateBody.parse(req.body);
// handle dynamic user
const dynamicUserList = Array.isArray(reqBody.user)
? getGroupName(reqBody.user)
: getUsernameList(reqBody.user);
const tAttendees = await getTranslation(language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const eventType =
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
// Check if required custom inputs exist
if (eventType.customInputs) {
eventType.customInputs.forEach((customInput) => {
if (customInput.required) {
requiredCustomInputSchema.parse(
reqBody.customInputs.find((userInput) => userInput.label === customInput.label)?.value
);
}
});
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(reqBody.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
});
} catch (error) {
if (error instanceof BookingDateInPastError) {
// TODO: HttpError should not bleed through to the console.
log.info(`Booking eventType ${eventTypeId} failed`, error);
throw new HttpError({ statusCode: 400, message: error.message });
}
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingTimeOutOfBounds",
message: `EventType '${eventType.eventName}' cannot be booked at this time.`,
};
throw new HttpError({ statusCode: 400, message: error.message });
}
let users = !eventTypeId
? await prisma.user.findMany({
where: {
username: {
in: dynamicUserList,
},
},
...userSelect,
})
: eventType.users;
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {
throw new HttpError({
message: "Some of the users in this group do not allow dynamic booking",
statusCode: 400,
});
}
// If this event was pre-relationship migration
// TODO: Establish whether this is dead code.
if (!users.length && eventType.userId) {
const eventTypeUser = await prisma.user.findUnique({
where: {
id: eventType.userId,
},
...userSelect,
});
if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
users.push(eventTypeUser);
}
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
if (eventType && eventType.hasOwnProperty("bookingLimits") && eventType?.bookingLimits) {
const startAsDate = dayjs(reqBody.start).toDate();
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id);
}
if (!eventType.seatsPerTimeSlot) {
const availableUsers = await ensureAvailableUsers(
{
...eventType,
users,
},
{
dateFrom: reqBody.start,
dateTo: reqBody.end,
}
);
// Add an if conditional if there are no seats on the event type
// Assign to only one user when ROUND_ROBIN
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
} else {
// excluding ROUND_ROBIN, all users have availability required.
if (availableUsers.length !== users.length) {
throw new Error("Some users are unavailable for booking.");
}
users = availableUsers;
}
}
console.log("available users", users);
// @TODO: use the returned address somewhere in booking creation?
// const address: string | undefined = await ...
await handleEthSignature(eventType.metadata, reqBody.ethSignature);
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common");
const invitee = [
{
email: reqBody.email,
name: reqBody.name,
timeZone: reqBody.timeZone,
language: { translate: tAttendees, locale: language ?? "en" },
},
];
const guests = (reqBody.guests || []).map((guest) => ({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
}));
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const bookingLocation = getLocationValueForDB(reqBody.location, eventType.locations);
console.log(bookingLocation, reqBody.location, eventType.locations);
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
const teamMemberPromises =
eventType.schedulingType === SchedulingType.COLLECTIVE
? users.slice(1).map(async function (user) {
return {
email: user.email || "",
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
})
: [];
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests, ...teamMembers];
const eventNameObject = {
attendeeName: reqBody.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
host: organizerUser.name || "Nameless",
location: bookingLocation,
t: tOrganizer,
};
const additionalNotes = reqBody.notes;
let evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
additionalNotes,
customInputs,
startTime: dayjs(reqBody.start).utc().format(),
endTime: dayjs(reqBody.end).utc().format(),
organizer: {
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
},
attendees: attendeesList,
location: bookingLocation, // Will be processed by the EventManager later.
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
eventTypeId: eventType.id,
};
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (reqBody.bookingUid) {
if (!eventType.seatsPerTimeSlot)
throw new HttpError({ statusCode: 404, message: "Event type does not have seats" });
const booking = await prisma.booking.findUnique({
where: {
uid: reqBody.bookingUid,
},
select: {
id: true,
attendees: true,
userId: true,
references: {
select: {
type: true,
uid: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
externalCalendarId: true,
},
},
},
});
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
if (booking.attendees.some((attendee) => attendee.email === invitee[0].email))
throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" });
await prisma.booking.update({
where: {
uid: reqBody.bookingUid,
},
data: {
attendees: {
create: {
email: invitee[0].email,
name: invitee[0].name,
timeZone: invitee[0].timeZone,
locale: invitee[0].language.locale,
},
},
},
});
const newSeat = booking.attendees.length !== 0;
await sendScheduledSeatsEmails(evt, invitee[0], newSeat);
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
await eventManager.updateCalendarAttendees(evt, booking);
req.statusCode = 201;
return booking;
}
if (reqBody.customInputs.length > 0) {
reqBody.customInputs.forEach(({ label, value }) => {
customInputs[label] = value;
});
}
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
evt.team = {
members: users.map((user) => user.name || user.username || "Nameless"),
name: eventType.team?.name || "Nameless",
}; // used for invitee emails
}
if (reqBody.recurringEventId && eventType.recurringEvent) {
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
evt.recurringEvent = eventType.recurringEvent;
}
// Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) {
return prisma.booking.findFirst({
where: {
uid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
},
},
include: {
attendees: {
select: {
name: true,
email: true,
locale: true,
timeZone: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
locale: true,
timeZone: true,
},
},
payment: true,
},
});
}
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
let originalRescheduledBooking: BookingType = null;
if (rescheduleUid) {
originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid);
}
async function createBooking() {
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.additionalNotes;
evt.location = originalRescheduledBooking?.location;
}
const eventTypeRel = !eventTypeId
? {}
: {
connect: {
id: eventTypeId,
},
};
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
// If the user is not the owner of the event, new booking should be always pending.
// Otherwise, an owner rescheduling should be always accepted.
const userReschedulingIsOwner = currentUser
? originalRescheduledBooking?.user?.id === currentUser.id
: false;
const isConfirmedByDefault =
(!eventType.requiresConfirmation && !eventType.price) || userReschedulingIsOwner;
const newBookingData: Prisma.BookingCreateInput = {
uid,
title: evt.title,
startTime: dayjs.utc(evt.startTime).toDate(),
endTime: dayjs.utc(evt.endTime).toDate(),
description: evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
eventType: eventTypeRel,
smsReminderNumber: reqBody.smsReminderNumber,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
//if attendee is team member, it should fetch their locale not booker's locale
//perhaps make email fetch request to see if his locale is stored, else
const retObj = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
};
return retObj;
}),
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: organizerUser.id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: undefined,
};
if (reqBody.recurringEventId) {
newBookingData.recurringEventId = reqBody.recurringEventId;
}
if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
if (newBookingData.attendees?.createMany?.data) {
newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees;
}
if (originalRescheduledBooking.recurringEventId) {
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
}
}
const createBookingObj = {
include: {
user: {
select: { email: true, name: true, timeZone: true },
},
attendees: true,
payment: true,
},
data: newBookingData,
};
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
if (bookingPayment) {
createBookingObj.data.payment = {
connect: { id: bookingPayment.id },
};
}
}
if (typeof eventType.price === "number" && eventType.price > 0) {
/* Validate if there is any stripe_payment credential for this user */
/* note: removes custom error message about stripe */
await prisma.credential.findFirstOrThrow({
where: {
type: "stripe_payment",
userId: organizerUser.id,
},
select: {
id: true,
},
});
}
return prisma.booking.create(createBookingObj);
}
let results: EventResult<AdditionalInformation>[] = [];
let referencesToCreate: PartialReference[] = [];
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
// Sync Services
await syncServicesUpdateWebUser(
currentUser &&
(await prisma.user.findFirst({
where: { id: currentUser.id },
select: { id: true, email: true, name: true, plan: true, username: true, createdDate: true },
}))
);
evt.uid = booking?.uid ?? null;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
throw new HttpError({ statusCode: 409, message: "booking.conflict" });
}
throw err;
}
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
booking?.id,
reqBody.rescheduleReason
);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
log.error(`Booking ${organizerUser.name} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
}
}
if (noEmail !== true) {
await sendRescheduledEmails({
...evt,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: reqBody.rescheduleReason,
});
}
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (!eventType.requiresConfirmation && !eventType.price) {
// Use EventManager to conditionally use all needed integrations.
const createManager = await eventManager.create(evt);
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = createManager.results;
referencesToCreate = createManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${organizerUser.username} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
if (noEmail !== true) {
await sendScheduledEmails({
...evt,
additionalInformation: metadata,
additionalNotes,
customInputs,
});
}
}
}
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
}
if (
!Number.isNaN(eventType.price) &&
eventType.price > 0 &&
!originalRescheduledBooking?.paid &&
!!booking
) {
const [firstStripeCredential] = organizerUser.credentials.filter((cred) => cred.type == "stripe_payment");
if (!firstStripeCredential)
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
if (!booking.user) booking.user = organizerUser;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment.uid };
}
log.debug(`Booking ${organizerUser.username} completed`);
const eventTrigger: WebhookTriggerEvents = rescheduleUid
? WebhookTriggerEvents.BOOKING_RESCHEDULED
: WebhookTriggerEvents.BOOKING_CREATED;
const subscriberOptions = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: eventTrigger,
};
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
};
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
subscribersMeetingEnded.forEach((subscriber) => {
if (rescheduleUid && originalRescheduledBooking) {
cancelScheduledJobs(originalRescheduledBooking);
}
if (booking && booking.status === BookingStatus.ACCEPTED) {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
}
});
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getWebhooks(subscriberOptions);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,
});
const bookingId = booking?.id;
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: eventType.requiresConfirmation || null,
price: eventType.price,
currency: eventType.currency,
length: eventType.length,
};
const promises = subscribers.map((sub) =>
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
rescheduleUid,
metadata: reqBody.metadata,
eventTypeId,
status: eventType.requiresConfirmation ? "PENDING" : "ACCEPTED",
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
);
await Promise.all(promises);
// Avoid passing referencesToCreate with id unique constrain values
// refresh hashed link if used
const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`;
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
if (hasHashedBookingLink) {
await prisma.hashedLink.update({
where: {
link: reqBody.hashedLink as string,
},
data: {
link: hashedUid,
},
});
}
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
await scheduleWorkflowReminders(
eventType.workflows,
reqBody.smsReminderNumber as string | null,
evt,
evt.requiresConfirmation || false,
rescheduleUid ? true : false
);
// booking successful
req.statusCode = 201;
/* To mimic API behavior */
req.userId = session?.user?.id;
const booking = await handleNewBooking(req);
return booking;
}
@@ -18,14 +18,13 @@ import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/direc
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRequestRescheduleEmail } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import sendPayload from "@lib/webhooks/sendPayload";
import getWebhooks from "@lib/webhooks/subscriptions";
export type RescheduleResponse = Booking & {
attendees: Attendee[];
};
+3 -4
View File
@@ -20,16 +20,15 @@ import { sendCancelledEmails } from "@calcom/emails";
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getSession } from "@calcom/lib/auth";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getSession } from "@lib/auth";
import sendPayload, { EventTypeInfo } from "@lib/webhooks/sendPayload";
import getWebhooks from "@lib/webhooks/subscriptions";
import { getTranslation } from "@server/lib/i18n";
const bodySchema = z.object({
@@ -1,82 +0,0 @@
import { AppCategories } from "@prisma/client";
import { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import AppCard from "@components/apps/AppCard";
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
const router = useRouter();
return (
<>
<Shell isPublic large>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 bg-gray-50 px-4 pb-2">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<Icon.FiChevronLeft className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
</div>
</div>
<div className="mb-16">
<h2 className="mb-2 text-lg font-semibold text-gray-900">All {router.query.category} apps</h2>
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps.map((app) => {
return <AppCard key={app.name} app={app} />;
})}
</div>
</div>
</Shell>
</>
);
}
export const getStaticPaths = async () => {
const appStore = await getAppRegistry();
const paths = appStore.reduce((categories, app) => {
if (!categories.includes(app.category)) {
categories.push(app.category);
}
return categories;
}, [] as string[]);
return {
paths: paths.map((category) => ({ params: { category } })),
fallback: false,
};
};
export const getStaticProps = async (context: GetStaticPropsContext) => {
const category = context.params?.category as AppCategories;
const appQuery = await prisma.app.findMany({
where: {
categories: {
has: category,
},
},
select: {
slug: true,
},
});
const dbAppsSlugs = appQuery.map((category) => category.slug);
const appStore = await getAppRegistry();
const apps = appStore.filter((app) => dbAppsSlugs.includes(app.slug));
return {
props: {
apps,
},
};
};
-44
View File
@@ -1,44 +0,0 @@
import { InferGetStaticPropsType } from "next";
import Link from "next/link";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import AppStoreCategories from "@components/apps/Categories";
export default function Apps({ categories }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
return (
<Shell isPublic large>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 bg-gray-50 px-4 pb-2">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<Icon.FiChevronLeft className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
</div>
</div>
<div className="mb-16">
<AppStoreCategories categories={categories} />
</div>
</Shell>
);
}
export const getStaticProps = async () => {
const appStore = await getAppRegistry();
const categories = appStore.reduce((c, app) => {
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
return c;
}, {} as Record<string, number>);
return {
props: {
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
},
};
};
-47
View File
@@ -1,47 +0,0 @@
import { InferGetStaticPropsType } from "next";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import Shell from "@calcom/ui/Shell";
import AppsShell from "@components/AppsShell";
import AllApps from "@components/apps/AllApps";
import AppStoreCategories from "@components/apps/Categories";
import TrendingAppsSlider from "@components/apps/TrendingAppsSlider";
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getStaticProps>) {
const { t } = useLocale();
return (
<Shell heading={t("app_store")} subtitle={t("app_store_description")} large isPublic>
<AppsShell>
<AppStoreCategories categories={categories} />
<TrendingAppsSlider items={appStore} />
<AllApps apps={appStore} />
</AppsShell>
</Shell>
);
}
export const getStaticProps = async () => {
const appStore = await getAppRegistry();
const categoryQuery = await prisma.app.findMany({
select: {
categories: true,
},
});
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
}
return c;
}, {} as Record<string, number>);
return {
props: {
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
appStore,
},
};
};
-200
View File
@@ -1,200 +0,0 @@
import React from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import { List } from "@calcom/ui/List";
import Shell, { ShellSubHeading } from "@calcom/ui/Shell";
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
import { QueryCell } from "@lib/QueryCell";
import AppsShell from "@components/AppsShell";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
function ConnectOrDisconnectIntegrationButton(props: {
credentialIds: number[];
type: App["type"];
isGlobal?: boolean;
installed?: boolean;
}) {
const { t } = useLocale();
const [credentialId] = props.credentialIds;
const type = props.type;
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
};
if (credentialId) {
if (type === "stripe_payment") {
return (
<DisconnectIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
);
}
return (
<DisconnectIntegration
id={credentialId}
render={(btnProps) => (
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
{t("disconnect")}
</Button>
)}
onOpenChange={handleOpenChange}
/>
);
}
if (!props.installed) {
return (
<div className="flex items-center truncate">
<Alert severity="warning" title={t("not_installed")} />
</div>
);
}
/** We don't need to "Connect", just show that it's installed */
if (props.isGlobal) {
return (
<div className="truncate px-3 py-2">
<h3 className="text-sm font-medium text-gray-700">{t("installed")}</h3>
</div>
);
}
return (
<InstallAppButton
type={props.type}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("connect")}
</Button>
)}
onChanged={handleOpenChange}
/>
);
}
interface IntegrationsContainerProps {
variant: App["variant"];
className?: string;
}
const IntegrationsContainer = ({ variant, className = "" }: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { variant, onlyInstalled: true }]);
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader className={className} />}
success={({ data }) => {
return (
<>
{data.items.length > 0 && (
<div className={className}>
<ShellSubHeading
title={
<SubHeadingTitleWithConnections title={t(variant)} numConnections={data.items.length} />
}
/>
<List>
{data.items.map((item) => (
<IntegrationListItem
name={item.name}
slug={item.slug}
key={item.title}
title={item.title}
logo={item.logo}
description={item.description}
actions={
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
/>
}
/>
))}
</List>
</div>
)}
</>
);
}}
/>
);
};
// Server side rendering
export async function getServerSideProps(ctx: AppGetServerSidePropsContext) {
// get return-to cookie and redirect if needed
const { cookies } = ctx.req;
if (cookies && cookies["return-to"]) {
const returnTo = cookies["return-to"];
if (returnTo) {
ctx.res.setHeader("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
return {
redirect: {
destination: `${returnTo}`,
permanent: false,
},
};
}
}
return {
props: {},
};
}
export default function IntegrationsPage() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations", { onlyInstalled: true }]);
return (
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
<AppsShell>
<QueryCell
query={query}
success={({ data }) => {
return data.items.length > 0 ? (
<>
<IntegrationsContainer variant="conferencing" />
<CalendarListContainer />
<IntegrationsContainer variant="payment" className="mt-8" />
<IntegrationsContainer variant="other" className="mt-8" />
<IntegrationsContainer variant="web3" className="mt-8" />
</>
) : (
<EmptyScreen
Icon={Icon.FiGrid}
headline={t("empty_installed_apps_headline")}
description={
<>
<span className="mb-6 block">{t("empty_installed_apps_description")}</span>
<Button href="/apps" EndIcon={Icon.FiArrowRight}>
{t("empty_installed_apps_button")}
</Button>
</>
}
/>
);
}}
/>
</AppsShell>
</Shell>
);
}
-193
View File
@@ -1,193 +0,0 @@
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Button from "@calcom/ui/Button";
import { BadgeCheckIcon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import Switch from "@calcom/ui/Switch";
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
import { Form } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import Schedule from "@components/availability/Schedule";
import EditableHeading from "@components/ui/EditableHeading";
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
*/
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const form = useForm({
defaultValues: {
schedule: props.availability || DEFAULT_SCHEDULE,
isDefault: !!props.isDefault,
timeZone: props.timeZone,
},
});
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
onSuccess: async ({ schedule }) => {
await utils.invalidateQueries(["viewer.availability.schedule"]);
await router.push("/availability");
showToast(
t("availability_updated_successfully", {
scheduleName: schedule.name,
}),
"success"
);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return (
<Form
form={form}
handleSubmit={async (values) => {
updateMutation.mutate({
scheduleId: parseInt(router.query.schedule as string, 10),
name: props.schedule.name,
...values,
});
}}
className="grid grid-cols-3 gap-2">
<div className="col-span-3 space-y-2 lg:col-span-2">
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
<Schedule name="schedule" />
</div>
<div className="space-x-2 text-right">
<Button color="secondary" href="/availability" tabIndex={-1}>
{t("cancel")}
</Button>
<Button>{t("save")}</Button>
</div>
</div>
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
{props.isDefault ? (
<div className="inline-block cursor-default rounded border border-gray-300 bg-gray-200 px-2 py-0.5 pl-1.5 text-sm font-medium text-neutral-800">
<span className="flex items-center">
<BadgeCheckIcon className="mr-1 h-4 w-4" /> {t("default")}
</span>
</div>
) : (
<Controller
name="isDefault"
render={({ field: { onChange, value } }) => (
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
)}
/>
)}
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
{t("timezone")}
</label>
<div className="mt-1">
<Controller
name="timeZone"
render={({ field: { onChange, value } }) => (
<TimezoneSelect
value={value}
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
)}
/>
</div>
</div>
<div className="mt-2 rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
<h3 className="text-base font-medium leading-6 text-gray-900">
{t("something_doesnt_look_right")}
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Button href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Button>
</div>
</div>
</div>
</Form>
);
}
const querySchema = z.object({
schedule: stringOrNumber,
});
export default function Availability() {
const router = useRouter();
const { i18n } = useLocale();
const { schedule: scheduleId } = router.isReady ? querySchema.parse(router.query) : { schedule: -1 };
const query = trpc.useQuery(["viewer.availability.schedule", { scheduleId }], { enabled: router.isReady });
const [name, setName] = useState<string>();
return (
<div>
<QueryCell
query={query}
success={({ data }) => {
return (
<Shell
heading={<EditableHeading title={name || data.schedule.name} onChange={setName} />}
subtitle={data.schedule.availability.map((availability) => (
<span key={availability.id}>
{availabilityAsString(availability, { locale: i18n.language })}
<br />
</span>
))}>
<AvailabilityForm
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
/>
</Shell>
);
}}
/>
</div>
);
}
export const getStaticProps: GetStaticProps = (ctx) => {
const params = querySchema.safeParse(ctx.params);
if (!params.success) return { notFound: true };
return {
props: {
schedule: params.data.schedule,
},
revalidate: 10, // seconds
};
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: "blocking",
};
};
-74
View File
@@ -1,74 +0,0 @@
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/index.tsx` instead
*/
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { withQuery } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
import SkeletonLoader from "@components/availability/SkeletonLoader";
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/index.tsx` instead
*/
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.availability.list"]);
showToast(t("schedule_deleted_successfully"), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return (
<>
{schedules.length === 0 ? (
<EmptyScreen
Icon={Icon.FiClock}
headline={t("new_schedule_heading")}
description={t("new_schedule_description")}
/>
) : (
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="schedules">
{schedules.map((schedule) => (
<ScheduleListItem
key={schedule.id}
schedule={schedule}
deleteFunction={deleteMutation.mutate}
/>
))}
</ul>
</div>
)}
</>
);
}
const WithQuery = withQuery(["viewer.availability.list"]);
export default function AvailabilityPage() {
const { t } = useLocale();
return (
<div>
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
</Shell>
</div>
);
}
@@ -1,121 +0,0 @@
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
*/
import type { IBusySlot } from "pages/v2/availability/troubleshoot";
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import Shell from "@calcom/ui/Shell";
import { QueryCell } from "@lib/QueryCell";
import Loader from "@components/Loader";
type User = inferQueryOutput<"viewer.me">;
/**
* @deprecated modifications to this file should be v2 only
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
*/
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const [selectedDate, setSelectedDate] = useState(dayjs());
const { data, isLoading } = trpc.useQuery(
[
"viewer.availability.user",
{
username: user.username!,
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
withSource: true,
},
],
{
enabled: !!user.username,
}
);
return (
<div className="max-w-xl overflow-hidden rounded-sm bg-white shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none p-0"
defaultValue={selectedDate.format("YYYY-MM-DD")}
onChange={(e) => {
if (e.target.value) setSelectedDate(dayjs(e.target.value));
}}
/>
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{isLoading ? (
<Loader />
) : data && data.busy.length > 0 ? (
data.busy
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={`${slot.start}-${slot.title ?? "untitled"}`}
className="overflow-hidden rounded-sm bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div>
))
) : (
<div className="overflow-hidden rounded-sm bg-neutral-100">
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
)}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const query = trpc.useQuery(["viewer.me"]);
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} subtitle={t("troubleshoot_description")}>
<QueryCell query={query} success={({ data }) => <AvailabilityView user={data} />} />
</Shell>
</div>
);
}
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? "0" + h : h;
const ms = m < 10 ? "0" + m : m;
return `${hs}:${ms}`;
}
-154
View File
@@ -1,154 +0,0 @@
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { Fragment } from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import BookingsShell from "@components/BookingsShell";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
const validStatuses = ["upcoming", "recurring", "past", "cancelled"] as const;
const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
past: "past_bookings",
cancelled: "cancelled_bookings",
unconfirmed: "unconfirmed_bookings",
};
const querySchema = z.object({
status: z.enum(validStatuses),
});
export default function Bookings() {
const router = useRouter();
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
const { t } = useLocale();
const query = trpc.useInfiniteQuery(["viewer.bookings", { status, limit: 10 }], {
// first render has status `undefined`
enabled: router.isReady,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
}
});
const isEmpty = !query.data?.pages[0]?.bookings.length;
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status === "cancelled") {
if (!booking.recurringEventId) {
return true;
}
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
shownBookings[booking.recurringEventId].push(booking);
return false;
}
shownBookings[booking.recurringEventId] = [booking];
}
return true;
};
return (
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<BookingsShell>
<div className="-mx-4 flex flex-col sm:mx-auto">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
listingStatus={status}
recurringBookings={page.recurringInfo}
{...booking}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</>
)}
{query.status === "success" && isEmpty && (
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
)}
</div>
</div>
</div>
</BookingsShell>
</Shell>
);
}
export const getStaticProps: GetStaticProps = (ctx) => {
const params = querySchema.safeParse(ctx.params);
if (!params.success) return { notFound: true };
return {
props: {
status: params.data.status,
},
};
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: validStatuses.map((status) => ({
params: { status },
locale: "en",
})),
fallback: "blocking",
};
};
File diff suppressed because it is too large. Load diff
-585
View File
@@ -1,585 +0,0 @@
/**
* @deprecated file is not used anymore
* Use `/apps/web/pages/v2/event-types/index.tsx` instead
*/
import { UserPlan } from "@prisma/client";
import { Trans } from "next-i18next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, useEffect, useState } from "react";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { TRPCClientError } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import Badge from "@calcom/ui/Badge";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog } from "@calcom/ui/Dialog";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/Dropdown";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { Tooltip } from "@calcom/ui/Tooltip";
import { withQuery } from "@lib/QueryCell";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
import Avatar from "@components/ui/Avatar";
import AvatarGroup from "@components/ui/AvatarGroup";
import { LinkText } from "@components/ui/LinkText";
import NoCalendarConnectedAlert from "@components/ui/NoCalendarConnectedAlert";
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
interface EventTypeListHeadingProps {
profile: EventTypeGroupProfile;
membershipCount: number;
}
type EventTypeGroup = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"][number];
type EventType = EventTypeGroup["eventTypes"][number];
interface EventTypeListProps {
group: EventTypeGroup;
groupIndex: number;
readOnly: boolean;
types: EventType[];
}
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
const { t } = useLocale();
return (
<Link href={"/event-types/" + type.id}>
<a
className="flex-grow truncate text-sm"
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
<div>
<span
className="truncate font-medium text-neutral-900 ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
{type.title}
</span>
<small
className="hidden text-neutral-500 sm:inline"
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
{type.hidden && (
<span className="rtl:mr-2inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ltr:ml-2">
{t("hidden") as string}
</span>
)}
{readOnly && (
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
{t("readonly") as string}
</span>
)}
</div>
<EventTypeDescription eventType={type} />
</a>
</Link>
);
};
const MemoizedItem = React.memo(Item);
/**
* @deprecated
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
*/
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
const { t } = useLocale();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const utils = trpc.useContext();
const mutation = trpc.useMutation("viewer.eventTypeOrder", {
onError: async (err) => {
console.error(err.message);
await utils.cancelQuery(["viewer.eventTypes"]);
await utils.invalidateQueries(["viewer.eventTypes"]);
},
onSettled: async () => {
await utils.invalidateQueries(["viewer.eventTypes"]);
},
});
function moveEventType(index: number, increment: 1 | -1) {
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
utils.cancelQuery(["viewer.eventTypes"]);
utils.setQueryData(["viewer.eventTypes"], (data) => {
// tRPC is very strict with the return signature...
if (!data)
return {
eventTypeGroups: [],
profiles: [],
viewer: { canAddEvents: true, plan: UserPlan.PRO },
};
return {
...data,
eventTypesGroups: [
...data.eventTypeGroups.slice(0, groupIndex),
{ ...group, eventTypes: newList },
...data.eventTypeGroups.slice(groupIndex + 1),
],
};
});
mutation.mutate({
ids: newList.map((type) => type.id),
});
}
async function deleteEventTypeHandler(id: number) {
const payload = { id };
deleteMutation.mutate(payload);
}
// inject selection data into url for correct router history
const openModal = (group: EventTypeGroup, type: EventType) => {
const query = {
...router.query,
dialog: "new-eventtype",
eventPage: group.profile.slug,
title: type.title,
slug: type.slug,
description: type.description,
length: type.length,
type: type.schedulingType,
teamId: group.teamId,
};
if (!group.teamId) {
delete query.teamId;
}
router.push(
{
pathname: router.pathname,
query,
},
undefined,
{ shallow: true }
);
};
const deleteMutation = trpc.useMutation("viewer.eventTypes.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.eventTypes"]);
showToast(t("event_type_deleted_successfully"), "success");
setDeleteDialogOpen(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
const [isNativeShare, setNativeShare] = useState(true);
useEffect(() => {
if (!navigator.share) {
setNativeShare(false);
}
}, []);
return (
<div className="mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200" data-testid="event-types">
{types.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${CAL_URL}/${embedLink}`;
return (
<li key={type.id}>
<div className="flex items-center justify-between hover:bg-neutral-50">
<div className="group flex w-full items-center justify-between px-4 py-4 pr-0 hover:bg-neutral-50 sm:px-6">
<button
className="invisible absolute left-[3px] -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 items-center justify-center rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[34px]"
onClick={() => moveEventType(index, -1)}>
<Icon.FiArrowUp className="h-5 w-5" />
</button>
<button
className="invisible absolute left-[3px] mt-8 -ml-4 hidden h-7 w-7 scale-0 items-center justify-center rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[34px]"
onClick={() => moveEventType(index, 1)}>
<Icon.FiArrowDown className="h-5 w-5" />
</button>
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.users?.length > 1 && (
<AvatarGroup
border="border-2 border-white"
className="relative top-1 right-3"
size={8}
truncateAfter={4}
items={type.users.map((organizer) => ({
alt: organizer.name || "",
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
title: organizer.name || "",
}))}
/>
)}
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<Tooltip side="top" content={t("preview") as string}>
<Button
target="_blank"
rel="noreferrer"
type="button"
size="icon"
color="minimal"
className="group-hover:text-black"
StartIcon={Icon.FiExternalLink}
href={calLink}
/>
</Tooltip>
<Tooltip side="top" content={t("copy_link") as string}>
<Button
type="button"
size="icon"
color="minimal"
className="group-hover:text-black"
StartIcon={Icon.FiLink}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
</div>
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button
type="button"
size="icon"
color="minimal"
className="group-hover:text-black"
StartIcon={Icon.FiMoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<Link href={"/event-types/" + type.id} passHref={true}>
<Button
type="button"
size="sm"
color="minimal"
StartIcon={Icon.FiEdit2}
className="w-full">
{t("edit") as string}
</Button>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
color="minimal"
size="sm"
type="button"
as={Button}
StartIcon={Icon.FiCode}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{/* 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) && (
<DropdownMenuItem>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="warn"
size="sm"
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</div>
</div>
<div className="mr-5 flex flex-shrink-0 sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button type="button" size="icon" color="minimal" StartIcon={Icon.FiMoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<Link href={calLink}>
<Button
rel="noreferrer"
target="_blank"
color="minimal"
size="sm"
StartIcon={Icon.FiExternalLink}
className="w-full rounded-none">
{t("preview") as string}
</Button>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
className="w-full rounded-none text-left"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiClipboard}
onClick={() => {
navigator.clipboard.writeText(calLink);
showToast(t("link_copied"), "success");
}}>
{t("copy_link") as string}
</Button>
</DropdownMenuItem>
{isNativeShare ? (
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiUpload}
onClick={() => {
navigator
.share({
title: t("share"),
text: t("share_event"),
url: calLink,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
}}>
{t("share") as string}
</Button>
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="outline-none">
<Button
type="button"
size="sm"
href={"/event-types/" + type.id}
color="minimal"
className="w-full rounded-none"
StartIcon={Icon.FiEdit2}>
{t("edit") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<Button
type="button"
color="minimal"
size="sm"
className="w-full rounded-none"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Icon.FiCopy}
onClick={() => openModal(group, type)}>
{t("duplicate") as string}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem className="outline-none">
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
color="warn"
size="sm"
StartIcon={Icon.FiTrash}
className="w-full rounded-none">
{t("delete") as string}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</div>
</div>
</li>
);
})}
</ul>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_event_type")}
confirmBtnText={t("confirm_delete_event_type")}
loadingText={t("confirm_delete_event_type")}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
{t("delete_event_type_description") as string}
</ConfirmationDialogContent>
</Dialog>
</div>
);
};
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => {
console.log(profile.slug);
return (
<div className="mb-4 flex">
<Link href="/settings/teams">
<a>
<Avatar
alt={profile?.name || ""}
imageSrc={`${WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
size={8}
className="mt-1 inline ltr:mr-2 rtl:ml-2"
/>
</a>
</Link>
<div>
<Link href="/settings/teams">
<a className="font-bold">{profile?.name || ""}</a>
</Link>
{membershipCount && (
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
<Link href="/settings/teams">
<a>
<Badge variant="gray">
<Icon.FiUsers className="mr-1 -mt-px inline h-3 w-3" />
{membershipCount}
</Badge>
</a>
</Link>
</span>
)}
{profile?.slug && (
<Link href={`${CAL_URL}/${profile.slug}`}>
<a className="block text-xs text-neutral-500">{`${CAL_URL?.replace("https://", "")}/${
profile.slug
}`}</a>
</Link>
)}
</div>
</div>
);
};
/**
* @deprecated
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
*/
const CreateFirstEventTypeView = () => {
const { t } = useLocale();
return (
<EmptyScreen
Icon={Icon.FiCalendar}
headline={t("new_event_type_heading")}
description={t("new_event_type_description")}
/>
);
};
const CTA = () => {
const query = trpc.useQuery(["viewer.eventTypes"]);
if (!query.data) return null;
return <CreateEventTypeButton canAddEvents={true} options={query.data.profiles} />;
};
const WithQuery = withQuery(["viewer.eventTypes"]);
/**
* @deprecated
* Use component from `/apps/web/pages/v2/event-types/index.tsx` instead
*/
const EventTypesPage = () => {
const { t } = useLocale();
return (
<div>
<Head>
<title>Home | Cal.com</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell
heading={t("event_types_page_title") as string}
subtitle={t("event_types_page_subtitle") as string}
CTA={<CTA />}>
<WithQuery
customLoader={<SkeletonLoader />}
success={({ data }) => (
<>
<NoCalendarConnectedAlert />
{data.eventTypeGroups.map((group, index) => (
<Fragment key={group.profile.slug}>
{/* hide list heading when there is only one (current user) */}
{(data.eventTypeGroups.length !== 1 || group.teamId) && (
<EventTypeListHeading
profile={group.profile}
membershipCount={group.metadata.membershipCount}
/>
)}
<EventTypeList
types={group.eventTypes}
group={group}
groupIndex={index}
readOnly={group.metadata.readOnly}
/>
</Fragment>
))}
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
<EmbedDialog />
</>
)}
/>
</Shell>
</div>
);
};
export default EventTypesPage;
-70
View File
@@ -1,70 +0,0 @@
import { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useRef } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { TextField } from "@calcom/ui/form/fields";
import { getSession } from "@lib/auth";
import SettingsShell from "@components/SettingsShell";
function AdminView() {
const { t } = useLocale();
const usernameRef = useRef<HTMLInputElement>(null);
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 lg:pb-8">
<form
className="mb-6 w-full sm:w-1/2"
onSubmit={(e) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
signIn("impersonation-auth", { username: enteredUsername }).then((res) => {
console.log(res);
});
}}>
<TextField
name="Impersonate User"
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
ref={usernameRef}
defaultValue={undefined}
/>
<p className="mt-2 text-sm text-gray-500" id="email-description">
{t("impersonate_user_tip")}
</p>
<div className="flex justify-end py-4">
<Button type="submit">{t("impersonate")}</Button>
</div>
</form>
</div>
<hr className="mt-8" />
</div>
);
}
export default function Admin() {
const { t } = useLocale();
return (
<SettingsShell heading={t("admin")}>
<AdminView />
</SettingsShell>
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id || session.user.role !== "ADMIN") {
return { redirect: { permanent: false, destination: "/settings/profile" } };
}
return { props: {} };
};
-80
View File
@@ -1,80 +0,0 @@
import { useState } from "react";
import { ReactNode } from "react";
import { HelpScout, useChat } from "react-live-chat-loader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import useMeQuery from "@lib/hooks/useMeQuery";
import SettingsShell from "@components/SettingsShell";
type CardProps = { title: string; description: string; className?: string; children: ReactNode };
const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => (
<div className={`border bg-white sm:rounded-sm ${className}`}>
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">{title}</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{description}</p>
</div>
<div className="mt-5">{children}</div>
</div>
</div>
);
export default function Billing() {
const { t } = useLocale();
const query = useMeQuery();
const { data } = query;
const [active, setActive] = useState(false);
const [, loadChat] = useChat();
const handleClick = () => {
setActive(true);
loadChat({ open: true });
};
return (
<SettingsShell heading={t("billing")} subtitle={t("manage_your_billing_info")}>
<>
<div className="py-6 lg:col-span-9 lg:pb-8">
{data?.plan && ["FREE", "TRIAL"].includes(data.plan) && (
<Card
title={t("plan_description", { plan: data.plan })}
description={t("plan_upgrade_invitation")}
className="mb-4">
<form method="POST" action="/api/upgrade">
<Button type="submit">
{t("upgrade_now")} <Icon.FiExternalLink className="ml-2 h-4 w-4" />
</Button>
</form>
</Card>
)}
<Card title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
<form method="POST" action="/api/integrations/stripepayment/portal">
<Button type="submit" color="secondary">
{t("go_to_billing_portal")} <Icon.FiExternalLink className="ml-2 h-4 w-4" />
</Button>
</form>
</Card>
<div className="mt-4 border bg-gray-50 sm:rounded-sm">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">{t("need_anything_else")}</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("further_billing_help")}</p>
</div>
<div className="mt-5">
<Button onClick={handleClick} color="secondary">
{t("contact_our_support_team")}
</Button>
</div>
</div>
</div>
</div>
{active && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
</>
</SettingsShell>
);
}
-30
View File
@@ -1,30 +0,0 @@
import ApiKeyListContainer from "@calcom/features/ee/api-keys/components/ApiKeyListContainer";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import SettingsShell from "@components/SettingsShell";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
export default function Settings() {
const { t } = useLocale();
const { data: routingForms } = trpc.useQuery([
"viewer.appById",
{
appId: "routing-forms",
},
]);
return (
<SettingsShell heading={t("developer")} subtitle={t("manage_developer_settings")}>
<WebhookListContainer title="Event Webhooks" subtitle={t("receive_cal_meeting_data")} />
{routingForms && (
<WebhookListContainer
appId="routing-forms"
title="Routing Webhooks"
subtitle="Receive Routing Form responses at a specified URL, in real-time, when a Routing Form is submitted"
/>
)}
<ApiKeyListContainer />
</SettingsShell>
);
}
-635
View File
@@ -1,635 +0,0 @@
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { useRouter } from "next/router";
import {
ComponentProps,
RefObject,
FormEvent,
useEffect,
useMemo,
useRef,
useState,
BaseSyntheticEvent,
} from "react";
import { useForm } from "react-hook-form";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Alert } from "@calcom/ui/Alert";
import Badge from "@calcom/ui/Badge";
import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { UpgradeToProDialog } from "@calcom/ui/UpgradeToProDialog";
import { Form, PasswordField } from "@calcom/ui/form/fields";
import { withQuery } from "@lib/QueryCell";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
import { ErrorCode, getSession } from "@lib/auth";
import { nameOfDay } from "@lib/core/i18n/weekday";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import ImageUploader from "@components/ImageUploader";
import SettingsShell from "@components/SettingsShell";
import TwoFactor from "@components/auth/TwoFactor";
import Avatar from "@components/ui/Avatar";
import InfoBadge from "@components/ui/InfoBadge";
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
import ColorPicker from "@components/ui/colorpicker";
import Select from "@components/ui/form/Select";
type Props = inferSSRProps<typeof getServerSideProps>;
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
const { user } = props;
const { t } = useLocale();
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={props.hideBrandingRef}
defaultChecked={isBrandingHidden(user)}
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800 disabled:opacity-50"
onClick={(e) => {
if (!e.currentTarget.checked || user.plan !== "FREE") {
return;
}
// prevent checking the input
e.preventDefault();
setModalOpen(true);
}}
/>
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
{t("remove_cal_branding_description")}
</UpgradeToProDialog>
</>
);
}
interface DeleteAccountValues {
totpCode: string;
}
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
const { user } = props;
const form = useForm<DeleteAccountValues>();
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const onSuccessMutation = async () => {
showToast(t("your_user_profile_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
await utils.invalidateQueries(["viewer.me"]);
};
const onErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
setHasErrors(true);
setErrorMessage(error.message);
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
};
const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: onSuccessMutation,
onError: onErrorMutation,
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
});
const onDeleteMeSuccessMutation = async () => {
await utils.invalidateQueries(["viewer.me"]);
showToast(t("Your account was deleted"), "success");
setHasDeleteErrors(false); // dismiss any open errors
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
signOut({ callbackUrl: "/auth/logout?survey=true" });
} else {
signOut({ callbackUrl: "/auth/logout" });
}
};
const onDeleteMeErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
setHasDeleteErrors(true);
setDeleteErrorMessage(errorMessages[error.message]);
};
const deleteMeMutation = trpc.useMutation("viewer.deleteMe", {
onSuccess: onDeleteMeSuccessMutation,
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.invalidateQueries(["viewer.me"]);
},
});
const localeOptions = useMemo(() => {
return (router.locales || []).map((locale) => ({
value: locale,
label: new Intl.DisplayNames(props.localeProp, { type: "language" }).of(locale) || "",
}));
}, [props.localeProp, router.locales]);
const themeOptions = [
{ value: "light", label: t("light") },
{ value: "dark", label: t("dark") },
];
const timeFormatOptions = [
{ value: 12, label: t("12_hour") },
{ value: 24, label: t("24_hour") },
];
const usernameRef = useRef<HTMLInputElement>(null!);
const passwordRef = useRef<HTMLInputElement>(null!);
const nameRef = useRef<HTMLInputElement>(null!);
const emailRef = useRef<HTMLInputElement>(null!);
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
const avatarRef = useRef<HTMLInputElement>(null!);
const hideBrandingRef = useRef<HTMLInputElement>(null!);
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
value: user.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
});
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(user.timeZone);
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
value: user.weekStart,
label: nameOfDay(props.localeProp, user.weekStart === "Sunday" ? 0 : 1),
});
const [selectedLanguage, setSelectedLanguage] = useState({
value: props.localeProp || "",
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
});
const [imageSrc, setImageSrc] = useState<string>(user.avatar || "");
const [hasErrors, setHasErrors] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const errorMessages: { [key: string]: string } = {
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
useEffect(() => {
if (!user.theme) return;
const userTheme = themeOptions.find((theme) => theme.value === user.theme);
if (!userTheme) return;
setSelectedTheme(userTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onConfirmButton = (e: FormEvent) => {
e.preventDefault();
const totpCode = form.getValues("totpCode");
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
};
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
e?.preventDefault();
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
};
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const enteredUsername = usernameRef.current.value.toLowerCase();
const enteredName = nameRef.current.value;
const enteredEmail = emailRef.current.value;
const enteredDescription = descriptionRef.current.value;
const enteredAvatar = avatarRef.current.value;
const enteredBrandColor = brandColor;
const enteredDarkBrandColor = darkBrandColor;
const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked;
const enteredAllowDynamicGroupBooking = allowDynamicGroupBookingRef.current.checked;
const enteredLanguage = selectedLanguage.value;
const enteredTimeFormat = selectedTimeFormat.value;
// Write time format to localStorage if available
// Embed isn't applicable to profile pages. So ignore the rule
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
setIs24hClockInLocalStorage(selectedTimeFormat.value === 24);
// TODO: Add validation
mutation.mutate({
username: enteredUsername,
name: enteredName,
email: enteredEmail,
bio: enteredDescription,
avatar: enteredAvatar,
timeZone: enteredTimeZone,
weekStart: asStringOrUndefined(enteredWeekStartDay),
hideBranding: enteredHideBranding,
allowDynamicBooking: enteredAllowDynamicGroupBooking,
theme: asStringOrNull(selectedTheme?.value),
brandColor: enteredBrandColor,
darkBrandColor: enteredDarkBrandColor,
locale: enteredLanguage,
timeFormat: enteredTimeFormat,
});
}
const [currentUsername, setCurrentUsername] = useState(user.username || undefined);
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
return (
<>
<div className="pt-6 pb-4 lg:pb-8">
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
<div className="w-full">
<UsernameAvailability
currentUsername={currentUsername}
setCurrentUsername={setCurrentUsername}
inputUsernameValue={inputUsernameValue}
usernameRef={usernameRef}
setInputUsernameValue={setInputUsernameValue}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
user={user}
/>
</div>
</div>
</div>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />}
<div className="pb-6 lg:pb-8">
<div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="block sm:flex">
<div className="w-full">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
{t("full_name")}
</label>
<input
ref={nameRef}
type="text"
name="name"
id="name"
autoComplete="given-name"
placeholder={t("your_name")}
required
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
defaultValue={user.name || undefined}
/>
</div>
</div>
<div className="block sm:flex">
<div className="mb-6 w-full sm:w-1/2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("email")}
</label>
<input
ref={emailRef}
type="email"
name="email"
id="email"
placeholder={t("your_email")}
className="mt-1 block w-full rounded-sm border-gray-300 text-sm focus:border-neutral-800 focus:ring-neutral-800"
defaultValue={user.email}
/>
<p className="mt-2 text-sm text-gray-500" id="email-description">
{t("change_email_tip")}
</p>
</div>
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
{t("about")}
</label>
<div className="mt-1">
<textarea
ref={descriptionRef}
id="about"
name="about"
placeholder={t("little_something_about")}
rows={3}
defaultValue={user.bio || undefined}
className="mt-1 block w-full rounded-sm border-gray-300 text-sm focus:border-neutral-800 focus:ring-neutral-800"
/>
</div>
</div>
<div>
<div className="mt-1 flex">
<Avatar
alt={user.name || ""}
className="relative h-10 w-10 rounded-full"
gravatarFallbackMd5={user.emailMd5}
imageSrc={imageSrc}
/>
<input
ref={avatarRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
defaultValue={imageSrc}
/>
<div className="flex items-center px-5">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
avatarRef.current.value = newAvatar;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current.dispatchEvent(ev2);
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
setImageSrc(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
</div>
<hr className="mt-6" />
</div>
<div>
<label htmlFor="language" className="block text-sm font-medium text-gray-700">
{t("language")}
</label>
<div className="mt-1">
<Select
id="languageSelect"
value={selectedLanguage || props.localeProp}
onChange={(v) => v && setSelectedLanguage(v)}
className="mt-1 block w-full rounded-sm text-sm capitalize"
options={localeOptions}
/>
</div>
</div>
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
{t("timezone")}
</label>
<div className="mt-1">
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={(v) => v && setSelectedTimeZone(v)}
className="mt-1 block w-full rounded-sm text-sm"
/>
</div>
</div>
<div>
<label htmlFor="timeFormat" className="block text-sm font-medium text-gray-700">
{t("time_format")}
</label>
<div className="mt-1">
<Select
id="timeFormatSelect"
value={selectedTimeFormat || user.timeFormat}
onChange={(v) => v && setSelectedTimeFormat(v)}
className="mt-1 block w-full rounded-sm text-sm capitalize"
options={timeFormatOptions}
/>
</div>
</div>
<div>
<label htmlFor="weekStart" className="block text-sm font-medium text-gray-700">
{t("first_day_of_week")}
</label>
<div className="mt-1">
<Select
id="weekStart"
value={selectedWeekStartDay}
onChange={(v) => v && setSelectedWeekStartDay(v)}
className="mt-1 block w-full rounded-sm text-sm capitalize"
options={[
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
{ value: "Tuesday", label: nameOfDay(props.localeProp, 2) },
{ value: "Wednesday", label: nameOfDay(props.localeProp, 3) },
{ value: "Thursday", label: nameOfDay(props.localeProp, 4) },
{ value: "Friday", label: nameOfDay(props.localeProp, 5) },
{ value: "Saturday", label: nameOfDay(props.localeProp, 6) },
]}
/>
</div>
</div>
<div className="relative mt-8 flex items-start">
<div className="flex h-5 items-center">
<input
id="dynamic-group-booking"
name="dynamic-group-booking"
type="checkbox"
ref={allowDynamicGroupBookingRef}
defaultChecked={props.user.allowDynamicBooking || false}
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label
htmlFor="dynamic-group-booking"
className="flex items-center font-medium text-gray-700">
{t("allow_dynamic_booking")} <InfoBadge content={t("allow_dynamic_booking_tooltip")} />
</label>
</div>
</div>
<div>
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
{t("single_theme")}
</label>
<div className="my-1">
<Select
id="theme"
isDisabled={!selectedTheme}
defaultValue={selectedTheme || themeOptions[0]}
value={selectedTheme || themeOptions[0]}
onChange={(v) => v && setSelectedTheme(v)}
className="mt-1 block w-full rounded-sm text-sm"
options={themeOptions}
/>
</div>
<div className="relative mt-8 flex items-start">
<div className="flex h-5 items-center">
<input
id="theme-adjust-os"
name="theme-adjust-os"
type="checkbox"
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
checked={!selectedTheme}
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label htmlFor="theme-adjust-os" className="font-medium text-gray-700">
{t("automatically_adjust_theme")}
</label>
</div>
</div>
</div>
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
<div className="mb-2 sm:w-1/2">
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("light_brand_color")}
</label>
<ColorPicker defaultValue={user.brandColor} onChange={setBrandColor} />
</div>
<div className="mb-2 sm:w-1/2">
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
{t("dark_brand_color")}
</label>
<ColorPicker defaultValue={user.darkBrandColor} onChange={setDarkBrandColor} />
</div>
</div>
<div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<HideBrandingInput user={user} hideBrandingRef={hideBrandingRef} />
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
{t("disable_cal_branding")}{" "}
{user.plan !== "PRO" && <Badge variant="default">TEAM</Badge>}
</label>
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
</div>
</div>
</div>
<h3 className="text-md mt-7 font-bold leading-6 text-red-700">{t("danger_zone")}</h3>
<div>
<div className="relative flex items-start">
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
color="warn"
StartIcon={Icon.FiTrash}
className="border-2 border-red-700 text-red-700"
data-testid="delete-account">
{t("delete_account")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("delete_account")}
confirmBtn={
<Button color="warn" data-testid="delete-account-confirm">
{t("confirm_delete_account")}
</Button>
}
onConfirm={onConfirmButton}>
<p className="mb-7">{t("delete_account_confirmation_message")}</p>
<PasswordField
data-testid="password"
name="password"
id="password"
type="password"
autoComplete="current-password"
required
label="Password"
ref={passwordRef}
/>
{user.twoFactorEnabled && (
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
<TwoFactor center={false} />
</Form>
)}
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>
</div>
<hr className="mt-8" />
<div className="flex justify-end py-4">
<Button type="submit">{t("save")}</Button>
</div>
</div>
</form>
</>
);
}
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
* We intend to not cache i18n query
**/
const WithQuery = withQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } });
export default function Settings(props: Props) {
const { t } = useLocale();
return (
<SettingsShell heading={t("profile")} subtitle={t("edit_profile_info_description")}>
<WithQuery success={({ data }) => <SettingsView {...props} localeProp={data.locale} />} />
</SettingsShell>
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
timeZone: true,
weekStart: true,
hideBranding: true,
theme: true,
plan: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
twoFactorEnabled: true,
timeFormat: true,
allowDynamicBooking: true,
},
});
if (!user) {
throw new Error("User seems logged in but cannot be found in the db");
}
return {
props: {
user: {
...user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
},
},
};
};
-48
View File
@@ -1,48 +0,0 @@
import { IdentityProvider } from "@prisma/client";
import React from "react";
import SAMLConfiguration from "@calcom/features/ee/common/components/SamlConfiguration";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { identityProviderNameMap } from "@lib/auth";
import SettingsShell from "@components/SettingsShell";
import ChangePasswordSection from "@components/security/ChangePasswordSection";
import DisableUserImpersonation from "@components/security/DisableUserImpersonation";
import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection";
export default function Security() {
const user = trpc.useQuery(["viewer.me"]).data;
const { t } = useLocale();
return (
<SettingsShell heading={t("security")} subtitle={t("manage_account_security")}>
<>
{user && user.identityProvider !== IdentityProvider.CAL ? (
<>
<div className="mt-6">
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">
{t("account_managed_by_identity_provider", {
provider: identityProviderNameMap[user.identityProvider],
})}
</h2>
</div>
<p className="mt-1 text-sm text-gray-500">
{t("account_managed_by_identity_provider_description", {
provider: identityProviderNameMap[user.identityProvider],
})}
</p>
</>
) : (
<div className="space-y-2 divide-y">
<ChangePasswordSection />
<TwoFactorAuthSection twoFactorEnabled={user?.twoFactorEnabled || false} />
<DisableUserImpersonation disableImpersonation={user?.disableImpersonation ?? true} />
</div>
)}
<SAMLConfiguration teamsView={false} teamId={null} />
</>
</SettingsShell>
);
}
@@ -1 +0,0 @@
export { default } from "@calcom/features/ee/teams/pages/availability";
@@ -1,181 +0,0 @@
import { MembershipRole } from "@prisma/client";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import SAMLConfiguration from "@calcom/features/ee/common/components/SamlConfiguration";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import showToast from "@calcom/lib/notification";
import { trpc } from "@calcom/trpc/react";
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import { Button } from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon";
import Shell from "@calcom/ui/Shell";
import { QueryCell } from "@lib/QueryCell";
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
import { useLocale } from "@lib/hooks/useLocale";
import DisableTeamImpersonation from "@components/team/DisableTeamImpersonation";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import MemberList from "@components/team/MemberList";
import TeamSettings from "@components/team/TeamSettings";
import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar";
import { UpgradeToFlexibleProModal } from "@components/team/UpgradeToFlexibleProModal";
import Avatar from "@components/ui/Avatar";
export function TeamSettingsPage() {
const { t } = useLocale();
const router = useRouter();
const userId = useCurrentUserId();
const upgraded = router.query.upgraded as string;
useEffect(() => {
if (upgraded) {
showToast(t("team_upgraded_successfully"), "success");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const query = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
onError: (e) => {
setErrorMessage(e.message);
},
});
return (
<QueryCell
query={query}
loading={() => {
return (
<Shell
backPath={!errorMessage ? `/settings/teams` : undefined}
heading={
<div className="pt-2">
<SkeletonText width="16" height="6" />
</div>
}
subtitle={<SkeletonText className="h-4 w-12" />}
HeadingLeftIcon={<SkeletonAvatar className="mt-1 h-12 w-12" />}>
<>
<div className="block sm:flex md:max-w-5xl">
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
<div className="-mx-0 h-[531px] rounded-sm border border-neutral-200 bg-white px-4 sm:px-6" />
<div className="mb-3 mt-7 flex items-center justify-between">
<SkeletonText className="h-4 w-12" />
</div>
<div className="-mx-0 h-16 rounded-sm border border-neutral-200 bg-white px-4 sm:px-6" />
</div>
</div>
</>
</Shell>
);
}}
success={({ data: team }) => {
const isAdmin =
team &&
(team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
return (
<Shell
backPath={!errorMessage ? `/settings/teams` : undefined}
heading={team?.name}
subtitle={team && t("manage_this_team")}
HeadingLeftIcon={
team && (
<Avatar
size={12}
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
alt="Team Logo"
className="mt-1"
/>
)
}>
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
{team && (
<>
<div className="block sm:flex md:max-w-5xl">
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
{team.membership.role === MembershipRole.OWNER &&
team.membership.isMissingSeat &&
team.requiresUpgrade ? (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={
<>
{t("hidden_team_owner_message")} <UpgradeToFlexibleProModal teamId={team.id} />
{/* <a href={"https://cal.com/upgrade"} className="underline">
{"https://cal.com/upgrade"}
</a> */}
</>
}
className="mb-4 "
/>
) : (
<>
{team.membership.isMissingSeat && (
<Alert
severity="warning"
title={t("hidden_team_member_title")}
message={t("hidden_team_member_message")}
className="mb-4 "
/>
)}
</>
)}
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 sm:px-6">
{isAdmin ? (
<TeamSettings team={team} />
) : (
<div className="py-5">
<span className="mb-1 font-bold">{t("team_info")}</span>
<p className="text-sm text-gray-700">{team.bio}</p>
</div>
)}
</div>
<div className="mb-3 mt-7 flex items-center justify-between">
<h3 className="font-cal text-xl leading-6 text-gray-900">{t("members")}</h3>
{isAdmin && (
<div className="relative flex items-center">
<Button
type="button"
color="secondary"
StartIcon={Icon.FiPlus}
onClick={() => setShowMemberInvitationModal(true)}
data-testid="new-member-button">
{t("new_member")}
</Button>
</div>
)}
</div>
<MemberList team={team} members={team.members || []} />
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
{userId && <DisableTeamImpersonation teamId={team.id} memberId={userId} />}
</div>
<div className="min-w-32 mt-8 w-full px-2 ltr:ml-2 rtl:mr-2 sm:mt-0 md:w-3/12">
<TeamSettingsRightSidebar role={team.membership.role} team={team} />
</div>
</div>
{showMemberInvitationModal && (
<MemberInvitationModal
isOpen={showMemberInvitationModal}
team={team}
currentMember={team.membership.role}
onExit={() => setShowMemberInvitationModal(false)}
/>
)}
</>
)}
</Shell>
);
}}
/>
);
}
export default TeamSettingsPage;
-88
View File
@@ -1,88 +0,0 @@
import classNames from "classnames";
import { Trans } from "next-i18next";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { Icon } from "@calcom/ui/Icon";
import useMeQuery from "@lib/hooks/useMeQuery";
import SettingsShell from "@components/SettingsShell";
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
import TeamCreateModal from "@components/team/TeamCreateModal";
import TeamList from "@components/team/TeamList";
function Teams() {
const { t } = useLocale();
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const me = useMeQuery();
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
onError: (e) => {
setErrorMessage(e.message);
},
});
const teams = data?.filter((m) => m.accepted) || [];
const invites = data?.filter((m) => !m.accepted) || [];
const isFreePlan = me.data?.plan === "FREE";
return (
<SettingsShell heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
<>
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
{isFreePlan && (
<Alert
severity="warning"
title={<>{t("plan_upgrade_teams")}</>}
message={
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
}
className="my-4"
/>
)}
{showCreateTeamModal && (
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
)}
<div className={classNames("my-4 flex justify-end", isFreePlan && "opacity-50")}>
<Button
disabled={isFreePlan}
type="button"
color="secondary"
onClick={() => setShowCreateTeamModal(true)}>
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-gray-700 group-hover:text-black ltr:mr-2 rtl:ml-2" />
{t("new_team")}
</Button>
</div>
{invites.length > 0 && (
<div className="mb-4">
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
<TeamList teams={invites} />
</div>
)}
{isLoading && <SkeletonLoaderTeamList />}
{!teams.length && !isLoading && (
<EmptyScreen Icon={Icon.FiUsers} headline={t("no_teams")} description={t("no_teams_description")} />
)}
{teams.length > 0 && <TeamList teams={teams} />}
</>
</SettingsShell>
);
}
Teams.requiresLicense = false;
export default Teams;
-33
View File
@@ -1,33 +0,0 @@
/**
* @deprecated file
* All new changes should be made to the V2 file in
* `apps/web/pages/v2/workflows/[workflow].tsx`
*/
import { GetStaticPaths, GetStaticProps } from "next";
import { z } from "zod";
export { default } from "@calcom/features/ee/workflows/pages/workflow";
const querySchema = z.object({
workflow: z.string(),
});
export const getStaticProps: GetStaticProps = (ctx) => {
const params = querySchema.safeParse(ctx.params);
console.log("Built workflow page:", params);
if (!params.success) return { notFound: true };
return {
props: {
workflow: params.data.workflow,
},
revalidate: 10, // seconds
};
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: "blocking",
};
};
-1
View File
@@ -1 +0,0 @@
export { default } from "@calcom/features/ee/workflows/pages/index";
@@ -2,8 +2,8 @@ import { Prisma, WebhookTriggerEvents } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/lib/webhooks/sendPayload";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { TRPCError } from "@calcom/trpc/server";
import { createProtectedRouter, createRouter } from "@calcom/trpc/server/createRouter";
@@ -2,7 +2,7 @@ import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SUPPORTED_CHAINS_FOR_FORM } from "@calcom/rainbow/utils/ethereum";
import type { FormValues } from "@calcom/web/pages/event-types/[type]";
import type { FormValues } from "@calcom/web/pages/v2/event-types/[type]";
import Select from "@components/ui/form/Select";
@@ -0,0 +1,916 @@
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import type { NextApiRequest } from "next";
import { RRule } from "rrule";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import dayjs from "@calcom/dayjs";
import {
sendAttendeeRequestEmail,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
sendScheduledSeatsEmails,
} from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { checkBookingLimits, getLuckyUser } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma, { userSelect } from "@calcom/prisma";
import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import sendPayload, { EventTypeInfo } from "../../webhooks/lib/sendPayload";
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
/**
* Refreshes a Credential with fresh data from the database.
*
* @param credential
*/
async function refreshCredential(credential: Credential): Promise<Credential> {
const newCredential = await prisma.credential.findUnique({
where: {
id: credential.id,
},
});
if (!newCredential) {
return credential;
} else {
return newCredential;
}
}
/**
* Refreshes the given set of credentials.
*
* @param credentials
*/
async function refreshCredentials(credentials: Array<Credential>): Promise<Array<Credential>> {
return await async.mapLimit(credentials, 5, refreshCredential);
}
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
// Check for conflicts
let t = true;
// Early return
if (!Array.isArray(busyTimes) || busyTimes.length < 1) return t;
let i = 0;
while (t === true && i < busyTimes.length) {
const busyTime = busyTimes[i];
i++;
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
break;
}
// Check if slot end time is between start and end time
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
t = false;
break;
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
t = false;
break;
}
}
return t;
}
const getEventTypesFromDB = async (eventTypeId: number) => {
const eventType = await prisma.eventType.findUniqueOrThrow({
where: {
id: eventTypeId,
},
select: {
id: true,
customInputs: true,
users: userSelect,
team: {
select: {
id: true,
name: true,
},
},
title: true,
length: true,
eventName: true,
schedulingType: true,
description: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
price: true,
currency: true,
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
seatsPerTimeSlot: true,
recurringEvent: true,
bookingLimits: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
locations: true,
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
return {
...eventType,
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: (eventType.locations ?? []) as LocationObject[],
};
};
async function ensureAvailableUsers(
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
users: User[];
},
input: { dateFrom: string; dateTo: string }
) {
const availableUsers: typeof eventType.users = [];
/** Let's start checking for availability */
for (const user of eventType.users) {
const { busy: bufferedBusyTimes } = await getUserAvailability(
{
userId: user.id,
eventTypeId: eventType.id,
...input,
},
{ user, eventType }
);
console.log("calendarBusyTimes==>>>", bufferedBusyTimes);
let isAvailableToBeBooked = true;
try {
if (eventType.recurringEvent) {
const recurringEvent = parseRecurringEvent(eventType.recurringEvent);
const allBookingDates = new RRule({ dtstart: new Date(input.dateFrom), ...recurringEvent }).all();
// Go through each date for the recurring event and check if each one's availability
// DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop
// running at the first unavailable time.
let i = 0;
while (isAvailableToBeBooked === true && i < allBookingDates.length) {
const aDate = allBookingDates[i];
i++;
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length);
// We bail at the first false, we don't need to keep checking
if (!isAvailableToBeBooked) break;
}
} else {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, input.dateFrom, eventType.length);
}
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (isAvailableToBeBooked) {
availableUsers.push(user);
}
}
if (!availableUsers.length) {
throw new Error("No available users found.");
}
return availableUsers;
}
async function handler(req: NextApiRequest & { userId?: number }) {
const { userId } = req;
const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } =
extendedBookingCreateBody.parse(req.body);
// handle dynamic user
const dynamicUserList = Array.isArray(reqBody.user)
? getGroupName(reqBody.user)
: getUsernameList(reqBody.user);
const tAttendees = await getTranslation(language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const eventType =
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
// Check if required custom inputs exist
if (eventType.customInputs) {
eventType.customInputs.forEach((customInput) => {
if (customInput.required) {
requiredCustomInputSchema.parse(
reqBody.customInputs.find((userInput) => userInput.label === customInput.label)?.value
);
}
});
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(reqBody.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
});
} catch (error) {
if (error instanceof BookingDateInPastError) {
// TODO: HttpError should not bleed through to the console.
log.info(`Booking eventType ${eventTypeId} failed`, error);
throw new HttpError({ statusCode: 400, message: error.message });
}
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingTimeOutOfBounds",
message: `EventType '${eventType.eventName}' cannot be booked at this time.`,
};
throw new HttpError({ statusCode: 400, message: error.message });
}
let users = !eventTypeId
? await prisma.user.findMany({
where: {
username: {
in: dynamicUserList,
},
},
...userSelect,
})
: eventType.users;
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {
throw new HttpError({
message: "Some of the users in this group do not allow dynamic booking",
statusCode: 400,
});
}
// If this event was pre-relationship migration
// TODO: Establish whether this is dead code.
if (!users.length && eventType.userId) {
const eventTypeUser = await prisma.user.findUnique({
where: {
id: eventType.userId,
},
...userSelect,
});
if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
users.push(eventTypeUser);
}
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
if (eventType && eventType.hasOwnProperty("bookingLimits") && eventType?.bookingLimits) {
const startAsDate = dayjs(reqBody.start).toDate();
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id);
}
if (!eventType.seatsPerTimeSlot) {
const availableUsers = await ensureAvailableUsers(
{
...eventType,
users,
},
{
dateFrom: reqBody.start,
dateTo: reqBody.end,
}
);
// Add an if conditional if there are no seats on the event type
// Assign to only one user when ROUND_ROBIN
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
} else {
// excluding ROUND_ROBIN, all users have availability required.
if (availableUsers.length !== users.length) {
throw new Error("Some users are unavailable for booking.");
}
users = availableUsers;
}
}
console.log("available users", users);
// @TODO: use the returned address somewhere in booking creation?
// const address: string | undefined = await ...
await handleEthSignature(eventType.metadata, reqBody.ethSignature);
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common");
const invitee = [
{
email: reqBody.email,
name: reqBody.name,
timeZone: reqBody.timeZone,
language: { translate: tAttendees, locale: language ?? "en" },
},
];
const guests = (reqBody.guests || []).map((guest) => ({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
}));
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const bookingLocation = getLocationValueForDB(reqBody.location, eventType.locations);
console.log(bookingLocation, reqBody.location, eventType.locations);
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
const teamMemberPromises =
eventType.schedulingType === SchedulingType.COLLECTIVE
? users.slice(1).map(async function (user) {
return {
email: user.email || "",
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
})
: [];
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests, ...teamMembers];
const eventNameObject = {
attendeeName: reqBody.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
host: organizerUser.name || "Nameless",
location: bookingLocation,
t: tOrganizer,
};
const additionalNotes = reqBody.notes;
let evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
additionalNotes,
customInputs,
startTime: dayjs(reqBody.start).utc().format(),
endTime: dayjs(reqBody.end).utc().format(),
organizer: {
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
},
attendees: attendeesList,
location: bookingLocation, // Will be processed by the EventManager later.
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
eventTypeId: eventType.id,
};
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (reqBody.bookingUid) {
if (!eventType.seatsPerTimeSlot)
throw new HttpError({ statusCode: 404, message: "Event type does not have seats" });
const booking = await prisma.booking.findUnique({
where: {
uid: reqBody.bookingUid,
},
select: {
id: true,
attendees: true,
userId: true,
references: {
select: {
type: true,
uid: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
externalCalendarId: true,
},
},
},
});
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
if (booking.attendees.some((attendee) => attendee.email === invitee[0].email))
throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" });
await prisma.booking.update({
where: {
uid: reqBody.bookingUid,
},
data: {
attendees: {
create: {
email: invitee[0].email,
name: invitee[0].name,
timeZone: invitee[0].timeZone,
locale: invitee[0].language.locale,
},
},
},
});
const newSeat = booking.attendees.length !== 0;
await sendScheduledSeatsEmails(evt, invitee[0], newSeat);
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
await eventManager.updateCalendarAttendees(evt, booking);
req.statusCode = 201;
return booking;
}
if (reqBody.customInputs.length > 0) {
reqBody.customInputs.forEach(({ label, value }) => {
customInputs[label] = value;
});
}
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
evt.team = {
members: users.map((user) => user.name || user.username || "Nameless"),
name: eventType.team?.name || "Nameless",
}; // used for invitee emails
}
if (reqBody.recurringEventId && eventType.recurringEvent) {
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
evt.recurringEvent = eventType.recurringEvent;
}
// Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid;
async function getOriginalRescheduledBooking(uid: string) {
return prisma.booking.findFirst({
where: {
uid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
},
},
include: {
attendees: {
select: {
name: true,
email: true,
locale: true,
timeZone: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
locale: true,
timeZone: true,
},
},
payment: true,
},
});
}
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
let originalRescheduledBooking: BookingType = null;
if (rescheduleUid) {
originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid);
}
async function createBooking() {
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.additionalNotes;
evt.location = originalRescheduledBooking?.location;
}
const eventTypeRel = !eventTypeId
? {}
: {
connect: {
id: eventTypeId,
},
};
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
// If the user is not the owner of the event, new booking should be always pending.
// Otherwise, an owner rescheduling should be always accepted.
const userReschedulingIsOwner = originalRescheduledBooking?.user?.id === userId;
const isConfirmedByDefault =
(!eventType.requiresConfirmation && !eventType.price) || userReschedulingIsOwner;
const newBookingData: Prisma.BookingCreateInput = {
uid,
title: evt.title,
startTime: dayjs.utc(evt.startTime).toDate(),
endTime: dayjs.utc(evt.endTime).toDate(),
description: evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
eventType: eventTypeRel,
smsReminderNumber: reqBody.smsReminderNumber,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
//if attendee is team member, it should fetch their locale not booker's locale
//perhaps make email fetch request to see if his locale is stored, else
const retObj = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
locale: attendee.language.locale,
};
return retObj;
}),
},
},
dynamicEventSlugRef,
dynamicGroupSlugRef,
user: {
connect: {
id: organizerUser.id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: undefined,
};
if (reqBody.recurringEventId) {
newBookingData.recurringEventId = reqBody.recurringEventId;
}
if (originalRescheduledBooking) {
newBookingData["paid"] = originalRescheduledBooking.paid;
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
if (newBookingData.attendees?.createMany?.data) {
newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees;
}
if (originalRescheduledBooking.recurringEventId) {
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
}
}
const createBookingObj = {
include: {
user: {
select: { email: true, name: true, timeZone: true },
},
attendees: true,
payment: true,
},
data: newBookingData,
};
if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
if (bookingPayment) {
createBookingObj.data.payment = {
connect: { id: bookingPayment.id },
};
}
}
if (typeof eventType.price === "number" && eventType.price > 0) {
/* Validate if there is any stripe_payment credential for this user */
/* note: removes custom error message about stripe */
await prisma.credential.findFirstOrThrow({
where: {
type: "stripe_payment",
userId: organizerUser.id,
},
select: {
id: true,
},
});
}
return prisma.booking.create(createBookingObj);
}
let results: EventResult<AdditionalInformation>[] = [];
let referencesToCreate: PartialReference[] = [];
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
// Sync Services
await syncServicesUpdateWebUser(
await prisma.user.findFirst({
where: { id: userId },
select: { id: true, email: true, name: true, plan: true, username: true, createdDate: true },
})
);
evt.uid = booking?.uid ?? null;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
throw new HttpError({ statusCode: 409, message: "booking.conflict" });
}
throw err;
}
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations.
const updateManager = await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
booking?.id,
reqBody.rescheduleReason
);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
log.error(`Booking ${organizerUser.name} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
}
}
if (noEmail !== true) {
await sendRescheduledEmails({
...evt,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: reqBody.rescheduleReason,
});
}
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (!eventType.requiresConfirmation && !eventType.price) {
// Use EventManager to conditionally use all needed integrations.
const createManager = await eventManager.create(evt);
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
results = createManager.results;
referencesToCreate = createManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${organizerUser.username} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
if (noEmail !== true) {
await sendScheduledEmails({
...evt,
additionalInformation: metadata,
additionalNotes,
customInputs,
});
}
}
}
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
}
if (
!Number.isNaN(eventType.price) &&
eventType.price > 0 &&
!originalRescheduledBooking?.paid &&
!!booking
) {
const [firstStripeCredential] = organizerUser.credentials.filter((cred) => cred.type == "stripe_payment");
if (!firstStripeCredential)
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
if (!booking.user) booking.user = organizerUser;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment.uid };
}
log.debug(`Booking ${organizerUser.username} completed`);
const eventTrigger: WebhookTriggerEvents = rescheduleUid
? WebhookTriggerEvents.BOOKING_RESCHEDULED
: WebhookTriggerEvents.BOOKING_CREATED;
const subscriberOptions = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: eventTrigger,
};
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
};
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
subscribersMeetingEnded.forEach((subscriber) => {
if (rescheduleUid && originalRescheduledBooking) {
cancelScheduledJobs(originalRescheduledBooking);
}
if (booking && booking.status === BookingStatus.ACCEPTED) {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
}
});
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getWebhooks(subscriberOptions);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,
});
const bookingId = booking?.id;
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: eventType.requiresConfirmation || null,
price: eventType.price,
currency: eventType.currency,
length: eventType.length,
};
const promises = subscribers.map((sub) =>
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
rescheduleUid,
metadata: reqBody.metadata,
eventTypeId,
status: eventType.requiresConfirmation ? "PENDING" : "ACCEPTED",
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
);
await Promise.all(promises);
// Avoid passing referencesToCreate with id unique constrain values
// refresh hashed link if used
const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`;
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
if (hasHashedBookingLink) {
await prisma.hashedLink.update({
where: {
link: reqBody.hashedLink as string,
},
data: {
link: hashedUid,
},
});
}
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
await scheduleWorkflowReminders(
eventType.workflows,
reqBody.smsReminderNumber as string | null,
evt,
evt.requiresConfirmation || false,
rescheduleUid ? true : false
);
// booking successful
req.statusCode = 201;
return booking;
}
export default handler;
@@ -11,7 +11,7 @@ import Switch from "@calcom/ui/v2/core/Switch";
import { Form, Label, TextArea, TextField } from "@calcom/ui/v2/core/form/fields";
import Select from "@calcom/ui/v2/core/form/select";
import customTemplate, { hasTemplateIntegration } from "../utils/integrationTemplate";
import customTemplate, { hasTemplateIntegration } from "../lib/integrationTemplate";
import WebhookTestDisclosure from "./WebhookTestDisclosure";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
@@ -1,6 +1,6 @@
import { WebhookTriggerEvents } from "@prisma/client";
import { PrismaClient, WebhookTriggerEvents } from "@prisma/client";
import prisma from "@calcom/prisma";
import defaultPrisma from "@calcom/prisma";
export type GetSubscriberOptions = {
userId: number;
@@ -8,7 +8,7 @@ export type GetSubscriberOptions = {
triggerEvent: WebhookTriggerEvents;
};
const getWebhooks = async (options: GetSubscriberOptions) => {
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
-108
View File
@@ -1,108 +0,0 @@
import { Webhook } from "@prisma/client";
import { createHmac } from "crypto";
import { compile } from "handlebars";
import type { CalendarEvent } from "@calcom/types/Calendar";
type ContentType = "application/json" | "application/x-www-form-urlencoded";
function applyTemplate(template: string, data: CalendarEvent, contentType: ContentType) {
const compiled = compile(template)(data);
if (contentType === "application/json") {
return JSON.stringify(jsonParse(compiled));
}
return compiled;
}
function jsonParse(jsonString: string) {
try {
return JSON.parse(jsonString);
} catch (e) {
// don't do anything.
}
return false;
}
const sendPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
data: CalendarEvent & {
metadata?: { [key: string]: string };
rescheduleUid?: string;
bookingId?: number;
}
) => {
const { appId, payloadTemplate: template } = webhook;
const contentType =
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
data.description = data.description || data.additionalNotes;
let body;
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
if (appId === "zapier") {
body = JSON.stringify(data);
} else if (template) {
body = applyTemplate(template, data, contentType);
} else {
body = JSON.stringify({
triggerEvent: triggerEvent,
createdAt: createdAt,
payload: data,
});
}
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, contentType);
};
export const sendGenericWebhookPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
data: Record<string, unknown>
) => {
const body = JSON.stringify(data);
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, "application/json");
};
const _sendPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
body: string,
contentType: "application/json" | "application/x-www-form-urlencoded"
) => {
const { subscriberUrl } = webhook;
if (!subscriberUrl || !body) {
throw new Error("Missing required elements to send webhook payload.");
}
const secretSignature = secretKey
? createHmac("sha256", secretKey).update(`${body}`).digest("hex")
: "no-secret-provided";
const response = await fetch(subscriberUrl, {
method: "POST",
headers: {
"Content-Type": contentType,
"X-Cal-Signature-256": secretSignature,
},
body,
});
const text = await response.text();
return {
ok: response.ok,
status: response.status,
message: text,
};
};
export default sendPayload;
@@ -16,11 +16,10 @@ import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { sendDeclinedEmails, sendLocationChangeEmails, sendScheduledEmails } from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/utils/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
@@ -2,10 +2,10 @@ import { Prisma } from "@prisma/client";
import { v4 } from "uuid";
import { z } from "zod";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/lib/webhooks/constants";
import sendPayload from "@calcom/lib/webhooks/sendPayload";
import { TRPCError } from "@trpc/server";