1762 lines
50 KiB
TypeScript
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" });
|
|
}
|
|
}),
|
|
});
|