diff --git a/apps/web/pages/insights/index.tsx b/apps/web/pages/insights/index.tsx
index 64ea23aae0..8919417300 100644
--- a/apps/web/pages/insights/index.tsx
+++ b/apps/web/pages/insights/index.tsx
@@ -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() {
+
+
+
+
+
+
{t("looking_for_more_insights")}{" "}
{
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) {
diff --git a/packages/features/insights/components/BookingStatusLineChart.tsx b/packages/features/insights/components/BookingStatusLineChart.tsx
index 6fa9285aff..216337cefe 100644
--- a/packages/features/insights/components/BookingStatusLineChart.tsx
+++ b/packages/features/insights/components/BookingStatusLineChart.tsx
@@ -59,9 +59,9 @@ export const BookingStatusLineChart = () => {
diff --git a/packages/features/insights/components/FeedbackTable.tsx b/packages/features/insights/components/FeedbackTable.tsx
new file mode 100644
index 0000000000..ad1e18a833
--- /dev/null
+++ b/packages/features/insights/components/FeedbackTable.tsx
@@ -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;
+ emailMd5?: string;
+ username?: string;
+ rating: number | null;
+ feedback: string | null;
+ }[]
+ | undefined;
+}) => {
+ const { t } = useLocale();
+ return (
+
+
+ <>
+ {data && data?.length > 0 ? (
+ data?.map((item) => (
+
+
+
+
+ {item.user.name}
+
+
+
+
+ {item.rating}
+
+
+
+
+ {item.feedback}
+
+
+
+ ))
+ ) : (
+
+ {t("workflows")}
+
+ }
+ />
+ )}
+ >
+
+
+ );
+};
diff --git a/packages/features/insights/components/HighestNoShowHostTable.tsx b/packages/features/insights/components/HighestNoShowHostTable.tsx
new file mode 100644
index 0000000000..72443de160
--- /dev/null
+++ b/packages/features/insights/components/HighestNoShowHostTable.tsx
@@ -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 ;
+
+ if (!isSuccess || !startDate || !endDate || !teamId) return null;
+
+ return data && data.length > 0 ? (
+
+ {t("most_no_show_host")}
+
+
+ ) : (
+ <>>
+ );
+};
diff --git a/packages/features/insights/components/HighestRatedMembersTable.tsx b/packages/features/insights/components/HighestRatedMembersTable.tsx
new file mode 100644
index 0000000000..7a1ce1bae5
--- /dev/null
+++ b/packages/features/insights/components/HighestRatedMembersTable.tsx
@@ -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 ;
+
+ if (!isSuccess || !startDate || !endDate || !teamId) return null;
+
+ return data && data.length > 0 ? (
+
+ {t("highest_rated_members")}
+
+
+ ) : (
+ <>>
+ );
+};
diff --git a/packages/features/insights/components/LowestRatedMembersTable.tsx b/packages/features/insights/components/LowestRatedMembersTable.tsx
new file mode 100644
index 0000000000..652b8d603b
--- /dev/null
+++ b/packages/features/insights/components/LowestRatedMembersTable.tsx
@@ -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 ;
+
+ if (!isSuccess || !startDate || !endDate || !teamId) return null;
+
+ return data && data.length > 0 ? (
+
+ {t("lowest_rated_members")}
+
+
+ ) : (
+ <>>
+ );
+};
diff --git a/packages/features/insights/components/RecentFeedbackTable.tsx b/packages/features/insights/components/RecentFeedbackTable.tsx
new file mode 100644
index 0000000000..969267ef0e
--- /dev/null
+++ b/packages/features/insights/components/RecentFeedbackTable.tsx
@@ -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 ;
+
+ if (!isSuccess || !startDate || !endDate || !teamId) return null;
+
+ return (
+
+ {t("recent_ratings")}
+
+
+ );
+};
diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx
index d6564795ca..405cfb8590 100644
--- a/packages/features/insights/components/TotalBookingUsersTable.tsx
+++ b/packages/features/insights/components/TotalBookingUsersTable.tsx
@@ -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 = ({
diff --git a/packages/features/insights/components/TotalUserFeedbackTable.tsx b/packages/features/insights/components/TotalUserFeedbackTable.tsx
new file mode 100644
index 0000000000..25c8b56c5a
--- /dev/null
+++ b/packages/features/insights/components/TotalUserFeedbackTable.tsx
@@ -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;
+ emailMd5?: string;
+ count?: number;
+ averageRating?: number | null;
+ username?: string;
+ }[]
+ | undefined;
+}) => {
+ return (
+
+
+ <>
+ {data &&
+ data?.length > 0 &&
+ data?.map((item) => (
+
+
+
+
+ {item.user.name}
+
+
+
+
+
+ {item.averageRating ? item.averageRating.toFixed(1) : item.count}
+
+
+
+
+ ))}
+ >
+
+
+ );
+};
diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts
index af5e7aa673..1448932927 100644
--- a/packages/features/insights/components/index.ts
+++ b/packages/features/insights/components/index.ts
@@ -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";
diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts
index fd6f040950..5ec6593a10 100644
--- a/packages/features/insights/server/events.ts
+++ b/packages/features/insights/server/events.ts
@@ -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,
});
diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts
index a13daf4175..9650e06464 100644
--- a/packages/features/insights/server/trpc-router.ts
+++ b/packages/features/insights/server/trpc-router.ts
@@ -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;
diff --git a/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql b/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql
new file mode 100644
index 0000000000..71ccaec2c4
--- /dev/null
+++ b/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql
@@ -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";
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 58773c0e99..bf7b424767 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -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 {
diff --git a/packages/prisma/seed-insights.ts b/packages/prisma/seed-insights.ts
index 7f4ad14529..0a32a8c6ed 100644
--- a/packages/prisma/seed-insights.ts
+++ b/packages/prisma/seed-insights.ts
@@ -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;
};