feat: Add ratings to insights (#14687)

This commit is contained in:
Syed Ali Shahbaz
2024-04-26 11:58:06 +01:00
committed by GitHub
parent d004815a68
commit 0650898302
17 changed files with 1061 additions and 24 deletions
+10
View File
@@ -7,6 +7,10 @@ import {
LeastBookedTeamMembersTable,
MostBookedTeamMembersTable,
PopularEventsTable,
HighestNoShowHostTable,
RecentFeedbackTable,
HighestRatedMembersTable,
LowestRatedMembersTable,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
@@ -90,6 +94,12 @@ export default function InsightsPage() {
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<RecentFeedbackTable />
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
@@ -2413,6 +2413,15 @@
"no_show_url_info":"The URL for No Show Feedback",
"no_support_needed":"No Support Needed?",
"hide_support":"Hide Support",
"event_ratings": "Average Ratings",
"event_no_show": "Host No Show",
"recent_ratings": "Recent ratings",
"no_ratings": "No ratings submitted",
"no_ratings_description": "Add a workflow with 'Rating' to collect ratings after meetings",
"most_no_show_host": "Most No Show Members",
"highest_rated_members": "Members With Highest Rated Meetings",
"lowest_rated_members": "Members With Lowest Rated Meetings",
"csat_score": "CSAT Score",
"lockedSMS": "Locked SMS",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -39,7 +39,7 @@ export const BookingKPICards = () => {
const categories: {
title: string;
index: "created" | "completed" | "rescheduled" | "cancelled";
index: "created" | "completed" | "rescheduled" | "cancelled" | "no_show" | "rating" | "csat";
}[] = [
{
title: t("events_created"),
@@ -57,6 +57,18 @@ export const BookingKPICards = () => {
title: t("events_cancelled"),
index: "cancelled",
},
{
title: t("event_ratings"),
index: "rating",
},
{
title: t("event_no_show"),
index: "no_show",
},
{
title: t("csat_score"),
index: "csat",
},
];
if (isPending) {
@@ -59,9 +59,9 @@ export const BookingStatusLineChart = () => {
<LineChart
className="linechart mt-4 h-80"
data={eventsTimeLine ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled"]}
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)"]}
index="Month"
colors={["purple", "green", "blue", "red"]}
colors={["purple", "green", "blue", "red", "slate"]}
valueFormatter={valueFormatter}
/>
</CardInsights>
@@ -0,0 +1,70 @@
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client";
import { Avatar, EmptyScreen, Button } from "@calcom/ui";
export const FeedbackTable = ({
data,
}: {
data:
| {
userId: number | null;
user: Pick<User, "avatarUrl" | "name">;
emailMd5?: string;
username?: string;
rating: number | null;
feedback: string | null;
}[]
| undefined;
}) => {
const { t } = useLocale();
return (
<Table>
<TableBody>
<>
{data && data?.length > 0 ? (
data?.map((item) => (
<TableRow key={item.userId}>
<TableCell className="flex flex-row">
<Avatar
alt={item.user.name || ""}
size="sm"
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
title={item.user.name || ""}
className="m-2"
/>
<p className="text-default mx-0 my-auto">
<strong>{item.user.name}</strong>
</p>
</TableCell>
<TableCell className="text-right">
<Text>
<strong className="text-default">{item.rating}</strong>
</Text>
</TableCell>
<TableCell className="text-right">
<Text>
<strong className="text-default">{item.feedback}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<EmptyScreen
Icon="zap"
headline={t("no_ratings")}
description={t("no_ratings_description")}
buttonRaw={
<Button target="_blank" color="secondary" href="/workflows">
{t("workflows")}
</Button>
}
/>
)}
</>
</TableBody>
</Table>
);
};
@@ -0,0 +1,47 @@
import { Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
export const HighestNoShowHostTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return data && data.length > 0 ? (
<CardInsights className="shadow-none">
<Title className="text-emphasis">{t("most_no_show_host")}</Title>
<TotalUserFeedbackTable data={data} />
</CardInsights>
) : (
<></>
);
};
@@ -0,0 +1,47 @@
import { Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
export const HighestRatedMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return data && data.length > 0 ? (
<CardInsights className="shadow-none">
<Title className="text-emphasis">{t("highest_rated_members")}</Title>
<TotalUserFeedbackTable data={data} />
</CardInsights>
) : (
<></>
);
};
@@ -0,0 +1,47 @@
import { Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
export const LowestRatedMembersTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return data && data.length > 0 ? (
<CardInsights className="shadow-none">
<Title className="text-emphasis">{t("lowest_rated_members")}</Title>
<TotalUserFeedbackTable data={data} />
</CardInsights>
) : (
<></>
);
};
@@ -0,0 +1,44 @@
import { Title } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { FeedbackTable } from "./FeedbackTable";
import { LoadingInsight } from "./LoadingInsights";
export const RecentFeedbackTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter;
const [startDate, endDate] = dateRange;
const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
<CardInsights>
<Title className="text-emphasis">{t("recent_ratings")}</Title>
<FeedbackTable data={data} />
</CardInsights>
);
};
@@ -1,5 +1,6 @@
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import { Avatar } from "@calcom/ui";
@@ -27,7 +28,7 @@ export const TotalBookingUsersTable = ({
<Avatar
alt={item.user.name || ""}
size="sm"
imageSrc={item.user.avatarUrl}
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
title={item.user.name || ""}
className="m-2"
/>
@@ -0,0 +1,54 @@
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import { Avatar } from "@calcom/ui";
export const TotalUserFeedbackTable = ({
data,
}: {
data:
| {
userId: number | null;
user: Pick<User, "avatarUrl" | "name">;
emailMd5?: string;
count?: number;
averageRating?: number | null;
username?: string;
}[]
| undefined;
}) => {
return (
<Table>
<TableBody>
<>
{data &&
data?.length > 0 &&
data?.map((item) => (
<TableRow key={item.userId}>
<TableCell className="flex flex-row">
<Avatar
alt={item.user.name || ""}
size="sm"
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
title={item.user.name || ""}
className="m-2"
/>
<p className="text-default mx-0 my-auto">
<strong>{item.user.name}</strong>
</p>
</TableCell>
<TableCell className="text-right">
<Text>
<strong className="text-default">
{item.averageRating ? item.averageRating.toFixed(1) : item.count}
</strong>
</Text>
</TableCell>
</TableRow>
))}
</>
</TableBody>
</Table>
);
};
@@ -4,3 +4,7 @@ export { BookingStatusLineChart } from "./BookingStatusLineChart";
export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable";
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
export { PopularEventsTable } from "./PopularEventsTable";
export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
@@ -77,6 +77,18 @@ class EventsInsights {
return result;
};
static getNoShowHostsInTimeRange = async (
timeRange: ITimeRange,
where: Prisma.BookingTimeStatusWhereInput
) => {
const result = await this.getBookingsInTimeRange(timeRange, {
...where,
noShowHost: true,
});
return result;
};
static getBaseBookingCountForEventStatus = async (where: Prisma.BookingTimeStatusWhereInput) => {
const baseBookings = await prisma.bookingTimeStatus.count({
where,
@@ -112,6 +124,47 @@ class EventsInsights {
});
};
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[] = [];
@@ -239,6 +292,9 @@ class EventsInsights {
paid: true,
userEmail: true,
username: true,
rating: true,
ratingFeedback: true,
noShowHost: true,
},
where: whereConditional,
});
@@ -112,6 +112,18 @@ const emptyResponseEventsByStatus = {
count: 0,
deltaPrevious: 0,
},
rating: {
count: 0,
deltaPrevious: 0,
},
no_show: {
count: 0,
deltaPrevious: 0,
},
csat: {
count: 0,
deltaPrevious: 0,
},
previousRange: {
startDate: dayjs().toISOString(),
endDate: dayjs().toISOString(),
@@ -257,6 +269,14 @@ export const insightsRouter = router({
const totalCancelled = await EventsInsights.getTotalCancelledEvents(baseWhereCondition);
const totalRatingsAggregate = await EventsInsights.getAverageRating(baseWhereCondition);
const averageRating = totalRatingsAggregate._avg.rating
? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1))
: 0;
const totalNoShow = await EventsInsights.getTotalNoShows(baseWhereCondition);
const totalCSAT = await EventsInsights.getTotalCSAT(baseWhereCondition);
const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day");
const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day");
@@ -278,6 +298,14 @@ export const insightsRouter = router({
);
const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseCondition);
const lastPeriodTotalRatingsAggregate = await EventsInsights.getAverageRating(lastPeriodBaseCondition);
const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating
? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1))
: 0;
const lastPeriodTotalNoShow = await EventsInsights.getTotalNoShows(lastPeriodBaseCondition);
const lastPeriodTotalCSAT = await EventsInsights.getTotalCSAT(lastPeriodBaseCondition);
const result = {
empty: false,
created: {
@@ -299,6 +327,18 @@ export const insightsRouter = router({
count: totalCancelled,
deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled),
},
no_show: {
count: totalNoShow,
deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow),
},
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"),
@@ -308,7 +348,9 @@ export const insightsRouter = router({
result.created.count === 0 &&
result.completed.count === 0 &&
result.rescheduled.count === 0 &&
result.cancelled.count === 0
result.cancelled.count === 0 &&
result.no_show.count === 0 &&
result.rating.count === 0
) {
return emptyResponseEventsByStatus;
}
@@ -474,6 +516,7 @@ export const insightsRouter = router({
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
"No-Show (Host)": 0,
};
const startOfEndOf = timeView;
let startDate = dayjs(date).startOf(startOfEndOf);
@@ -511,11 +554,19 @@ export const insightsRouter = router({
},
whereConditional
),
EventsInsights.getNoShowHostsInTimeRange(
{
start: startDate,
end: endDate,
},
whereConditional
),
]);
EventData["Created"] = promisesResult[0];
EventData["Completed"] = promisesResult[1];
EventData["Rescheduled"] = promisesResult[2];
EventData["Cancelled"] = promisesResult[3];
EventData["No-Show (Host)"] = promisesResult[4];
result.push(EventData);
}
@@ -1437,6 +1488,538 @@ export const insightsRouter = router({
return eventTypeResult;
}),
recentRatings: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
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"] = [
{
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
teamId: null,
},
];
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
bookingWhere["OR"] = [
{
teamId,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
];
}
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(
z.object({
teamId: z.coerce.number().nullable().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
noShowHost: true,
};
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"] = [
{
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
teamId: null,
},
];
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
bookingWhere["OR"] = [
{
teamId,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
];
}
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(
z.object({
teamId: z.coerce.number().nullable().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
rating: { 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"] = [
{
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
teamId: null,
},
];
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
bookingWhere["OR"] = [
{
teamId,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: 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(
z.object({
teamId: z.coerce.number().nullable().optional(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
if (!teamId) {
return [];
}
const user = ctx.user;
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
rating: { 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"] = [
{
teamId: {
in: teamsFromOrg.map((t) => t.id),
},
},
{
userId: {
in: usersFromTeam.map((u) => u.userId),
},
teamId: null,
},
];
}
if (teamId && !isAll) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
bookingWhere["OR"] = [
{
teamId,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: 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;
@@ -0,0 +1,34 @@
CREATE OR REPLACE VIEW public."BookingTimeStatus"
AS
SELECT
"Booking".id,
"Booking".uid,
"Booking"."eventTypeId",
"Booking".title,
"Booking".description,
"Booking"."startTime",
"Booking"."endTime",
"Booking"."createdAt",
"Booking".location,
"Booking".paid,
"Booking".status,
"Booking".rescheduled,
"Booking"."userId",
et."teamId",
et.length AS "eventLength",
CASE
WHEN "Booking".rescheduled IS TRUE THEN 'rescheduled'::text
WHEN "Booking".status = 'cancelled'::"BookingStatus" AND "Booking".rescheduled IS NULL THEN 'cancelled'::text
WHEN "Booking"."endTime" < now() THEN 'completed'::text
WHEN "Booking"."endTime" > now() THEN 'uncompleted'::text
ELSE NULL::text
END AS "timeStatus",
et."parentId" AS "eventParentId",
"u"."email" AS "userEmail",
"u"."username" AS "username",
"Booking"."ratingFeedback",
"Booking"."rating",
"Booking"."noShowHost"
FROM "Booking"
LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id
LEFT JOIN users u ON u.id = "Booking"."userId";
+22 -19
View File
@@ -1123,25 +1123,28 @@ enum AccessScope {
}
view BookingTimeStatus {
id Int @unique
uid String?
eventTypeId Int?
title String?
description String?
startTime DateTime?
endTime DateTime?
createdAt DateTime?
location String?
paid Boolean?
status BookingStatus?
rescheduled Boolean?
userId Int?
teamId Int?
eventLength Int?
timeStatus String?
eventParentId Int?
userEmail String?
username String?
id Int @unique
uid String?
eventTypeId Int?
title String?
description String?
startTime DateTime?
endTime DateTime?
createdAt DateTime?
location String?
paid Boolean?
status BookingStatus?
rescheduled Boolean?
userId Int?
teamId Int?
eventLength Int?
timeStatus String?
eventParentId Int?
userEmail String?
username String?
ratingFeedback String?
rating Int?
noShowHost Boolean?
}
model CalendarCache {
+16
View File
@@ -6,6 +6,18 @@ import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { BookingStatus } from "@calcom/prisma/enums";
function getRandomRatingFeedback() {
const feedbacks = [
"Great chat!",
"Okay-ish",
"Quite Poor",
"Excellent chat!",
"Could be better",
"Wonderful!",
];
return feedbacks[Math.floor(Math.random() * feedbacks.length)];
}
const shuffle = (
booking: any,
year: number,
@@ -62,6 +74,10 @@ const shuffle = (
console.log("This should not happen");
}
booking.rating = Math.floor(Math.random() * 5) + 1; // Generates a random rating from 1 to 5
booking.ratingFeedback = getRandomRatingFeedback(); // Random feedback from a predefined list
booking.noShowHost = Math.random() < 0.5;
return booking;
};