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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 })),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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: {} };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 +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];
|
||||
|
||||
File renamed without changes.
+3
-3
@@ -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: {
|
||||
File renamed without changes.
File renamed without changes.
@@ -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";
|
||||
|
||||
|
||||
Reference in new issue
Block a user