Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CE-1045 add action taken filter for ceeb #707

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
8 changes: 5 additions & 3 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export interface ComplaintFilterParameters {
status?: string;
girTypeCode?: string;
complaintMethod?: string;
actionTaken?: string;
}
11 changes: 7 additions & 4 deletions backend/src/v1/complaint/complaint.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")
Expand Down
69 changes: 68 additions & 1 deletion backend/src/v1/complaint/complaint.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -843,6 +844,7 @@ export class ComplaintService {
complaintType: COMPLAINT_TYPE,
model: ComplaintSearchParameters,
hasCEEBRole: boolean,
token?: string,
): Promise<SearchResults> => {
try {
let results: SearchResults = { totalCount: 0, complaints: [] };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -946,6 +972,7 @@ export class ComplaintService {
complaintType: COMPLAINT_TYPE,
model: ComplaintSearchParameters,
hasCEEBRole: boolean,
token?: string,
): Promise<MapSearchResults> => {
const { orderBy, sortBy, page, pageSize, query, ...filters } = model;

Expand Down Expand Up @@ -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();

Expand All @@ -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");
Expand Down
5 changes: 5 additions & 0 deletions frontend/cypress/e2e/complaint-list.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
43 changes: 43 additions & 0 deletions frontend/cypress/e2e/complaint-search.v2.cy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Roles from "../../src/app/types/app/roles";
/*
Tests to verify complaint list specification functionality
*/
Expand Down Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const ComplaintFilterBar: FC<Props> = ({
violationType,
girType,
complaintMethod,
actionTaken,
} = state;

const dateRangeLabel = (): string | undefined => {
Expand Down Expand Up @@ -220,6 +221,15 @@ export const ComplaintFilterBar: FC<Props> = ({
clear={removeFilter}
/>
)}

{hasFilter("actionTaken") && (
<FilterButton
id="comp-complaint-method-filter"
label={actionTaken?.label}
name="actionTaken"
clear={removeFilter}
/>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -42,6 +45,7 @@ export const ComplaintFilter: FC<Props> = ({ type }) => {
endDate,
girType,
complaintMethod,
actionTaken,
},
dispatch,
} = useContext(ComplaintFilterContext);
Expand All @@ -62,6 +66,7 @@ export const ComplaintFilter: FC<Props> = ({ type }) => {
const communities = useAppSelector(selectCascadedCommunity(region?.value, zone?.value, community?.value));

const complaintMethods = useAppSelector(selectComplaintReceivedMethodDropdown);
const decisionTypeOptions = useAppSelector(selectDecisionTypeDropdown);

const activeFilters = useAppSelector(listActiveFilters());

Expand Down Expand Up @@ -224,8 +229,9 @@ export const ComplaintFilter: FC<Props> = ({ type }) => {
<div>
<button
aria-label="Previous Month"
className={`react-datepicker__navigation react-datepicker__navigation--previous ${customHeaderCount === 1 ? "datepicker-nav-hidden" : "datepicker-nav-visible"
}`}
className={`react-datepicker__navigation react-datepicker__navigation--previous ${
customHeaderCount === 1 ? "datepicker-nav-hidden" : "datepicker-nav-visible"
}`}
onClick={decreaseMonth}
>
<span
Expand All @@ -244,8 +250,9 @@ export const ComplaintFilter: FC<Props> = ({ type }) => {
</span>
<button
aria-label="Next Month"
className={`react-datepicker__navigation react-datepicker__navigation--next ${customHeaderCount === 1 ? "datepicker-nav-hidden" : "datepicker-nav-visible"
}`}
className={`react-datepicker__navigation react-datepicker__navigation--next ${
customHeaderCount === 1 ? "datepicker-nav-hidden" : "datepicker-nav-visible"
}`}
onClick={increaseMonth}
>
<span
Expand Down Expand Up @@ -320,6 +327,28 @@ export const ComplaintFilter: FC<Props> = ({ type }) => {
</div>
</div>
)}
{UserService.hasRole(Roles.CEEB) && (
<div id="comp-filter-action-taken-id">
<label htmlFor="action-taken-select-id">Action Taken</label>
<div className="filter-select-padding">
<CompSelect
id="action-taken-select-id"
classNamePrefix="comp-select"
onChange={(option) => {
setFilter("actionTaken", option);
}}
classNames={{
menu: () => "top-layer-select",
}}
options={decisionTypeOptions}
placeholder="Select"
enableValidation={false}
value={actionTaken}
isClearable={true}
/>
</div>
</div>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const generateComplaintRequestPayload = (
violationType,
girType,
complaintMethod,
actionTaken,
} = filters;

const common = {
Expand Down Expand Up @@ -80,6 +81,7 @@ export const generateComplaintRequestPayload = (
...common,
violationFilter: violationType,
complaintMethodFilter: complaintMethod,
actionTakenFilter: actionTaken,
} as ComplaintRequestPayload;
case COMPLAINT_TYPES.HWCR:
default:
Expand Down Expand Up @@ -114,7 +116,6 @@ export const ComplaintList: FC<Props> = ({ 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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const generateMapComplaintRequestPayload = (
natureOfComplaint,
violationType,
complaintMethod,
actionTaken,
} = filters;

const common = {
Expand All @@ -48,6 +49,7 @@ export const generateMapComplaintRequestPayload = (
startDateFilter: startDate,
endDateFilter: endDate,
complaintStatusFilter: status,
actionTakenFilter: actionTaken,
};

switch (complaintType) {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/providers/complaint-filter-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ let initialState: ComplaintFilters = {
violationType: null,
filters: [],
complaintMethod: null,
actionTaken: null,
};

const ComplaintFilterContext = createContext<ComplaintFilterContextType>({
Expand Down
Loading
Loading