* feat: optimize Prisma queries by replacing findFirst with findUnique where applicable - Replace findFirst/findFirstOrThrow with findUnique/findUniqueOrThrow for queries using unique constraints - Maintain existing functionality and error handling behavior - Focus on queries using primary keys and unique index fields from schema - Revert problematic changes that caused test failures to maintain stability Co-Authored-By: benny@cal.com <benny@cal.com> * revert: exclude API files from Prisma query optimizations per user request - Reverted all 55 API-related files to their original state - Kept all non-API Prisma query optimizations intact - API files include apps/api/v1, apps/api/v2, apps/web/app/api, and packages/app-store/*/api - Non-API optimizations remain for packages/lib, packages/features, apps/web (non-api), etc. Co-Authored-By: benny@cal.com <benny@cal.com> * feat: optimize membership query in attributeUtils to use findUnique with userId_teamId constraint Co-Authored-By: benny@cal.com <benny@cal.com> * revert: exclude test files from Prisma query optimizations per user request Co-Authored-By: benny@cal.com <benny@cal.com> * revert: revert attributeUtils.ts to use findFirst for test compatibility Co-Authored-By: benny@cal.com <benny@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: benny@cal.com <benny@cal.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
682 lines
18 KiB
TypeScript
682 lines
18 KiB
TypeScript
import type { Dayjs } from "@calcom/dayjs";
|
|
import dayjs from "@calcom/dayjs";
|
|
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
|
import { Prisma } from "@calcom/prisma/client";
|
|
|
|
import type { RawDataInput } from "./raw-data.schema";
|
|
|
|
type TimeViewType = "week" | "month" | "year" | "day";
|
|
|
|
type StatusAggregate = {
|
|
completed: number;
|
|
rescheduled: number;
|
|
cancelled: number;
|
|
noShowHost: number;
|
|
noShowGuests: number;
|
|
_all: number;
|
|
uncompleted: number;
|
|
};
|
|
|
|
type AggregateResult = {
|
|
[date: string]: StatusAggregate;
|
|
};
|
|
|
|
// Recursive function to convert a JSON condition object into SQL
|
|
// Helper type guard function to check if value has 'in' property
|
|
function isInCondition(value: any): value is { in: any[] } {
|
|
return typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in);
|
|
}
|
|
|
|
// Helper type guard function to check if value has 'gte' property
|
|
function isGteCondition(value: any): value is { gte: any } {
|
|
return typeof value === "object" && value !== null && "gte" in value;
|
|
}
|
|
|
|
// Helper type guard function to check if value has 'lte' property
|
|
function isLteCondition(value: any): value is { lte: any } {
|
|
return typeof value === "object" && value !== null && "lte" in value;
|
|
}
|
|
|
|
function buildSqlCondition(condition: any): string {
|
|
if (Array.isArray(condition.OR)) {
|
|
return `(${condition.OR.map(buildSqlCondition).join(" OR ")})`;
|
|
} else if (Array.isArray(condition.AND)) {
|
|
return `(${condition.AND.map(buildSqlCondition).join(" AND ")})`;
|
|
} else {
|
|
const clauses: string[] = [];
|
|
for (const [key, value] of Object.entries(condition)) {
|
|
if (isInCondition(value)) {
|
|
const valuesList = value.in.map((v) => `'${v}'`).join(", ");
|
|
clauses.push(`"${key}" IN (${valuesList})`);
|
|
} else if (isGteCondition(value)) {
|
|
clauses.push(`"${key}" >= '${value.gte}'`);
|
|
} else if (isLteCondition(value)) {
|
|
clauses.push(`"${key}" <= '${value.lte}'`);
|
|
} else {
|
|
const formattedValue = typeof value === "string" ? `'${value}'` : value;
|
|
clauses.push(`"${key}" = ${formattedValue}`);
|
|
}
|
|
}
|
|
return clauses.join(" AND ");
|
|
}
|
|
}
|
|
|
|
export interface DateRange {
|
|
startDate: string;
|
|
endDate: string;
|
|
formattedDate: string;
|
|
}
|
|
|
|
export interface GetDateRangesParams {
|
|
startDate: string;
|
|
endDate: string;
|
|
timeZone: string;
|
|
timeView: "day" | "week" | "month" | "year";
|
|
weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday";
|
|
}
|
|
|
|
class EventsInsights {
|
|
static countGroupedByStatusForRanges = async (
|
|
whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput,
|
|
startDate: Dayjs,
|
|
endDate: Dayjs,
|
|
dateRanges: DateRange[],
|
|
timeZone: string
|
|
): Promise<AggregateResult> => {
|
|
const formattedStartDate = dayjs(startDate).format("YYYY-MM-DD HH:mm:ss");
|
|
const formattedEndDate = dayjs(endDate).format("YYYY-MM-DD HH:mm:ss");
|
|
const whereClause = buildSqlCondition(whereConditional);
|
|
|
|
const data = await prisma.$queryRaw<
|
|
{
|
|
date: Date;
|
|
bookingsCount: number;
|
|
timeStatus: string;
|
|
noShowHost: boolean;
|
|
noShowGuests: number;
|
|
}[]
|
|
>`
|
|
SELECT
|
|
"date",
|
|
CAST(COUNT(*) AS INTEGER) AS "bookingsCount",
|
|
CAST(COUNT(CASE WHEN "isNoShowGuest" = true THEN 1 END) AS INTEGER) AS "noShowGuests",
|
|
"timeStatus",
|
|
"noShowHost"
|
|
FROM (
|
|
SELECT
|
|
DATE("createdAt" AT TIME ZONE ${timeZone}) as "date",
|
|
"a"."noShow" AS "isNoShowGuest",
|
|
"timeStatus",
|
|
"noShowHost"
|
|
FROM
|
|
"BookingTimeStatusDenormalized"
|
|
JOIN
|
|
"Attendee" "a" ON "a"."bookingId" = "BookingTimeStatusDenormalized"."id"
|
|
WHERE
|
|
"createdAt" BETWEEN ${formattedStartDate}::timestamp AND ${formattedEndDate}::timestamp
|
|
AND ${Prisma.raw(whereClause)}
|
|
) AS bookings
|
|
GROUP BY
|
|
"date",
|
|
"timeStatus",
|
|
"noShowHost"
|
|
ORDER BY
|
|
"date";
|
|
`;
|
|
|
|
const aggregate: AggregateResult = {};
|
|
|
|
// Initialize all date ranges with zero counts
|
|
dateRanges.forEach(({ formattedDate }) => {
|
|
aggregate[formattedDate] = {
|
|
completed: 0,
|
|
rescheduled: 0,
|
|
cancelled: 0,
|
|
noShowHost: 0,
|
|
noShowGuests: 0,
|
|
_all: 0,
|
|
uncompleted: 0,
|
|
};
|
|
});
|
|
|
|
// Process the raw data
|
|
data.forEach(({ date, bookingsCount, timeStatus, noShowHost, noShowGuests }) => {
|
|
// Find which date range this date belongs to
|
|
const dateRange = dateRanges.find((range) =>
|
|
dayjs(date).isBetween(range.startDate, range.endDate, null, "[]")
|
|
);
|
|
|
|
if (!dateRange) return;
|
|
|
|
const formattedDate = dateRange.formattedDate;
|
|
const statusKey = timeStatus as keyof StatusAggregate;
|
|
|
|
// Add to the specific status count
|
|
aggregate[formattedDate][statusKey] += Number(bookingsCount);
|
|
|
|
// Add to the total count (_all)
|
|
aggregate[formattedDate]["_all"] += Number(bookingsCount);
|
|
|
|
// Track no-show host counts separately
|
|
if (noShowHost) {
|
|
aggregate[formattedDate]["noShowHost"] += Number(bookingsCount);
|
|
}
|
|
|
|
// Track no-show guests explicitly
|
|
aggregate[formattedDate]["noShowGuests"] += noShowGuests;
|
|
});
|
|
|
|
return aggregate;
|
|
};
|
|
|
|
static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
|
const bookings = await prisma.bookingTimeStatusDenormalized.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
const { _count: totalNoShowGuests } = await prisma.attendee.aggregate({
|
|
where: {
|
|
bookingId: {
|
|
in: bookings.map((booking) => booking.id),
|
|
},
|
|
noShow: true,
|
|
},
|
|
_count: true,
|
|
});
|
|
|
|
return totalNoShowGuests;
|
|
};
|
|
|
|
static countGroupedByStatus = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
|
const data = await prisma.bookingTimeStatusDenormalized.groupBy({
|
|
where,
|
|
by: ["timeStatus", "noShowHost"],
|
|
_count: {
|
|
_all: true,
|
|
},
|
|
});
|
|
|
|
return data.reduce(
|
|
(aggregate: { [x: string]: number }, item) => {
|
|
if (typeof item.timeStatus === "string" && item) {
|
|
aggregate[item.timeStatus] += item?._count?._all ?? 0;
|
|
aggregate["_all"] += item?._count?._all ?? 0;
|
|
|
|
if (item.noShowHost) {
|
|
aggregate["noShowHost"] += item?._count?._all ?? 0;
|
|
}
|
|
}
|
|
return aggregate;
|
|
},
|
|
{
|
|
completed: 0,
|
|
rescheduled: 0,
|
|
cancelled: 0,
|
|
noShowHost: 0,
|
|
_all: 0,
|
|
}
|
|
);
|
|
};
|
|
|
|
static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
|
return await prisma.bookingTimeStatusDenormalized.aggregate({
|
|
_avg: {
|
|
rating: true,
|
|
},
|
|
where: {
|
|
...whereConditional,
|
|
rating: {
|
|
not: null, // Exclude null ratings
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
|
const result = await prisma.bookingTimeStatusDenormalized.findMany({
|
|
where: {
|
|
...whereConditional,
|
|
rating: {
|
|
not: null,
|
|
},
|
|
},
|
|
select: { rating: true },
|
|
});
|
|
|
|
const totalResponses = result.length;
|
|
const satisfactoryResponses = result.filter((item) => item.rating && item.rating > 3).length;
|
|
const csat = totalResponses > 0 ? (satisfactoryResponses / totalResponses) * 100 : 0;
|
|
|
|
return csat;
|
|
};
|
|
|
|
static getTimeView = (startDate: string, endDate: string) => {
|
|
const diff = dayjs(endDate).diff(dayjs(startDate), "day");
|
|
if (diff > 365) {
|
|
return "year";
|
|
} else if (diff > 90) {
|
|
return "month";
|
|
} else if (diff > 30) {
|
|
return "week";
|
|
} else {
|
|
return "day";
|
|
}
|
|
};
|
|
|
|
static getPercentage = (actualMetric: number, previousMetric: number) => {
|
|
const differenceActualVsPrevious = actualMetric - previousMetric;
|
|
if (differenceActualVsPrevious === 0) {
|
|
return 0;
|
|
}
|
|
const result = (differenceActualVsPrevious * 100) / previousMetric;
|
|
|
|
if (isNaN(result) || !isFinite(result)) {
|
|
return 0;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
static getDateRanges({
|
|
startDate: _startDate,
|
|
endDate: _endDate,
|
|
timeZone,
|
|
timeView,
|
|
weekStart,
|
|
}: GetDateRangesParams): DateRange[] {
|
|
if (!["day", "week", "month", "year"].includes(timeView)) {
|
|
return [];
|
|
}
|
|
|
|
const startDate = dayjs(_startDate).tz(timeZone);
|
|
const endDate = dayjs(_endDate).tz(timeZone);
|
|
const ranges: DateRange[] = [];
|
|
let currentStartDate = startDate;
|
|
|
|
while (currentStartDate.isBefore(endDate)) {
|
|
let currentEndDate = currentStartDate.endOf(timeView).tz(timeZone);
|
|
|
|
// Adjust week boundaries based on weekStart parameter
|
|
if (timeView === "week") {
|
|
const weekStartNum =
|
|
{
|
|
Sunday: 0,
|
|
Monday: 1,
|
|
Tuesday: 2,
|
|
Wednesday: 3,
|
|
Thursday: 4,
|
|
Friday: 5,
|
|
Saturday: 6,
|
|
}[weekStart] ?? 0;
|
|
|
|
currentEndDate = currentEndDate.add(weekStartNum, "day");
|
|
if (currentEndDate.subtract(7, "day").isAfter(currentStartDate)) {
|
|
currentEndDate = currentEndDate.subtract(7, "day");
|
|
}
|
|
}
|
|
|
|
if (currentEndDate.isAfter(endDate)) {
|
|
currentEndDate = endDate;
|
|
ranges.push({
|
|
startDate: currentStartDate.toISOString(),
|
|
endDate: currentEndDate.toISOString(),
|
|
formattedDate: this.formatPeriod({
|
|
start: currentStartDate,
|
|
end: currentEndDate,
|
|
timeView,
|
|
wholeStart: startDate,
|
|
wholeEnd: endDate,
|
|
}),
|
|
});
|
|
break;
|
|
}
|
|
|
|
ranges.push({
|
|
startDate: currentStartDate.toISOString(),
|
|
endDate: currentEndDate.toISOString(),
|
|
formattedDate: this.formatPeriod({
|
|
start: currentStartDate,
|
|
end: currentEndDate,
|
|
timeView,
|
|
wholeStart: startDate,
|
|
wholeEnd: endDate,
|
|
}),
|
|
});
|
|
|
|
currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone);
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
static formatPeriod({
|
|
start,
|
|
end,
|
|
timeView,
|
|
wholeStart,
|
|
wholeEnd,
|
|
}: {
|
|
start: dayjs.Dayjs;
|
|
end: dayjs.Dayjs;
|
|
timeView: TimeViewType;
|
|
wholeStart: dayjs.Dayjs;
|
|
wholeEnd: dayjs.Dayjs;
|
|
}): string {
|
|
const omitYear = wholeStart.year() === wholeEnd.year();
|
|
|
|
switch (timeView) {
|
|
case "day":
|
|
const shouldShowMonth = wholeStart.isSame(start, "day") || start.date() === 1;
|
|
|
|
if (shouldShowMonth) {
|
|
return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY");
|
|
} else {
|
|
return omitYear ? start.format("D") : start.format("D, YYYY");
|
|
}
|
|
case "week":
|
|
const startFormat = "MMM D";
|
|
let endFormat = "MMM D";
|
|
if (start.format("MMM") === end.format("MMM")) {
|
|
endFormat = "D";
|
|
}
|
|
|
|
if (start.format("YYYY") !== end.format("YYYY")) {
|
|
return `${start.format(`${startFormat} , YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`;
|
|
}
|
|
|
|
if (omitYear) {
|
|
return `${start.format(startFormat)} - ${end.format(endFormat)}`;
|
|
} else {
|
|
return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`;
|
|
}
|
|
case "month":
|
|
return omitYear ? start.format("MMM") : start.format("MMM YYYY");
|
|
case "year":
|
|
return start.format("YYYY");
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
static getCsvData = async (
|
|
props: RawDataInput & {
|
|
organizationId: number | null;
|
|
isOrgAdminOrOwner: boolean | null;
|
|
}
|
|
) => {
|
|
// Obtain the where conditional
|
|
const whereConditional = await this.obtainWhereConditionalForDownload(props);
|
|
const limit = props.limit ?? 100; // Default batch size
|
|
const offset = props.offset ?? 0;
|
|
|
|
const totalCountPromise = prisma.bookingTimeStatusDenormalized.count({
|
|
where: whereConditional,
|
|
});
|
|
|
|
const csvDataPromise = prisma.bookingTimeStatusDenormalized.findMany({
|
|
select: {
|
|
id: true,
|
|
uid: true,
|
|
title: true,
|
|
createdAt: true,
|
|
timeStatus: true,
|
|
eventTypeId: true,
|
|
eventLength: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
paid: true,
|
|
userEmail: true,
|
|
userUsername: true,
|
|
rating: true,
|
|
ratingFeedback: true,
|
|
noShowHost: true,
|
|
},
|
|
where: whereConditional,
|
|
skip: offset,
|
|
take: limit,
|
|
});
|
|
|
|
const [totalCount, csvData] = await Promise.all([totalCountPromise, csvDataPromise]);
|
|
|
|
const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string);
|
|
|
|
if (uids.length === 0) {
|
|
return { data: csvData, total: totalCount };
|
|
}
|
|
|
|
const bookings = await prisma.booking.findMany({
|
|
where: {
|
|
uid: {
|
|
in: uids,
|
|
},
|
|
},
|
|
select: {
|
|
uid: true,
|
|
attendees: {
|
|
select: {
|
|
name: true,
|
|
email: true,
|
|
noShow: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const bookingMap = new Map(bookings.map((booking) => [booking.uid, booking.attendees[0] || null]));
|
|
|
|
const data = csvData.map((bookingTimeStatus) => {
|
|
if (!bookingTimeStatus.uid) {
|
|
// should not be reached because we filtered above
|
|
return bookingTimeStatus;
|
|
}
|
|
|
|
const booker = bookingMap.get(bookingTimeStatus.uid);
|
|
|
|
if (!booker) {
|
|
return bookingTimeStatus;
|
|
}
|
|
|
|
return {
|
|
...bookingTimeStatus,
|
|
noShowGuest: booker.noShow,
|
|
bookerEmail: booker.email,
|
|
bookerName: booker.name,
|
|
};
|
|
});
|
|
|
|
return { data, total: totalCount };
|
|
};
|
|
|
|
/*
|
|
* This is meant to be used for all functions inside insights router,
|
|
* but it's currently used only for CSV download.
|
|
* Ideally we should have a view that have all of this data
|
|
* The order where will be from the most specific to the least specific
|
|
* starting from the top will be:
|
|
* - memberUserId
|
|
* - eventTypeId
|
|
* - userId
|
|
* - teamId
|
|
* Generics will be:
|
|
* - isAll
|
|
* - startDate
|
|
* - endDate
|
|
* @param props
|
|
* @returns
|
|
*/
|
|
static obtainWhereConditionalForDownload = async (
|
|
props: RawDataInput & { organizationId: number | null; isOrgAdminOrOwner: boolean | null }
|
|
) => {
|
|
const {
|
|
startDate,
|
|
endDate,
|
|
teamId,
|
|
userId,
|
|
memberUserId,
|
|
isAll,
|
|
eventTypeId,
|
|
organizationId,
|
|
isOrgAdminOrOwner,
|
|
} = props;
|
|
|
|
// Obtain the where conditional
|
|
let whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {};
|
|
|
|
if (startDate && endDate) {
|
|
whereConditional.createdAt = {
|
|
gte: dayjs(startDate).toISOString(),
|
|
lte: dayjs(endDate).toISOString(),
|
|
};
|
|
}
|
|
|
|
if (eventTypeId) {
|
|
whereConditional["eventTypeId"] = eventTypeId;
|
|
}
|
|
if (memberUserId) {
|
|
whereConditional["userId"] = memberUserId;
|
|
}
|
|
if (userId) {
|
|
whereConditional["teamId"] = null;
|
|
whereConditional["userId"] = userId;
|
|
}
|
|
|
|
if (isAll && isOrgAdminOrOwner && organizationId) {
|
|
const teamsFromOrg = await prisma.team.findMany({
|
|
where: {
|
|
parentId: organizationId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
if (teamsFromOrg.length === 0) {
|
|
return {};
|
|
}
|
|
const teamIds: number[] = [organizationId, ...teamsFromOrg.map((t) => t.id)];
|
|
const usersFromOrg = await prisma.membership.findMany({
|
|
where: {
|
|
teamId: {
|
|
in: teamIds,
|
|
},
|
|
accepted: true,
|
|
},
|
|
select: {
|
|
userId: true,
|
|
},
|
|
});
|
|
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
|
|
whereConditional = {
|
|
...whereConditional,
|
|
OR: [
|
|
{
|
|
userId: {
|
|
in: userIdsFromOrg,
|
|
},
|
|
isTeamBooking: false,
|
|
},
|
|
{
|
|
teamId: {
|
|
in: teamIds,
|
|
},
|
|
isTeamBooking: true,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (teamId && !isAll) {
|
|
const usersFromTeam = await prisma.membership.findMany({
|
|
where: {
|
|
teamId: teamId,
|
|
accepted: true,
|
|
},
|
|
select: {
|
|
userId: true,
|
|
},
|
|
});
|
|
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
|
whereConditional = {
|
|
...whereConditional,
|
|
OR: [
|
|
{
|
|
teamId,
|
|
isTeamBooking: true,
|
|
},
|
|
{
|
|
userId: {
|
|
in: userIdsFromTeam,
|
|
},
|
|
isTeamBooking: false,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return whereConditional;
|
|
};
|
|
|
|
static userIsOwnerAdminOfTeam = async ({
|
|
sessionUserId,
|
|
teamId,
|
|
}: {
|
|
sessionUserId: number;
|
|
teamId: number;
|
|
}) => {
|
|
const isOwnerAdminOfTeam = await prisma.membership.findUnique({
|
|
where: {
|
|
userId_teamId: {
|
|
userId: sessionUserId,
|
|
teamId,
|
|
},
|
|
accepted: true,
|
|
role: {
|
|
in: ["OWNER", "ADMIN"],
|
|
},
|
|
},
|
|
});
|
|
|
|
return !!isOwnerAdminOfTeam;
|
|
};
|
|
|
|
static userIsOwnerAdminOfParentTeam = async ({
|
|
sessionUserId,
|
|
teamId,
|
|
}: {
|
|
sessionUserId: number;
|
|
teamId: number;
|
|
}) => {
|
|
const team = await prisma.team.findFirst({
|
|
select: {
|
|
parentId: true,
|
|
},
|
|
where: {
|
|
id: teamId,
|
|
},
|
|
});
|
|
|
|
if (!team || team.parentId === null) {
|
|
return false;
|
|
}
|
|
|
|
const isOwnerAdminOfParentTeam = await prisma.membership.findUnique({
|
|
where: {
|
|
userId_teamId: {
|
|
userId: sessionUserId,
|
|
teamId: team.parentId,
|
|
},
|
|
accepted: true,
|
|
role: {
|
|
in: ["OWNER", "ADMIN"],
|
|
},
|
|
},
|
|
});
|
|
|
|
return !!isOwnerAdminOfParentTeam;
|
|
};
|
|
}
|
|
|
|
export { EventsInsights };
|