Skip to content

Commit

Permalink
Merge pull request #5 from beabee-communityrm/feat/1048-contact-callo…
Browse files Browse the repository at this point in the history
…ut-filters

feat: add callout filters to contact
  • Loading branch information
JumpLink authored Jun 11, 2024
2 parents d063f82 + 6cc3aa0 commit 2134e16
Show file tree
Hide file tree
Showing 54 changed files with 1,253 additions and 902 deletions.
151 changes: 81 additions & 70 deletions apps/backend/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,52 +29,19 @@ 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
)`)
);
},
/**
* Filter for responses with a specific tag
*/
tags: (qb, args) => {
const subQb = createQueryBuilder()
.subQuery()
.select("crt.responseId")
.from(CalloutResponseTag, "crt");

if (args.operator === "contains" || args.operator === "not_contains") {
subQb.where(args.addParamSuffix("crt.tag = :valueA"));
}

const inOp =
args.operator === "not_contains" || args.operator === "is_not_empty"
? "NOT IN"
: "IN";

qb.where(`${args.fieldPrefix}id ${inOp} ${subQb.getQuery()}`);
}
};
filters = calloutResponseFilters;
filterHandlers = calloutResponseFilterHandlers;

protected async transformFilters(
query: GetOptsDto & PaginatedQuery
): 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 @@ -112,38 +79,82 @@ const answerArrayOperators: Partial<
is_not_empty: (field) => `jsonb_path_exists(${field}, '$.* ? (@ == true)')`
};

export const individualAnswerFilterHandler: FilterHandler = (qb, args) => {
const answerField = `${args.fieldPrefix}answers -> :slideId -> :answerKey`;
export const calloutResponseFilterHandlers: FilterHandlers<string> = {
/**
* Filter for responses with a specific tag
*/
tags: (qb, args) => {
const subQb = createQueryBuilder()
.subQuery()
.select("crt.responseId")
.from(CalloutResponseTag, "crt");

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");
if (args.operator === "contains" || args.operator === "not_contains") {
subQb.where(args.addParamSuffix("crt.tag = :valueA"));
}
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 ->)

const inOp =
args.operator === "not_contains" || args.operator === "is_not_empty"
? "NOT IN"
: "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(
`${args.fieldPrefix}answers -> :slideId ->> :answerKey`
)
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`;

const [_, slideId, answerKey] = args.field.split(".");
return { 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 };
}
};
36 changes: 20 additions & 16 deletions apps/backend/src/api/transformers/BaseContactTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
PaginatedQuery,
Rule,
RuleGroup,
calloutResponseFilters,
contactCalloutFilters,
contactFilters,
getCalloutFilters,
Expand All @@ -14,9 +15,9 @@ import { Brackets } from "typeorm";

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

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

import Callout from "@models/Callout";
import CalloutResponse from "@models/CalloutResponse";
Expand Down Expand Up @@ -53,7 +54,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 +80,15 @@ export abstract class BaseContactTransformer<
Object.assign(
filters,
prefixKeys(`callouts.${calloutId}.`, contactCalloutFilters),
prefixKeys(
`callouts.${calloutId}.responses.`,
getCalloutFilters(callout.formSchema)
)
prefixKeys(`callouts.${calloutId}.responses.`, {
...calloutResponseFilters,
...getCalloutFilters(callout.formSchema)
})
);
}
}

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

Expand Down Expand Up @@ -157,28 +159,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(
calloutResponseFilterHandlers,
responseField
);
params = filterHandler(subQb, { ...args, field: responseField });

subQb
.andWhere(args.addParamSuffix("item.calloutId = :calloutId"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import NotFoundError from "@api/errors/NotFoundError";
import ContactTransformer, {
loadContactRoles
} from "@api/transformers/ContactTransformer";
import { BaseCalloutResponseTransformer } from "@api/transformers/BaseCalloutResponseTransformer";
import CalloutTransformer from "@api/transformers/CalloutTransformer";
import CalloutResponseCommentTransformer from "@api/transformers/CalloutResponseCommentTransformer";
import CalloutTagTransformer from "@api/transformers/CalloutTagTransformer";
import { BaseCalloutResponseTransformer } from "@api/transformers/BaseCalloutResponseTransformer";
import { batchUpdate } from "@api/utils/rules";

import Callout from "@models/Callout";
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/api/transformers/ContactTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
GetContactOptsDto,
ListContactsDto
} from "@api/dto/ContactDto";
import { BaseContactTransformer } from "@api/transformers/BaseContactTransformer";
import ContactRoleTransformer from "@api/transformers/ContactRoleTransformer";
import ContactProfileTransformer from "@api/transformers/ContactProfileTransformer";
import { mergeRules } from "@api/utils/rules";

import { GetContactWith } from "@enums/get-contact-with";

import { AuthInfo } from "@type/auth-info";
import { BaseContactTransformer } from "./BaseContactTransformer";

class ContactTransformer extends BaseContactTransformer<
GetContactDto,
Expand Down
38 changes: 26 additions & 12 deletions apps/backend/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

0 comments on commit 2134e16

Please sign in to comment.