Skip to content
This repository has been archived by the owner on Aug 11, 2024. It is now read-only.

feat: add more callout response filters #430

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 66 additions & 56 deletions src/api/transformers/BaseCalloutResponseTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CalloutResponse from "@models/CalloutResponse";
import CalloutResponseTag from "@models/CalloutResponseTag";

import { AuthInfo } from "@type/auth-info";
import { FilterHandler, FilterHandlers } from "@type/filter-handlers";
import { FilterHandlers } from "@type/filter-handlers";

export abstract class BaseCalloutResponseTransformer<
GetDto,
Expand All @@ -29,20 +29,9 @@ export abstract class BaseCalloutResponseTransformer<
GetOptsDto
> {
protected model = CalloutResponse;
protected filters = calloutResponseFilters;
protected filterHandlers: FilterHandlers<string> = {
/**
* Text search across all answers in a response by aggregating them into a
* single string
*/
answers: (qb, args) => {
qb.where(
args.convertToWhereClause(`(
SELECT string_agg(answer.value, '')
FROM jsonb_each(${args.fieldPrefix}answers) AS slide, jsonb_each_text(slide.value) AS answer
)`)
);
},
filters = calloutResponseFilters;

filterHandlers: FilterHandlers<string> = {
/**
* Filter for responses with a specific tag
*/
Expand All @@ -62,6 +51,63 @@ export abstract class BaseCalloutResponseTransformer<
: "IN";

qb.where(`${args.fieldPrefix}id ${inOp} ${subQb.getQuery()}`);
},
/**
* Text search across all answers in a response by aggregating them into a
* single string
*/
answers: (qb, args) => {
qb.where(
args.convertToWhereClause(`(
SELECT string_agg(answer.value, '')
FROM jsonb_each(${args.fieldPrefix}answers) AS slide, jsonb_each_text(slide.value) AS answer
)`)
);
},
/**
* Filter responses by a specific answer. The key will be formatted as
* answers.<slideId>.<answerKey>.
*
* Answers are stored as a JSONB object, this method maps the filter
* operators to their appropriate JSONB operators.
*/
"answers.": (qb, args) => {
const answerField = `${args.fieldPrefix}answers -> :slideId -> :answerKey`;

if (args.type === "array") {
// Override operator function for array types
const operatorFn = answerArrayOperators[args.operator];
if (!operatorFn) {
// Shouln't be able to happen as rule has been validated
throw new Error("Invalid ValidatedRule");
}
qb.where(args.addParamSuffix(operatorFn(answerField)));
} else if (
args.operator === "is_empty" ||
args.operator === "is_not_empty"
) {
// is_empty and is_not_empty need special treatment for JSONB values
const operator = args.operator === "is_empty" ? "IN" : "NOT IN";
qb.where(
args.addParamSuffix(
`COALESCE(${answerField}, 'null') ${operator} ('null', '""')`
)
);
} else if (args.type === "number" || args.type === "boolean") {
// Cast from JSONB to native type for comparison
const cast = args.type === "number" ? "numeric" : "boolean";
qb.where(args.convertToWhereClause(`(${answerField})::${cast}`));
} else {
// Extract as text instead of JSONB (note ->> instead of ->)
qb.where(
args.convertToWhereClause(
`${args.fieldPrefix}answers -> :slideId ->> :answerKey`
)
);
}

const [_, slideId, answerKey] = args.field.split(".");
return { slideId, answerKey };
}
};

Expand All @@ -70,11 +116,11 @@ export abstract class BaseCalloutResponseTransformer<
): Promise<
[Partial<Filters<CalloutResponseFilterName>>, FilterHandlers<string>]
> {
// If looking for responses for a particular callout then add answer filtering
const filters = query.callout
? getCalloutFilters(query.callout.formSchema)
: {};
return [filters, { "answers.": individualAnswerFilterHandler }];
return [
// If looking for responses for a particular callout then add answer filtering
query.callout ? getCalloutFilters(query.callout.formSchema) : {},
{}
];
}

protected transformQuery<T extends GetOptsDto & PaginatedQuery>(
Expand Down Expand Up @@ -111,39 +157,3 @@ const answerArrayOperators: Partial<
is_empty: (field) => `NOT jsonb_path_exists(${field}, '$.* ? (@ == true)')`,
is_not_empty: (field) => `jsonb_path_exists(${field}, '$.* ? (@ == true)')`
};

export const individualAnswerFilterHandler: FilterHandler = (qb, args) => {
const answerField = `${args.fieldPrefix}answers -> :slideId -> :answerKey`;

if (args.type === "array") {
// Override operator function for array types
const operatorFn = answerArrayOperators[args.operator];
if (!operatorFn) {
// Shouln't be able to happen as rule has been validated
throw new Error("Invalid ValidatedRule");
}
qb.where(args.addParamSuffix(operatorFn(answerField)));
} else if (args.operator === "is_empty" || args.operator === "is_not_empty") {
// is_empty and is_not_empty need special treatment for JSONB values
const operator = args.operator === "is_empty" ? "IN" : "NOT IN";
qb.where(
args.addParamSuffix(
`COALESCE(${answerField}, 'null') ${operator} ('null', '""')`
)
);
} else if (args.type === "number" || args.type === "boolean") {
// Cast from JSONB to native type for comparison
const cast = args.type === "number" ? "numeric" : "boolean";
qb.where(args.convertToWhereClause(`(${answerField})::${cast}`));
} else {
// Extract as text instead of JSONB (note ->> instead of ->)
qb.where(
args.convertToWhereClause(
`${args.fieldPrefix}answers -> :slideId ->> :answerKey`
)
);
}

const [_, slideId, answerKey] = args.field.split(".");
return { slideId, answerKey };
};
35 changes: 19 additions & 16 deletions src/api/transformers/BaseContactTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import { Brackets } from "typeorm";

