import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { readonlyPrisma as prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import type { RawDataInput } from "./raw-data.schema"; interface ITimeRange { start: Dayjs; end: Dayjs; } type TimeViewType = "week" | "month" | "year" | "day"; class EventsInsights { static countGroupedByStatus = async (where: Prisma.BookingTimeStatusWhereInput) => { const data = await prisma.bookingTimeStatus.groupBy({ where, by: ["timeStatus"], _count: { _all: true, }, }); return data.reduce( (aggregate: { [x: string]: number }, item) => { if (typeof item.timeStatus === "string") { aggregate[item.timeStatus] = item._count._all; aggregate["_all"] += item._count._all; } return aggregate; }, { completed: 0, rescheduled: 0, cancelled: 0, _all: 0, } ); }; static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { return await prisma.bookingTimeStatus.aggregate({ _avg: { rating: true, }, where: { ...whereConditional, rating: { not: null, // Exclude null ratings }, }, }); }; static getTotalNoShows = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { return await prisma.bookingTimeStatus.count({ where: { ...whereConditional, noShowHost: true, }, }); }; static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { const result = await prisma.bookingTimeStatus.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 getTimeLine = async (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => { let resultTimeLine: string[] = []; if (timeView) { switch (timeView) { case "day": resultTimeLine = this.getDailyTimeline(startDate, endDate); break; case "week": resultTimeLine = this.getWeekTimeline(startDate, endDate); break; case "month": resultTimeLine = this.getMonthTimeline(startDate, endDate); break; case "year": resultTimeLine = this.getYearTimeline(startDate, endDate); break; default: resultTimeLine = this.getWeekTimeline(startDate, endDate); break; } } return resultTimeLine; }; static getTimeView = (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => { let resultTimeView = timeView; if (startDate.diff(endDate, "day") > 90) { resultTimeView = "month"; } else if (startDate.diff(endDate, "day") > 365) { resultTimeView = "year"; } return resultTimeView; }; static getDailyTimeline(startDate: Dayjs, endDate: Dayjs): string[] { const now = dayjs(); const endOfDay = now.endOf("day"); let pivotDate = dayjs(startDate); const dates: string[] = []; while ((pivotDate.isBefore(endDate) || pivotDate.isSame(endDate)) && pivotDate.isBefore(endOfDay)) { dates.push(pivotDate.format("YYYY-MM-DD")); pivotDate = pivotDate.add(1, "day"); } return dates; } static getWeekTimeline(startDate: Dayjs, endDate: Dayjs): string[] { const now = dayjs(); const endOfDay = now.endOf("day"); let pivotDate = dayjs(startDate); const dates: string[] = []; while (pivotDate.isBefore(endDate) || pivotDate.isSame(endDate)) { const pivotAdded = pivotDate.add(6, "day"); const weekEndDate = pivotAdded.isBefore(endOfDay) ? pivotAdded : endOfDay; dates.push(pivotDate.format("YYYY-MM-DD")); if (pivotDate.isSame(endDate)) { break; } pivotDate = weekEndDate.add(1, "day"); } return dates; } static getMonthTimeline(startDate: Dayjs, endDate: Dayjs) { let pivotDate = dayjs(startDate); const dates = []; while (pivotDate.isBefore(endDate)) { pivotDate = pivotDate.set("month", pivotDate.get("month") + 1); dates.push(pivotDate.format("YYYY-MM-DD")); } return dates; } static getYearTimeline(startDate: Dayjs, endDate: Dayjs) { const pivotDate = dayjs(startDate); const dates = []; while (pivotDate.isBefore(endDate)) { pivotDate.set("year", pivotDate.get("year") + 1); dates.push(pivotDate.format("YYYY-MM-DD")); } return dates; } 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 getCsvData = async ( props: RawDataInput & { organizationId: number | null; isOrgAdminOrOwner: boolean | null; } ) => { // Obtain the where conditional const whereConditional = await this.obtainWhereConditional(props); const csvData = await prisma.bookingTimeStatus.findMany({ select: { id: true, uid: true, title: true, createdAt: true, timeStatus: true, eventTypeId: true, eventLength: true, startTime: true, endTime: true, paid: true, userEmail: true, username: true, rating: true, ratingFeedback: true, noShowHost: true, }, where: whereConditional, }); return csvData; }; /* * This is meant to be used for all functions inside insights router, 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 obtainWhereConditional = 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.BookingTimeStatusWhereInput = {}; let teamConditional: Prisma.TeamWhereInput = {}; if (startDate && endDate) { whereConditional.createdAt = { gte: dayjs(startDate).toISOString(), lte: dayjs(endDate).toISOString(), }; } if (eventTypeId) { whereConditional["OR"] = [ { eventTypeId, }, { eventParentId: 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 {}; } teamConditional = { id: { in: [organizationId, ...teamsFromOrg.map((t) => t.id)], }, }; const usersFromOrg = await prisma.membership.findMany({ where: { team: teamConditional, accepted: true, }, select: { userId: true, }, }); const userIdsFromOrg = usersFromOrg.map((u) => u.userId); whereConditional = { ...whereConditional, OR: [ { userId: { in: userIdsFromOrg, }, teamId: null, }, { teamId: { in: [organizationId, ...teamsFromOrg.map((t) => t.id)], }, }, ], }; } 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, }, { userId: { in: userIdsFromTeam, }, teamId: null, }, ], }; } return whereConditional; }; static userIsOwnerAdminOfTeam = async ({ sessionUserId, teamId, }: { sessionUserId: number; teamId: number; }) => { const isOwnerAdminOfTeam = await prisma.membership.findFirst({ where: { 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.findFirst({ where: { userId: sessionUserId, teamId: team.parentId, accepted: true, role: { in: ["OWNER", "ADMIN"], }, }, }); return !!isOwnerAdminOfParentTeam; }; static objectToCsv(data: Record[]) { // if empty data return empty string if (!data.length) { return ""; } const header = `${Object.keys(data[0]).join(",")}\n`; const rows = data.map((obj: any) => `${Object.values(obj).join(",")}\n`); return header + rows.join(""); } } export { EventsInsights };