Skip to content

Commit a494428

Browse files
refactor: FailedBookingsByField to use Insights Routing Service (calcom#23259)
* refactor: FailedBookingsByField to use Insights Routing Service - Move getFailedBookingsByRoutingFormGroup logic to InsightsRoutingBaseService - Replace legacy getWhereForTeamOrAllTeams with getBaseConditions() - Add failedBookingsByFieldInputSchema with date filtering support - Update tRPC endpoint to use createInsightsRoutingService pattern - Update component to pass startDate/endDate parameters - Remove legacy method from routing-events.ts Follows same pattern as PR calcom#23031 for RoutedToPerPeriod refactoring Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: use useInsightsRoutingParameters hook in FailedBookingsByField - Add missing import for useInsightsRoutingParameters - Update component to follow same pattern as PR calcom#23031 - Add columnFilters support to failedBookingsByFieldInputSchema - Maintain routingFormId parameter from useInsightsParameters Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove separate routingFormId parameter - Remove routingFormId parameter from getFailedBookingsByFieldData method - Update component to only use insightsRoutingParameters - Remove routingFormId from failedBookingsByFieldInputSchema - Let getBaseConditions handle all filtering including routing form filtering - Simplify tRPC endpoint to not pass separate routingFormId Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up * fix * refactor: use RoutingFormResponseDenormalized directly in getFailedBookingsByFieldData - Replace complex CTE approach with direct denormalized table query - Join with App_RoutingForms_Form to access field definitions from JSON - Use RoutingFormResponseField for response values - Filter failed bookings with bookingUid IS NULL - Maintain same data structure for UI compatibility - Follow existing service patterns for authorization and date filtering Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fixing failed bookings query * fix query * fix integration test --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 7145a2d commit a494428

5 files changed

Lines changed: 190 additions & 286 deletions

File tree

packages/features/insights/components/routing/FailedBookingsByField.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
1616
import { trpc } from "@calcom/trpc";
1717
import { ToggleGroup } from "@calcom/ui/components/form";
1818

19-
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
19+
import { useInsightsRoutingParameters } from "../../hooks/useInsightsRoutingParameters";
2020
import { ChartCard } from "../ChartCard";
2121

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

132132
export function FailedBookingsByField() {
133133
const { t } = useLocale();
134-
const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters();
135-
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery({
136-
userId,
137-
teamId,
138-
isAll,
139-
routingFormId,
140-
});
134+
const insightsRoutingParams = useInsightsRoutingParameters();
135+
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams);
141136

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

packages/features/insights/server/routing-events.ts

Lines changed: 0 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -214,143 +214,6 @@ class RoutingEventsInsights {
214214
return fields;
215215
}
216216

217-
static async getFailedBookingsByRoutingFormGroup({
218-
userId,
219-
teamId,
220-
isAll,
221-
routingFormId,
222-
organizationId,
223-
}: RoutingFormInsightsTeamFilter) {
224-
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
225-
userId,
226-
teamId,
227-
isAll,
228-
organizationId,
229-
routingFormId,
230-
});
231-
232-
const teamConditions = [];
233-
234-
// @ts-expect-error it doesn't exist but TS isn't smart enough when it's a number or int filter
235-
if (formsWhereCondition.teamId?.in) {
236-
// @ts-expect-error it doesn't exist but TS isn't smart enough when it's a number or int filter
237-
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
238-
}
239-
// @ts-expect-error it doesn't exist but TS isn't smart enough when it's a number or int filter
240-
if (!formsWhereCondition.teamId?.in && userId) {
241-
teamConditions.push(`f."userId" = ${userId}`);
242-
}
243-
if (routingFormId) {
244-
teamConditions.push(`f.id = '${routingFormId}'`);
245-
}
246-
247-
const whereClause = teamConditions.length
248-
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
249-
: Prisma.sql``;
250-
251-
// 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.
252-
const result = await prisma.$queryRaw<
253-
{
254-
formId: string;
255-
formName: string;
256-
fieldId: string;
257-
fieldLabel: string;
258-
optionId: string;
259-
optionLabel: string;
260-
count: number;
261-
}[]
262-
>`
263-
WITH form_fields AS (
264-
SELECT
265-
f.id as form_id,
266-
f.name as form_name,
267-
field->>'id' as field_id,
268-
field->>'label' as field_label,
269-
opt->>'id' as option_id,
270-
opt->>'label' as option_label
271-
FROM "App_RoutingForms_Form" f,
272-
LATERAL jsonb_array_elements(f.fields) as field
273-
LEFT JOIN LATERAL jsonb_array_elements(field->'options') as opt ON true
274-
WHERE true
275-
${whereClause}
276-
),
277-
response_stats AS (
278-
SELECT
279-
r."formId",
280-
key as field_id,
281-
CASE
282-
WHEN jsonb_typeof(value->'value') = 'array' THEN
283-
v.value_item
284-
ELSE
285-
value->>'value'
286-
END as selected_option,
287-
COUNT(DISTINCT r.id) as response_count
288-
FROM "App_RoutingForms_FormResponse" r
289-
CROSS JOIN jsonb_each(r.response::jsonb) as fields(key, value)
290-
LEFT JOIN LATERAL jsonb_array_elements_text(
291-
CASE
292-
WHEN jsonb_typeof(value->'value') = 'array'
293-
THEN value->'value'
294-
ELSE NULL
295-
END
296-
) as v(value_item) ON true
297-
WHERE r."routedToBookingUid" IS NULL
298-
GROUP BY r."formId", key, selected_option
299-
)
300-
SELECT
301-
ff.form_id as "formId",
302-
ff.form_name as "formName",
303-
ff.field_id as "fieldId",
304-
ff.field_label as "fieldLabel",
305-
ff.option_id as "optionId",
306-
ff.option_label as "optionLabel",
307-
COALESCE(rs.response_count, 0)::integer as count
308-
FROM form_fields ff
309-
LEFT JOIN response_stats rs ON
310-
rs."formId" = ff.form_id AND
311-
rs.field_id = ff.field_id AND
312-
rs.selected_option = ff.option_id
313-
WHERE ff.option_id IS NOT NULL
314-
ORDER BY count DESC`;
315-
316-
// First group by form and field
317-
const groupedByFormAndField = result.reduce((acc, curr) => {
318-
const formKey = curr.formName;
319-
acc[formKey] = acc[formKey] || {};
320-
const labelKey = curr.fieldLabel;
321-
acc[formKey][labelKey] = acc[formKey][labelKey] || [];
322-
acc[formKey][labelKey].push({
323-
optionId: curr.optionId,
324-
count: curr.count,
325-
optionLabel: curr.optionLabel,
326-
});
327-
return acc;
328-
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
329-
330-
// NOTE: totalCount represents the sum of all response counts across all fields and options for a form
331-
// For example, if a form has 2 fields with 2 options each:
332-
// Field1: Option1 (5 responses), Option2 (3 responses)
333-
// Field2: Option1 (2 responses), Option2 (4 responses)
334-
// Then totalCount = 5 + 3 + 2 + 4 = 14 total responses
335-
const sortedEntries = Object.entries(groupedByFormAndField)
336-
.map(([formName, fields]) => ({
337-
formName,
338-
fields,
339-
totalCount: Object.values(fields)
340-
.flat()
341-
.reduce((sum, item) => sum + item.count, 0),
342-
}))
343-
.sort((a, b) => b.totalCount - a.totalCount);
344-
345-
// Convert back to original format
346-
const sortedGroupedByFormAndField = sortedEntries.reduce((acc, { formName, fields }) => {
347-
acc[formName] = fields;
348-
return acc;
349-
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
350-
351-
return sortedGroupedByFormAndField;
352-
}
353-
354217
static async getRoutingFormHeaders({
355218
userId,
356219
teamId,

packages/features/insights/server/trpc-router.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -907,20 +907,14 @@ export const insightsRouter = router({
907907
return options;
908908
}),
909909
failedBookingsByField: userBelongsToTeamProcedure
910-
.input(
911-
z.object({
912-
userId: z.number().optional(),
913-
teamId: z.number().optional(),
914-
isAll: z.boolean(),
915-
routingFormId: z.string().optional(),
916-
})
917-
)
910+
.input(insightsRoutingServiceInputSchema)
918911
.query(async ({ ctx, input }) => {
919-
return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
920-
...input,
921-
userId: ctx.user.id,
922-
organizationId: ctx.user.organizationId ?? null,
923-
});
912+
const insightsRoutingService = createInsightsRoutingService(ctx, input);
913+
try {
914+
return await insightsRoutingService.getFailedBookingsByFieldData();
915+
} catch (e) {
916+
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
917+
}
924918
}),
925919
routingFormResponsesHeaders: userBelongsToTeamProcedure
926920
.input(

packages/lib/server/service/InsightsRoutingBaseService.ts

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ export class InsightsRoutingBaseService {
671671
// Date range filtering
672672
if (!exclude.createdAt) {
673673
conditions.push(
674-
Prisma.sql`"createdAt" >= ${this.filters.startDate}::timestamp AND "createdAt" <= ${this.filters.endDate}::timestamp`
674+
Prisma.sql`rfrd."createdAt" >= ${this.filters.startDate}::timestamp AND rfrd."createdAt" <= ${this.filters.endDate}::timestamp`
675675
);
676676
}
677677

@@ -692,7 +692,7 @@ export class InsightsRoutingBaseService {
692692
if (bookingStatusOrder && isMultiSelectFilterValue(bookingStatusOrder.value)) {
693693
const statusCondition = makeSqlCondition(bookingStatusOrder.value);
694694
if (statusCondition) {
695-
conditions.push(Prisma.sql`"bookingStatusOrder" ${statusCondition}`);
695+
conditions.push(Prisma.sql`rfrd."bookingStatusOrder" ${statusCondition}`);
696696
}
697697
}
698698

@@ -701,7 +701,7 @@ export class InsightsRoutingBaseService {
701701
if (bookingAssignmentReason && isTextFilterValue(bookingAssignmentReason.value)) {
702702
const reasonCondition = makeSqlCondition(bookingAssignmentReason.value);
703703
if (reasonCondition) {
704-
conditions.push(Prisma.sql`"bookingAssignmentReason" ${reasonCondition}`);
704+
conditions.push(Prisma.sql`rfrd."bookingAssignmentReason" ${reasonCondition}`);
705705
}
706706
}
707707

@@ -710,7 +710,7 @@ export class InsightsRoutingBaseService {
710710
if (bookingUid && isTextFilterValue(bookingUid.value)) {
711711
const uidCondition = makeSqlCondition(bookingUid.value);
712712
if (uidCondition) {
713-
conditions.push(Prisma.sql`"bookingUid" ${uidCondition}`);
713+
conditions.push(Prisma.sql`rfrd."bookingUid" ${uidCondition}`);
714714
}
715715
}
716716

@@ -744,15 +744,15 @@ export class InsightsRoutingBaseService {
744744
// Extract member user IDs filter (multi-select)
745745
const memberUserIds = filtersMap["bookingUserId"];
746746
if (memberUserIds && isMultiSelectFilterValue(memberUserIds.value)) {
747-
conditions.push(Prisma.sql`"bookingUserId" = ANY(${memberUserIds.value.data})`);
747+
conditions.push(Prisma.sql`rfrd."bookingUserId" = ANY(${memberUserIds.value.data})`);
748748
}
749749

750750
// Extract form ID filter (single-select)
751751
const formId = filtersMap["formId"];
752752
if (formId && isSingleSelectFilterValue(formId.value)) {
753753
const formIdCondition = makeSqlCondition(formId.value);
754754
if (formIdCondition) {
755-
conditions.push(Prisma.sql`"formId" ${formIdCondition}`);
755+
conditions.push(Prisma.sql`rfrd."formId" ${formIdCondition}`);
756756
}
757757
}
758758

@@ -802,7 +802,7 @@ export class InsightsRoutingBaseService {
802802
}
803803

804804
if (scope === "user") {
805-
return Prisma.sql`"formUserId" = ${this.options.userId} AND "formTeamId" IS NULL`;
805+
return Prisma.sql`rfrd."formUserId" = ${this.options.userId} AND rfrd."formTeamId" IS NULL`;
806806
} else if (scope === "org") {
807807
return await this.buildOrgAuthorizationCondition(this.options);
808808
} else if (scope === "team") {
@@ -824,7 +824,7 @@ export class InsightsRoutingBaseService {
824824

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

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

830830
private async buildTeamAuthorizationCondition(
@@ -840,7 +840,7 @@ export class InsightsRoutingBaseService {
840840
return NOTHING_CONDITION;
841841
}
842842

843-
return Prisma.sql`"formTeamId" = ${options.teamId}`;
843+
return Prisma.sql`rfrd."formTeamId" = ${options.teamId}`;
844844
}
845845

846846
private async isOwnerOrAdmin(userId: number, targetId: number): Promise<boolean> {
@@ -933,4 +933,105 @@ export class InsightsRoutingBaseService {
933933
AND ${columnCondition}
934934
)`;
935935
}
936+
937+
async getFailedBookingsByFieldData(): Promise<
938+
Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>
939+
> {
940+
const baseConditions = await this.getBaseConditions();
941+
942+
// Get failed bookings (responses without a successful booking) grouped by form, field, and option
943+
const result = await this.prisma.$queryRaw<
944+
{
945+
formId: string;
946+
formName: string;
947+
fieldId: string;
948+
fieldLabel: string;
949+
optionId: string;
950+
optionLabel: string;
951+
count: number;
952+
}[]
953+
>`
954+
WITH form_fields AS (
955+
SELECT DISTINCT
956+
rfrd."formId" as form_id,
957+
rfrd."formName" as form_name,
958+
field->>'id' as field_id,
959+
field->>'label' as field_label,
960+
opt->>'id' as option_id,
961+
opt->>'label' as option_label
962+
FROM "RoutingFormResponseDenormalized" rfrd
963+
JOIN "App_RoutingForms_Form" f ON rfrd."formId" = f.id,
964+
LATERAL jsonb_array_elements(f.fields) as field
965+
LEFT JOIN LATERAL jsonb_array_elements(field->'options') as opt ON true
966+
WHERE
967+
${baseConditions}
968+
),
969+
response_stats AS (
970+
SELECT
971+
rfrd."formId",
972+
f."fieldId" as field_id,
973+
COALESCE(arr.value, f."valueString", f."valueNumber"::text) as selected_option,
974+
COUNT(DISTINCT rfrd.id) as response_count
975+
FROM "RoutingFormResponseDenormalized" rfrd
976+
JOIN "RoutingFormResponseField" f ON rfrd.id = f."responseId"
977+
LEFT JOIN LATERAL unnest(f."valueStringArray") as arr(value) ON f."valueStringArray" != '{}'
978+
WHERE ${baseConditions}
979+
AND rfrd."bookingUid" IS NULL
980+
AND COALESCE(arr.value, f."valueString", f."valueNumber"::text) IS NOT NULL
981+
GROUP BY rfrd."formId", f."fieldId", COALESCE(arr.value, f."valueString", f."valueNumber"::text)
982+
)
983+
SELECT
984+
ff.form_id as "formId",
985+
ff.form_name as "formName",
986+
ff.field_id as "fieldId",
987+
ff.field_label as "fieldLabel",
988+
ff.option_id as "optionId",
989+
ff.option_label as "optionLabel",
990+
COALESCE(rs.response_count, 0)::integer as count
991+
FROM form_fields ff
992+
LEFT JOIN response_stats rs ON
993+
rs."formId" = ff.form_id AND
994+
rs.field_id = ff.field_id AND
995+
rs.selected_option = ff.option_id
996+
WHERE ff.option_id IS NOT NULL
997+
ORDER BY count DESC
998+
`;
999+
1000+
// First group by form and field
1001+
const groupedByFormAndField = result.reduce((acc, curr) => {
1002+
const formKey = curr.formName;
1003+
acc[formKey] = acc[formKey] || {};
1004+
const labelKey = curr.fieldLabel;
1005+
acc[formKey][labelKey] = acc[formKey][labelKey] || [];
1006+
acc[formKey][labelKey].push({
1007+
optionId: curr.optionId,
1008+
count: curr.count,
1009+
optionLabel: curr.optionLabel,
1010+
});
1011+
return acc;
1012+
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
1013+
1014+
// NOTE: totalCount represents the sum of all response counts across all fields and options for a form
1015+
// For example, if a form has 2 fields with 2 options each:
1016+
// Field1: Option1 (5 responses), Option2 (3 responses)
1017+
// Field2: Option1 (2 responses), Option2 (4 responses)
1018+
// Then totalCount = 5 + 3 + 2 + 4 = 14 total responses
1019+
const sortedEntries = Object.entries(groupedByFormAndField)
1020+
.map(([formName, fields]) => ({
1021+
formName,
1022+
fields,
1023+
totalCount: Object.values(fields)
1024+
.flat()
1025+
.reduce((sum, item) => sum + item.count, 0),
1026+
}))
1027+
.sort((a, b) => b.totalCount - a.totalCount);
1028+
1029+
// Convert back to original format
1030+
const sortedGroupedByFormAndField = sortedEntries.reduce((acc, { formName, fields }) => {
1031+
acc[formName] = fields;
1032+
return acc;
1033+
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
1034+
1035+
return sortedGroupedByFormAndField;
1036+
}
9361037
}

0 commit comments

Comments
 (0)