Availabilty consolitadion (#3010)

This commit is contained in:
Omar López 2022-06-10 12:38:46 -06:00 committed by GitHub
parent 1f1e364a30
commit 22d2bae46b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 553 additions and 519 deletions

View File

@ -826,22 +826,8 @@ const BookingPage = ({
</Button>
</div>
</Form>
{mutation.isError && (
<div
data-testid="booking-fail"
className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ltr:ml-3 rtl:mr-3">
<p className="text-sm text-yellow-700">
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
{(mutation.error as HttpError)?.message}
</p>
</div>
</div>
</div>
{(mutation.isError || recurringMutation.isError) && (
<ErrorMessage error={mutation.error || recurringMutation.error} />
)}
</div>
</div>
@ -853,3 +839,24 @@ const BookingPage = ({
};
export default BookingPage;
function ErrorMessage({ error }: { error: unknown }) {
const { t } = useLocale();
const { query: { rescheduleUid } = {} } = useRouter();
return (
<div data-testid="booking-fail" className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ltr:ml-3 rtl:mr-3">
<p className="text-sm text-yellow-700">
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
{error instanceof HttpError || error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
</div>
</div>
);
}

View File

@ -1,15 +0,0 @@
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",
data
);
return response;
};
export default createEventType;

View File

@ -1,14 +0,0 @@
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",
data
);
return response;
};
export default deleteEventType;

View File

@ -1,18 +0,0 @@
import { EventType } from "@prisma/client";
import * as fetch from "@lib/core/http/fetch-wrapper";
import { EventTypeInput } from "@lib/types/event-type";
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;
};
export default updateEventType;

View File

@ -1,87 +0,0 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBusyTimes from "@lib/getBusyTimes";
import prisma from "@lib/prisma";
export async function getUserAvailability(query: {
username: string;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
timezone?: string;
}) {
const username = asStringOrNull(query.username);
const dateFrom = dayjs(asStringOrNull(query.dateFrom));
const dateTo = dayjs(asStringOrNull(query.dateTo));
if (!username) throw new Error("Missing username");
if (!dateFrom.isValid() || !dateTo.isValid()) throw new Error("Invalid time range given.");
const rawUser = await prisma.user.findUnique({
where: {
username: username,
},
select: {
credentials: true,
timeZone: true,
bufferTime: true,
availability: true,
id: true,
startTime: true,
endTime: true,
selectedCalendars: true,
},
});
const getEventType = (id: number) =>
prisma.eventType.findUnique({
where: { id },
select: {
timeZone: true,
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
type EventType = Prisma.PromiseReturnType<typeof getEventType>;
let eventType: EventType | null = null;
if (query.eventTypeId) eventType = await getEventType(query.eventTypeId);
if (!rawUser) throw new Error("No user found");
const { selectedCalendars, ...currentUser } = rawUser;
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: dateFrom.format(),
endTime: dateTo.format(),
eventTypeId: query.eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
const timeZone = query.timezone || eventType?.timeZone || currentUser.timeZone;
const workingHours = getWorkingHours(
{ timeZone },
eventType?.availability.length ? eventType.availability : currentUser.availability
);
return {
busy: bufferedBusyTimes,
timeZone,
workingHours,
};
}

View File

@ -1,38 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { ZodError } from "zod";
import { HttpError } from "@calcom/lib/http-error";
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
/** Allows us to get type inference from API handler responses */
function defaultResponder<T>(f: Handle<T>) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
const result = await f(req, res);
res.json(result);
} catch (err) {
if (err instanceof HttpError) {
res.statusCode = err.statusCode;
res.json({ message: err.message });
} else if (err instanceof Stripe.errors.StripeInvalidRequestError) {
console.error("err", err);
res.statusCode = err.statusCode || 500;
res.json({ message: "Stripe error: " + err.message });
} else if (err instanceof ZodError && err.name === "ZodError") {
console.error("err", JSON.parse(err.message)[0].message);
res.statusCode = 400;
res.json({
message: "Validation errors: " + err.issues.map((i) => `—'${i.path}' ${i.message}`).join(". "),
});
} else {
console.error("err", err);
res.statusCode = 500;
res.json({ message: "Unknown error" });
}
}
};
}
export default defaultResponder;

View File

@ -1,2 +0,0 @@
export { default as defaultHandler } from "./defaultHandler";
export { default as defaultResponder } from "./defaultResponder";

View File

@ -1 +0,0 @@
export * from "./api";

View File

@ -1,141 +1,25 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import type { NextApiRequest } from "next";
import { z } from "zod";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBusyTimes from "@lib/getBusyTimes";
import prisma from "@lib/prisma";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { defaultResponder } from "@calcom/lib/server";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
dayjs.extend(utc);
dayjs.extend(timezone);
const availabilitySchema = z.object({
user: z.string(),
dateFrom: z.string(),
dateTo: z.string(),
eventTypeId: stringOrNumber.optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = asStringOrNull(req.query.user);
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
const dateTo = dayjs(asStringOrNull(req.query.dateTo));
const eventTypeId = typeof req.query.eventTypeId === "string" ? parseInt(req.query.eventTypeId) : undefined;
if (!dateFrom.isValid() || !dateTo.isValid()) {
return res.status(400).json({ message: "Invalid time range given." });
}
const rawUser = await prisma.user.findUnique({
where: {
username: user as string,
},
select: {
credentials: true,
timeZone: true,
bufferTime: true,
availability: true,
id: true,
startTime: true,
endTime: true,
selectedCalendars: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
},
},
defaultScheduleId: true,
},
});
const getEventType = (id: number) =>
prisma.eventType.findUnique({
where: { id },
select: {
seatsPerTimeSlot: true,
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
type EventType = Prisma.PromiseReturnType<typeof getEventType>;
let eventType: EventType | null = null;
if (eventTypeId) eventType = await getEventType(eventTypeId);
if (!rawUser) throw new Error("No user found");
const { selectedCalendars, ...currentUser } = rawUser;
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: dateFrom.format(),
endTime: dateTo.format(),
async function handler(req: NextApiRequest) {
const { user: username, eventTypeId, dateTo, dateFrom } = availabilitySchema.parse(req.query);
return getUserAvailability({
username,
dateFrom,
dateTo,
eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute"),
end: dayjs(a.end).add(currentUser.bufferTime, "minute"),
}));
const schedule = eventType?.schedule
? { ...eventType?.schedule }
: {
...currentUser.schedules.filter(
(schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId
)[0],
};
const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone;
const workingHours = getWorkingHours(
{
timeZone,
},
schedule.availability ||
(eventType?.availability.length ? eventType.availability : currentUser.availability)
);
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats;
if (eventType?.seatsPerTimeSlot) {
currentSeats = await prisma.booking.findMany({
where: {
eventTypeId: eventTypeId,
startTime: {
gte: dateFrom.format(),
lte: dateTo.format(),
},
},
select: {
uid: true,
startTime: true,
_count: {
select: {
attendees: true,
},
},
},
});
}
res.status(200).json({
busy: bufferedBusyTimes,
timeZone,
workingHours,
currentSeats,
});
}
export default defaultResponder(handler);

View File

@ -6,6 +6,7 @@ import EventManager from "@calcom/core/EventManager";
import { sendDeclinedEmails, sendScheduledEmails } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server";
@ -15,8 +16,6 @@ import { HttpError } from "@lib/core/http/error";
import { getTranslation } from "@server/lib/i18n";
import { defaultHandler, defaultResponder } from "~/common";
const authorized = async (
currentUser: Pick<User, "id">,
booking: Pick<Booking, "eventTypeId" | "userId">

View File

@ -5,33 +5,35 @@ import dayjsBusinessTime from "dayjs-business-days2";
import isBetween from "dayjs/plugin/isBetween";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import type { NextApiRequest } from "next";
import rrule from "rrule";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import EventManager from "@calcom/core/EventManager";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import {
sendAttendeeRequestEmail,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
} from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getLuckyUsers, isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { defaultResponder } from "@calcom/lib/server";
import prisma, { userSelect } from "@calcom/prisma";
import { extendedBookingCreateBody } 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 { handlePayment } from "@ee/lib/stripe/server";
import { HttpError } from "@lib/core/http/error";
import { ensureArray } from "@lib/ensureArray";
import { getEventName } from "@lib/event";
import getBusyTimes from "@lib/getBusyTimes";
import isOutOfBounds from "@lib/isOutOfBounds";
import prisma from "@lib/prisma";
import { BookingCreateBody } from "@lib/types/booking";
import sendPayload from "@lib/webhooks/sendPayload";
import getSubscribers from "@lib/webhooks/subscriptions";
@ -81,45 +83,38 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, lengt
// Check for conflicts
let t = true;
if (Array.isArray(busyTimes) && busyTimes.length > 0) {
busyTimes.forEach((busyTime) => {
const startTime = dayjs(busyTime.start);
const endTime = dayjs(busyTime.end);
// Early return
if (!Array.isArray(busyTimes) || busyTimes.length < 1) return t;
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
}
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 slot end time is between start and end time
if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
t = false;
}
// Check if time is between start and end times
if (dayjs(time).isBetween(startTime, endTime, null, "[)")) {
t = false;
break;
}
// Check if startTime is between slot
if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
t = false;
}
});
// 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 userSelect = Prisma.validator<Prisma.UserArgs>()({
select: {
id: true,
email: true,
name: true,
username: true,
timeZone: true,
credentials: true,
bufferTime: true,
destinationCalendar: true,
locale: true,
},
});
const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNames: string[]) => {
const users = await prisma.user.findMany({
where: {
@ -191,6 +186,20 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
seatsPerTimeSlot: true,
recurringEvent: true,
locations: true,
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
@ -202,23 +211,15 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
type User = Prisma.UserGetPayload<typeof userSelect>;
type ExtendedBookingCreateBody = BookingCreateBody & {
noEmail?: boolean;
recurringCount?: number;
rescheduleReason?: string;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody;
async function handler(req: NextApiRequest) {
const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } =
extendedBookingCreateBody.parse(req.body);
// handle dynamic user
const dynamicUserList = Array.isArray(reqBody.user)
? getGroupName(req.body.user)
: getUsernameList(reqBody.user as string);
const hasHashedBookingLink = reqBody.hasHashedBookingLink;
const eventTypeSlug = reqBody.eventTypeSlug;
const eventTypeId = reqBody.eventTypeId;
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
? 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`);
@ -233,11 +234,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
log.error(`Booking ${eventTypeId} failed`, error);
return res.status(400).json(error);
throw new HttpError({ statusCode: 400, message: error.message });
}
const eventType = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
if (!eventType) return res.status(404).json({ message: "eventType.notFound" });
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
let users = !eventTypeId
? await prisma.user.findMany({
@ -258,7 +259,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
...userSelect,
});
if (!eventTypeUser) return res.status(404).json({ message: "eventTypeUser.notFound" });
if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
users.push(eventTypeUser);
}
const [organizerUser] = users;
@ -287,23 +288,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: reqBody.email,
name: reqBody.name,
timeZone: reqBody.timeZone,
language: { translate: tAttendees, locale: reqBody.language ?? "en" },
language: { translate: tAttendees, locale: language ?? "en" },
},
];
const guests = (reqBody.guests || []).map((guest) => {
const g = {
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
};
return g;
});
const guests = (reqBody.guests || []).map((guest) => ({
email: guest,
name: "",
timeZone: reqBody.timeZone,
language: { translate: tGuests, locale: "en" },
}));
// 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)
return res.status(404).json({ message: "Event type does not have seats" });
throw new HttpError({ statusCode: 404, message: "Event type does not have seats" });
const booking = await prisma.booking.findUnique({
where: {
@ -313,13 +311,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: true,
},
});
if (!booking) return res.status(404).json({ message: "Booking not found" });
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
return res.status(409).json({ message: "Booking seats are full" });
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
if (booking.attendees.some((attendee) => attendee.email === invitee[0].email))
return res.status(409).json({ message: "Already signed up for time slot" });
throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" });
await prisma.booking.update({
where: {
@ -336,7 +334,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
},
});
return res.status(201).json(booking);
req.statusCode = 201;
return booking;
}
const teamMemberPromises =
@ -358,7 +357,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const attendeesList = [...invitee, ...guests, ...teamMembers];
const seed = `${organizerUser.username}:${dayjs(req.body.start).utc().format()}:${new Date().getTime()}`;
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const location = !!eventType.locations ? (eventType.locations as Array<{ type: string }>)[0] : "";
@ -456,8 +455,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
async function createBooking() {
// @TODO: check as metadata
if (req.body.web3Details) {
const { web3Details } = req.body;
if (reqBody.web3Details) {
const { web3Details } = reqBody;
await verifyAccount(web3Details.userSignature, web3Details.userWallet);
}
@ -477,7 +476,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
const isConfirmedByDefault = (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid;
const newBookingData: Prisma.BookingCreateInput = {
uid,
@ -548,25 +546,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
/* Validate if there is any stripe_payment credential for this user */
const stripePaymentCredential = await prisma.credential.findFirst({
where: {
type: "stripe_payment",
userId: organizerUser.id,
},
select: {
id: true,
},
});
/** eventType doesnt require payment then we create a booking
* OR
* stripePaymentCredential is found and price is higher than 0 then we create a booking
*/
if (!eventType.price || (stripePaymentCredential && eventType.price > 0)) {
return prisma.booking.create(createBookingObj);
if (typeof eventType.price === "number" && eventType.price > 0) {
/* Validate if there is any stripe_payment credential for this user */
await prisma.credential.findFirst({
rejectOnNotFound(err) {
throw new HttpError({ statusCode: 400, message: "Missing stripe credentials", cause: err });
},
where: {
type: "stripe_payment",
userId: organizerUser.id,
},
select: {
id: true,
},
});
}
// stripePaymentCredential not found and eventType requires payment we return null
return null;
return prisma.booking.create(createBookingObj);
}
let results: EventResult[] = [];
@ -577,31 +573,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
for (const currentUser of users) {
if (!currentUser) {
console.error(`currentUser not found`);
return;
continue;
}
if (!user) user = currentUser;
const selectedCalendars = await prisma.selectedCalendar.findMany({
where: {
const { busy: bufferedBusyTimes } = await getUserAvailability(
{
userId: currentUser.id,
dateFrom: reqBody.start,
dateTo: reqBody.end,
eventTypeId,
},
});
{ user, eventType }
);
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: reqBody.start,
endTime: reqBody.end,
eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
console.log("calendarBusyTimes==>>>", busyTimes);
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
console.log("calendarBusyTimes==>>>", bufferedBusyTimes);
let isAvailableToBeBooked = true;
try {
@ -609,9 +595,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const recurringEvent = parseRecurringEvent(eventType.recurringEvent);
const allBookingDates = new rrule({ dtstart: new Date(reqBody.start), ...recurringEvent }).all();
// Go through each date for the recurring event and check if each one's availability
isAvailableToBeBooked = allBookingDates
.map((aDate) => isAvailable(bufferedBusyTimes, aDate, eventType.length)) // <-- array of booleans
.reduce((acc, value) => acc && value, true); // <-- checks boolean array applying "AND" to each value and the current one, starting in true
// 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, reqBody.start, eventType.length);
}
@ -628,8 +621,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
log.debug(`Booking ${currentUser.name} failed`, error);
res.status(409).json(error);
return;
throw new HttpError({ statusCode: 409, message: error.message });
}
let timeOutOfBounds = false;
@ -655,8 +647,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
log.debug(`Booking ${currentUser.name} failed`, error);
res.status(400).json(error);
return;
throw new HttpError({ statusCode: 409, message: error.message });
}
}
@ -669,14 +660,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
res.status(409).json({ message: "booking.conflict" });
return;
throw new HttpError({ statusCode: 409, message: "booking.conflict" });
}
res.status(500).end();
return;
throw err;
}
if (!user) throw Error("Can't continue, user not found.");
if (!user) throw new HttpError({ statusCode: 404, message: "Can't continue, user not found." });
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
const credentials = await refreshCredentials(user.credentials);
@ -777,21 +766,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
!originalRescheduledBooking?.paid &&
!!booking
) {
try {
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
if (!firstStripeCredential) return res.status(500).json({ message: "Missing payment credentials" });
if (!firstStripeCredential)
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
if (!booking.user) booking.user = user;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
if (!booking.user) booking.user = user;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
res.status(201).json({ ...booking, message: "Payment required", paymentUid: payment.uid });
return;
} catch (e) {
log.error(`Creating payment failed`, e);
res.status(500).json({ message: "Payment Failed" });
return;
}
req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment.uid };
}
log.debug(`Booking ${user.username} completed`);
@ -821,7 +805,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await Promise.all(promises);
// Avoid passing referencesToCreate with id unique constrain values
// refresh hashed link if used
const urlSeed = `${organizerUser.username}:${dayjs(req.body.start).utc().format()}`;
const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`;
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
if (hasHashedBookingLink) {
@ -834,32 +818,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
}
if (booking) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
references: {
createMany: {
data: referencesToCreate,
},
},
});
// booking successful
return res.status(201).json(booking);
}
return res.status(400).json({ message: "There is not a stripe_payment credential" });
},
});
// booking successful
req.statusCode = 201;
return booking;
}
export function getLuckyUsers(
users: User[],
bookingCounts: Prisma.PromiseReturnType<typeof getUserNameWithBookingCounts>
) {
if (!bookingCounts.length) users.slice(0, 1);
const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
return luckyUser ? [luckyUser] : users;
}
export default defaultResponder(handler);

