Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { ToggleGroup } from "@calcom/ui/components/form";

import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { useInsightsRoutingParameters } from "../../hooks/useInsightsRoutingParameters";
import { ChartCard } from "../ChartCard";

// Custom Tooltip component
Expand Down Expand Up @@ -131,13 +131,8 @@ function FormCard({ formName, fields }: FormCardProps) {

export function FailedBookingsByField() {
const { t } = useLocale();
const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters();
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery({
userId,
teamId,
isAll,
routingFormId,
});
const insightsRoutingParams = useInsightsRoutingParameters();
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams);

if (!data || Object.entries(data).length === 0) return null;

Expand Down
137 changes: 0 additions & 137 deletions packages/features/insights/server/routing-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,143 +215,6 @@ class RoutingEventsInsights {
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,
Expand Down
20 changes: 7 additions & 13 deletions packages/features/insights/server/trpc-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,20 +885,14 @@ export const insightsRouter = router({
return options;
}),
failedBookingsByField: userBelongsToTeamProcedure
.input(
z.object({
userId: z.number().optional(),
teamId: z.number().optional(),
isAll: z.boolean(),
routingFormId: z.string().optional(),
})
)
.input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
...input,
userId: ctx.user.id,
organizationId: ctx.user.organizationId ?? null,
});
const insightsRoutingService = createInsightsRoutingService(ctx, input);
try {
return await insightsRoutingService.getFailedBookingsByFieldData();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
routingFormResponsesHeaders: userBelongsToTeamProcedure
.input(
Expand Down
119 changes: 110 additions & 9 deletions packages/lib/server/service/InsightsRoutingBaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ export class InsightsRoutingBaseService {
// Date range filtering
if (this.filters.startDate && this.filters.endDate) {
conditions.push(
Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp AND "createdAt" <= ${this.filters.endDate}::timestamp`
Prisma.sql`rfrd."createdAt" >= ${this.filters.startDate}::timestamp AND rfrd."createdAt" <= ${this.filters.endDate}::timestamp`
);
}

Expand All @@ -450,7 +450,7 @@ export class InsightsRoutingBaseService {
if (bookingStatusOrder && isMultiSelectFilterValue(bookingStatusOrder.value)) {
const statusCondition = makeSqlCondition(bookingStatusOrder.value);
if (statusCondition) {
conditions.push(Prisma.sql`"bookingStatusOrder" ${statusCondition}`);
conditions.push(Prisma.sql`rfrd."bookingStatusOrder" ${statusCondition}`);
}
}

Expand All @@ -459,7 +459,7 @@ export class InsightsRoutingBaseService {
if (bookingAssignmentReason && isTextFilterValue(bookingAssignmentReason.value)) {
const reasonCondition = makeSqlCondition(bookingAssignmentReason.value);
if (reasonCondition) {
conditions.push(Prisma.sql`"bookingAssignmentReason" ${reasonCondition}`);
conditions.push(Prisma.sql`rfrd."bookingAssignmentReason" ${reasonCondition}`);
}
}

Expand All @@ -468,7 +468,7 @@ export class InsightsRoutingBaseService {
if (bookingUid && isTextFilterValue(bookingUid.value)) {
const uidCondition = makeSqlCondition(bookingUid.value);
if (uidCondition) {
conditions.push(Prisma.sql`"bookingUid" ${uidCondition}`);
conditions.push(Prisma.sql`rfrd."bookingUid" ${uidCondition}`);
}
}

Expand Down Expand Up @@ -502,15 +502,15 @@ export class InsightsRoutingBaseService {
// Extract member user IDs filter (multi-select)
const memberUserIds = filtersMap["bookingUserId"];
if (memberUserIds && isMultiSelectFilterValue(memberUserIds.value)) {
conditions.push(Prisma.sql`"bookingUserId" = ANY(${memberUserIds.value.data})`);
conditions.push(Prisma.sql`rfrd."bookingUserId" = ANY(${memberUserIds.value.data})`);
}

// Extract form ID filter (single-select)
const formId = filtersMap["formId"];
if (formId && isSingleSelectFilterValue(formId.value)) {
const formIdCondition = makeSqlCondition(formId.value);
if (formIdCondition) {
conditions.push(Prisma.sql`"formId" ${formIdCondition}`);
conditions.push(Prisma.sql`rfrd."formId" ${formIdCondition}`);
}
}

Expand Down Expand Up @@ -562,7 +562,7 @@ export class InsightsRoutingBaseService {
}

if (scope === "user") {
return Prisma.sql`"formUserId" = ${this.options.userId} AND "formTeamId" IS NULL`;
return Prisma.sql`rfrd."formUserId" = ${this.options.userId} AND rfrd."formTeamId" IS NULL`;
} else if (scope === "org") {
return await this.buildOrgAuthorizationCondition(this.options);
} else if (scope === "team") {
Expand All @@ -584,7 +584,7 @@ export class InsightsRoutingBaseService {

const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];

return Prisma.sql`("formTeamId" = ANY(${teamIds})) OR ("formUserId" = ${options.userId} AND "formTeamId" IS NULL)`;
return Prisma.sql`(rfrd."formTeamId" = ANY(${teamIds})) OR (rfrd."formUserId" = ${options.userId} AND rfrd."formTeamId" IS NULL)`;
}

private async buildTeamAuthorizationCondition(
Expand All @@ -600,7 +600,7 @@ export class InsightsRoutingBaseService {
return NOTHING_CONDITION;
}

return Prisma.sql`"formTeamId" = ${options.teamId}`;
return Prisma.sql`rfrd."formTeamId" = ${options.teamId}`;
}

private async isOwnerOrAdmin(userId: number, targetId: number): Promise<boolean> {
Expand Down Expand Up @@ -693,4 +693,105 @@ export class InsightsRoutingBaseService {
AND ${columnCondition}
)`;
}

async getFailedBookingsByFieldData(): Promise<
Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>
> {
const baseConditions = await this.getBaseConditions();

// Get failed bookings (responses without a successful booking) grouped by form, field, and option
const result = await this.prisma.$queryRaw<
{
formId: string;
formName: string;
fieldId: string;
fieldLabel: string;
optionId: string;
optionLabel: string;
count: number;
}[]
>`
WITH form_fields AS (
SELECT DISTINCT
rfrd."formId" as form_id,
rfrd."formName" as form_name,
field->>'id' as field_id,
field->>'label' as field_label,
opt->>'id' as option_id,
opt->>'label' as option_label
FROM "RoutingFormResponseDenormalized" rfrd
JOIN "App_RoutingForms_Form" f ON rfrd."formId" = f.id,
LATERAL jsonb_array_elements(f.fields) as field
LEFT JOIN LATERAL jsonb_array_elements(field->'options') as opt ON true
WHERE
${baseConditions}
),
response_stats AS (
SELECT
rfrd."formId",
f."fieldId" as field_id,
COALESCE(arr.value, f."valueString", f."valueNumber"::text) as selected_option,
COUNT(DISTINCT rfrd.id) as response_count
FROM "RoutingFormResponseDenormalized" rfrd
JOIN "RoutingFormResponseField" f ON rfrd.id = f."responseId"
LEFT JOIN LATERAL unnest(f."valueStringArray") as arr(value) ON f."valueStringArray" != '{}'
WHERE ${baseConditions}
AND rfrd."bookingUid" IS NULL
AND COALESCE(arr.value, f."valueString", f."valueNumber"::text) IS NOT NULL
GROUP BY rfrd."formId", f."fieldId", COALESCE(arr.value, f."valueString", f."valueNumber"::text)
)
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;
}
}
Loading
Loading