Files
calendar/packages/features/insights/server/trpc-router.ts
T
2025-02-12 19:16:52 +00:00

1762 lines
50 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import md5 from "md5";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
rawDataInputSchema,
routingFormResponsesInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
import { randomString } from "@calcom/lib/random";
import type { readonlyPrisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { EventsInsights } from "./events";
import { RoutingEventsInsights } from "./routing-events";
import { VirtualQueuesInsights } from "./virtual-queues";
const UserBelongsToTeamInput = z.object({
teamId: z.coerce.number().optional().nullable(),
isAll: z.boolean().optional(),
});
type BuildBaseWhereConditionCtxType = {
userIsOwnerAdminOfParentTeam: boolean;
userOrganizationId: number | null;
insightsDb: typeof readonlyPrisma;
};
interface BuildBaseWhereConditionType {
teamId?: number | null;
eventTypeId?: number;
memberUserId?: number;
userId?: number;
isAll?: boolean;
ctx: BuildBaseWhereConditionCtxType;
}
const bookingStatusSchema = z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).optional();
const buildBaseWhereCondition = async ({
teamId,
eventTypeId,
memberUserId,
userId,
isAll,
ctx,
}: BuildBaseWhereConditionType): Promise<{
whereCondition: Prisma.BookingTimeStatusWhereInput;
isEmptyResponse?: boolean;
}> => {
let whereCondition: Prisma.BookingTimeStatusWhereInput = {};
// EventType Filter
if (eventTypeId) whereCondition.OR = [{ eventTypeId }, { eventParentId: eventTypeId }];
// User/Member filter
if (memberUserId) whereCondition.userId = memberUserId;
if (userId) {
whereCondition.teamId = null;
whereCondition.userId = userId;
}
// organization-wide queries condition
if (isAll && ctx.userIsOwnerAdminOfParentTeam && ctx.userOrganizationId) {
const teamsFromOrg = await ctx.insightsDb.team.findMany({
where: {
parentId: ctx.userOrganizationId,
},
select: {
id: true,
},
});
if (teamsFromOrg.length === 0) {
return {
whereCondition: {
...whereCondition,
OR: [
...(whereCondition.OR ?? []),
{
teamId: ctx.userOrganizationId,
isTeamBooking: true,
},
],
},
isEmptyResponse: true,
};
}
const teamConditional = {
id: {
in: [ctx.userOrganizationId, ...teamsFromOrg.map((t) => t.id)],
},
};
const usersFromOrg = await ctx.insightsDb.membership.findMany({
where: {
team: teamConditional,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
whereCondition = {
...whereCondition,
OR: [
{
teamId: {
in: [ctx.userOrganizationId, ...teamsFromOrg.map((t) => t.id)],
},
isTeamBooking: true,
},
{
userId: {
in: userIdsFromOrg,
},
isTeamBooking: false,
},
],
};
}
if (teamId && !isAll && !eventTypeId) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId: teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
whereCondition = {
...whereCondition,
OR: [
{
teamId,
isTeamBooking: true,
},
{
userId: {
in: userIdsFromTeam,
},
isTeamBooking: false,
},
],
};
}
return { whereCondition };
};
const buildHashMapForUsers = <
T extends { avatarUrl: string | null; id: number; username: string | null; [key: string]: unknown }
>(
usersFromTeam: T[]
) => {
const userHashMap = new Map<number | null, Omit<T, "avatarUrl"> & { avatarUrl: string }>();
usersFromTeam.forEach((user) => {
userHashMap.set(user.id, {
...user,
// TODO: Use AVATAR_FALLBACK when avatar.png endpoint is fased out
avatarUrl: user.avatarUrl || `/${user.username}/avatar.png`,
});
});
return userHashMap;
};
const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, getRawInput }) => {
const parse = UserBelongsToTeamInput.safeParse(await getRawInput());
if (!parse.success) {
throw new TRPCError({ code: "BAD_REQUEST" });
}
// If teamId is provided, check if user belongs to team
// If teamId is not provided, check if user belongs to any team
const membershipWhereConditional: Prisma.MembershipWhereInput = {
userId: ctx.user.id,
accepted: true,
};
if (parse.data.teamId) {
membershipWhereConditional["teamId"] = parse.data.teamId;
}
const membership = await ctx.insightsDb.membership.findFirst({
where: membershipWhereConditional,
});
let isOwnerAdminOfParentTeam = false;
// Probably we couldn't find a membership because the user is not a direct member of the team
// So that would mean ctx.user.organization is present
if ((parse.data.isAll && ctx.user.organizationId) || (!membership && ctx.user.organizationId)) {
//Look for membership type in organizationId
if (!membership && ctx.user.organizationId && parse.data.teamId) {
const isChildTeamOfOrg = await ctx.insightsDb.team.findFirst({
where: {
id: parse.data.teamId,
parentId: ctx.user.organizationId,
},
});
if (!isChildTeamOfOrg) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const membershipOrg = await ctx.insightsDb.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: ctx.user.organizationId,
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
},
});
if (!membershipOrg) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
isOwnerAdminOfParentTeam = true;
}
return next({
ctx: {
user: {
...ctx.user,
isOwnerAdminOfParentTeam,
},
},
});
});
const userSelect = {
id: true,
name: true,
email: true,
username: true,
avatarUrl: true,
};
const emptyResponseEventsByStatus = {
empty: true,
created: {
count: 0,
deltaPrevious: 0,
},
completed: {
count: 0,
deltaPrevious: 0,
},
rescheduled: {
count: 0,
deltaPrevious: 0,
},
cancelled: {
count: 0,
deltaPrevious: 0,
},
rating: {
count: 0,
deltaPrevious: 0,
},
no_show: {
count: 0,
deltaPrevious: 0,
},
no_show_guest: {
count: 0,
deltaPrevious: 0,
},
csat: {
count: 0,
deltaPrevious: 0,
},
previousRange: {
startDate: dayjs().toISOString(),
endDate: dayjs().toISOString(),
},
};
export interface IResultTeamList {
id: number;
slug: string | null;
name: string | null;
logoUrl: string | null;
userId?: number;
isOrg?: boolean;
}
const BATCH_SIZE = 1000; // Adjust based on your needs
export const insightsRouter = router({
eventsByStatus: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input;
if (userId && userId !== ctx.user.id) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
const { whereCondition: whereConditional } = r;
const baseWhereCondition = {
...whereConditional,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day");
const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day");
const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day");
const lastPeriodBaseCondition = {
...whereConditional,
createdAt: {
gte: lastPeriodStartDate.toDate(),
lte: lastPeriodEndDate.toDate(),
},
};
const [
countGroupedByStatus,
totalRatingsAggregate,
totalCSAT,
totalNoShowGuests,
lastPeriodCountGroupedByStatus,
lastPeriodTotalRatingsAggregate,
lastPeriodTotalCSAT,
lastPeriodTotalNoShowGuests,
] = await Promise.all([
EventsInsights.countGroupedByStatus(baseWhereCondition),
EventsInsights.getAverageRating(baseWhereCondition),
EventsInsights.getTotalCSAT(baseWhereCondition),
EventsInsights.getTotalNoShowGuests(baseWhereCondition),
EventsInsights.countGroupedByStatus(lastPeriodBaseCondition),
EventsInsights.getAverageRating(lastPeriodBaseCondition),
EventsInsights.getTotalCSAT(lastPeriodBaseCondition),
EventsInsights.getTotalNoShowGuests(lastPeriodBaseCondition),
]);
const baseBookingsCount = countGroupedByStatus["_all"];
const totalCompleted = countGroupedByStatus["completed"];
const totalRescheduled = countGroupedByStatus["rescheduled"];
const totalCancelled = countGroupedByStatus["cancelled"];
const totalNoShow = countGroupedByStatus["noShowHost"];
const averageRating = totalRatingsAggregate._avg.rating
? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1))
: 0;
const lastPeriodBaseBookingsCount = lastPeriodCountGroupedByStatus["_all"];
const lastPeriodTotalRescheduled = lastPeriodCountGroupedByStatus["rescheduled"];
const lastPeriodTotalCancelled = lastPeriodCountGroupedByStatus["cancelled"];
const lastPeriodTotalNoShow = lastPeriodCountGroupedByStatus["noShowHost"];
const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating
? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1))
: 0;
const result = {
empty: false,
created: {
count: baseBookingsCount,
deltaPrevious: EventsInsights.getPercentage(baseBookingsCount, lastPeriodBaseBookingsCount),
},
completed: {
count: totalCompleted,
deltaPrevious: EventsInsights.getPercentage(
baseBookingsCount - totalCancelled - totalRescheduled,
lastPeriodBaseBookingsCount - lastPeriodTotalCancelled - lastPeriodTotalRescheduled
),
},
rescheduled: {
count: totalRescheduled,
deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled),
},
cancelled: {
count: totalCancelled,
deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled),
},
no_show: {
count: totalNoShow,
deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow),
},
no_show_guest: {
count: totalNoShowGuests,
deltaPrevious: EventsInsights.getPercentage(totalNoShowGuests, lastPeriodTotalNoShowGuests),
},
rating: {
count: averageRating,
deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating),
},
csat: {
count: totalCSAT,
deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT),
},
previousRange: {
startDate: lastPeriodStartDate.format("YYYY-MM-DD"),
endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
},
};
if (
result.created.count === 0 &&
result.completed.count === 0 &&
result.rescheduled.count === 0 &&
result.cancelled.count === 0 &&
result.no_show.count === 0 &&
result.no_show_guest.count === 0 &&
result.rating.count === 0
) {
return emptyResponseEventsByStatus;
}
return result;
}),
eventsTimeline: userBelongsToTeamProcedure
.input(
rawDataInputSchema.extend({
timeView: z.enum(["week", "month", "year", "day"]),
})
)
.query(async ({ ctx, input }) => {
const {
teamId,
eventTypeId,
memberUserId,
isAll,
startDate: startDateString,
endDate: endDateString,
timeView: inputTimeView,
userId: selfUserId,
} = input;
// Convert to UTC without shifting the time zone
let startDate = dayjs.utc(startDateString).startOf("day");
let endDate = dayjs.utc(endDateString).endOf("day");
if (selfUserId && ctx.user?.id !== selfUserId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!teamId && !selfUserId) {
return [];
}
let timeView = inputTimeView;
// Adjust the timeView if the range is less than 14 days
if (timeView === "week" && endDate.diff(startDate, "day") < 14) {
timeView = "day";
}
// Align startDate to the Monday of the week if timeView is 'week'
if (timeView === "week") {
startDate = startDate.day(1); // Set startDate to Monday
endDate = endDate.day(1).add(6, "day").endOf("day"); // Set endDate to Sunday of the same week
}
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: selfUserId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
const { whereCondition: whereConditional } = r;
const timeline = await EventsInsights.getTimeLine(timeView, startDate, endDate);
if (!timeline) {
return [];
}
const dateFormat: string = timeView === "year" ? "YYYY" : timeView === "month" ? "MMM YYYY" : "ll";
// Align date ranges consistently with weeks starting on Monday
const dateRanges = timeline.map((date) => {
let startOfRange = dayjs.utc(date).startOf(timeView);
let endOfRange = dayjs.utc(date).endOf(timeView);
if (timeView === "week") {
startOfRange = dayjs.utc(date).day(1); // Start at Monday in UTC
endOfRange = startOfRange.add(6, "day").endOf("day"); // End at Sunday in UTC
}
return {
startDate: startOfRange.toISOString(),
endDate: endOfRange.toISOString(),
formattedDate: startOfRange.format(dateFormat), // Align formatted date for consistency
};
});
// Fetch counts grouped by status for the entire range
const countsByStatus = await EventsInsights.countGroupedByStatusForRanges(
whereConditional,
startDate,
endDate,
timeView
);
const result = dateRanges.map(({ formattedDate }) => {
const EventData = {
Month: formattedDate,
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
"No-Show (Host)": 0,
"No-Show (Guest)": 0,
};
const countsForDateRange = countsByStatus[formattedDate];
if (countsForDateRange) {
EventData["Created"] = countsForDateRange["_all"] || 0;
EventData["Completed"] = countsForDateRange["completed"] || 0;
EventData["Rescheduled"] = countsForDateRange["rescheduled"] || 0;
EventData["Cancelled"] = countsForDateRange["cancelled"] || 0;
EventData["No-Show (Host)"] = countsForDateRange["noShowHost"] || 0;
EventData["No-Show (Guest)"] = countsForDateRange["noShowGuests"] || 0;
}
return EventData;
});
return result;
}),
popularEventTypes: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input;
const user = ctx.user;
if (userId && user?.id !== userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!teamId && !userId) {
return [];
}
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
...bookingWhere,
createdAt: {
gte: dayjs(startDate).toISOString(),
lte: dayjs(endDate).toISOString(),
},
};
const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatus.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const eventTypeIds = bookingsFromSelected
.filter((booking) => typeof booking.eventTypeId === "number")
.map((booking) => booking.eventTypeId);
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds as number[],
},
};
const eventTypesFrom = await ctx.insightsDb.eventType.findMany({
select: {
id: true,
title: true,
teamId: true,
userId: true,
slug: true,
users: {
select: {
username: true,
},
},
team: {
select: {
slug: true,
},
},
},
where: eventTypeWhereConditional,
});
const eventTypeHashMap: Map<
number,
Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
teamId: true;
userId: true;
slug: true;
users: {
select: {
username: true;
};
};
team: {
select: {
slug: true;
};
};
};
}>
> = new Map();
eventTypesFrom.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType);
});
const result = bookingsFromSelected.map((booking) => {
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
if (!eventTypeSelected) {
return {};
}
let eventSlug = "";
if (eventTypeSelected.userId) {
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
}
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
}
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventSlug,
count: booking._count.id,
};
});
return result;
}),
averageEventDuration: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
const {
teamId,
startDate: startDateString,
endDate: endDateString,
memberUserId,
userId,
eventTypeId,
isAll,
} = input;
if (userId && ctx.user?.id !== userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!teamId && !userId) {
return [];
}
const startDate = dayjs.utc(startDateString).startOf("day");
const endDate = dayjs.utc(endDateString).endOf("day");
const { whereCondition: whereConditional } = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
const timeView = EventsInsights.getTimeView("week", startDate, endDate);
const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate);
if (!timeLine) {
return [];
}
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
const allBookings = await ctx.insightsDb.bookingTimeStatus.findMany({
select: {
eventLength: true,
createdAt: true,
},
where: {
...whereConditional,
createdAt: {
gte: startDate.toDate(),
lte: endDate.toDate(),
},
},
});
const resultMap = new Map<string, { totalDuration: number; count: number }>();
for (const date of timeLine) {
resultMap.set(dayjs(date).startOf(startOfEndOf).format("ll"), { totalDuration: 0, count: 0 });
}
for (const booking of allBookings) {
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
if (resultMap.has(periodStart)) {
const current = resultMap.get(periodStart)!;
current.totalDuration += booking.eventLength || 0;
current.count += 1;
}
}
const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
Date: date,
Average: count > 0 ? totalDuration / count : 0,
}));
return result;
}),
membersWithMostBookings: userBelongsToTeamProcedure
.input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
...bookingWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) {
userIds.push(booking.userId);
}
return userIds;
}, []);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.insightsDb.user.findMany({
where: {
id: {
in: userIds,
},
},
select: userSelect,
});
const userHashMap = buildHashMapForUsers(usersFromTeam);
const result = bookingsFromTeam.map((booking) => {
return {
userId: booking.userId,
// We know with 100% certainty that userHashMap.get(...) will retrieve a user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user: userHashMap.get(booking.userId)!,
emailMd5: md5(user?.email),
count: booking._count.id,
};
});
return result;
}),
membersWithLeastBookings: userBelongsToTeamProcedure
.input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
...bookingWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "asc",
},
},
take: 10,
});
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) {
userIds.push(booking.userId);
}
return userIds;
}, []);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.insightsDb.user.findMany({
where: {
id: {
in: userIds,
},
},
select: userSelect,
});
const userHashMap = buildHashMapForUsers(usersFromTeam);
const result = bookingsFromTeam.map((booking) => ({
userId: booking.userId,
// We know with 100% certainty that userHashMap.get(...) will retrieve a user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user: userHashMap.get(booking.userId)!,
emailMd5: md5(user?.email),
count: booking._count.id,
}));
return result;
}),
teamListForUser: authedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
// Fetch user data
const userData = await ctx.insightsDb.user.findUnique({
where: {
id: user.id,
},
select: userSelect,
});
if (!userData) {
return [];
}
// Validate if user belongs to org as admin/owner
if (user.organizationId && user.organization.isOrgAdmin) {
const teamsAndOrg = await ctx.insightsDb.team.findMany({
where: {
OR: [{ parentId: user.organizationId }, { id: user.organizationId }],
},
select: {
id: true,
slug: true,
name: true,
logoUrl: true,
},
});
const teamsFromOrg = teamsAndOrg.filter((team) => team.id !== user.organizationId);
const orgTeam = teamsAndOrg.find((team) => team.id === user.organizationId);
if (!orgTeam) {
return [];
}
const result: IResultTeamList[] = [
{
...orgTeam,
isOrg: true,
},
...teamsFromOrg,
];
return result;
}
// Look if user it's admin/owner in multiple teams
const belongsToTeams = await ctx.insightsDb.membership.findMany({
where: {
team: {
slug: { not: null },
},
accepted: true,
userId: user.id,
OR: [
{
role: "ADMIN",
},
{
role: "OWNER",
},
],
},
include: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
slug: true,
metadata: true,
},
},
},
});
if (belongsToTeams.length === 0) {
return [];
}
const result: IResultTeamList[] = belongsToTeams.map((membership) => {
return { ...membership.team };
});
return result;
}),
userList: authedProcedure
.input(
z.object({
teamId: z.number().optional(),
isAll: z.boolean().nullable(),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
const { teamId, isAll } = input;
if (isAll && user.organizationId && user.organization.isOrgAdmin) {
const usersInTeam = await ctx.insightsDb.membership.findMany({
where: {
team: {
parentId: user.organizationId,
},
},
include: {
user: {
select: userSelect,
},
},
distinct: ["userId"],
});
return usersInTeam.map((membership) => membership.user);
}
if (!teamId) {
return [];
}
const membership = await ctx.insightsDb.membership.findFirst({
where: {
userId: user.id,
teamId,
accepted: true,
},
include: {
user: {
select: userSelect,
},
},
});
if (!membership) {
return [];
}
const isMember = membership && membership.role === "MEMBER";
// If user is not admin, return himself only
if (isMember) {
return [membership.user];
}
const usersInTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId,
accepted: true,
},
include: {
user: {
select: userSelect,
},
},
distinct: ["userId"],
});
return usersInTeam.map((membership) => membership.user);
}),
eventTypeList: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullish(),
userId: z.coerce.number().nullish(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { prisma, user } = ctx;
const { teamId, userId, isAll } = input;
if (!teamId && !userId) {
return [];
}
const membershipWhereConditional: Prisma.MembershipWhereInput = {};
let childrenTeamIds: number[] = [];
if (isAll && teamId && user.organizationId && user.isOwnerAdminOfParentTeam) {
const childTeams = await prisma.team.findMany({
where: {
parentId: user.organizationId,
},
select: {
id: true,
},
});
if (childTeams.length > 0) {
childrenTeamIds = childTeams.map((team) => team.id);
}
membershipWhereConditional["teamId"] = {
in: [user.organizationId, ...childrenTeamIds],
};
}
if (teamId && !isAll) {
membershipWhereConditional["teamId"] = teamId;
membershipWhereConditional["userId"] = user.id;
}
if (userId) {
membershipWhereConditional["userId"] = userId;
}
// I'm not using unique here since when userId comes from input we should look for every
// event type that user owns
const membership = await prisma.membership.findFirst({
where: membershipWhereConditional,
});
if (!membership && !user.isOwnerAdminOfParentTeam) {
throw new Error("User is not part of a team/org");
}
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {};
if (isAll && childrenTeamIds.length > 0 && user.organizationId && user.isOwnerAdminOfParentTeam) {
eventTypeWhereConditional["teamId"] = {
in: [user.organizationId, ...childrenTeamIds],
};
}
if (teamId && !isAll) {
eventTypeWhereConditional["teamId"] = teamId;
}
if (userId) {
eventTypeWhereConditional["userId"] = userId;
}
let eventTypeResult: Prisma.EventTypeGetPayload<{
select: {
id: true;
slug: true;
teamId: true;
title: true;
team: {
select: {
name: true;
};
};
};
}>[] = [];
let isMember = membership?.role === "MEMBER";
if (user.isOwnerAdminOfParentTeam) {
isMember = false;
}
if (isMember) {
eventTypeWhereConditional["OR"] = [
{ userId: user.id },
{ users: { some: { id: user.id } } },
// @TODO this is not working as expected
// hosts: { some: { id: user.id } },
];
}
eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
title: true,
teamId: true,
team: {
select: {
name: true,
},
},
},
where: eventTypeWhereConditional,
});
return eventTypeResult;
}),
recentRatings: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
...bookingWhere,
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
ratingFeedback: { not: null },
};
if (isAll && user.isOwnerAdminOfParentTeam) {
delete bookingWhere.teamId;
const teamsFromOrg = await ctx.insightsDb.team.findMany({
where: {
parentId: user?.organizationId,
},
select: {
id: true,
},
});
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
accepted: true,
},
select: {
userId: true,
},
});
bookingWhere["OR"] = [
...(bookingWhere.OR || []),
{
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
isTeamBooking: true,
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
isTeamBooking: false,
},
];
}
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.findMany({
where: bookingWhere,
orderBy: {
endTime: "desc",
},
select: {
userId: true,
rating: true,
ratingFeedback: true,
},
take: 10,
});
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
if (!!booking.userId && !userIds.includes(booking.userId)) {
userIds.push(booking.userId);
}
return userIds;
}, []);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.insightsDb.user.findMany({
where: {
id: {
in: userIds,
},
},
select: userSelect,
});
const userHashMap = buildHashMapForUsers(usersFromTeam);
const result = bookingsFromTeam.map((booking) => ({
userId: booking.userId,
// We know with 100% certainty that userHashMap.get(...) will retrieve a user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user: userHashMap.get(booking.userId)!,
emailMd5: md5(user?.email),
rating: booking.rating,
feedback: booking.ratingFeedback,
}));
return result;
}),
membersWithMostNoShow: userBelongsToTeamProcedure
.input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
...bookingWhere,
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
noShowHost: true,
};
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "asc",
},
},
take: 10,
});
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) {
userIds.push(booking.userId);
}
return userIds;
}, []);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.insightsDb.user.findMany({
where: {
id: {
in: userIds,
},
},
select: userSelect,
});
const userHashMap = buildHashMapForUsers(usersFromTeam);
const result = bookingsFromTeam.map((booking) => ({
userId: booking.userId,
// We know with 100% certainty that userHashMap.get(...) will retrieve a user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user: userHashMap.get(booking.userId)!,
emailMd5: md5(user?.email),
count: booking._count.id,
}));
return result;
}),
membersWithHighestRatings: userBelongsToTeamProcedure
.input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
rating: { not: null },
};
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_avg: {
rating: true,
},
orderBy: {
_avg: {
rating: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) {
userIds.push(booking.userId);
}
return userIds;
}, []);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.insightsDb.user.findMany({
where: {
id: {
in: userIds,
},
},
select: userSelect,
});
const userHashMap = buildHashMapForUsers(usersFromTeam);
const result = bookingsFromTeam.map((booking) => ({
userId: booking.userId,
// We know with 100% certainty that userHashMap.get(...) will retrieve a user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user: userHashMap.get(booking.userId)!,
emailMd5: md5(user?.email),
averageRating: booking._avg.rating,
}));
return result;
}),
membersWithLowestRatings: userBelongsToTeamProcedure
.input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
eventTypeId: eventTypeId ?? undefined,
memberUserId: memberUserId ?? undefined,
userId: userId ?? undefined,
isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
insightsDb: ctx.insightsDb,
},
});
let { whereCondition: bookingWhere } = r;
bookingWhere = {
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
rating: { not: null },
};
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_avg: {
rating: true,
},
orderBy: {
_avg: {
rating: "asc",
},
},
take: 10,
});
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) {
userIds.push(booking.userId);
}
return userIds;
}, []);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.insightsDb.user.findMany({
where: {
id: {
in: userIds,
},
},
select: userSelect,
});
const userHashMap = buildHashMapForUsers(usersFromTeam);
const result = bookingsFromTeam.map((booking) => ({
userId: booking.userId,
// We know with 100% certainty that userHashMap.get(...) will retrieve a user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user: userHashMap.get(booking.userId)!,
emailMd5: md5(user?.email),
averageRating: booking._avg.rating,
}));
return result;
}),
rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
const { startDate, endDate, teamId, userId, memberUserId, isAll, eventTypeId } = input;
const isOrgAdminOrOwner = ctx.user.isOwnerAdminOfParentTeam;
try {
// Get the data
const csvData = await EventsInsights.getCsvData({
startDate,
endDate,
teamId,
userId,
memberUserId,
isAll,
isOrgAdminOrOwner,
eventTypeId,
organizationId: ctx.user.organizationId || null,
});
const csvAsString = EventsInsights.objectToCsv(csvData);
const downloadAs = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format(
"YYYY-MM-DD"
)}-${randomString(10)}.csv`;
return { data: csvAsString, filename: downloadAs };
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
return { data: "", filename: "" };
}),
getRoutingFormsForFilters: userBelongsToTeamProcedure
.input(z.object({ userId: z.number().optional(), teamId: z.number().optional(), isAll: z.boolean() }))
.query(async ({ ctx, input }) => {
const { userId, teamId, isAll } = input;
return await RoutingEventsInsights.getRoutingFormsForFilters({
userId: ctx.user.id,
teamId,
isAll,
organizationId: ctx.user.organizationId ?? undefined,
});
}),
routingFormsByStatus: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormStats({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}),
routingFormResponses: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponses({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}),
routingFormResponsesForDownload: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit ?? BATCH_SIZE,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ input, ctx }) => {
const options = await RoutingEventsInsights.getRoutingFormFieldOptions({
...input,
userId: ctx.user.id,
organizationId: ctx.user.organizationId ?? null,
});
return options;
}),
failedBookingsByField: userBelongsToTeamProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
...input,
userId: ctx.user.id,
organizationId: ctx.user.organizationId ?? null,
});
}),
routingFormResponsesHeaders: userBelongsToTeamProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const headers = await RoutingEventsInsights.getRoutingFormHeaders({
userId: ctx.user.id,
teamId: input.teamId ?? null,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
});
return headers || [];
}),
routedToPerPeriod: userBelongsToTeamProcedure
.input(
rawDataInputSchema.extend({
period: z.enum(["perDay", "perWeek", "perMonth"]),
cursor: z
.object({
userCursor: z.number().optional(),
periodCursor: z.string().optional(),
})
.optional(),
routingFormId: z.string().optional(),
limit: z.number().optional(),
searchQuery: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, userId, startDate, endDate, period, cursor, limit, isAll, routingFormId, searchQuery } =
input;
return await RoutingEventsInsights.routedToPerPeriod({
userId: ctx.user.id,
teamId: teamId ?? null,
startDate,
endDate,
period,
cursor: cursor?.periodCursor,
userCursor: cursor?.userCursor,
limit,
isAll: isAll ?? false,
organizationId: ctx.user.organizationId ?? null,
routingFormId: routingFormId ?? null,
searchQuery: searchQuery,
});
}),
routedToPerPeriodCsv: userBelongsToTeamProcedure
.input(
rawDataInputSchema.extend({
period: z.enum(["perDay", "perWeek", "perMonth"]),
searchQuery: z.string().optional(),
routingFormId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = input;
try {
const csvData = await RoutingEventsInsights.routedToPerPeriodCsv({
userId: ctx.user.id,
teamId: input.teamId ?? null,
startDate,
endDate,
isAll: input.isAll ?? false,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
period: input.period,
searchQuery: input.searchQuery,
});
const csvString = RoutingEventsInsights.objectToCsv(csvData);
const downloadAs = `routed-to-${input.period}-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(
endDate
).format("YYYY-MM-DD")}.csv`;
return { data: csvString, filename: downloadAs };
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
getUserRelevantTeamRoutingForms: authedProcedure.query(async ({ ctx }) => {
try {
const routingForms = await VirtualQueuesInsights.getUserRelevantTeamRoutingForms({
userId: ctx.user.id,
});
return routingForms;
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});