View File

@ -12,7 +12,7 @@ import { NextPageContext } from "next";
import { useSession } from "next-auth/react";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useEffect, useRef, useState, useCallback } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
@ -120,21 +120,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
[props.user.id]
);
const createEventType = async (data: Prisma.EventTypeCreateInput) => {
const res = await fetch(`/api/availability/eventtype`, {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error((await res.json()).message);
}
const responseData = await res.json();
return responseData.data;
};
const createEventType = trpc.useMutation("viewer.eventTypes.create");
const createSchedule = trpc.useMutation("viewer.availability.schedule.create", {
onError: (err) => {
@ -229,7 +215,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
if (eventTypes.length === 0) {
await Promise.all(
DEFAULT_EVENT_TYPES.map(async (event) => {
return await createEventType(event);
return createEventType.mutate(event);
})
);
}

View File

@ -2,7 +2,9 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { randomBytes } from "crypto";
import { z } from "zod";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { sendTeamInviteEmail } from "@calcom/emails";
import { availabilityUserSelect } from "@calcom/prisma";
import {
addSeat,
downgradeTeamMembers,
@ -13,7 +15,6 @@ import {
} from "@calcom/stripe/team-billing";
import { BASE_URL, HOSTED_CAL_FEATURES } from "@lib/config/constants";
import { getUserAvailability } from "@lib/queries/availability";
import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@lib/queries/teams";
import slugify from "@lib/slugify";
@ -408,7 +409,14 @@ export const viewerTeamsRouter = createProtectedRouter()
// verify member is in team
const members = await ctx.prisma.membership.findMany({
where: { teamId: input.teamId },
include: { user: true },
include: {
user: {
select: {
username: true,
...availabilityUserSelect,
},
},
},
});
const member = members?.find((m) => m.userId === input.memberId);
if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
@ -416,12 +424,15 @@ export const viewerTeamsRouter = createProtectedRouter()
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
// get availability for this member
return await getUserAvailability({
username: member.user.username,
timezone: input.timezone,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
});
return await getUserAvailability(
{
username: member.user.username,
timezone: input.timezone,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
},
{ user: member.user }
);
},
})
.mutation("upgradeTeam", {

View File

@ -1,6 +1,6 @@
import { UserPlan } from "@prisma/client";
import { getLuckyUsers } from "../../pages/api/book/event";
import { getLuckyUsers } from "@calcom/lib";
const baseUser = {
id: 0,

View File

@ -3,11 +3,10 @@ import { BookingStatus, Credential, SelectedCalendar } from "@prisma/client";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient";
import notEmpty from "@calcom/lib/notEmpty";
import prisma from "@calcom/prisma";
import type { EventBusyDate } from "@calcom/types/Calendar";
import prisma from "@lib/prisma";
async function getBusyTimes(params: {
export async function getBusyTimes(params: {
credentials: Credential[];
userId: number;
eventTypeId?: number;
@ -32,7 +31,7 @@ async function getBusyTimes(params: {
endTime: true,
},
})
.then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime })));
.then((bookings) => bookings.map(({ startTime, endTime }) => ({ end: endTime, start: startTime })));
if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars);

View File

@ -0,0 +1,145 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import { z } from "zod";
import { getWorkingHours } from "@calcom/lib/availability";
import { HttpError } from "@calcom/lib/http-error";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { stringToDayjs } from "@calcom/prisma/zod-utils";
import { getBusyTimes } from "./getBusyTimes";
const availabilitySchema = z
.object({
dateFrom: stringToDayjs,
dateTo: stringToDayjs,
eventTypeId: z.number().optional(),
timezone: z.string().optional(),
username: z.string().optional(),
userId: z.number().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
const getEventType = (id: number) =>
prisma.eventType.findUnique({
where: { id },
select: {
seatsPerTimeSlot: true,
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
},
});
type EventType = Awaited<ReturnType<typeof getEventType>>;
const getUser = (where: Prisma.UserWhereUniqueInput) =>
prisma.user.findUnique({
where,
select: availabilityUserSelect,
});
type User = Awaited<ReturnType<typeof getUser>>;
export async function getUserAvailability(
query: ({ username: string } | { userId: number }) & {
dateFrom: string;
dateTo: string;
eventTypeId?: number;
timezone?: string;
},
initialData?: {
user?: User;
eventType?: EventType;
}
) {
const { username, userId, dateFrom, dateTo, eventTypeId, timezone } = availabilitySchema.parse(query);
if (!dateFrom.isValid() || !dateTo.isValid())
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
const where: Prisma.UserWhereUniqueInput = {};
if (username) where.username = username;
if (userId) where.id = userId;
let user: User | null = initialData?.user || null;
if (!user) user = await getUser(where);
if (!user) throw new HttpError({ statusCode: 404, message: "No user found" });
let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
const { selectedCalendars, ...currentUser } = user;
const busyTimes = await getBusyTimes({
credentials: currentUser.credentials,
startTime: dateFrom.toISOString(),
endTime: dateTo.toISOString(),
eventTypeId,
userId: currentUser.id,
selectedCalendars,
});
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toISOString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toISOString(),
}));
const timeZone = timezone || eventType?.timeZone || currentUser.timeZone;
const schedule = eventType?.schedule
? { ...eventType?.schedule }
: {
...currentUser.schedules.filter(
(schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId
)[0],
};
const workingHours = getWorkingHours(
{ timeZone },
schedule.availability ||
(eventType?.availability.length ? eventType.availability : currentUser.availability)
);
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats;
if (eventType?.seatsPerTimeSlot) {
currentSeats = await prisma.booking.findMany({
where: {
eventTypeId: eventTypeId,
startTime: {
gte: dateFrom.format(),
lte: dateTo.format(),
},
},
select: {
uid: true,
startTime: true,
_count: {
select: {
attendees: true,
},
},
},
});
}
return {
busy: bufferedBusyTimes,
timeZone,
workingHours,
currentSeats,
};
}

View File

@ -1,3 +1,4 @@
export * from "./CalendarManager";
export * from "./EventManager";
export { default as getBusyTimes } from "./getBusyTimes";
export * from "./videoClient";

View File

@ -1,5 +1,10 @@
import type { EventTypeCustomInput } from "@prisma/client";
import { PeriodType, SchedulingType, UserPlan } from "@prisma/client";
import { PeriodType, Prisma, SchedulingType, UserPlan } from "@prisma/client";
import { baseUserSelect } from "@calcom/prisma/selects";
const userSelectData = Prisma.validator<Prisma.UserArgs>()({ select: baseUserSelect });
type User = Prisma.UserGetPayload<typeof userSelectData>;
const availability = [
{
@ -81,7 +86,13 @@ const commons = {
theme: null,
brandColor: "#292929",
darkBrandColor: "#fafafa",
},
availability: [],
selectedCalendars: [],
startTime: 0,
endTime: 0,
schedules: [],
defaultScheduleId: null,
} as User,
],
};

View File

@ -0,0 +1,19 @@
import { Prisma } from "@prisma/client";
import { userSelect } from "@calcom/prisma";
type User = Prisma.UserGetPayload<typeof userSelect>;
export function getLuckyUsers(
users: User[],
bookingCounts: {
username: string | null;
bookingCount: number;
}[]
) {
if (!bookingCounts.length) users.slice(0, 1);
const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
return luckyUser ? [luckyUser] : users;
}

View File

@ -1,2 +1,3 @@
export { getLuckyUsers } from "./getLuckyUsers";
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
export * from "./isRecurringEvent";

View File

@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { performance } from "perf_hooks";
import { perfObserver } from ".";
import { getServerErrorFromUnkown } from "./getServerErrorFromUnkown";
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
perfObserver.observe({ entryTypes: ["measure"], buffered: true });
/** Allows us to get type inference from API handler responses */
function defaultResponder<T>(f: Handle<T>) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
performance.mark("Start");
const result = await f(req, res);
res.json(result);
} catch (err) {
const error = getServerErrorFromUnkown(err);
res.statusCode = error.statusCode;
res.json({ message: error.message });
} finally {
performance.mark("End");
performance.measure("Measuring endpoint: " + req.url, "Start", "End");
}
};
}
export default defaultResponder;

