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; };