cal/packages/core/getUserAvailability.ts
Efraín Rochín 4284a4d16d
feat: Organizations users calendar cache (#9498)
* Initial commit

* Adding feature flag

* feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209)

* Change scopedMembers to orgMembers

* Change to orgUsers

* Letting duplicate slugs for teams to support orgs

* Covering null on unique clauses

* Supporting having the orgId in the session cookie

* feat: organization event type filter (#9253)

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Missing changes to support orgs schema changes

* feat: Onboarding process to create an organization (#9184)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Making sure we check requestedSlug now

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* feat: [CAL-1816] Organization subdomain support (#9345)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* Covering users and subteams, excluding non-org users

* Unpublished teams shows correctly

* Create subdomain in Vercel

* feedback

* Renaming Vercel env vars

* Vercel domain check before creation

* Supporting cal-staging.com

* Change to have vercel detect it

* vercel domain check data message error

* Remove check domain

* Making sure we check requestedSlug now

* Feedback and unneeded code

* Reverting unneeded changes

* Unneeded changes

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Vercel subdomain creation in PROD only

* Making sure we let localhost still work

* Feedback

* Type check fixes

* feat: Organization branding in side menu (#9279)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Org branding provider used in shell sidebar

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Using org avatar (temp)

* Not showing org logo if not set

* User onboarding with org branding (slug)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Feedback

* Org public profile

* Public profiles for team event types

* Added setup profile alert

* Using org avatar on subteams avatar

* Making sure we show the set up profile on org only

* Profile username availability rely on org hook

* Update apps/web/pages/team/[slug].tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Update apps/web/pages/team/[slug].tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* feat: Organization support for event types page (#9449)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Org branding provider used in shell sidebar

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Using org avatar (temp)

* Not showing org logo if not set

* User onboarding with org branding (slug)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Feedback

* Org public profile

* Public profiles for team event types

* Added setup profile alert

* Using org avatar on subteams avatar

* Processing orgs and children as profile options

* Reverting change not belonging to this PR

* Making sure we show the set up profile on org only

* Removing console.log

* Comparing memberships to choose the highest one

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Type errors

* Refactor and type fixes

* Update orgDomains.ts

* Feedback

* Reverting

* NIT

* fix issue getting org slug from domain

* Improving orgDomains util

* Host comes with port

* Update useRouterQuery.ts

* add org users cache-calendar

* fix typo

* re-use the simple user calendar-cache page

* Apply suggestions from code review

* Update packages/core/CalendarManager.ts

* type fixes

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2023-06-15 00:40:47 +00:00

391 lines
12 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { checkBookingLimit } from "@calcom/lib/server";
import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar";
import { getBusyTimes } from "./getBusyTimes";
const availabilitySchema = z
.object({
dateFrom: stringToDayjs,
dateTo: stringToDayjs,
eventTypeId: z.number().optional(),
username: z.string().optional(),
userId: z.number().optional(),
afterEventBuffer: z.number().optional(),
beforeEventBuffer: z.number().optional(),
duration: z.number().optional(),
withSource: z.boolean().optional(),
orgSlug: z.string().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
const getEventType = async (id: number) => {
const eventType = await prisma.eventType.findUnique({
where: { id },
select: {
id: true,
seatsPerTimeSlot: true,
bookingLimits: true,
durationLimits: true,
timeZone: true,
metadata: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
date: true,
},
},
},
});
if (!eventType) {
return eventType;
}
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
};
};
type EventType = Awaited<ReturnType<typeof getEventType>>;
const getUser = (where: Prisma.UserWhereInput) =>
prisma.user.findFirst({
where,
select: {
...availabilityUserSelect,
credentials: true,
organization: {
select: {
slug: true,
},
},
},
});
type User = Awaited<ReturnType<typeof getUser>>;
export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Dayjs) =>
prisma.booking.findMany({
where: {
eventTypeId,
startTime: {
gte: dateFrom.format(),
lte: dateTo.format(),
},
},
select: {
uid: true,
startTime: true,
_count: {
select: {
attendees: true,
},
},
},
});
export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
export async function getUserAvailability(
query: {
withSource?: boolean;
username?: string;
userId?: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
afterEventBuffer?: number;
beforeEventBuffer?: number;
duration?: number;
orgSlug?: string;
},
initialData?: {
user?: User;
eventType?: EventType;
currentSeats?: CurrentSeats;
}
) {
const {
username,
userId,
dateFrom,
dateTo,
eventTypeId,
afterEventBuffer,
beforeEventBuffer,
duration,
orgSlug,
} = availabilitySchema.parse(query);
if (!dateFrom.isValid() || !dateTo.isValid()) {
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
}
const where: Prisma.UserWhereInput = {};
if (username) where.username = username;
if (orgSlug) where.organization = { slug: orgSlug };
if (userId) where.id = userId;
const user = initialData?.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);
/* 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: CurrentSeats | null = initialData?.currentSeats || null;
if (!currentSeats && eventType?.seatsPerTimeSlot) {
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
}
const busyTimes = await getBusyTimes({
credentials: user.credentials,
startTime: dateFrom.toISOString(),
endTime: dateTo.toISOString(),
eventTypeId,
userId: user.id,
username: `${user.username}`,
organizationSlug: initialData?.user?.organization?.slug,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars: user.selectedCalendars,
});
let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
title: a.title,
source: query.withSource ? a.source : undefined,
}));
const bookings = busyTimes.filter((busyTime) => busyTime.source?.startsWith(`eventType-${eventType?.id}`));
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
if (bookingLimits) {
const bookingBusyTimes = await getBusyTimesFromBookingLimits(
bookings,
bookingLimits,
dateFrom,
dateTo,
eventType
);
bufferedBusyTimes = bufferedBusyTimes.concat(bookingBusyTimes);
}
const durationLimits = parseDurationLimit(eventType?.durationLimits);
if (durationLimits) {
const durationBusyTimes = await getBusyTimesFromDurationLimits(
bookings,
durationLimits,
dateFrom,
dateTo,
duration,
eventType
);
bufferedBusyTimes = bufferedBusyTimes.concat(durationBusyTimes);
}
const userSchedule = user.schedules.filter(
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const schedule =
!eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule
? eventType.schedule
: userSchedule;
const startGetWorkingHours = performance.now();
const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone;
const availability = (
schedule.availability || (eventType?.availability.length ? eventType.availability : user.availability)
).map((a) => ({
...a,
userId: user.id,
}));
const workingHours = getWorkingHours({ timeZone }, availability);
const endGetWorkingHours = performance.now();
logger.debug(`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`);
const dateOverrides = availability
.filter((availability) => !!availability.date)
.map((override) => {
const startTime = dayjs.utc(override.startTime);
const endTime = dayjs.utc(override.endTime);
return {
start: dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()).toDate(),
end: dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()).toDate(),
};
});
return {
busy: bufferedBusyTimes,
timeZone,
workingHours,
dateOverrides,
currentSeats,
};
}
const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week" | "month" | "year") => {
const dates = [];
let startDate = dayjs(dateFrom).startOf(period);
const endDate = dayjs(dateTo).endOf(period);
while (startDate.isBefore(endDate)) {
dates.push(startDate);
startDate = startDate.add(1, period);
}
return dates;
};
const getBusyTimesFromBookingLimits = async (
bookings: EventBusyDetails[],
bookingLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
eventType: EventType | undefined
) => {
const busyTimes: EventBusyDetails[] = [];
// Apply booking limit filter against our bookings
for (const [key, limit] of Object.entries(bookingLimits)) {
const limitKey = key as keyof IntervalLimit;
if (limitKey === "PER_YEAR") {
const yearlyBusyTime = await checkBookingLimit({
eventStartDate: dateFrom.toDate(),
limitingNumber: limit,
eventId: eventType?.id as number,
key: "PER_YEAR",
returnBusyTimes: true,
});
if (!yearlyBusyTime) break;
busyTimes.push({
start: yearlyBusyTime.start.toISOString(),
end: yearlyBusyTime.end.toISOString(),
});
break;
}
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
const dates = getDatesBetween(dateFrom, dateTo, filter);
// loop through all dates and check if we have reached the limit
for (const date of dates) {
let total = 0;
const startDate = date.startOf(filter);
// this is parsed above with parseBookingLimit so we know it's safe.
const endDate = date.endOf(filter);
for (const booking of bookings) {
const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
if (
// Only check OUR booking that matches the current eventTypeId
// we don't care about another event type in this case as we dont need to know their booking limits
!(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
) {
continue;
}
// increment total and check against the limit, adding a busy time if condition is met.
total++;
if (total >= limit) {
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
break;
}
}
}
}
return busyTimes;
};
const getBusyTimesFromDurationLimits = async (
bookings: EventBusyDetails[],
durationLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: EventType | undefined
) => {
const busyTimes: EventBusyDetails[] = [];
// Start check from larger time periods to smaller time periods, to skip unnecessary checks
for (const [key, limit] of Object.entries(durationLimits).reverse()) {
// Use aggregate sql query if we are checking PER_YEAR
if (key === "PER_YEAR") {
const totalBookingDuration = await getTotalBookingDuration({
eventId: eventType?.id as number,
startDate: dateFrom.startOf("year").toDate(),
endDate: dateFrom.endOf("year").toDate(),
});
if (totalBookingDuration + (duration ?? 0) > limit) {
busyTimes.push({
start: dateFrom.startOf("year").toISOString(),
end: dateFrom.endOf("year").toISOString(),
});
}
continue;
}
const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
const dates = getDatesBetween(dateFrom, dateTo, filter);
// loop through all dates and check if we have reached the limit
for (const date of dates) {
let total = duration ?? 0;
const startDate = date.startOf(filter);
const endDate = date.endOf(filter);
// add busy time if we have already reached the limit with just the selected duration
if (total > limit) {
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
continue;
}
for (const booking of bookings) {
const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
if (
// Only check OUR booking that matches the current eventTypeId
// we don't care about another event type in this case as we dont need to know their booking limits
!(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
) {
continue;
}
// Add current booking duration to total and check against the limit, adding a busy time if condition is met.
total += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (total > limit) {
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
break;
}
}
}
}
return busyTimes;
};