View File

@ -0,0 +1,29 @@
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { ZodError } from "zod";
import { HttpError } from "../http-error";
export function getServerErrorFromUnkown(cause: unknown): HttpError {
if (cause instanceof Prisma.PrismaClientKnownRequestError) {
return new HttpError({ statusCode: 400, message: cause.message, cause });
}
if (cause instanceof Error) {
return new HttpError({ statusCode: 500, message: cause.message, cause });
}
if (cause instanceof HttpError) {
return cause;
}
if (cause instanceof Stripe.errors.StripeInvalidRequestError) {
return new HttpError({ statusCode: 400, message: cause.message, cause });
}
if (cause instanceof ZodError) {
return new HttpError({ statusCode: 400, message: cause.message, cause });
}
if (typeof cause === "string") {
// @ts-expect-error https://github.com/tc39/proposal-error-cause
return new Error(cause, { cause });
}
return new HttpError({ statusCode: 500, message: `Unhandled error of type '${typeof cause}'` });
}

View File

@ -0,0 +1,5 @@
export { default as defaultHandler } from "./defaultHandler";
export { default as defaultResponder } from "./defaultResponder";
export { getServerErrorFromUnkown } from "./getServerErrorFromUnkown";
export { getTranslation } from "./i18n";
export { default as perfObserver } from "./perfObserver";

