Allow choosing destination calendar per event type (#1514)

This commit is contained in:
Omar López 2022-01-21 14:35:31 -07:00 committed by GitHub
parent 7737164bbf
commit 8f6f34931b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1448 additions and 510 deletions

View File

@ -1 +1,2 @@
node_modules
prisma/zod

View File

@ -0,0 +1,92 @@
import React, { useEffect, useState } from "react";
import Select from "react-select";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import Button from "@components/ui/Button";
interface Props {
onChange: (value: { externalId: string; integration: string }) => void;
isLoading?: boolean;
hidePlaceholder?: boolean;
/** The external Id of the connected calendar */
value: string | undefined;
}
const DestinationCalendarSelector = ({
onChange,
isLoading,
value,
hidePlaceholder,
}: Props): JSX.Element | null => {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"]);
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
useEffect(() => {
if (!selectedOption) {
const selected = query.data?.connectedCalendars
.map((connected) => connected.calendars ?? [])
.flat()
.find((cal) => cal.externalId === value);
if (selected) {
setSelectedOption({
value: `${selected.integration}:${selected.externalId}`,
label: selected.name || "",
});
}
}
}, [query.data?.connectedCalendars, selectedOption, value]);
if (!query.data?.connectedCalendars.length) {
return null;
}
const options =
query.data.connectedCalendars.map((selectedCalendar) => ({
key: selectedCalendar.credentialId,
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
options: (selectedCalendar.calendars ?? []).map((cal) => ({
label: cal.name || "",
value: `${cal.integration}:${cal.externalId}`,
})),
})) ?? [];
return (
<div className="relative">
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
{!hidePlaceholder && (
<div className="absolute z-10 pointer-events-none">
<Button size="sm" color="secondary" className="border-transparent m-[1px] rounded-sm">
{t("select_destination_calendar")}: {selectedOption?.label || ""}
</Button>
</div>
)}
<Select
name={"primarySelectedCalendar"}
placeholder={!hidePlaceholder ? `${t("select_destination_calendar")}:` : undefined}
options={options}
isSearchable={false}
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
onChange={(option) => {
setSelectedOption(option);
if (!option) {
return;
}
/* Split only the first `:`, since Apple uses the full URL as externalId */
const [integration, externalId] = option.value.split(/:(.+)/);
onChange({
integration,
externalId,
});
}}
isLoading={isLoading}
value={selectedOption}
/>
</div>
);
};
export default DestinationCalendarSelector;

View File

@ -1,19 +1,20 @@
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod";
import { SchedulingType } from "@prisma/client";
import { useRouter } from "next/router";
import { createEventTypeInput } from "prisma/zod/eventtypeCustom";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useMutation } from "react-query";
import type { z } from "zod";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import createEventType from "@lib/mutations/event-types/create-event-type";
import showToast from "@lib/notification";
import { CreateEventType } from "@lib/types/event-type";
import { trpc } from "@lib/trpc";
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import { TextField, InputLeading, TextAreaField, Form } from "@components/form/fields";
import { Form, InputLeading, TextAreaField, TextField } from "@components/form/fields";
import Avatar from "@components/ui/Avatar";
import { Button } from "@components/ui/Button";
import Dropdown, {
@ -47,8 +48,14 @@ export default function CreateEventTypeButton(props: Props) {
const router = useRouter();
const modalOpen = useToggleQuery("new");
const form = useForm<CreateEventType>({
defaultValues: { length: 15 },
// URL encoded params
const teamId: number | undefined = Number(router.query.teamId) || undefined;
const pageSlug = router.query.eventPage || props.options[0].slug;
const hasTeams = !!props.options.find((option) => option.teamId);
const form = useForm<z.infer<typeof createEventTypeInput>>({
resolver: zodResolver(createEventTypeInput),
defaultValues: { length: 15, teamId },
});
const { setValue, watch, register } = form;
@ -62,20 +69,16 @@ export default function CreateEventTypeButton(props: Props) {
return () => subscription.unsubscribe();
}, [watch, setValue]);
// URL encoded params
const teamId: number | null = Number(router.query.teamId) || null;
const pageSlug = router.query.eventPage || props.options[0].slug;
const hasTeams = !!props.options.find((option) => option.teamId);
const createMutation = useMutation(createEventType, {
const createMutation = trpc.useMutation("viewer.eventTypes.create", {
onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id);
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
@ -83,19 +86,19 @@ export default function CreateEventTypeButton(props: Props) {
const openModal = (option: EventTypeParent) => {
// setTimeout fixes a bug where the url query params are removed immediately after opening the modal
setTimeout(() => {
router.push({
pathname: router.pathname,
query: {
...router.query,
new: "1",
eventPage: option.slug,
...(option.teamId
? {
teamId: option.teamId,
}
: {}),
router.push(
{
pathname: router.pathname,
query: {
...router.query,
new: "1",
eventPage: option.slug,
teamId: option.teamId || undefined,
},
},
});
undefined,
{ shallow: true }
);
});
};
@ -103,7 +106,7 @@ export default function CreateEventTypeButton(props: Props) {
const closeModal = () => {
router.replace({
pathname: router.pathname,
query: { id: router.query.id },
query: { id: router.query.id || undefined },
});
};
@ -160,20 +163,10 @@ export default function CreateEventTypeButton(props: Props) {
<Form
form={form}
handleSubmit={(values) => {
const payload: CreateEventType = {
title: values.title,
slug: values.slug,
description: values.description,
length: values.length,
};
if (router.query.teamId) {
payload.teamId = parseInt(`${router.query.teamId}`, 10);
payload.schedulingType = values.schedulingType as SchedulingType;
}
createMutation.mutate(payload);
createMutation.mutate(values);
}}>
<div className="mt-3 space-y-4">
{teamId && <input type="hidden" {...register("teamId", { valueAsNumber: true })} />}
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
<TextField
@ -201,7 +194,7 @@ export default function CreateEventTypeButton(props: Props) {
defaultValue={15}
label={t("length")}
className="pr-20"
{...register("length")}
{...register("length", { valueAsNumber: true })}
/>
<div className="absolute inset-y-0 right-0 flex items-center pt-4 mt-1.5 pr-3 text-sm text-gray-400">
{t("minutes")}

View File

@ -1,12 +1,12 @@
import React, { Fragment, useState } from "react";
import React, { Fragment } from "react";
import { useMutation } from "react-query";
import Select from "react-select";
import { QueryCell } from "@lib/QueryCell";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import { List } from "@components/List";
import { ShellSubHeading } from "@components/Shell";
import { Alert } from "@components/ui/Alert";
@ -161,76 +161,6 @@ function ConnectedCalendarsList(props: Props) {
);
}
function PrimaryCalendarSelector() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"], {
suspense: true,
});
const [selectedOption, setSelectedOption] = useState(() => {
const selected = query.data?.connectedCalendars
.map((connected) => connected.calendars ?? [])
.flat()
.find((cal) => cal.externalId === query.data.destinationCalendar?.externalId);
if (!selected) {
return null;
}
return {
value: `${selected.integration}:${selected.externalId}`,
label: selected.name,
};
});
const mutation = trpc.useMutation("viewer.setUserDestinationCalendar");
if (!query.data?.connectedCalendars.length) {
return null;
}
const options =
query.data.connectedCalendars.map((selectedCalendar) => ({
key: selectedCalendar.credentialId,
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
options: (selectedCalendar.calendars ?? []).map((cal) => ({
label: cal.name || "",
value: `${cal.integration}:${cal.externalId}`,
})),
})) ?? [];
return (
<div className="relative">
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
<div className="absolute z-10 pointer-events-none">
<Button size="sm" color="secondary" className="border-transparent m-[1px] rounded-sm">
{t("select_destination_calendar")}: {selectedOption?.label || ""}
</Button>
</div>
<Select
name={"primarySelectedCalendar"}
placeholder={`${t("select_destination_calendar")}:`}
options={options}
isSearchable={false}
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
onChange={(option) => {
setSelectedOption(option);
if (!option) {
return;
}
/* Split only the first `:`, since Apple uses the full URL as externalId */
const [integration, externalId] = option.value.split(/:(.+)/);
mutation.mutate({
integration,
externalId,
});
}}
isLoading={mutation.isLoading}
value={selectedOption}
/>
</div>
);
}
function CalendarList(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations"]);
@ -272,6 +202,8 @@ export function CalendarListContainer(props: { heading?: false }) {
utils.invalidateQueries(["viewer.connectedCalendars"]),
]);
const query = trpc.useQuery(["viewer.connectedCalendars"]);
const mutation = trpc.useMutation("viewer.setDestinationCalendar");
return (
<>
{heading && (
@ -286,7 +218,11 @@ export function CalendarListContainer(props: { heading?: false }) {
subtitle={t("configure_how_your_event_types_interact")}
actions={
<div className="block max-w-full sm:min-w-80">
<PrimaryCalendarSelector />
<DestinationCalendarSelector
onChange={mutation.mutate}
isLoading={mutation.isLoading}
value={query.data?.destinationCalendar?.externalId}
/>
</div>
}
/>

View File

@ -7,7 +7,6 @@ import React, { useEffect, useState } from "react";
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
import { WorkingHours } from "@lib/types/schedule";
import Button from "@components/ui/Button";
@ -17,11 +16,16 @@ import SetTimesModal from "./modal/SetTimesModal";
dayjs.extend(utc);
dayjs.extend(timezone);
type AvailabilityInput = Pick<Availability, "days" | "startTime" | "endTime">;
type Props = {
timeZone: string;
availability: Availability[];
setTimeZone: (timeZone: string) => void;
setAvailability: (schedule: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }) => void;
setAvailability: (schedule: {
openingHours: AvailabilityInput[];
dateOverrides: AvailabilityInput[];
}) => void;
};
/**

View File

@ -6,7 +6,6 @@ import React, { SyntheticEvent, useEffect, useState } from "react";
import { PaymentData } from "@ee/lib/stripe/server";
import useDarkMode from "@lib/core/browser/useDarkMode";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
@ -18,8 +17,8 @@ const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
},
style: {
base: {
color: "#000",
iconColor: "#000",
color: "#666",
iconColor: "#666",
fontFamily: "ui-sans-serif, system-ui",
fontSmoothing: "antialiased",
fontSize: "16px",
@ -53,18 +52,10 @@ export default function PaymentComponent(props: Props) {
const stripe = useStripe();
const elements = useElements();
const { isDarkMode } = useDarkMode();
useEffect(() => {
elements?.update({ locale: i18n.language as StripeElementLocale });
}, [elements, i18n.language]);
if (isDarkMode) {
CARD_OPTIONS.style!.base!.color = "#fff";
CARD_OPTIONS.style!.base!.iconColor = "#fff";
CARD_OPTIONS.style!.base!["::placeholder"]!.color = "#fff";
}
const handleChange = async (event: StripeCardElementChangeEvent) => {
// Listen for changes in the CardElement
// and display any errors as the customer types their card details

View File

@ -50,6 +50,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
id: true,
uid: true,
paid: true,
destinationCalendar: true,
user: {
select: {
id: true,
@ -87,6 +88,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone },
attendees: booking.attendees,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
language: t,
};

View File

@ -257,6 +257,10 @@ export default class EventManager {
return Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)));
}
/**
* Not ideal but, if we don't find a destination calendar,
* fallback to the first connected calendar
*/
const [credential] = this.calendarCredentials;
if (!credential) {
return [];

View File

@ -26,6 +26,7 @@ export type NewCalendarEventType = {
export type CalendarEventType = {
uid: string;
etag: string;
/** This is the actual caldav event url, not the location url. */
url: string;
summary: string;
description: string;

View File

@ -64,7 +64,7 @@ type EventBusyDate = Record<"start" | "end", Date | string>;
export interface Calendar {
createEvent(event: CalendarEvent): Promise<NewCalendarEventType>;
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
updateEvent(uid: string, event: CalendarEvent): Promise<unknown>;
deleteEvent(uid: string, event: CalendarEvent): Promise<unknown>;

View File

@ -116,10 +116,11 @@ export default abstract class BaseCalendarService implements Calendar {
}
}
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
async updateEvent(uid: string, event: CalendarEvent): Promise<unknown> {
try {
const events = await this.getEventsByUID(uid);
/** We generate the ICS files */
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
@ -138,15 +139,15 @@ export default abstract class BaseCalendarService implements Calendar {
return {};
}
const eventsToUpdate = events.filter((event) => event.uid === uid);
const eventsToUpdate = events.filter((e) => e.uid === uid);
return Promise.all(
eventsToUpdate.map((event) => {
eventsToUpdate.map((e) => {
return updateCalendarObject({
calendarObject: {
url: event.url,
url: e.url,
data: iCalString,
etag: event?.etag,
etag: e?.etag,
},
headers: this.headers,
});

View File

@ -1,6 +1,9 @@
import * as fetch from "@lib/core/http/fetch-wrapper";
import { CreateEventType, CreateEventTypeResponse } from "@lib/types/event-type";
/**
* @deprecated Use `trpc.useMutation("viewer.eventTypes.create")` instead.
*/
const createEventType = async (data: CreateEventType) => {
const response = await fetch.post<CreateEventType, CreateEventTypeResponse>(
"/api/availability/eventtype",

View File

@ -1,5 +1,8 @@
import * as fetch from "@lib/core/http/fetch-wrapper";
/**
* @deprecated Use `trpc.useMutation("viewer.eventTypes.delete")` instead.
*/
const deleteEventType = async (data: { id: number }) => {
const response = await fetch.remove<{ id: number }, Record<string, never>>(
"/api/availability/eventtype",

View File

@ -7,6 +7,9 @@ type EventTypeResponse = {
eventType: EventType;
};
/**
* @deprecated Use `trpc.useMutation("viewer.eventTypes.update")` instead.
*/
const updateEventType = async (data: EventTypeInput) => {
const response = await fetch.patch<EventTypeInput, EventTypeResponse>("/api/availability/eventtype", data);
return response;

View File

@ -20,6 +20,12 @@ export type AdvancedOptions = {
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
customInputs?: EventTypeCustomInput[];
timeZone?: string;
destinationCalendar?: {
userId?: number;
eventTypeId?: number;
integration: string;
externalId: string;
};
};
export type EventTypeCustomInput = {
@ -49,6 +55,7 @@ export type EventTypeInput = AdvancedOptions & {
slug: string;
description: string;
length: number;
teamId?: number;
hidden: boolean;
locations: unknown;
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };

View File

@ -8,7 +8,7 @@
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"dev": "next dev",
"db-up": "docker-compose up -d",
"db-migrate": "yarn prisma migrate dev",
"db-migrate": "yarn prisma migrate dev && yarn format-schemas",
"db-deploy": "yarn prisma migrate deploy",
"db-seed": "yarn prisma db seed",
"db-nuke": "docker-compose down --volumes --remove-orphans",
@ -22,7 +22,9 @@
"type-check": "tsc --pretty --noEmit",
"build": "next build",
"start": "next start",
"postinstall": "prisma generate",
"format-schemas": "prettier --write ./prisma",
"generate-schemas": "prisma generate && yarn format-schemas",
"postinstall": "yarn generate-schemas",
"pre-commit": "lint-staged",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"prepare": "husky install",
@ -145,7 +147,8 @@
"tailwindcss": "^3.0.0",
"ts-jest": "^26.0.0",
"ts-node": "^10.2.1",
"typescript": "^4.5.2"
"typescript": "^4.5.2",
"zod-prisma": "^0.5.2"
},
"lint-staged": {
"./{*,{ee,pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [

View File

@ -1,227 +1,32 @@
import { EventTypeCustomInput, MembershipRole, Prisma, PeriodType } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { WorkingHours } from "@lib/types/schedule";
function isPeriodType(keyInput: string): keyInput is PeriodType {
return Object.keys(PeriodType).includes(keyInput);
}
function handlePeriodType(periodType: string): PeriodType | undefined {
if (typeof periodType !== "string") return undefined;
const passedPeriodType = periodType.toUpperCase();
if (!isPeriodType(passedPeriodType)) return undefined;
return PeriodType[passedPeriodType];
}
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
if (!customInputs || !customInputs?.length) return undefined;
const cInputsIdsToDelete = customInputs.filter((input) => input.id > 0).map((e) => e.id);
const cInputsToCreate = customInputs
.filter((input) => input.id < 0)
.map((input) => ({
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
}));
const cInputsToUpdate = customInputs
.filter((input) => input.id > 0)
.map((input) => ({
data: {
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
},
where: {
id: input.id,
},
}));
return {
deleteMany: {
eventTypeId,
NOT: {
id: { in: cInputsIdsToDelete },
},
},
createMany: {
data: cInputsToCreate,
},
update: cInputsToUpdate,
};
}
import { createContext } from "@server/createContext";
import { viewerRouter } from "@server/routers/viewer";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
/** So we can reuse tRCP queries */
const trpcCtx = await createContext({ req, res });
if (!session) {
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (!session.user?.id) {
console.error("Session is missing a user id");
return res.status(500).json({ message: "Something went wrong" });
if (req.method === "POST") {
const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.create", req.body);
res.status(201).json({ eventType });
}
if (req.method !== "POST") {
const event = await prisma.eventType.findUnique({
where: { id: req.body.id },
include: {
users: true,
team: {
select: {
members: {
select: {
userId: true,
role: true,
},
},
},
},
},
});
if (!event) {
return res.status(404).json({ message: "No event exists matching that id." });
}
const isAuthorized = (function () {
if (event.team) {
return event.team.members
.filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN)
.map((member) => member.userId)
.includes(session.user.id);
}
return (
event.userId === session.user.id ||
event.users.find((user) => {
return user.id === session.user?.id;
})
);
})();
if (!isAuthorized) {
console.warn(`User ${session.user.id} attempted to an access an event ${event.id} they do not own.`);
return res.status(403).json({ message: "No event exists matching that id." });
}
if (req.method === "PATCH") {
const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.update", req.body);
res.status(201).json({ eventType });
}
if (req.method === "PATCH" || req.method === "POST") {
const data: Prisma.EventTypeCreateInput | Prisma.EventTypeUpdateInput = {
title: req.body.title,
slug: req.body.slug.trim(),
description: req.body.description,
length: parseInt(req.body.length),
hidden: req.body.hidden,
requiresConfirmation: req.body.requiresConfirmation,
disableGuests: req.body.disableGuests,
locations: req.body.locations,
eventName: req.body.eventName,
customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id),
periodType: handlePeriodType(req.body.periodType),
periodDays: req.body.periodDays,
periodStartDate: req.body.periodStartDate,
periodEndDate: req.body.periodEndDate,
periodCountCalendarDays: req.body.periodCountCalendarDays,
minimumBookingNotice:
req.body.minimumBookingNotice || req.body.minimumBookingNotice === 0
? parseInt(req.body.minimumBookingNotice, 10)
: undefined,
slotInterval: req.body.slotInterval,
price: req.body.price,
currency: req.body.currency,
};
if (req.body.schedulingType) {
data.schedulingType = req.body.schedulingType;
}
if (req.method == "POST") {
if (req.body.teamId) {
data.team = {
connect: {
id: req.body.teamId,
},
};
}
const eventType = await prisma.eventType.create({
data: {
...(data as Prisma.EventTypeCreateInput),
users: {
connect: {
id: session?.user?.id,
},
},
},
});
res.status(201).json({ eventType });
} else if (req.method == "PATCH") {
if (req.body.users) {
data.users = {
set: [],
connect: req.body.users.map((id: string) => ({ id: parseInt(id) })),
};
}
if (req.body.timeZone) {
data.timeZone = req.body.timeZone;
}
if (req.body.availability) {
const openingHours: WorkingHours[] = req.body.availability.openingHours || [];
// const overrides = req.body.availability.dateOverrides || [];
const eventTypeId = +req.body.id;
if (eventTypeId) {
await prisma.availability.deleteMany({
where: {
eventTypeId,
},
});
}
const availabilityToCreate = openingHours.map((openingHour) => ({
startTime: new Date(openingHour.startTime),
endTime: new Date(openingHour.endTime),
days: openingHour.days,
}));
data.availability = {
createMany: {
data: availabilityToCreate,
},
};
}
const eventType = await prisma.eventType.update({
where: {
id: req.body.id,
},
data,
});
res.status(200).json({ eventType });
}
}
if (req.method == "DELETE") {
await prisma.eventTypeCustomInput.deleteMany({
where: {
eventTypeId: req.body.id,
},
});
await prisma.eventType.delete({
where: {
id: req.body.id,
},
});
res.status(200).json({});
if (req.method === "DELETE") {
await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.delete", { id: req.body.id });
res.status(200).json({ id: req.body.id, message: "Event Type deleted" });
}
}

View File

@ -96,6 +96,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: true,
uid: true,
payment: true,
destinationCalendar: true,
},
});
@ -126,6 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
location: booking.location ?? "",
uid: booking.uid,
language: t,
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
};
if (reqBody.confirmed) {

View File

@ -224,6 +224,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId: true,
price: true,
currency: true,
destinationCalendar: true,
},
});
@ -306,7 +307,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
location: reqBody.location, // Will be processed by the EventManager later.
language: t,
/** For team events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: users[0].destinationCalendar,
destinationCalendar: eventType.destinationCalendar || users[0].destinationCalendar,
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
@ -350,6 +351,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: users[0].id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: undefined,
},
});
}

View File

@ -64,6 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: true,
uid: true,
eventTypeId: true,
destinationCalendar: true,
},
});
@ -111,7 +112,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
uid: bookingToDelete?.uid,
location: bookingToDelete?.location,
language: t,
destinationCalendar: bookingToDelete?.user.destinationCalendar,
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
};
// Hook up the webhook logic here
@ -171,6 +172,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
location: bookingToDelete.location ?? "",
uid: bookingToDelete.uid ?? "",
language: t,
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
};
await refund(bookingToDelete, evt);
await prisma.booking.update({

View File

@ -44,10 +44,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
username: true,
locale: true,
timeZone: true,
destinationCalendar: true,
},
},
id: true,
uid: true,
destinationCalendar: true,
},
});
@ -88,6 +90,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: booking.attendees,
uid: booking.uid,
language: t,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
};
await sendOrganizerRequestReminderEmail(evt);

View File

@ -1,9 +1,9 @@
import { PhoneIcon, XIcon } from "@heroicons/react/outline";
import {
ChevronRightIcon,
ClockIcon,
DocumentIcon,
ExternalLinkIcon,
ClockIcon,
LinkIcon,
LocationMarkerIcon,
PencilIcon,
@ -12,7 +12,7 @@ import {
UserAddIcon,
UsersIcon,
} from "@heroicons/react/solid";
import { EventTypeCustomInput, Prisma, SchedulingType } from "@prisma/client";
import { Availability, EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import * as RadioGroup from "@radix-ui/react-radio-group";
import dayjs from "dayjs";
@ -21,28 +21,25 @@ import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { useMutation } from "react-query";
import Select from "react-select";
import { StripeData } from "@ee/lib/stripe/server";
import { asNumberOrUndefined, asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
import { LocationType } from "@lib/location";
import deleteEventType from "@lib/mutations/event-types/delete-event-type";
import updateEventType from "@lib/mutations/event-types/update-event-type";
import showToast from "@lib/notification";
import prisma from "@lib/prisma";
import { defaultAvatarSrc } from "@lib/profile";
import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type";
import { trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { WorkingHours } from "@lib/types/schedule";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
@ -60,6 +57,8 @@ import * as RadioArea from "@components/ui/form/radio-area";
dayjs.extend(utc);
dayjs.extend(timezone);
type AvailabilityInput = Pick<Availability, "days" | "startTime" | "endTime">;
type OptionTypeBase = {
label: string;
value: LocationType;
@ -109,27 +108,32 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const router = useRouter();
const updateMutation = useMutation(updateEventType, {
const updateMutation = trpc.useMutation("viewer.eventTypes.update", {
onSuccess: async ({ eventType }) => {
await router.push("/event-types");
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
const deleteMutation = useMutation(deleteEventType, {
const deleteMutation = trpc.useMutation("viewer.eventTypes.delete", {
onSuccess: async () => {
await router.push("/event-types");
showToast(t("event_type_deleted_successfully"), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
const [editIcon, setEditIcon] = useState(true);
const [showLocationModal, setShowLocationModal] = useState(false);
@ -207,8 +211,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
return <p className="text-sm">{t("cal_provide_zoom_meeting_url")}</p>;
case LocationType.Daily:
return <p className="text-sm">{t("cal_provide_video_meeting_url")}</p>;
default:
return null;
}
return null;
};
const removeCustom = (index: number) => {
@ -255,7 +260,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const formMethods = useForm<{
title: string;
eventTitle: string;
eventName: string;
slug: string;
length: number;
description: string;
@ -263,20 +268,22 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
requiresConfirmation: boolean;
schedulingType: SchedulingType | null;
price: number;
isHidden: boolean;
hidden: boolean;
locations: { type: LocationType; address?: string }[];
customInputs: EventTypeCustomInput[];
users: string[];
scheduler: {
enteredAvailability: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
selectedTimezone: string;
};
periodType: string | number;
availability: { openingHours: AvailabilityInput[]; dateOverrides: AvailabilityInput[] };
timeZone: string;
periodType: PeriodType;
periodDays: number;
periodDaysType: string;
periodCountCalendarDays: "1" | "0";
periodDates: { startDate: Date; endDate: Date };
minimumBookingNotice: number;
slotInterval: number | null;
destinationCalendar: {
integration: string;
externalId: string;
};
}>({
defaultValues: {
locations: eventType.locations || [],
@ -504,46 +511,14 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<Form
form={formMethods}
handleSubmit={async (values) => {
const enteredTitle: string = values.title;
const advancedPayload: AdvancedOptions = {};
if (advancedSettingsVisible) {
advancedPayload.eventName = values.eventTitle;
advancedPayload.periodType = asStringOrUndefined(values.periodType);
advancedPayload.periodDays = asNumberOrUndefined(values.periodDays);
advancedPayload.periodCountCalendarDays = Boolean(parseInt(values.periodDaysType));
advancedPayload.periodStartDate = values.periodDates.startDate || undefined;
advancedPayload.periodEndDate = values.periodDates.endDate || undefined;
advancedPayload.minimumBookingNotice = values.minimumBookingNotice;
advancedPayload.slotInterval = values.slotInterval;
advancedPayload.price = requirePayment
? Math.round(parseFloat(asStringOrThrow(values.price)) * 100)
: 0;
advancedPayload.currency = currency;
advancedPayload.availability = values.scheduler.enteredAvailability || undefined;
advancedPayload.customInputs = values.customInputs;
advancedPayload.timeZone = values.scheduler.selectedTimezone;
advancedPayload.disableGuests = values.disableGuests;
advancedPayload.requiresConfirmation = values.requiresConfirmation;
}
const payload: EventTypeInput = {
const { periodDates, periodCountCalendarDays, ...input } = values;
updateMutation.mutate({
...input,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id,
title: enteredTitle,
slug: asStringOrThrow(values.slug),
description: asStringOrThrow(values.description),
length: values.length,
hidden: values.isHidden,
locations: values.locations,
...advancedPayload,
...(team
? {
schedulingType: values.schedulingType as SchedulingType,
users: values.users,
}
: {}),
};
updateMutation.mutate(payload);
});
}}
className="space-y-6">
<div className="space-y-3">
@ -704,6 +679,36 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</span>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-6">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attende calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
<div className="items-center block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor="eventName" className="flex text-sm font-medium text-neutral-700">
Create events on:
</label>
</div>
<div className="w-full">
<div className="relative mt-1 rounded-sm shadow-sm">
<Controller
control={formMethods.control}
name="destinationCalendar"
defaultValue={eventType.destinationCalendar || undefined}
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
/>
)}
/>
</div>
</div>
</div>
)}
<div className="items-center block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor="eventName" className="flex text-sm font-medium text-neutral-700">
@ -717,7 +722,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder={t("meeting_with_user")}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventTitle")}
{...formMethods.register("eventName")}
/>
</div>
</div>
@ -914,7 +919,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
render={() => (
<RadioGroup.Root
defaultValue={periodType?.type}
onValueChange={(val) => formMethods.setValue("periodType", val)}>
onValueChange={(val) =>
formMethods.setValue("periodType", val as PeriodType)
}>
{PERIOD_TYPES.map((period) => (
<div className="flex items-center mb-2 text-sm" key={period.type}>
<RadioGroup.Item
@ -927,19 +934,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
{period.type === "ROLLING" && (
<div className="inline-flex">
<input
type="text"
className="block w-12 mr-2 border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
type="number"
className="block w-12 mr-2 border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [appearance:textfield]"
placeholder="30"
{...formMethods.register("periodDays")}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
onChange={(e) => {
formMethods.setValue("periodDays", Number(e.target.value));
}}
/>
<select
id=""
className="block w-full py-2 pl-3 pr-10 text-base border-gray-300 rounded-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...formMethods.register("periodDaysType")}
{...formMethods.register("periodCountCalendarDays")}
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
<option value="1">{t("calendar_days")}</option>
<option value="0">{t("business_days")}</option>
@ -985,21 +989,18 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
<div className="w-full">
<Controller
name="scheduler"
name="availability"
control={formMethods.control}
render={() => (
<Scheduler
setAvailability={(val: {
openingHours: WorkingHours[];
dateOverrides: WorkingHours[];
}) => {
formMethods.setValue("scheduler.enteredAvailability", {
setAvailability={(val) => {
formMethods.setValue("availability", {
openingHours: val.openingHours,
dateOverrides: val.dateOverrides,
});
}}
setTimeZone={(timeZone) => {
formMethods.setValue("scheduler.selectedTimezone", timeZone);
formMethods.setValue("timeZone", timeZone);
setSelectedTimeZone(timeZone);
}}
timeZone={selectedTimeZone}
@ -1033,7 +1034,12 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
onChange={(event) => setRequirePayment(event.target.checked)}
onChange={(event) => {
setRequirePayment(event.target.checked);
if (!event.target.checked) {
formMethods.setValue("price", 0);
}
}}
id="requirePayment"
name="requirePayment"
type="checkbox"
@ -1063,16 +1069,25 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="items-center block sm:flex">
<div className="w-full">
<div className="relative mt-1 rounded-sm shadow-sm">
<input
type="number"
step="0.01"
required
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Price"
defaultValue={
eventType.price > 0 ? eventType.price / 100.0 : undefined
}
{...formMethods.register("price")}
<Controller
defaultValue={eventType.price}
control={formMethods.control}
name="price"
render={({ field }) => (
<input
{...field}
step="0.01"
min="0.5"
type="number"
required
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Price"
onChange={(e) => {
field.onChange(e.target.valueAsNumber * 100);
}}
value={field.value > 0 ? field.value / 100 : 0}
/>
)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<span className="text-gray-500 sm:text-sm" id="duration">
@ -1103,7 +1118,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<Button href="/event-types" color="secondary" tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit">{t("update")}</Button>
<Button type="submit" disabled={updateMutation.isLoading}>
{t("update")}
</Button>
</div>
</Form>
</div>
@ -1111,14 +1128,14 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="w-full px-2 mt-8 ml-2 sm:w-3/12 sm:mt-0 min-w-[177px] ">
<div className="px-2">
<Controller
name="isHidden"
name="hidden"
control={formMethods.control}
defaultValue={eventType.hidden}
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
formMethods.setValue("isHidden", isChecked);
formMethods.setValue("hidden", isChecked);
}}
label={t("hide_event_type")}
/>
@ -1397,6 +1414,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
userId: true,
price: true,
currency: true,
destinationCalendar: true,
},
});

View File

@ -10,6 +10,13 @@ generator client {
provider = "prisma-client-js"
}
generator zod {
provider = "zod-prisma"
output = "./zod"
imports = "./zod-utils"
relationModel = "default"
}
enum SchedulingType {
ROUND_ROBIN @map("roundRobin")
COLLECTIVE @map("collective")
@ -23,10 +30,13 @@ enum PeriodType {
model EventType {
id Int @id @default(autoincrement())
/// @zod.nonempty()
title String
/// @zod.custom(imports.eventTypeSlug)
slug String
description String?
position Int @default(0)
/// @zod.custom(imports.eventTypeLocations)
locations Json?
length Int
hidden Boolean @default(false)
@ -36,7 +46,7 @@ model EventType {
teamId Int?
bookings Booking[]
availability Availability[]
destinationCalendar DestinationCalendar[]
destinationCalendar DestinationCalendar?
eventName String?
customInputs EventTypeCustomInput[]
timeZone String?
@ -93,6 +103,7 @@ model User {
id Int @id @default(autoincrement())
username String? @unique
name String?
/// @zod.email()
email String @unique
emailVerified DateTime?
password String?

11
prisma/zod-utils.ts Normal file
View File

@ -0,0 +1,11 @@
import { z } from "zod";
import { LocationType } from "@lib/location";
export const eventTypeLocations = z.array(
z.object({ type: z.nativeEnum(LocationType), address: z.string().optional() })
);
export const eventTypeSlug = z.string().transform((val) => val.trim());
export const stringToDate = z.string().transform((a) => new Date(a));
export const stringOrNumber = z.union([z.string().transform((v) => parseInt(v, 10)), z.number().int()]);

27
prisma/zod/attendee.ts Normal file
View File

@ -0,0 +1,27 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteBooking, BookingModel } from "./index";
export const _AttendeeModel = z.object({
id: z.number().int(),
email: z.string(),
name: z.string(),
timeZone: z.string(),
bookingId: z.number().int().nullish(),
});
export interface CompleteAttendee extends z.infer<typeof _AttendeeModel> {
booking?: CompleteBooking | null;
}
/**
* AttendeeModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const AttendeeModel: z.ZodSchema<CompleteAttendee> = z.lazy(() =>
_AttendeeModel.extend({
booking: BookingModel.nullish(),
})
);

View File

@ -0,0 +1,32 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteUser, UserModel, CompleteEventType, EventTypeModel } from "./index";
export const _AvailabilityModel = z.object({
id: z.number().int(),
label: z.string().nullish(),
userId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
days: z.number().int().array(),
startTime: z.date(),
endTime: z.date(),
date: z.date().nullish(),
});
export interface CompleteAvailability extends z.infer<typeof _AvailabilityModel> {
user?: CompleteUser | null;
eventType?: CompleteEventType | null;
}
/**
* AvailabilityModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const AvailabilityModel: z.ZodSchema<CompleteAvailability> = z.lazy(() =>
_AvailabilityModel.extend({
user: UserModel.nullish(),
eventType: EventTypeModel.nullish(),
})
);

65
prisma/zod/booking.ts Normal file
View File

@ -0,0 +1,65 @@
import * as z from "zod";
import { BookingStatus } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import {
CompleteUser,
UserModel,
CompleteBookingReference,
BookingReferenceModel,
CompleteEventType,
EventTypeModel,
CompleteAttendee,
AttendeeModel,
CompleteDailyEventReference,
DailyEventReferenceModel,
CompletePayment,
PaymentModel,
CompleteDestinationCalendar,
DestinationCalendarModel,
} from "./index";
export const _BookingModel = z.object({
id: z.number().int(),
uid: z.string(),
userId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
title: z.string(),
description: z.string().nullish(),
startTime: z.date(),
endTime: z.date(),
location: z.string().nullish(),
createdAt: z.date(),
updatedAt: z.date().nullish(),
confirmed: z.boolean(),
rejected: z.boolean(),
status: z.nativeEnum(BookingStatus),
paid: z.boolean(),
});
export interface CompleteBooking extends z.infer<typeof _BookingModel> {
user?: CompleteUser | null;
references: CompleteBookingReference[];
eventType?: CompleteEventType | null;
attendees: CompleteAttendee[];
dailyRef?: CompleteDailyEventReference | null;
payment: CompletePayment[];
destinationCalendar?: CompleteDestinationCalendar | null;
}
/**
* BookingModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const BookingModel: z.ZodSchema<CompleteBooking> = z.lazy(() =>
_BookingModel.extend({
user: UserModel.nullish(),
references: BookingReferenceModel.array(),
eventType: EventTypeModel.nullish(),
attendees: AttendeeModel.array(),
dailyRef: DailyEventReferenceModel.nullish(),
payment: PaymentModel.array(),
destinationCalendar: DestinationCalendarModel.nullish(),
})
);

View File

@ -0,0 +1,29 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteBooking, BookingModel } from "./index";
export const _BookingReferenceModel = z.object({
id: z.number().int(),
type: z.string(),
uid: z.string(),
meetingId: z.string().nullish(),
meetingPassword: z.string().nullish(),
meetingUrl: z.string().nullish(),
bookingId: z.number().int().nullish(),
});
export interface CompleteBookingReference extends z.infer<typeof _BookingReferenceModel> {
booking?: CompleteBooking | null;
}
/**
* BookingReferenceModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const BookingReferenceModel: z.ZodSchema<CompleteBookingReference> = z.lazy(() =>
_BookingReferenceModel.extend({
booking: BookingModel.nullish(),
})
);

34
prisma/zod/credential.ts Normal file
View File

@ -0,0 +1,34 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteUser, UserModel } from "./index";
// Helper schema for JSON fields
type Literal = boolean | number | string;
type Json = Literal | { [key: string]: Json } | Json[];
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
export const _CredentialModel = z.object({
id: z.number().int(),
type: z.string(),
key: jsonSchema,
userId: z.number().int().nullish(),
});
export interface CompleteCredential extends z.infer<typeof _CredentialModel> {
user?: CompleteUser | null;
}
/**
* CredentialModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const CredentialModel: z.ZodSchema<CompleteCredential> = z.lazy(() =>
_CredentialModel.extend({
user: UserModel.nullish(),
})
);

View File

@ -0,0 +1,26 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteBooking, BookingModel } from "./index";
export const _DailyEventReferenceModel = z.object({
id: z.number().int(),
dailyurl: z.string(),
dailytoken: z.string(),
bookingId: z.number().int().nullish(),
});
export interface CompleteDailyEventReference extends z.infer<typeof _DailyEventReferenceModel> {
booking?: CompleteBooking | null;
}
/**
* DailyEventReferenceModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const DailyEventReferenceModel: z.ZodSchema<CompleteDailyEventReference> = z.lazy(() =>
_DailyEventReferenceModel.extend({
booking: BookingModel.nullish(),
})
);

View File

@ -0,0 +1,39 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import {
CompleteUser,
UserModel,
CompleteBooking,
BookingModel,
CompleteEventType,
EventTypeModel,
} from "./index";
export const _DestinationCalendarModel = z.object({
id: z.number().int(),
integration: z.string(),
externalId: z.string(),
userId: z.number().int().nullish(),
bookingId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
});
export interface CompleteDestinationCalendar extends z.infer<typeof _DestinationCalendarModel> {
user?: CompleteUser | null;
booking?: CompleteBooking | null;
eventType?: CompleteEventType | null;
}
/**
* DestinationCalendarModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const DestinationCalendarModel: z.ZodSchema<CompleteDestinationCalendar> = z.lazy(() =>
_DestinationCalendarModel.extend({
user: UserModel.nullish(),
booking: BookingModel.nullish(),
eventType: EventTypeModel.nullish(),
})
);

82
prisma/zod/eventtype.ts Normal file
View File

@ -0,0 +1,82 @@
import * as z from "zod";
import { PeriodType, SchedulingType } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import {
CompleteUser,
UserModel,
CompleteTeam,
TeamModel,
CompleteBooking,
BookingModel,
CompleteAvailability,
AvailabilityModel,
CompleteDestinationCalendar,
DestinationCalendarModel,
CompleteEventTypeCustomInput,
EventTypeCustomInputModel,
CompleteSchedule,
ScheduleModel,
} from "./index";
// Helper schema for JSON fields
type Literal = boolean | number | string;
type Json = Literal | { [key: string]: Json } | Json[];
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
export const _EventTypeModel = z.object({
id: z.number().int(),
title: z.string().nonempty(),
slug: imports.eventTypeSlug,
description: z.string().nullish(),
position: z.number().int(),
locations: imports.eventTypeLocations,
length: z.number().int(),
hidden: z.boolean(),
userId: z.number().int().nullish(),
teamId: z.number().int().nullish(),
eventName: z.string().nullish(),
timeZone: z.string().nullish(),
periodType: z.nativeEnum(PeriodType),
periodStartDate: z.date().nullish(),
periodEndDate: z.date().nullish(),
periodDays: z.number().int().nullish(),
periodCountCalendarDays: z.boolean().nullish(),
requiresConfirmation: z.boolean(),
disableGuests: z.boolean(),
minimumBookingNotice: z.number().int(),
schedulingType: z.nativeEnum(SchedulingType).nullish(),
price: z.number().int(),
currency: z.string(),
slotInterval: z.number().int().nullish(),
});
export interface CompleteEventType extends z.infer<typeof _EventTypeModel> {
users: CompleteUser[];
team?: CompleteTeam | null;
bookings: CompleteBooking[];
availability: CompleteAvailability[];
destinationCalendar?: CompleteDestinationCalendar | null;
customInputs: CompleteEventTypeCustomInput[];
Schedule: CompleteSchedule[];
}
/**
* EventTypeModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const EventTypeModel: z.ZodSchema<CompleteEventType> = z.lazy(() =>
_EventTypeModel.extend({
users: UserModel.array(),
team: TeamModel.nullish(),
bookings: BookingModel.array(),
availability: AvailabilityModel.array(),
destinationCalendar: DestinationCalendarModel.nullish(),
customInputs: EventTypeCustomInputModel.array(),
Schedule: ScheduleModel.array(),
})
);

View File

@ -0,0 +1,17 @@
import { _EventTypeModel } from "prisma/zod";
const createEventTypeBaseInput = _EventTypeModel
.pick({
title: true,
slug: true,
description: true,
length: true,
teamId: true,
schedulingType: true,
})
.refine((data) => (data.teamId ? data.teamId && data.schedulingType : true), {
path: ["schedulingType"],
message: "You must select a scheduling type for team events",
});
export const createEventTypeInput = createEventTypeBaseInput;

View File

@ -0,0 +1,29 @@
import * as z from "zod";
import { EventTypeCustomInputType } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import { CompleteEventType, EventTypeModel } from "./index";
export const _EventTypeCustomInputModel = z.object({
id: z.number().int(),
eventTypeId: z.number().int(),
label: z.string(),
type: z.nativeEnum(EventTypeCustomInputType),
required: z.boolean(),
placeholder: z.string(),
});
export interface CompleteEventTypeCustomInput extends z.infer<typeof _EventTypeCustomInputModel> {
eventType: CompleteEventType;
}
/**
* EventTypeCustomInputModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const EventTypeCustomInputModel: z.ZodSchema<CompleteEventTypeCustomInput> = z.lazy(() =>
_EventTypeCustomInputModel.extend({
eventType: EventTypeModel,
})
);

19
prisma/zod/index.ts Normal file
View File

@ -0,0 +1,19 @@
export * from "./eventtype";
export * from "./credential";
export * from "./destinationcalendar";
export * from "./user";
export * from "./team";
export * from "./membership";
export * from "./verificationrequest";
export * from "./bookingreference";
export * from "./attendee";
export * from "./dailyeventreference";
export * from "./booking";
export * from "./schedule";
export * from "./availability";
export * from "./selectedcalendar";
export * from "./eventtypecustominput";
export * from "./resetpasswordrequest";
export * from "./remindermail";
export * from "./payment";
export * from "./webhook";

29
prisma/zod/membership.ts Normal file
View File

@ -0,0 +1,29 @@
import * as z from "zod";
import { MembershipRole } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import { CompleteTeam, TeamModel, CompleteUser, UserModel } from "./index";
export const _MembershipModel = z.object({
teamId: z.number().int(),
userId: z.number().int(),
accepted: z.boolean(),
role: z.nativeEnum(MembershipRole),
});
export interface CompleteMembership extends z.infer<typeof _MembershipModel> {
team: CompleteTeam;
user: CompleteUser;
}
/**
* MembershipModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const MembershipModel: z.ZodSchema<CompleteMembership> = z.lazy(() =>
_MembershipModel.extend({
team: TeamModel,
user: UserModel,
})
);

42
prisma/zod/payment.ts Normal file
View File

@ -0,0 +1,42 @@
import * as z from "zod";
import { PaymentType } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import { CompleteBooking, BookingModel } from "./index";
// Helper schema for JSON fields
type Literal = boolean | number | string;
type Json = Literal | { [key: string]: Json } | Json[];
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
export const _PaymentModel = z.object({
id: z.number().int(),
uid: z.string(),
type: z.nativeEnum(PaymentType),
bookingId: z.number().int(),
amount: z.number().int(),
fee: z.number().int(),
currency: z.string(),
success: z.boolean(),
refunded: z.boolean(),
data: jsonSchema,
externalId: z.string(),
});
export interface CompletePayment extends z.infer<typeof _PaymentModel> {
booking?: CompleteBooking | null;
}
/**
* PaymentModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const PaymentModel: z.ZodSchema<CompletePayment> = z.lazy(() =>
_PaymentModel.extend({
booking: BookingModel.nullish(),
})
);

View File

@ -0,0 +1,12 @@
import * as z from "zod";
import { ReminderType } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
export const _ReminderMailModel = z.object({
id: z.number().int(),
referenceId: z.number().int(),
reminderType: z.nativeEnum(ReminderType),
elapsedMinutes: z.number().int(),
createdAt: z.date(),
});

View File

@ -0,0 +1,11 @@
import * as z from "zod";
import * as imports from "../zod-utils";
export const _ResetPasswordRequestModel = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
email: z.string(),
expires: z.date(),
});

37
prisma/zod/schedule.ts Normal file
View File

@ -0,0 +1,37 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteUser, UserModel, CompleteEventType, EventTypeModel } from "./index";
// Helper schema for JSON fields
type Literal = boolean | number | string;
type Json = Literal | { [key: string]: Json } | Json[];
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
export const _ScheduleModel = z.object({
id: z.number().int(),
userId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
title: z.string().nullish(),
freeBusyTimes: jsonSchema,
});
export interface CompleteSchedule extends z.infer<typeof _ScheduleModel> {
user?: CompleteUser | null;
eventType?: CompleteEventType | null;
}
/**
* ScheduleModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const ScheduleModel: z.ZodSchema<CompleteSchedule> = z.lazy(() =>
_ScheduleModel.extend({
user: UserModel.nullish(),
eventType: EventTypeModel.nullish(),
})
);

View File

@ -0,0 +1,25 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteUser, UserModel } from "./index";
export const _SelectedCalendarModel = z.object({
userId: z.number().int(),
integration: z.string(),
externalId: z.string(),
});
export interface CompleteSelectedCalendar extends z.infer<typeof _SelectedCalendarModel> {
user: CompleteUser;
}
/**
* SelectedCalendarModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const SelectedCalendarModel: z.ZodSchema<CompleteSelectedCalendar> = z.lazy(() =>
_SelectedCalendarModel.extend({
user: UserModel,
})
);

30
prisma/zod/team.ts Normal file
View File

@ -0,0 +1,30 @@
import * as z from "zod";
import * as imports from "../zod-utils";
import { CompleteMembership, MembershipModel, CompleteEventType, EventTypeModel } from "./index";
export const _TeamModel = z.object({
id: z.number().int(),
name: z.string().nullish(),
slug: z.string().nullish(),
logo: z.string().nullish(),
bio: z.string().nullish(),
hideBranding: z.boolean(),
});
export interface CompleteTeam extends z.infer<typeof _TeamModel> {
members: CompleteMembership[];
eventTypes: CompleteEventType[];
}
/**
* TeamModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const TeamModel: z.ZodSchema<CompleteTeam> = z.lazy(() =>
_TeamModel.extend({
members: MembershipModel.array(),
eventTypes: EventTypeModel.array(),
})
);

93
prisma/zod/user.ts Normal file
View File

@ -0,0 +1,93 @@
import * as z from "zod";
import { IdentityProvider, UserPlan } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import {
CompleteEventType,
EventTypeModel,
CompleteCredential,
CredentialModel,
CompleteMembership,
MembershipModel,
CompleteBooking,
BookingModel,
CompleteAvailability,
AvailabilityModel,
CompleteSelectedCalendar,
SelectedCalendarModel,
CompleteSchedule,
ScheduleModel,
CompleteWebhook,
WebhookModel,
CompleteDestinationCalendar,
DestinationCalendarModel,
} from "./index";
// Helper schema for JSON fields
type Literal = boolean | number | string;
type Json = Literal | { [key: string]: Json } | Json[];
const literalSchema = z.union([z.string(), z.number(), z.boolean()]);
const jsonSchema: z.ZodSchema<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
export const _UserModel = z.object({
id: z.number().int(),
username: z.string().nullish(),
name: z.string().nullish(),
email: z.string().email(),
emailVerified: z.date().nullish(),
password: z.string().nullish(),
bio: z.string().nullish(),
avatar: z.string().nullish(),
timeZone: z.string(),
weekStart: z.string(),
startTime: z.number().int(),
endTime: z.number().int(),
bufferTime: z.number().int(),
hideBranding: z.boolean(),
theme: z.string().nullish(),
createdDate: z.date(),
completedOnboarding: z.boolean(),
locale: z.string().nullish(),
twoFactorSecret: z.string().nullish(),
twoFactorEnabled: z.boolean(),
identityProvider: z.nativeEnum(IdentityProvider),
identityProviderId: z.string().nullish(),
invitedTo: z.number().int().nullish(),
plan: z.nativeEnum(UserPlan),
brandColor: z.string(),
away: z.boolean(),
metadata: jsonSchema,
});
export interface CompleteUser extends z.infer<typeof _UserModel> {
eventTypes: CompleteEventType[];
credentials: CompleteCredential[];
teams: CompleteMembership[];
bookings: CompleteBooking[];
availability: CompleteAvailability[];
selectedCalendars: CompleteSelectedCalendar[];
Schedule: CompleteSchedule[];
webhooks: CompleteWebhook[];
destinationCalendar?: CompleteDestinationCalendar | null;
}
/**
* UserModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const UserModel: z.ZodSchema<CompleteUser> = z.lazy(() =>
_UserModel.extend({
eventTypes: EventTypeModel.array(),
credentials: CredentialModel.array(),
teams: MembershipModel.array(),
bookings: BookingModel.array(),
availability: AvailabilityModel.array(),
selectedCalendars: SelectedCalendarModel.array(),
Schedule: ScheduleModel.array(),
webhooks: WebhookModel.array(),
destinationCalendar: DestinationCalendarModel.nullish(),
})
);

View File

@ -0,0 +1,12 @@
import * as z from "zod";
import * as imports from "../zod-utils";
export const _VerificationRequestModel = z.object({
id: z.number().int(),
identifier: z.string(),
token: z.string(),
expires: z.date(),
createdAt: z.date(),
updatedAt: z.date(),
});

30
prisma/zod/webhook.ts Normal file
View File

@ -0,0 +1,30 @@
import * as z from "zod";
import { WebhookTriggerEvents } from "../../node_modules/@prisma/client";
import * as imports from "../zod-utils";
import { CompleteUser, UserModel } from "./index";
export const _WebhookModel = z.object({
id: z.string(),
userId: z.number().int(),
subscriberUrl: z.string(),
payloadTemplate: z.string().nullish(),
createdAt: z.date(),
active: z.boolean(),
eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(),
});
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
user: CompleteUser;
}
/**
* WebhookModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() =>
_WebhookModel.extend({
user: UserModel,
})
);

View File

@ -20,6 +20,7 @@ import {
import slugify from "@lib/slugify";
import { Schedule } from "@lib/types/schedule";
import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
import { TRPCError } from "@trpc/server";
import { createProtectedRouter, createRouter } from "../createRouter";
@ -61,45 +62,27 @@ const publicViewerRouter = createRouter()
// routes only available to authenticated users
const loggedInViewerRouter = createProtectedRouter()
.query("me", {
resolve({ ctx }) {
const {
// pick only the part we want to expose in the API
id,
name,
username,
email,
startTime,
endTime,
bufferTime,
locale,
avatar,
createdDate,
completedOnboarding,
twoFactorEnabled,
identityProvider,
brandColor,
plan,
away,
} = ctx.user;
const me = {
id,
name,
username,
email,
startTime,
endTime,
bufferTime,
locale,
avatar,
createdDate,
completedOnboarding,
twoFactorEnabled,
identityProvider,
brandColor,
plan,
away,
resolve({ ctx: { user } }) {
// Destructuring here only makes it more illegible
// pick only the part we want to expose in the API
return {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
startTime: user.startTime,
endTime: user.endTime,
bufferTime: user.bufferTime,
locale: user.locale,
avatar: user.avatar,
createdDate: user.createdDate,
completedOnboarding: user.completedOnboarding,
twoFactorEnabled: user.twoFactorEnabled,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
plan: user.plan,
away: user.away,
};
return me;
},
})
.mutation("deleteMe", {
@ -442,34 +425,40 @@ const loggedInViewerRouter = createProtectedRouter()
};
},
})
.mutation("setUserDestinationCalendar", {
.mutation("setDestinationCalendar", {
input: z.object({
integration: z.string(),
externalId: z.string(),
eventTypeId: z.number().optional(),
bookingId: z.number().optional(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const userId = ctx.user.id;
const { integration, externalId, eventTypeId, bookingId } = input;
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
if (
!allCals.find((cal) => cal.externalId === input.externalId && cal.integration === input.integration)
) {
if (!allCals.find((cal) => cal.externalId === externalId && cal.integration === integration)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` });
}
let where;
if (eventTypeId) where = { eventTypeId };
else if (bookingId) where = { bookingId };
else where = { userId: user.id };
await ctx.prisma.destinationCalendar.upsert({
where: {
userId,
},
where,
update: {
...input,
userId,
integration,
externalId,
},
create: {
...input,
userId,
...where,
integration,
externalId,
},
});
},
@ -782,5 +771,6 @@ const loggedInViewerRouter = createProtectedRouter()
export const viewerRouter = createRouter()
.merge(publicViewerRouter)
.merge(loggedInViewerRouter)
.merge("eventTypes.", eventTypesRouter)
.merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter);

View File

@ -0,0 +1,249 @@
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
import {
_AvailabilityModel,
_DestinationCalendarModel,
_EventTypeCustomInputModel,
_EventTypeModel,
} from "prisma/zod";
import { stringOrNumber } from "prisma/zod-utils";
import { createEventTypeInput } from "prisma/zod/eventtypeCustom";
import { z } from "zod";
import { createProtectedRouter } from "@server/createRouter";
import { viewerRouter } from "@server/routers/viewer";
import { TRPCError } from "@trpc/server";
function isPeriodType(keyInput: string): keyInput is PeriodType {
return Object.keys(PeriodType).includes(keyInput);
}
function handlePeriodType(periodType: string | undefined): PeriodType | undefined {
if (typeof periodType !== "string") return undefined;
const passedPeriodType = periodType.toUpperCase();
if (!isPeriodType(passedPeriodType)) return undefined;
return PeriodType[passedPeriodType];
}
function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
const cInputsIdsToDelete = customInputs.filter((input) => input.id > 0).map((e) => e.id);
const cInputsToCreate = customInputs
.filter((input) => input.id < 0)
.map((input) => ({
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
}));
const cInputsToUpdate = customInputs
.filter((input) => input.id > 0)
.map((input) => ({
data: {
type: input.type,
label: input.label,
required: input.required,
placeholder: input.placeholder,
},
where: {
id: input.id,
},
}));
return {
deleteMany: {
eventTypeId,
NOT: {
id: { in: cInputsIdsToDelete },
},
},
createMany: {
data: cInputsToCreate,
},
update: cInputsToUpdate,
};
}
const AvailabilityInput = _AvailabilityModel.pick({
days: true,
startTime: true,
endTime: true,
});
const EventTypeUpdateInput = _EventTypeModel
/** Optional fields */
.extend({
availability: z
.object({
openingHours: z.array(AvailabilityInput).optional(),
dateOverrides: z.array(AvailabilityInput).optional(),
})
.optional(),
customInputs: z.array(_EventTypeCustomInputModel),
destinationCalendar: _DestinationCalendarModel.pick({
integration: true,
externalId: true,
}),
users: z.array(stringOrNumber).optional(),
})
.partial()
.merge(
_EventTypeModel
/** Required fields */
.pick({
id: true,
})
);
export const eventTypesRouter = createProtectedRouter()
.query("list", {
async resolve({ ctx }) {
return await ctx.prisma.webhook.findMany({
where: {
userId: ctx.user.id,
},
});
},
})
.mutation("create", {
input: createEventTypeInput,
async resolve({ ctx, input }) {
const { schedulingType, teamId, ...rest } = input;
const data: Prisma.EventTypeCreateInput = {
...rest,
users: {
connect: {
id: ctx.user.id,
},
},
};
if (teamId && schedulingType) {
data.team = {
connect: {
id: teamId,
},
};
data.schedulingType = schedulingType;
}
const eventType = await ctx.prisma.eventType.create({ data });
return { eventType };
},
})
// Prevent non-owners to update/delete a team event
.middleware(async ({ ctx, rawInput, next }) => {
const event = await ctx.prisma.eventType.findUnique({
where: { id: (rawInput as Record<"id", number>)?.id },
include: {
users: true,
team: {
select: {
members: {
select: {
userId: true,
role: true,
},
},
},
},
},
});
if (!event) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const isAuthorized = (function () {
if (event.team) {
return event.team.members
.filter((member) => member.role === MembershipRole.OWNER || member.role === MembershipRole.ADMIN)
.map((member) => member.userId)
.includes(ctx.user.id);
}
return event.userId === ctx.user.id || event.users.find((user) => user.id === ctx.user.id);
})();
if (!isAuthorized) {
console.warn(`User ${ctx.user.id} attempted to an access an event ${event.id} they do not own.`);
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
})
.mutation("update", {
input: EventTypeUpdateInput.strict(),
async resolve({ ctx, input }) {
const { availability, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
input;
const data: Prisma.EventTypeUpdateInput = rest;
data.locations = locations ?? undefined;
if (periodType) {
data.periodType = handlePeriodType(periodType);
}
if (destinationCalendar) {
/** We connect or create a destination calendar to the event type instead of the user */
await viewerRouter.createCaller(ctx).mutation("setDestinationCalendar", {
...destinationCalendar,
eventTypeId: id,
});
}
if (customInputs) {
data.customInputs = handleCustomInputs(customInputs, id);
}
if (users) {
data.users = {
set: [],
connect: users.map((userId) => ({ id: userId })),
};
}
if (availability?.openingHours) {
await ctx.prisma.availability.deleteMany({
where: {
eventTypeId: input.id,
},
});
data.availability = {
createMany: {
data: availability.openingHours,
},
};
}
const eventType = await ctx.prisma.eventType.update({
where: { id },
data,
});
return { eventType };
},
})
.mutation("delete", {
input: z.object({
id: z.number(),
}),
async resolve({ ctx, input }) {
const { id } = input;
await ctx.prisma.eventTypeCustomInput.deleteMany({
where: {
eventTypeId: id,
},
});
await ctx.prisma.eventType.delete({
where: {
id,
},
});
return {
id,
};
},
});

109
yarn.lock
View File

@ -1855,6 +1855,15 @@
dependencies:
"@prisma/engines-version" "2.31.0-32.2452cc6313d52b8b9a96999ac0e974d0aedf88db"
"@prisma/debug@3.8.1":
version "3.8.1"
resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-3.8.1.tgz#3c6717d6e0501651709714774ea6d90127c6a2d3"
integrity sha512-ft4VPTYME1UBJ7trfrBuF2w9jX1ipDy786T9fAEskNGb+y26gPDqz5fiEWc2kgHNeVdz/qTI/V3wXILRyEcgxQ==
dependencies:
"@types/debug" "4.1.7"
ms "2.1.3"
strip-ansi "6.0.1"
"@prisma/engines-version@2.31.0-32.2452cc6313d52b8b9a96999ac0e974d0aedf88db":
version "2.31.0-32.2452cc6313d52b8b9a96999ac0e974d0aedf88db"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.31.0-32.2452cc6313d52b8b9a96999ac0e974d0aedf88db.tgz#c45323e420f47dd950b22c873bdcf38f75e65779"
@ -1865,6 +1874,16 @@
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.31.0-32.2452cc6313d52b8b9a96999ac0e974d0aedf88db.tgz#b6cf70bc05dd2a62168a16f3ea58a1b011074621"
integrity sha512-Q9CwN6e5E5Abso7J3A1fHbcF4NXGRINyMnf7WQ07fXaebxTTARY5BNUzy2Mo5uH82eRVO5v7ImNuR044KTjLJg==
"@prisma/generator-helper@~3.8.1":
version "3.8.1"
resolved "https://registry.yarnpkg.com/@prisma/generator-helper/-/generator-helper-3.8.1.tgz#eb1dcc8382faa17c784a9d0e0d79fd207a222aa4"
integrity sha512-3zSy+XTEjmjLj6NO+/YPN1Cu7or3xA11TOoOnLRJ9G4pTT67RJXjK0L9Xy5n+3I0Xlb7xrWCgo8MvQQLMWzxPA==
dependencies:
"@prisma/debug" "3.8.1"
"@types/cross-spawn" "6.0.2"
chalk "4.1.2"
cross-spawn "7.0.3"
"@radix-ui/number@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-0.1.0.tgz#73ad13d5cc5f75fa5e147d72e5d5d5e50d688256"
@ -2380,6 +2399,16 @@
dependencies:
tslib "^2.1.0"
"@ts-morph/common@~0.12.2":
version "0.12.2"
resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.12.2.tgz#61d07a47d622d231e833c44471ab306faaa41aed"
integrity sha512-m5KjptpIf1K0t0QL38uE+ol1n+aNn9MgRq++G3Zym1FlqfN+rThsXlp3cAgib14pIeXF7jk3UtJQOviwawFyYg==
dependencies:
fast-glob "^3.2.7"
minimatch "^3.0.4"
mkdirp "^1.0.4"
path-browserify "^1.0.1"
"@tsconfig/node10@^1.0.7":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9"
@ -2464,6 +2493,20 @@
resolved "https://registry.yarnpkg.com/@types/bcryptjs/-/bcryptjs-2.4.2.tgz#e3530eac9dd136bfdfb0e43df2c4c5ce1f77dfae"
integrity sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==
"@types/cross-spawn@6.0.2":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@types/cross-spawn/-/cross-spawn-6.0.2.tgz#168309de311cd30a2b8ae720de6475c2fbf33ac7"
integrity sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==
dependencies:
"@types/node" "*"
"@types/debug@4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==
dependencies:
"@types/ms" "*"
"@types/engine.io@*":
version "3.1.7"
resolved "https://registry.yarnpkg.com/@types/engine.io/-/engine.io-3.1.7.tgz#86e541a5dc52fb7e97735383564a6ae4cfe2e8f5"
@ -2546,6 +2589,11 @@
"@types/node" "*"
"@types/socket.io" "2.1.13"
"@types/ms@*":
version "0.7.31"
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node@*", "@types/node@>=8.1.0":
version "16.11.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.6.tgz#6bef7a2a0ad684cf6e90fcfe31cecabd9ce0a3ae"
@ -3679,7 +3727,7 @@ chalk@4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@ -3840,6 +3888,13 @@ co@^4.6.0:
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
code-block-writer@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-11.0.0.tgz#5956fb186617f6740e2c3257757fea79315dd7d4"
integrity sha512-GEqWvEWWsOvER+g9keO4ohFoD3ymwyCnqY3hoTr7GZipYFwEhMHJw+TtV0rfgRhNImM6QWZGO2XYjlJVyYT62w==
dependencies:
tslib "2.3.1"
collect-v8-coverage@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"
@ -4062,6 +4117,15 @@ cross-fetch@3.1.4:
dependencies:
node-fetch "2.6.1"
cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
which "^2.0.1"
cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@ -4073,15 +4137,6 @@ cross-spawn@^6.0.0, cross-spawn@^6.0.5:
shebang-command "^1.2.0"
which "^1.2.9"
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
which "^2.0.1"
crypto-browserify@3.12.0, crypto-browserify@^3.11.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec"
@ -8151,6 +8206,11 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parenthesis@^3.1.8:
version "3.1.8"
resolved "https://registry.yarnpkg.com/parenthesis/-/parenthesis-3.1.8.tgz#3457fccb8f05db27572b841dad9d2630b912f125"
integrity sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==
parse-asn1@^5.0.0, parse-asn1@^5.1.5:
version "5.1.6"
resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4"
@ -8230,7 +8290,7 @@ pascalcase@^0.1.1:
resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
path-browserify@1.0.1:
path-browserify@1.0.1, path-browserify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
@ -10404,6 +10464,14 @@ ts-jest@^26.0.0:
semver "7.x"
yargs-parser "20.x"
ts-morph@^13.0.2:
version "13.0.2"
resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-13.0.2.tgz#55546023493ef82389d9e4f28848a556c784bac4"
integrity sha512-SjeeHaRf/mFsNeR3KTJnx39JyEOzT4e+DX28gQx5zjzEOuFs2eGrqeN2PLKs/+AibSxPmzV7RD8nJVKmFJqtLA==
dependencies:
"@ts-morph/common" "~0.12.2"
code-block-writer "^11.0.0"
ts-node@^10.2.1:
version "10.4.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.4.0.tgz#680f88945885f4e6cf450e7f0d6223dd404895f7"
@ -10440,16 +10508,16 @@ tslib@2.0.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"
integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==
tslib@2.3.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tslib@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
@ -11192,6 +11260,15 @@ zen-observable@0.8.15:
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
zod-prisma@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/zod-prisma/-/zod-prisma-0.5.2.tgz#b089e531756073333f986db98190c55c44078db8"
integrity sha512-uL7LDCum1LsJbxq4SrrQYkYG7cnAYJCWkLQWVW+e0AJo6UJRjjKb2tmRmU55BLAI6rBT72SWDyHrV28o/7O2pQ==
dependencies:
"@prisma/generator-helper" "~3.8.1"
parenthesis "^3.1.8"
ts-morph "^13.0.2"
zod@^3.8.2:
version "3.11.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483"