From 3bf09f6f3a8a13b34c93401aa71d0b88c9cb2e90 Mon Sep 17 00:00:00 2001 From: Mike Vesprini Date: Tue, 15 Oct 2024 15:58:12 -0700 Subject: [PATCH 1/2] Add Action Taken filter to complaint search Added the Action Taken filter to the Complaint list for CEEB users. The filter also applies to the map view. In both the complaint search and map search, if and only if the filters contain an Action Taken, a call to the Case Management service is made requesting the lead identifiers (which in NATCom are complaint identifiers) of all leads belonging to a case_file that has had the specified action taken on it. If the list is not empty, we add an AND clause to the search to filter the results against the list. If the list is empty then the search should return no results, however SQL errors if you pass an empty list to an IN. To avoid this, a list of a single value that is not a valid complaint identifier is passed in its stead, giving the desired result. --- backend/src/app.module.ts | 8 ++- .../complaints/complaint-filter-parameters.ts | 1 + .../src/v1/complaint/complaint.controller.ts | 11 +-- backend/src/v1/complaint/complaint.service.ts | 69 ++++++++++++++++++- frontend/cypress/e2e/complaint-list.cy.ts | 5 ++ .../complaints/complaint-filter-bar.tsx | 10 +++ .../complaints/complaint-filter.tsx | 37 ++++++++-- .../containers/complaints/complaint-list.tsx | 3 +- .../containers/complaints/complaint-map.tsx | 2 + .../providers/complaint-filter-provider.tsx | 1 + frontend/src/app/store/reducers/complaints.ts | 4 ++ .../app/types/complaints/complaint-filters.ts | 1 + .../complaint-filters/complaint-filters.ts | 2 + 13 files changed, 141 insertions(+), 13 deletions(-) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4b26b75c0..ffe9880f0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,5 +1,5 @@ import "dotenv/config"; -import {MiddlewareConsumer, Module, RequestMethod} from "@nestjs/common"; +import { MiddlewareConsumer, Module, RequestMethod } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { ConfigModule } from "@nestjs/config"; import { AutomapperModule } from "@automapper/nestjs"; @@ -135,7 +135,9 @@ if (process.env.POSTGRESQL_PASSWORD != null) { export class AppModule { // let's add a middleware on all routes configure(consumer: MiddlewareConsumer) { - consumer.apply(HTTPLoggerMiddleware).exclude({ path: '', method: RequestMethod.ALL }).forRoutes("*"); - consumer.apply(RequestTokenMiddleware).forRoutes("v1/code-table", "v1/case", "v1/configuration"); + consumer.apply(HTTPLoggerMiddleware).exclude({ path: "", method: RequestMethod.ALL }).forRoutes("*"); + consumer + .apply(RequestTokenMiddleware) + .forRoutes("v1/code-table", "v1/case", "v1/configuration", "v1/complaint/search", "v1/complaint/map/search"); } } diff --git a/backend/src/types/models/complaints/complaint-filter-parameters.ts b/backend/src/types/models/complaints/complaint-filter-parameters.ts index c61ef73bb..d35f18387 100644 --- a/backend/src/types/models/complaints/complaint-filter-parameters.ts +++ b/backend/src/types/models/complaints/complaint-filter-parameters.ts @@ -11,4 +11,5 @@ export interface ComplaintFilterParameters { status?: string; girTypeCode?: string; complaintMethod?: string; + actionTaken?: string; } diff --git a/backend/src/v1/complaint/complaint.controller.ts b/backend/src/v1/complaint/complaint.controller.ts index 6a8663afd..20a53a4f3 100644 --- a/backend/src/v1/complaint/complaint.controller.ts +++ b/backend/src/v1/complaint/complaint.controller.ts @@ -3,6 +3,7 @@ import { ComplaintService } from "./complaint.service"; import { Role } from "../../enum/role.enum"; import { Roles } from "../../auth/decorators/roles.decorator"; import { JwtRoleGuard } from "../../auth/jwtrole.guard"; +import { Token } from "../../auth/decorators/token.decorator"; import { ApiTags } from "@nestjs/swagger"; import { COMPLAINT_TYPE } from "../../types/models/complaints/complaint-type"; import { WildlifeComplaintDto } from "../../types/models/complaints/wildlife-complaint"; @@ -45,22 +46,24 @@ export class ComplaintController { @Param("complaintType") complaintType: COMPLAINT_TYPE, @Query() model: ComplaintSearchParameters, @Request() req, + @Token() token, ) { - const hasCEEBRole = hasRole(req, Role.CEEB); - return this.service.mapSearch(complaintType, model, hasCEEBRole); + return this.service.mapSearch(complaintType, model, hasCEEBRole, token); } @Get("/search/:complaintType") @Roles(Role.COS_OFFICER, Role.CEEB) - search( + async search( @Param("complaintType") complaintType: COMPLAINT_TYPE, @Query() model: ComplaintSearchParameters, @Request() req, + @Token() token, ) { const hasCEEBRole = hasRole(req, Role.CEEB); - return this.service.search(complaintType, model, hasCEEBRole); + const result = await this.service.search(complaintType, model, hasCEEBRole, token); + return result; } @Patch("/update-status-by-id/:id") diff --git a/backend/src/v1/complaint/complaint.service.ts b/backend/src/v1/complaint/complaint.service.ts index 3f44293e7..3456c9c2d 100644 --- a/backend/src/v1/complaint/complaint.service.ts +++ b/backend/src/v1/complaint/complaint.service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Brackets, DataSource, QueryRunner, Repository, SelectQueryBuilder } from "typeorm"; import { InjectMapper } from "@automapper/nestjs"; import { Mapper } from "@automapper/core"; +import { get } from "../../external_api/case_management"; import { applyAllegationComplaintMap, @@ -843,6 +844,7 @@ export class ComplaintService { complaintType: COMPLAINT_TYPE, model: ComplaintSearchParameters, hasCEEBRole: boolean, + token?: string, ): Promise => { try { let results: SearchResults = { totalCount: 0, complaints: [] }; @@ -871,6 +873,30 @@ export class ComplaintService { builder.andWhere("violation_code.agency_code = :agency", { agency: "EPO" }); } + // -- filter by complaint identifiers returned by case management if actionTaken filter is present + if (hasCEEBRole && filters.actionTaken) { + const { data, errors } = await get(token, { + query: `{getLeadsByActionTaken (actionCode: "${filters.actionTaken}")}`, + }); + + if (errors) { + this.logger.error("GraphQL errors:", errors); + throw new Error("GraphQL errors occurred"); + } + /** + * If no leads in the case manangement database have had the selected action taken, `getLeadsByActionTaken` + * returns an empty array, and WHERE...IN () does not accept an empty set, it throws an error. In our use + * case, if `getLeadsByActionTaken` returns an empty array, we do not want the entire search to error, it + * should simply return an empty result set. To handle this, if `getLeadsByActionTaken` returns an empty + * array, we populate the array with a value that would never match on a complaint_identifier, -1. + */ + const complaintIdentifiers = data.getLeadsByActionTaken.length > 0 ? data.getLeadsByActionTaken : [-1]; + + builder.andWhere("complaint.complaint_identifier IN(:...complaint_identifiers)", { + complaint_identifiers: complaintIdentifiers, + }); + } + //-- apply search if (query) { builder = this._applySearch(builder, complaintType, query); @@ -946,6 +972,7 @@ export class ComplaintService { complaintType: COMPLAINT_TYPE, model: ComplaintSearchParameters, hasCEEBRole: boolean, + token?: string, ): Promise => { const { orderBy, sortBy, page, pageSize, query, ...filters } = model; @@ -985,6 +1012,29 @@ export class ComplaintService { complaintBuilder.andWhere("ST_X(complaint.location_geometry_point) <> 0"); complaintBuilder.andWhere("ST_Y(complaint.location_geometry_point) <> 0"); + // -- filter by complaint identifiers returned by case management if actionTaken filter is present + if (hasCEEBRole && filters.actionTaken) { + const { data, errors } = await get(token, { + query: `{getLeadsByActionTaken (actionCode: "${filters.actionTaken}")}`, + }); + if (errors) { + this.logger.error("GraphQL errors:", errors); + throw new Error("GraphQL errors occurred"); + } + /** + * If no leads in the case manangement database have had the selected action taken, `getLeadsByActionTaken` + * returns an empty array, and WHERE...IN () does not accept an empty set, it throws an error. In our use + * case, if `getLeadsByActionTaken` returns an empty array, we do not want the entire search to error, it + * should simply return an empty result set. To handle this, if `getLeadsByActionTaken` returns an empty + * array, we populate the array with a value that would never match on a complaint_identifier, -1. + */ + const complaintIdentifiers = data.getLeadsByActionTaken.length > 0 ? data.getLeadsByActionTaken : [-1]; + + complaintBuilder.andWhere("complaint.complaint_identifier IN(:...complaint_identifiers)", { + complaint_identifiers: complaintIdentifiers, + }); + } + //-- run query const mappedComplaints = await complaintBuilder.getMany(); @@ -1008,13 +1058,30 @@ export class ComplaintService { }); } - //-- added this for consistency with search method //-- return Waste and Pestivide complaints for CEEB users if (hasCEEBRole && complaintType === "ERS") { unMappedBuilder.andWhere("violation_code.agency_code = :agency", { agency: "EPO" }); } + // -- filter by complaint identifiers returned by case management if actionTaken filter is present + if (hasCEEBRole && filters.actionTaken) { + const { data, errors } = await get(token, { + query: `{getLeadsByActionTaken (actionCode: "${filters.actionTaken}")}`, + }); + if (errors) { + this.logger.error("GraphQL errors:", errors); + throw new Error("GraphQL errors occurred"); + } + // If no complaint indentifiers are returned by the CM database, provide a non-empty non-matching list to avoid + // the SQL error of IN empty set. + const complaintIdentifiers = data.getLeadsByActionTaken.length > 0 ? data.getLeadsByActionTaken : [-1]; + + unMappedBuilder.andWhere("complaint.complaint_identifier IN(:...complaint_identifiers)", { + complaint_identifiers: complaintIdentifiers, + }); + } + //-- filter locations without coordinates unMappedBuilder.andWhere("ST_X(complaint.location_geometry_point) = 0"); unMappedBuilder.andWhere("ST_Y(complaint.location_geometry_point) = 0"); diff --git a/frontend/cypress/e2e/complaint-list.cy.ts b/frontend/cypress/e2e/complaint-list.cy.ts index 8e920061d..274f77d8a 100644 --- a/frontend/cypress/e2e/complaint-list.cy.ts +++ b/frontend/cypress/e2e/complaint-list.cy.ts @@ -50,6 +50,11 @@ describe("CEEB User Stuff?", () => { beforeEach(function () { cy.viewport("macbook-16"); cy.kcLogout().kcLogin(); + cy.applyRoles(roles); + }); + + afterEach(function () { + cy.resetRoles(); }); Cypress._.times(roles.length, (index) => { diff --git a/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx b/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx index f0ad8dd28..e1a6a9ffd 100644 --- a/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx @@ -41,6 +41,7 @@ export const ComplaintFilterBar: FC = ({ violationType, girType, complaintMethod, + actionTaken, } = state; const dateRangeLabel = (): string | undefined => { @@ -220,6 +221,15 @@ export const ComplaintFilterBar: FC = ({ clear={removeFilter} /> )} + + {hasFilter("actionTaken") && ( + + )} ); diff --git a/frontend/src/app/components/containers/complaints/complaint-filter.tsx b/frontend/src/app/components/containers/complaints/complaint-filter.tsx index cbc1cc817..9bb94df69 100644 --- a/frontend/src/app/components/containers/complaints/complaint-filter.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-filter.tsx @@ -13,6 +13,7 @@ import { selectGirTypeCodeDropdown, selectComplaintReceivedMethodDropdown, } from "../../../store/reducers/code-table"; +import { selectDecisionTypeDropdown } from "../../../store/reducers/code-table-selectors"; import { selectOfficersByAgencyDropdown } from "../../../store/reducers/officer"; import COMPLAINT_TYPES from "../../../types/app/complaint-types"; import DatePicker from "react-datepicker"; @@ -22,6 +23,8 @@ import { ComplaintFilterPayload, updateFilter } from "../../../store/reducers/co import Option from "../../../types/app/option"; import { getUserAgency } from "../../../service/user-service"; import { listActiveFilters } from "../../../store/reducers/app"; +import UserService from "../../../service/user-service"; +import Roles from "../../../types/app/roles"; type Props = { type: string; @@ -42,6 +45,7 @@ export const ComplaintFilter: FC = ({ type }) => { endDate, girType, complaintMethod, + actionTaken, }, dispatch, } = useContext(ComplaintFilterContext); @@ -62,6 +66,7 @@ export const ComplaintFilter: FC = ({ type }) => { const communities = useAppSelector(selectCascadedCommunity(region?.value, zone?.value, community?.value)); const complaintMethods = useAppSelector(selectComplaintReceivedMethodDropdown); + const decisionTypeOptions = useAppSelector(selectDecisionTypeDropdown); const activeFilters = useAppSelector(listActiveFilters()); @@ -224,8 +229,9 @@ export const ComplaintFilter: FC = ({ type }) => {
)} + {UserService.hasRole(Roles.CEEB) && ( +
+ +
+ { + setFilter("actionTaken", option); + }} + classNames={{ + menu: () => "top-layer-select", + }} + options={decisionTypeOptions} + placeholder="Select" + enableValidation={false} + value={actionTaken} + isClearable={true} + /> +
+
+ )} ); }; diff --git a/frontend/src/app/components/containers/complaints/complaint-list.tsx b/frontend/src/app/components/containers/complaints/complaint-list.tsx index 7a5fbec05..187770ed6 100644 --- a/frontend/src/app/components/containers/complaints/complaint-list.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-list.tsx @@ -52,6 +52,7 @@ export const generateComplaintRequestPayload = ( violationType, girType, complaintMethod, + actionTaken, } = filters; const common = { @@ -80,6 +81,7 @@ export const generateComplaintRequestPayload = ( ...common, violationFilter: violationType, complaintMethodFilter: complaintMethod, + actionTakenFilter: actionTaken, } as ComplaintRequestPayload; case COMPLAINT_TYPES.HWCR: default: @@ -114,7 +116,6 @@ export const ComplaintList: FC = ({ type, searchQuery }) => { if (searchQuery) { payload = { ...payload, query: searchQuery }; } - dispatch(getComplaints(type, payload)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters, sortKey, sortDirection, page, pageSize]); diff --git a/frontend/src/app/components/containers/complaints/complaint-map.tsx b/frontend/src/app/components/containers/complaints/complaint-map.tsx index 4ad79b41d..d599e8ea9 100644 --- a/frontend/src/app/components/containers/complaints/complaint-map.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-map.tsx @@ -36,6 +36,7 @@ export const generateMapComplaintRequestPayload = ( natureOfComplaint, violationType, complaintMethod, + actionTaken, } = filters; const common = { @@ -48,6 +49,7 @@ export const generateMapComplaintRequestPayload = ( startDateFilter: startDate, endDateFilter: endDate, complaintStatusFilter: status, + actionTakenFilter: actionTaken, }; switch (complaintType) { diff --git a/frontend/src/app/providers/complaint-filter-provider.tsx b/frontend/src/app/providers/complaint-filter-provider.tsx index 61bfd985e..b38aa4c16 100644 --- a/frontend/src/app/providers/complaint-filter-provider.tsx +++ b/frontend/src/app/providers/complaint-filter-provider.tsx @@ -26,6 +26,7 @@ let initialState: ComplaintFilters = { violationType: null, filters: [], complaintMethod: null, + actionTaken: null, }; const ComplaintFilterContext = createContext({ diff --git a/frontend/src/app/store/reducers/complaints.ts b/frontend/src/app/store/reducers/complaints.ts index 5c2460777..b761d1a7a 100644 --- a/frontend/src/app/store/reducers/complaints.ts +++ b/frontend/src/app/store/reducers/complaints.ts @@ -277,6 +277,7 @@ export const getComplaints = girTypeFilter, complaintStatusFilter, complaintMethodFilter, + actionTakenFilter, page, pageSize, query, @@ -300,6 +301,7 @@ export const getComplaints = girTypeCode: girTypeFilter?.value, status: complaintStatusFilter?.value, complaintMethod: complaintMethodFilter?.value, + actionTaken: actionTakenFilter?.value, page: page, pageSize: pageSize, query: query, @@ -331,6 +333,7 @@ export const getMappedComplaints = violationFilter, complaintStatusFilter, complaintMethodFilter, + actionTakenFilter, page, pageSize, query, @@ -353,6 +356,7 @@ export const getMappedComplaints = violationCode: violationFilter?.value, status: complaintStatusFilter?.value, complaintMethod: complaintMethodFilter?.value, + actionTaken: actionTakenFilter?.value, page: page, pageSize: pageSize, query: query, diff --git a/frontend/src/app/types/complaints/complaint-filters.ts b/frontend/src/app/types/complaints/complaint-filters.ts index 40e84c81d..21c182f2c 100644 --- a/frontend/src/app/types/complaints/complaint-filters.ts +++ b/frontend/src/app/types/complaints/complaint-filters.ts @@ -15,6 +15,7 @@ export interface ComplaintFilters { endDateFilter?: Date; complaintStatusFilter?: Option; complaintMethodFilter?: Option; + actionTakenFilter?: Option; page?: number; pageSize?: number; query?: string; diff --git a/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts b/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts index 6e8975c90..614098d0a 100644 --- a/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts +++ b/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts @@ -20,5 +20,7 @@ export type ComplaintFilters = { complaintMethod: DropdownOption | null; + actionTaken?: DropdownOption | null; + filters: Array; }; From 5394c1457281e3bb33b4c1e74a3ab8c0df6a865e Mon Sep 17 00:00:00 2001 From: Mike Vesprini Date: Wed, 16 Oct 2024 09:13:58 -0700 Subject: [PATCH 2/2] Add E2E test for action taken filter --- .../cypress/e2e/complaint-search.v2.cy.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/frontend/cypress/e2e/complaint-search.v2.cy.ts b/frontend/cypress/e2e/complaint-search.v2.cy.ts index 3e65b789d..b9f922c46 100644 --- a/frontend/cypress/e2e/complaint-search.v2.cy.ts +++ b/frontend/cypress/e2e/complaint-search.v2.cy.ts @@ -1,3 +1,4 @@ +import Roles from "../../src/app/types/app/roles"; /* Tests to verify complaint list specification functionality */ @@ -146,3 +147,45 @@ describe("Complaint Search Functionality", () => { }); }); }); + +/** + * Test that CEEB specific search filters work + */ +describe("Verify CEEB specific search filters work", () => { + beforeEach(function () { + cy.viewport("macbook-16"); + cy.kcLogout().kcLogin(Roles.CEEB); + }); + + it.only("allows filtering of complaints by Action Taken", function () { + // Navigate to the complaint list + const complaintWithActionTakenID = "23-030990"; + const actionTaken = "Forward to lead agency"; + cy.visit("/"); + cy.waitForSpinner(); + + // Set an 'action taken' on a complaint, so it can be filtered + cy.get("#comp-officer-filter").should("exist").click(); + cy.get(`#${complaintWithActionTakenID}`).should("exist").click(); + // cy.get(".input-group > .comp-form-control").should("exist").click().type("111"); + cy.selectItemById("outcome-decision-schedule-sector", "Other"); + cy.selectItemById("outcome-decision-sector-category", "None"); + cy.selectItemById("outcome-decision-discharge", "Pesticides"); + cy.selectItemById("outcome-decision-action-taken", actionTaken); + cy.selectItemById("outcome-decision-lead-agency", "Other"); + cy.enterDateTimeInDatePicker("outcome-decision-outcome-date", "01"); + cy.get("#details-screen-assign-button").should("exist").click(); + cy.get("#self_assign_button").should("exist").click(); + cy.get(".modal").should("not.exist"); + cy.get("#outcome-decision-save-button").click(); + cy.contains("div", "Decision added").should("exist"); + + // Return to the complaints view + cy.get("#complaints-link").click(); + + // Filter by action taken + cy.get("#comp-filter-btn").should("exist").click({ force: true }); + cy.selectItemById("action-taken-select-id", actionTaken); + cy.get(`#${complaintWithActionTakenID}`).should("exist"); + }); +});