View File

@ -0,0 +1,20 @@
import { PerformanceObserver } from "perf_hooks";
declare global {
// eslint-disable-next-line no-var
var perfObserver: PerformanceObserver | undefined;
}
export const perfObserver =
globalThis.perfObserver ||
new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
console.log(entry); // fake call to our custom logging solution
});
});
if (process.env.NODE_ENV !== "production") {
globalThis.perfObserver = perfObserver;
}
export default perfObserver;

View File

@ -1,2 +1,3 @@
export * from "./booking";
export * from "./event-types";
export * from "./user";

View File

@ -0,0 +1,52 @@
import { Prisma } from "@prisma/client";
export const availabilityUserSelect = Prisma.validator<Prisma.UserSelect>()({
credentials: true,
timeZone: true,
bufferTime: true,
availability: true,
id: true,
startTime: true,
endTime: true,
selectedCalendars: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
},
},
defaultScheduleId: true,
});
export const baseUserSelect = Prisma.validator<Prisma.UserSelect>()({
email: true,
name: true,
username: true,
destinationCalendar: true,
locale: true,
plan: true,
avatar: true,
hideBranding: true,
theme: true,
brandColor: true,
darkBrandColor: true,
...availabilityUserSelect,
});
export const userSelect = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
name: true,
username: true,
destinationCalendar: true,
locale: true,
plan: true,
avatar: true,
hideBranding: true,
theme: true,
brandColor: true,
darkBrandColor: true,
...availabilityUserSelect,
},
});

View File

@ -56,3 +56,39 @@ export const stringOrNumber = z.union([
]);
export const stringToDayjs = z.string().transform((val) => dayjs(val));
export const bookingCreateBodySchema = z.object({
email: z.string(),
end: z.string(),
web3Details: z
.object({
userWallet: z.string(),
userSignature: z.string(),
})
.optional(),
eventTypeId: z.number(),
eventTypeSlug: z.string(),
guests: z.array(z.string()).optional(),
location: z.string(),
name: z.string(),
notes: z.string().optional(),
rescheduleUid: z.string().optional(),
recurringEventId: z.string().optional(),
start: z.string(),
timeZone: z.string(),
user: z.union([z.string(), z.array(z.string())]).optional(),
language: z.string(),
bookingUid: z.string().optional(),
customInputs: z.array(z.object({ label: z.string(), value: z.union([z.string(), z.boolean()]) })),
metadata: z.record(z.string()),
hasHashedBookingLink: z.boolean(),
hashedLink: z.string().nullish(),
});
export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
z.object({
noEmail: z.boolean().optional(),
recurringCount: z.number().optional(),
rescheduleReason: z.string().optional(),
})
);