Files
calendar/packages/features/insights/server/routing-events.ts
T
Alex van AndelandGitHub 07accf700e refactor: Enabling typescript 5.3.3 in @calcom/trpc (#19394)
* Refactors related to enabling typescript 5.3.3 in @calcom/trpc

* Also skip respective tests (obviously logic is broken)

* This causes problems for some reason, revert

* Bring back QueryCell as it's used by atoms
2025-02-21 13:22:31 +00:00

1182 lines
36 KiB
TypeScript

import { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import mapKeys from "lodash/mapKeys";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import {
RoutingFormFieldType,
isValidRoutingFormFieldType,
} from "@calcom/app-store/routing-forms/lib/FieldTypes";
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { ColumnFilterType } from "@calcom/features/data-table";
import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server";
import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table/lib/types";
import type { RoutingFormResponsesInput } from "@calcom/features/insights/server/raw-data.schema";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
import { type ResponseValue, ZResponse } from "../lib/types";
type RoutingFormInsightsTeamFilter = {
userId?: number | null;
teamId?: number | null;
isAll: boolean;
organizationId?: number | null;
routingFormId?: string | null;
};
type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
startDate?: string;
endDate?: string;
memberUserId?: number | null;
searchQuery?: string | null;
bookingStatus?: BookingStatus | "NO_BOOKING" | null;
fieldFilter?: {
fieldId: string;
optionId: string;
} | null;
columnFilters: ColumnFilter[];
};
type RoutingFormResponsesFilter = RoutingFormResponsesInput & {
organizationId: number | null;
};
type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
class RoutingEventsInsights {
private static async getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter): Promise<WhereForTeamOrAllTeams> {
// Get team IDs based on organization if applicable
let teamIds: number[] = [];
if (isAll && organizationId) {
const teamsFromOrg = await prisma.team.findMany({
where: {
parentId: organizationId,
},
select: {
id: true,
},
});
teamIds = [organizationId, ...teamsFromOrg.map((t) => t.id)];
} else if (teamId) {
teamIds = [teamId];
}
// Filter teamIds to only include teams the user has access to
if (teamIds.length > 0) {
const accessibleTeams = await prisma.membership.findMany({
where: {
userId: userId ?? -1,
teamId: {
in: teamIds,
},
accepted: true,
},
select: {
teamId: true,
},
});
teamIds = accessibleTeams.map((membership) => membership.teamId);
}
// Base where condition for forms
const formsWhereCondition: WhereForTeamOrAllTeams = {
...(teamIds.length > 0
? {
teamId: {
in: teamIds,
},
}
: {
userId: userId ?? -1,
teamId: null,
}),
...(routingFormId && {
id: routingFormId,
}),
};
return formsWhereCondition;
}
static async getRoutingFormStats({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const whereClause = await this.getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
});
const totalPromise = prisma.routingFormResponse.count({
where: whereClause,
});
const totalWithoutBookingPromise = prisma.routingFormResponse.count({
where: {
...whereClause,
bookingUid: null,
},
});
const [total, totalWithoutBooking] = await Promise.all([totalPromise, totalWithoutBookingPromise]);
return {
total,
totalWithoutBooking,
totalWithBooking: total - totalWithoutBooking,
};
}
static async getRoutingFormsForFilters({
userId,
teamId,
isAll,
organizationId,
}: {
userId: number;
teamId?: number;
isAll: boolean;
organizationId?: number | undefined;
routingFormId?: string | undefined;
}) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
});
return await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
_count: {
select: {
responses: true,
},
},
},
});
}
static async getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
memberUserIds,
columnFilters,
}: RoutingFormResponsesFilter) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const getLowercasedFilterValue = <TData extends ColumnFilterType>(filter: TypedColumnFilter<TData>) => {
if (filter.value.type === ColumnFilterType.TEXT) {
return {
...filter.value,
data: {
...filter.value.data,
operand: filter.value.data.operand.toLowerCase(),
},
};
}
return filter.value;
};
const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder");
const bookingAssignmentReason = columnFilters.find(
(filter) => filter.id === "bookingAssignmentReason"
) as TypedColumnFilter<ColumnFilterType.TEXT> | undefined;
const assignmentReasonValue = bookingAssignmentReason
? getLowercasedFilterValue(bookingAssignmentReason)
: undefined;
const responseFilters = columnFilters.filter(
(filter) => filter.id !== "bookingStatusOrder" && filter.id !== "bookingAssignmentReason"
);
const whereClause: Prisma.RoutingFormResponseWhereInput = {
...(formsTeamWhereCondition.id !== undefined && {
formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">,
}),
...(formsTeamWhereCondition.teamId !== undefined && {
formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">,
}),
...(formsTeamWhereCondition.userId !== undefined && {
formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">,
}),
// bookingStatus
...(bookingStatusOrder &&
makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })),
// bookingAssignmentReason
...(assignmentReasonValue &&
makeWhereClause({
columnName: "bookingAssignmentReasonLowercase",
filterValue: assignmentReasonValue,
})),
// memberUserId
...(memberUserIds && memberUserIds.length > 0 && { bookingUserId: { in: memberUserIds } }),
// createdAt
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
// AND clause
...(responseFilters.length > 0 && {
AND: responseFilters.map((fieldFilter) => {
return makeWhereClause({
columnName: "responseLowercase",
filterValue: getLowercasedFilterValue(fieldFilter),
json: { path: [fieldFilter.id, "value"] },
});
}),
}),
};
return whereClause;
}
static async getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const whereClause = await this.getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
});
const totalResponsePromise = prisma.routingFormResponse.count({
where: whereClause,
});
const responsesPromise = prisma.routingFormResponse.findMany({
select: {
id: true,
response: true,
formId: true,
formName: true,
bookingUid: true,
bookingStatus: true,
bookingStatusOrder: true,
bookingCreatedAt: true,
bookingAttendees: true,
bookingUserId: true,
bookingUserName: true,
bookingUserEmail: true,
bookingUserAvatarUrl: true,
bookingAssignmentReason: true,
bookingStartTime: true,
bookingEndTime: true,
createdAt: true,
},
where: whereClause,
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
cursor: cursor ? { id: cursor } : undefined,
});
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
const hasNextPage = responses.length > (limit ?? 0);
const responsesToReturn = responses.slice(0, limit ? limit : responses.length);
type Response = Omit<
(typeof responsesToReturn)[number],
"response" | "responseLowercase" | "bookingAttendees"
> & {
response: Record<string, ResponseValue>;
responseLowercase: Record<string, ResponseValue>;
bookingAttendees?: { name: string; email: string; timeZone: string }[];
};
return {
total: totalResponses,
data: responsesToReturn as Response[],
nextCursor: hasNextPage ? responsesToReturn[responsesToReturn.length - 1].id : undefined,
};
}
static async getRoutingFormPaginatedResponsesForDownload({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const headersPromise = this.getRoutingFormHeaders({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const dataPromise = this.getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
userId,
memberUserIds,
limit,
columnFilters,
sorting,
});
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
const dataWithFlatResponse = data.data.map((item) => {
const { bookingAttendees } = item;
const responseParseResult = ZResponse.safeParse(item.response);
const response = responseParseResult.success ? responseParseResult.data : {};
const fields = (headers || []).reduce((acc, header) => {
const id = header.id;
if (!response[id]) {
acc[header.label] = "";
return acc;
}
if (header.type === "select") {
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label;
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) {
acc[header.label] = (response[id].value as string[])
.map((value) => header.options?.find((option) => option.id === value)?.label)
.filter((label): label is string => label !== undefined)
.sort()
.join(", ");
} else {
acc[header.label] = response[id].value as string;
}
return acc;
}, {} as Record<string, string | undefined>);
return {
"Response ID": item.id,
"Form Name": item.formName,
"Submitted At": item.createdAt.toISOString(),
"Has Booking": item.bookingUid !== null,
"Booking Status": item.bookingStatus || "NO_BOOKING",
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
"Booking End Time": item.bookingEndTime?.toISOString() || "",
"Attendee Name": (bookingAttendees as any)?.[0]?.name,
"Attendee Email": (bookingAttendees as any)?.[0]?.email,
"Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone,
"Assignment Reason": item.bookingAssignmentReason || "",
"Routed To Name": item.bookingUserName || "",
"Routed To Email": item.bookingUserEmail || "",
...mapKeys(fields, (_, key) => startCase(key)),
};
});
return {
data: dataWithFlatResponse,
nextCursor: data.nextCursor,
};
}
static async getRoutingFormFieldOptions({
userId,
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
routingFormId,
organizationId,
});
const routingForms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
fields: true,
},
});
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
return fields;
}
static async getFailedBookingsByRoutingFormGroup({
userId,
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const teamConditions = [];
// @ts-expect-error it doesn't exist but TS isn't smart enough when it's a number or int filter
if (formsWhereCondition.teamId?.in) {
// @ts-expect-error it doesn't exist but TS isn't smart enough when it's a number or int filter
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
// @ts-expect-error it doesn't exist but TS isn't smart enough when it's a number or int filter
if (!formsWhereCondition.teamId?.in && userId) {
teamConditions.push(`f."userId" = ${userId}`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
const whereClause = teamConditions.length
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
: Prisma.sql``;
// If you're at this point wondering what this does. This groups the responses by form and field and counts the number of responses for each option that don't have a booking.
const result = await prisma.$queryRaw<
{
formId: string;
formName: string;
fieldId: string;
fieldLabel: string;
optionId: string;
optionLabel: string;
count: number;
}[]
>`
WITH form_fields AS (
SELECT
f.id as form_id,
f.name as form_name,
field->>'id' as field_id,
field->>'label' as field_label,
opt->>'id' as option_id,
opt->>'label' as option_label
FROM "App_RoutingForms_Form" f,
LATERAL jsonb_array_elements(f.fields) as field
LEFT JOIN LATERAL jsonb_array_elements(field->'options') as opt ON true
WHERE true
${whereClause}
),
response_stats AS (
SELECT
r."formId",
key as field_id,
CASE
WHEN jsonb_typeof(value->'value') = 'array' THEN
v.value_item
ELSE
value->>'value'
END as selected_option,
COUNT(DISTINCT r.id) as response_count
FROM "App_RoutingForms_FormResponse" r
CROSS JOIN jsonb_each(r.response::jsonb) as fields(key, value)
LEFT JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN jsonb_typeof(value->'value') = 'array'
THEN value->'value'
ELSE NULL
END
) as v(value_item) ON true
WHERE r."routedToBookingUid" IS NULL
GROUP BY r."formId", key, selected_option
)
SELECT
ff.form_id as "formId",
ff.form_name as "formName",
ff.field_id as "fieldId",
ff.field_label as "fieldLabel",
ff.option_id as "optionId",
ff.option_label as "optionLabel",
COALESCE(rs.response_count, 0)::integer as count
FROM form_fields ff
LEFT JOIN response_stats rs ON
rs."formId" = ff.form_id AND
rs.field_id = ff.field_id AND
rs.selected_option = ff.option_id
WHERE ff.option_id IS NOT NULL
ORDER BY count DESC`;
// First group by form and field
const groupedByFormAndField = result.reduce((acc, curr) => {
const formKey = curr.formName;
acc[formKey] = acc[formKey] || {};
const labelKey = curr.fieldLabel;
acc[formKey][labelKey] = acc[formKey][labelKey] || [];
acc[formKey][labelKey].push({
optionId: curr.optionId,
count: curr.count,
optionLabel: curr.optionLabel,
});
return acc;
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
// NOTE: totalCount represents the sum of all response counts across all fields and options for a form
// For example, if a form has 2 fields with 2 options each:
// Field1: Option1 (5 responses), Option2 (3 responses)
// Field2: Option1 (2 responses), Option2 (4 responses)
// Then totalCount = 5 + 3 + 2 + 4 = 14 total responses
const sortedEntries = Object.entries(groupedByFormAndField)
.map(([formName, fields]) => ({
formName,
fields,
totalCount: Object.values(fields)
.flat()
.reduce((sum, item) => sum + item.count, 0),
}))
.sort((a, b) => b.totalCount - a.totalCount);
// Convert back to original format
const sortedGroupedByFormAndField = sortedEntries.reduce((acc, { formName, fields }) => {
acc[formName] = fields;
return acc;
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
return sortedGroupedByFormAndField;
}
static async getRoutingFormHeaders({
userId,
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const routingForms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
fields: true,
},
});
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
const ids = new Set<string>();
const headers = (fields || [])
.map((f) => {
return {
id: f.id,
label: f.label,
type: f.type,
options: f.options,
};
})
.filter((field) => {
if (!field.label || !isValidRoutingFormFieldType(field.type)) {
return false;
}
if (
field.type === RoutingFormFieldType.SINGLE_SELECT ||
field.type === RoutingFormFieldType.MULTI_SELECT
) {
return field.options && field.options.length > 0;
}
return true;
})
.filter((field) => {
// Remove duplicate fields
// because we aggregate fields from multiple routing forms.
if (ids.has(field.id)) {
return false;
} else {
ids.add(field.id);
return true;
}
});
return headers;
}
static async routedToPerPeriod({
userId,
teamId,
isAll,
organizationId,
routingFormId,
startDate: _startDate,
endDate: _endDate,
period,
cursor,
userCursor,
limit = 10,
searchQuery,
}: RoutingFormInsightsTeamFilter & {
period: "perDay" | "perWeek" | "perMonth";
startDate: string;
endDate: string;
cursor?: string;
userCursor?: number;
limit?: number;
searchQuery?: string;
}) {
const dayJsPeriodMap = {
perDay: "day",
perWeek: "week",
perMonth: "month",
} as const;
const dayjsPeriod = dayJsPeriodMap[period];
const startDate = dayjs(_startDate).startOf(dayjsPeriod).toDate();
const endDate = dayjs(_endDate).endOf(dayjsPeriod).toDate();
// Build the team conditions for the WHERE clause
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const teamConditions = [];
// @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
if (formsWhereCondition.teamId?.in) {
// @ts-expect-error same as above
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
// @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
if (!formsWhereCondition.teamId?.in && userId) {
teamConditions.push(`f."userId" = ${userId}`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
const searchCondition = searchQuery
? Prisma.sql`AND (u.name ILIKE ${`%${searchQuery}%`} OR u.email ILIKE ${`%${searchQuery}%`})`
: Prisma.empty;
const whereClause = teamConditions.length
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
: Prisma.empty;
// Get users who have been routed to during the period
const usersQuery = await prisma.$queryRaw<
Array<{
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
}>
>`
WITH routed_responses AS (
SELECT DISTINCT ON (b."userId")
b."userId",
u.id,
u.name,
u.email,
u."avatarUrl"
FROM "App_RoutingForms_FormResponse" r
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
JOIN "users" u ON b."userId" = u.id
WHERE r."routedToBookingUid" IS NOT NULL
AND r."createdAt" >= ${startDate}
AND r."createdAt" <= ${endDate}
${searchCondition}
${whereClause}
${userCursor ? Prisma.sql`AND b."userId" > ${userCursor}` : Prisma.empty}
ORDER BY b."userId", r."createdAt" DESC
)
SELECT *
FROM routed_responses
ORDER BY id ASC
LIMIT ${limit}
`;
const users = usersQuery;
const hasMoreUsers = users.length === limit;
// Return early if no users found
if (users.length === 0) {
return {
users: {
data: [],
nextCursor: undefined,
},
periodStats: {
data: [],
nextCursor: undefined,
},
};
}
// Get periods with pagination
const periodStats = await prisma.$queryRaw<
Array<{
userId: number;
period_start: Date;
total: number;
}>
>`
-- First, generate all months in the range
WITH RECURSIVE date_range AS (
SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
UNION ALL
SELECT date + (CASE
WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
END)
FROM date_range
WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
),
-- Get all unique users we want to show
all_users AS (
SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]) as user_id
),
-- Get periods with pagination
paginated_periods AS (
SELECT date as period_start
FROM date_range
ORDER BY date ASC
${cursor ? Prisma.sql`OFFSET ${parseInt(cursor, 10)}` : Prisma.empty}
LIMIT ${limit}
),
-- Generate combinations for paginated periods
date_user_combinations AS (
SELECT
period_start,
user_id as "userId"
FROM paginated_periods
CROSS JOIN all_users
),
-- Count bookings per user per period
booking_counts AS (
SELECT
b."userId",
date_trunc(${dayjsPeriod}, b."createdAt") as period_start,
COUNT(DISTINCT b.id)::integer as total
FROM "Booking" b
JOIN "App_RoutingForms_FormResponse" r ON r."routedToBookingUid" = b.uid
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
WHERE b."userId" IN (SELECT user_id FROM all_users)
AND date_trunc(${dayjsPeriod}, b."createdAt") >= (SELECT MIN(period_start) FROM paginated_periods)
AND date_trunc(${dayjsPeriod}, b."createdAt") <= (SELECT MAX(period_start) FROM paginated_periods)
${whereClause}
GROUP BY 1, 2
)
-- Join everything together
SELECT
c."userId",
c.period_start,
COALESCE(b.total, 0)::integer as total
FROM date_user_combinations c
LEFT JOIN booking_counts b ON
b."userId" = c."userId" AND
b.period_start = c.period_start
ORDER BY c.period_start ASC, c."userId" ASC
`;
// Get total number of periods to determine if there are more
const totalPeriodsQuery = await prisma.$queryRaw<[{ count: number }]>`
WITH RECURSIVE date_range AS (
SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
UNION ALL
SELECT date + (CASE
WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
END)
FROM date_range
WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
)
SELECT COUNT(*)::integer as count FROM date_range
`;
// Get statistics for the entire period for comparison
const statsQuery = await prisma.$queryRaw<
Array<{
userId: number;
total_bookings: number;
}>
>`
SELECT
b."userId",
COUNT(*)::integer as total_bookings
FROM "App_RoutingForms_FormResponse" r
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
WHERE r."routedToBookingUid" IS NOT NULL
AND r."createdAt" >= ${startDate}
AND r."createdAt" <= ${endDate}
${whereClause}
GROUP BY b."userId"
ORDER BY total_bookings ASC
`;
// Calculate average and median
const average =
statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
// Create a map of user performance indicators
const userPerformance = statsQuery.reduce((acc, stat) => {
acc[stat.userId] = {
total: stat.total_bookings,
performance:
stat.total_bookings > average
? "above_average"
: stat.total_bookings === median
? "median"
: stat.total_bookings < average
? "below_average"
: "at_average",
};
return acc;
}, {} as Record<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
const totalPeriods = totalPeriodsQuery[0].count;
const currentPeriodOffset = cursor ? parseInt(cursor, 10) : 0;
const hasMorePeriods = currentPeriodOffset + limit < totalPeriods;
return {
users: {
data: users.map((user) => ({
...user,
performance: userPerformance[user.id]?.performance || "no_data",
totalBookings: userPerformance[user.id]?.total || 0,
})),
nextCursor: hasMoreUsers ? users[users.length - 1].id : undefined,
},
periodStats: {
data: periodStats,
nextCursor: hasMorePeriods ? (currentPeriodOffset + limit).toString() : undefined,
},
};
}
static async routedToPerPeriodCsv({
userId,
teamId,
isAll,
organizationId,
routingFormId,
startDate: _startDate,
endDate: _endDate,
period,
searchQuery,
}: RoutingFormInsightsTeamFilter & {
period: "perDay" | "perWeek" | "perMonth";
startDate: string;
endDate: string;
searchQuery?: string;
}) {
const dayJsPeriodMap = {
perDay: "day",
perWeek: "week",
perMonth: "month",
} as const;
const dayjsPeriod = dayJsPeriodMap[period];
const startDate = dayjs(_startDate).startOf(dayjsPeriod).toDate();
const endDate = dayjs(_endDate).endOf(dayjsPeriod).toDate();
// Build the team conditions for the WHERE clause
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const teamConditions = [];
// @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
if (formsWhereCondition.teamId?.in) {
// @ts-expect-error same as above
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
// @ts-expect-error it does exist but TS isn't smart enough when it's a number or int filter
if (!formsWhereCondition.teamId?.in && userId) {
teamConditions.push(`f."userId" = ${userId}`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
const searchCondition = searchQuery
? Prisma.sql`AND (u.name ILIKE ${`%${searchQuery}%`} OR u.email ILIKE ${`%${searchQuery}%`})`
: Prisma.empty;
const whereClause = teamConditions.length
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
: Prisma.empty;
// Get users who have been routed to during the period
const usersQuery = await prisma.$queryRaw<
Array<{
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
}>
>`
WITH routed_responses AS (
SELECT DISTINCT ON (b."userId")
b."userId",
u.id,
u.name,
u.email,
u."avatarUrl"
FROM "App_RoutingForms_FormResponse" r
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
JOIN "users" u ON b."userId" = u.id
WHERE r."routedToBookingUid" IS NOT NULL
AND r."createdAt" >= ${startDate}
AND r."createdAt" <= ${endDate}
${searchCondition}
${whereClause}
ORDER BY b."userId", r."createdAt" DESC
)
SELECT *
FROM routed_responses
ORDER BY id ASC
`;
// Get all periods without pagination
const periodStats = await prisma.$queryRaw<
Array<{
userId: number;
period_start: Date;
total: number;
}>
>`
WITH RECURSIVE date_range AS (
SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
UNION ALL
SELECT date + (CASE
WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
END)
FROM date_range
WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
),
all_users AS (
SELECT unnest(ARRAY[${Prisma.join(usersQuery.map((u) => u.id))}]) as user_id
),
date_user_combinations AS (
SELECT
date as period_start,
user_id as "userId"
FROM date_range
CROSS JOIN all_users
),
booking_counts AS (
SELECT
b."userId",
date_trunc(${dayjsPeriod}, b."createdAt") as period_start,
COUNT(DISTINCT b.id)::integer as total
FROM "Booking" b
JOIN "App_RoutingForms_FormResponse" r ON r."routedToBookingUid" = b.uid
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
WHERE b."userId" IN (SELECT user_id FROM all_users)
AND date_trunc(${dayjsPeriod}, b."createdAt") >= (SELECT MIN(date) FROM date_range)
AND date_trunc(${dayjsPeriod}, b."createdAt") <= (SELECT MAX(date) FROM date_range)
${whereClause}
GROUP BY 1, 2
)
SELECT
c."userId",
c.period_start,
COALESCE(b.total, 0)::integer as total
FROM date_user_combinations c
LEFT JOIN booking_counts b ON
b."userId" = c."userId" AND
b.period_start = c.period_start
ORDER BY c.period_start ASC, c."userId" ASC
`;
// Get statistics for the entire period for comparison
const statsQuery = await prisma.$queryRaw<
Array<{
userId: number;
total_bookings: number;
}>
>`
SELECT
b."userId",
COUNT(*)::integer as total_bookings
FROM "App_RoutingForms_FormResponse" r
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
WHERE r."routedToBookingUid" IS NOT NULL
AND r."createdAt" >= ${startDate}
AND r."createdAt" <= ${endDate}
${whereClause}
GROUP BY b."userId"
ORDER BY total_bookings ASC
`;
// Calculate average and median
const average =
statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
// Create a map of user performance indicators
const userPerformance = statsQuery.reduce((acc, stat) => {
acc[stat.userId] = {
total: stat.total_bookings,
performance:
stat.total_bookings > average
? "above_average"
: stat.total_bookings === median
? "median"
: stat.total_bookings < average
? "below_average"
: "at_average",
};
return acc;
}, {} as Record<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
// Group period stats by user
const userPeriodStats = periodStats.reduce((acc, stat) => {
if (!acc[stat.userId]) {
acc[stat.userId] = [];
}
acc[stat.userId].push({
period_start: stat.period_start,
total: stat.total,
});
return acc;
}, {} as Record<number, Array<{ period_start: Date; total: number }>>);
// Format data for CSV
return usersQuery.map((user) => {
const stats = userPeriodStats[user.id] || [];
const periodData = stats.reduce(
(acc, stat) => ({
...acc,
[`Responses ${dayjs(stat.period_start).format("YYYY-MM-DD")}`]: stat.total.toString(),
}),
{} as Record<string, string>
);
return {
"User ID": user.id.toString(),
Name: user.name || "",
Email: user.email,
"Total Bookings": (userPerformance[user.id]?.total || 0).toString(),
Performance: userPerformance[user.id]?.performance || "no_data",
...periodData,
};
});
}
static objectToCsv(data: Record<string, string>[]) {
if (!data.length) return "";
const headers = Object.keys(data[0]);
const csvRows = [
headers.join(","),
...data.map((row) =>
headers
.map((header) => {
const value = row[header]?.toString() || "";
// Escape quotes and wrap in quotes if contains comma or newline
return value.includes(",") || value.includes("\n") || value.includes('"')
? `"${value.replace(/"/g, '""')}"` // escape double quotes
: value;
})
.join(",")
),
];
return csvRows.join("\n");
}
}
export { RoutingEventsInsights };