import { createQueryBuilder, getRepository } from "@core/database";

import { individualAnswerFilterHandler } from "@api/transformers/BaseCalloutResponseTransformer";
import { BaseTransformer } from "@api/transformers/BaseTransformer";
import { prefixKeys } from "@api/utils";
import CalloutResponseTransformer from "@api/transformers/CalloutResponseTransformer";
import { getFilterHandler, prefixKeys } from "@api/utils";

import Callout from "@models/Callout";
import CalloutResponse from "@models/CalloutResponse";
Expand Down Expand Up @@ -53,7 +53,8 @@ export abstract class BaseContactTransformer<
manualPaymentSource: (qb, args) => {
contributionField("mandateId")(qb, args);
qb.andWhere(`${args.fieldPrefix}contributionType = 'Manual'`);
}
},
"callouts.": calloutsFilterHandler
};

protected async transformFilters(
Expand All @@ -78,15 +79,15 @@ export abstract class BaseContactTransformer<
Object.assign(
filters,
prefixKeys(`callouts.${calloutId}.`, contactCalloutFilters),
prefixKeys(
`callouts.${calloutId}.responses.`,
getCalloutFilters(callout.formSchema)
)
prefixKeys(`callouts.${calloutId}.responses.`, {
...CalloutResponseTransformer.filters,
...getCalloutFilters(callout.formSchema)
})
);
}
}

return [filters, { "callouts.": calloutsFilterHandler }];
return [filters, {}];
}
}

Expand Down Expand Up @@ -157,28 +158,30 @@ const activePermission: FilterHandler = (qb, args) => {
};

const calloutsFilterHandler: FilterHandler = (qb, args) => {
// Split out callouts.<id>.<filterName>[.<answerFields...>]
const [, calloutId, subField, ...answerFields] = args.field.split(".");
// Split out callouts.<id>.<filterName>[.<restFields...>]
const [, calloutId, subField, ...restFields] = args.field.split(".");

let params;

switch (subField) {
/**
* Filter contacts by their answers to a callout, uses the same filters as
* Filter contacts by their responses to a callout, uses the same filters as
* callout responses endpoints

* Filter field: callout.<id>.responses.<answerFields>
* Filter field: callout.<id>.responses.<restFields>
*/
case "responses": {
const subQb = createQueryBuilder()
.subQuery()
.select("item.contactId")
.from(CalloutResponse, "item");

params = individualAnswerFilterHandler(subQb, {
...args,
field: answerFields.join(".")
});
const responseField = restFields.join(".");
const filterHandler = getFilterHandler(
CalloutResponseTransformer.filterHandlers,
responseField
);
params = filterHandler(subQb, { ...args, field: responseField });

subQb
.andWhere(args.addParamSuffix("item.calloutId = :calloutId"))
Expand Down
38 changes: 26 additions & 12 deletions src/api/utils/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,31 @@ function prepareRule(
}
}

/**
* Find the filter handler for a field. If there isn't a specific handler then
* it will try to find a catch all handler. Catch all handlers end in a "."
*
* i.e. "callouts." will match any fields starting with "callouts.", e.g.
* "callouts.id", "callouts.foo"
*
* @param filterHandlers A set of filter handlers
* @param field The field name
* @returns The most appropriate filter handler
*/
export function getFilterHandler(
filterHandlers: FilterHandlers<string> | undefined,
field: string
): FilterHandler {
let filterHandler = filterHandlers?.[field];
// See if there is a catch all field handler for subfields
if (!filterHandler && field.includes(".")) {
const catchallField = field.split(".", 1)[0] + ".";
filterHandler = filterHandlers?.[catchallField];
}

return filterHandler || simpleFilterHandler;
}

/**
* The query builder doesn't support having the same parameter names for
* different parts of the query and subqueries, so we have to ensure each query
Expand All @@ -255,17 +280,6 @@ export function convertRulesToWhereClause(
};
let ruleNo = 0;

function getFilterHandler(field: string): FilterHandler {
let filterHandler = filterHandlers?.[field];
// See if there is a catch all field handler for subfields
if (!filterHandler && field.includes(".")) {
const catchallField = field.split(".", 1)[0] + ".";
filterHandler = filterHandlers?.[catchallField];
}

return filterHandler || simpleFilterHandler;
}

function parseRule(rule: ValidatedRule<string>) {
return (qb: WhereExpressionBuilder): void => {
const applyOperator = operatorsWhereByType[rule.type][rule.operator];
Expand All @@ -285,7 +299,7 @@ export function convertRulesToWhereClause(
const addParamSuffix = (field: string) =>
field.replace(/[^:]:[a-zA-Z]+/g, "$&" + paramSuffix);

const newParams = getFilterHandler(rule.field)(qb, {
const newParams = getFilterHandler(filterHandlers, rule.field)(qb, {
fieldPrefix,
field: rule.field,
operator: rule.operator,
Expand Down
Loading