From 02ac804122f54985a8e6554a3759d8dc3bf7fa37 Mon Sep 17 00:00:00 2001 From: Ryan Rondeau Date: Fri, 17 Jan 2025 11:27:22 -0800 Subject: [PATCH 01/15] fix: CE-1371 (#877) --- .../outcomes/ceeb/ceeb-decision/decision-form.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx index b90be5d0d..bf272f5ba 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx @@ -293,7 +293,7 @@ export const DecisionForm: FC = ({ handleScheduleChange(evt.value); } }} - value={getDropdownOption(schedule, schedulesOptions)} + value={getDropdownOption(data.schedule, schedulesOptions)} /> @@ -316,7 +316,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("sector", evt?.value); }} - value={getDropdownOption(sector, sectorsOptions)} + value={getDropdownOption(data.sector, sectorsOptions) || { value: "", label: "" }} /> @@ -339,7 +339,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("discharge", evt?.value); }} - value={getDropdownOption(discharge, dischargesOptions)} + value={getDropdownOption(data.discharge, dischargesOptions)} /> @@ -362,7 +362,7 @@ export const DecisionForm: FC = ({ const action = evt?.value ? evt?.value : ""; handleActionTakenChange(action); }} - value={getDropdownOption(actionTaken, decisionTypeOptions)} + value={getDropdownOption(data.actionTaken, decisionTypeOptions)} /> @@ -384,7 +384,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("leadAgency", evt?.value); }} - value={getDropdownOption(leadAgency, leadAgencyOptions)} + value={getDropdownOption(data.leadAgency, leadAgencyOptions)} /> @@ -432,7 +432,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("nonCompliance", evt?.value); }} - value={getDropdownOption(nonCompliance, nonComplianceOptions)} + value={getDropdownOption(data.nonCompliance, nonComplianceOptions)} /> From a742772d89d7d76031e04d966c75f060e2304e19 Mon Sep 17 00:00:00 2001 From: Ryan Rondeau Date: Mon, 20 Jan 2025 08:55:04 -0800 Subject: [PATCH 02/15] feat: CE-1331 List View Performance Tuning (#878) --- backend/dataloader/bulk-data-loader.js | 80 +++++++-- backend/package-lock.json | 79 +++++++++ backend/package.json | 2 + backend/src/external_api/css/css.module.ts | 3 +- .../src/external_api/css/css.service.spec.ts | 2 + backend/src/external_api/css/css.service.ts | 166 ++++++++++++------ .../v1/case_file/case_file.service.spec.ts | 3 +- .../v1/complaint/complaint.service.spec.ts | 5 +- backend/src/v1/complaint/complaint.service.ts | 17 +- .../v1/document/document.controller.spec.ts | 2 + .../src/v1/document/document.service.spec.ts | 3 +- .../src/v1/officer/officer.controller.spec.ts | 2 + .../src/v1/officer/officer.service.spec.ts | 2 + .../src/v1/officer/officer.service.v2.spec.ts | 2 + backend/src/v1/team/team.controller.spec.ts | 2 + backend/src/v1/team/team.service.spec.ts | 2 + .../mock-allegation-complaint-repository.ts | 1 + .../mocks/mock-complaints-repositories.ts | 2 + ...k-general-incident-complaint-repository.ts | 1 + ...-wildlife-conflict-complaint-repository.ts | 1 + ...cos-geo-org-flat-vw-definition-update1.sql | 44 +---- ...R__view-cos-geo-org-flat-vw-definition.sql | 70 ++++---- migrations/migrations/V0.35.1__CE-1331.sql | 2 + 23 files changed, 335 insertions(+), 158 deletions(-) create mode 100644 migrations/migrations/V0.35.1__CE-1331.sql diff --git a/backend/dataloader/bulk-data-loader.js b/backend/dataloader/bulk-data-loader.js index ccad9bfd5..7f18661e0 100644 --- a/backend/dataloader/bulk-data-loader.js +++ b/backend/dataloader/bulk-data-loader.js @@ -1,5 +1,6 @@ // Instruction for running: from backend directory: node dataloader/bulk-data-loader.js // Ensure parameters at the bottom of this file are updated as required + require('dotenv').config(); const faker = require('faker'); const db = require('pg-promise')(); @@ -248,16 +249,69 @@ const insertData = async (data) => { } }; -// Adjust these as required. -// No more than 10k at a time or the insert will blow up. -const yearPrefix = 25; -const numRecords = 10000; -const startingRecord = 100000; - -// Validate parameters -if (numRecords > 10000) { - console.log ("Please adjust the numRecords parameter to be less than 10000"); - return; -} -const records = generateBulkData(yearPrefix, numRecords, startingRecord); -insertData(records); +// Process any pending complaints +const processPendingComplaints = async () => { + const query = `DO $$ + DECLARE + complaint_record RECORD; + BEGIN + -- Loop through each pending complaint + FOR complaint_record IN + SELECT complaint_identifier + FROM staging_complaint + WHERE staging_status_code = 'PENDING' + LOOP + -- Call the insert_complaint_from_staging function for each complaint + PERFORM public.insert_complaint_from_staging(complaint_record.complaint_identifier); + END LOOP; + + TRUNCATE TABLE staging_complaint; + + -- Optional: Add a message indicating completion + RAISE NOTICE 'All pending complaints processed successfully.'; + END; + $$;`; + + try { + // Perform the bulk insert + await pg.none(query); + console.log('Data processing complete.'); + } catch (error) { + console.error('Error processing data:', error); + } +}; + +const bulkDataLoad = async () => { + + // Adjust these as required. + const processAfterInsert = true // Will move complaints from staging to the live table after each iteration + const numRecords = 10000; // Records created per iteration, no more than 10k at a time or the insert will blow up. + const yearPrefix = 20; // Year will increment per iteration + const startingRecord = 110000; + const iterations = 10; + + // Validate parameters + if (numRecords > 10000) { + console.log ("Please adjust the numRecords parameter to be less than 10000"); + return; + } + if (iterations * numRecords > 1000000) { + console.log ("Please adjust the iterations so that fewer than 1000000 records are inserted"); + return; + } + + for (let i = 0; i < iterations; i++) { + const startingRecordForIteration = startingRecord + (i * numRecords); + const records = generateBulkData(yearPrefix + i, numRecords, startingRecordForIteration); + // Insert the staging data + await insertData(records); + // Process the staging data + if (processAfterInsert) + { + await processPendingComplaints(); + } + console.log(`Inserted records ${startingRecordForIteration} through ${startingRecordForIteration + numRecords}.`); + } +}; + +bulkDataLoad(); diff --git a/backend/package-lock.json b/backend/package-lock.json index d86579e13..4b8fecfb4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "@automapper/types": "^6.3.1", "@golevelup/ts-jest": "^0.4.0", "@nestjs/axios": "^3.0.3", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/cli": "^10.4.5", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", @@ -30,6 +31,7 @@ "@nestjs/typeorm": "^10.0.2", "automapper": "^1.0.0", "axios": "^1.7.4", + "cache-manager": "^5.7.6", "cron": "^3.1.7", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", @@ -1541,6 +1543,17 @@ "rxjs": "^6.0.0 || ^7.0.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.3.0.tgz", + "integrity": "sha512-pxeBp9w/s99HaW2+pezM1P3fLiWmUEnTUoUMLa9UYViCtjj0E0A19W/vaT5JFACCzFIeNrwH4/16jkpAhQ25Vw==", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -3313,6 +3326,25 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -5017,6 +5049,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -8844,6 +8881,14 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "engines": { + "node": ">=16" + } + }, "node_modules/promise-polyfill": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", @@ -12184,6 +12229,12 @@ "integrity": "sha512-h6TCn3yJwD6OKqqqfmtRS5Zo4E46Ip2n+gK1sqwzNBC+qxQ9xpCu+ODVRFur6V3alHSCSBxb3nNtt73VEdluyA==", "requires": {} }, + "@nestjs/cache-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.3.0.tgz", + "integrity": "sha512-pxeBp9w/s99HaW2+pezM1P3fLiWmUEnTUoUMLa9UYViCtjj0E0A19W/vaT5JFACCzFIeNrwH4/16jkpAhQ25Vw==", + "requires": {} + }, "@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -13496,6 +13547,24 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, + "cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "requires": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "dependencies": { + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + } + } + }, "call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -14728,6 +14797,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -17531,6 +17605,11 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==" + }, "promise-polyfill": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 2ccc84dda..88f005f43 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,6 +47,7 @@ "@automapper/types": "^6.3.1", "@golevelup/ts-jest": "^0.4.0", "@nestjs/axios": "^3.0.3", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/cli": "^10.4.5", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", @@ -61,6 +62,7 @@ "@nestjs/typeorm": "^10.0.2", "automapper": "^1.0.0", "axios": "^1.7.4", + "cache-manager": "^5.7.6", "cron": "^3.1.7", "date-fns": "^3.6.0", "date-fns-tz": "^3.1.3", diff --git a/backend/src/external_api/css/css.module.ts b/backend/src/external_api/css/css.module.ts index 7b3ad6a9d..1a3c13760 100644 --- a/backend/src/external_api/css/css.module.ts +++ b/backend/src/external_api/css/css.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { CssService } from "./css.service"; import { ConfigurationModule } from "../../v1/configuration/configuration.module"; +import { CacheModule } from "@nestjs/cache-manager"; @Module({ - imports: [ConfigurationModule], + imports: [ConfigurationModule, CacheModule.register()], providers: [CssService], exports: [CssService], }) diff --git a/backend/src/external_api/css/css.service.spec.ts b/backend/src/external_api/css/css.service.spec.ts index 0ae63760d..36484ea4d 100644 --- a/backend/src/external_api/css/css.service.spec.ts +++ b/backend/src/external_api/css/css.service.spec.ts @@ -3,12 +3,14 @@ import { getRepositoryToken } from "@nestjs/typeorm"; import { ConfigurationService } from "../../v1/configuration/configuration.service"; import { Configuration } from "../../v1/configuration/entities/configuration.entity"; import { CssService } from "./css.service"; +import { CacheModule } from "@nestjs/cache-manager"; describe("CssService", () => { let service: CssService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [CacheModule.register()], providers: [ CssService, ConfigurationService, diff --git a/backend/src/external_api/css/css.service.ts b/backend/src/external_api/css/css.service.ts index 01e28b6d7..7e454527e 100644 --- a/backend/src/external_api/css/css.service.ts +++ b/backend/src/external_api/css/css.service.ts @@ -4,6 +4,8 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import { get } from "../../helpers/axios-api"; import { ConfigurationService } from "../../v1/configuration/configuration.service"; import { CssUser } from "src/types/css/cssUser"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; @Injectable() export class CssService implements ExternalApiService { @@ -20,6 +22,9 @@ export class CssService implements ExternalApiService { @Inject(ConfigurationService) readonly configService: ConfigurationService; + @Inject(CACHE_MANAGER) + readonly cacheManager: Cache; + constructor() { this.authApi = process.env.CSS_TOKEN_URL; this.baseUri = process.env.CSS_URL; @@ -112,6 +117,10 @@ export class CssService implements ExternalApiService { }, }; const response = await axios.post(url, userRoles, config); + + // clear cache + await this.clearUserRoleCache(); + return response?.data.data; } catch (error) { this.logger.error(`exception: unable to update user's roles ${userIdir} - error: ${error}`); @@ -130,6 +139,10 @@ export class CssService implements ExternalApiService { }, }; const response = await axios.delete(url, config); + + // clear cache + await this.clearUserRoleCache(); + return response?.data.data; } catch (error) { this.logger.error(`exception: unable to delete user's role ${userIdir} - error: ${error}`); @@ -137,69 +150,110 @@ export class CssService implements ExternalApiService { } }; - getUserRoleMapping = async (): Promise => { - try { - const apiToken = await this.authenticate(); - //Get all roles from NatCom CSS integation - const rolesUrl = `${this.baseUri}/api/v1/integrations/4794/${this.env}/roles`; - const config: AxiosRequestConfig = { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiToken}`, - }, - }; - const roleRes = await get(apiToken, rolesUrl, config); - if (roleRes?.data.data.length > 0) { - const { - data: { data: roleList }, - } = roleRes; - - //Get all users for each role - let usersUrl: string = ""; - const pages = Array.from(Array(this.maxPages), (_, i) => i + 1); - const usersRoles = await Promise.all( - roleList.map(async (role) => { - let usersRolesTemp = []; - for (const page of pages) { - usersUrl = `${this.baseUri}/api/v1/integrations/4794/${this.env}/roles/${role.name}/users?page=${page}`; - const userRes = await get(apiToken, encodeURI(usersUrl), config); - if (userRes?.data.data.length > 0) { - const { - data: { data: users }, - } = userRes; - let usersRolesSinglePage = await Promise.all( - users.map((user) => { - return { - userId: user.username - .replace(/@idir$/i, "") - .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"), - role: role.name, - }; - }), - ); - usersRolesTemp.push(...usersRolesSinglePage); - } else { - break; - } + private readonly clearUserRoleCache = async (): Promise => { + await this.cacheManager.del("css-users-roles-fresh"); + await this.cacheManager.del("css-users-roles-stale"); + await this.cacheManager.del("css-users-roles-refresh-status"); + }; + + private readonly fetchAndGroupUserRoles = async (): Promise => { + //Try to avoid multiple refreshes of cache in case of multiple requests while the cache is being refreshed + await this.cacheManager.set("css-users-roles-refresh-status", true, 15000); + + const apiToken = await this.authenticate(); + //Get all roles from NatCom CSS integration + const rolesUrl = `${this.baseUri}/api/v1/integrations/4794/${this.env}/roles`; + const config: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + }; + const roleRes = await get(apiToken, rolesUrl, config); + if (roleRes?.data.data.length > 0) { + const { + data: { data: roleList }, + } = roleRes; + + //Get all users for each role + let usersUrl: string = ""; + const pages = Array.from(Array(this.maxPages), (_, i) => i + 1); + const usersRoles = await Promise.all( + roleList.map(async (role) => { + let usersRolesTemp = []; + for (const page of pages) { + usersUrl = `${this.baseUri}/api/v1/integrations/4794/${this.env}/roles/${role.name}/users?page=${page}`; + const userRes = await get(apiToken, encodeURI(usersUrl), config); + if (userRes?.data.data.length > 0) { + const { + data: { data: users }, + } = userRes; + let usersRolesSinglePage = await Promise.all( + users.map((user) => { + return { + userId: user.username + .replace(/@idir$/i, "") + .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"), + role: role.name, + }; + }), + ); + usersRolesTemp.push(...usersRolesSinglePage); + } else { + break; } - return usersRolesTemp; - }), - ); + } + return usersRolesTemp; + }), + ); - //exclude empty roles and concatenate all sub-array elements - const usersRolesFlat = usersRoles.filter((item) => item !== undefined).flat(); + //exclude empty roles and concatenate all sub-array elements + const usersRolesFlat = usersRoles.filter((item) => item !== undefined).flat(); - //group the array elements by a user id - const usersRolesGroupped = usersRolesFlat.reduce((grouping, item) => { - grouping[item.userId] = [...(grouping[item.userId] || []), item.role]; - return grouping; - }, {}); + //group the array elements by a user id + const usersRolesGrouped = usersRolesFlat.reduce((grouping, item) => { + grouping[item.userId] = [...(grouping[item.userId] || []), item.role]; + return grouping; + }, {}); - return usersRolesGroupped; + //set the fresh cache for 10 minutes + await this.cacheManager.set("css-users-roles-fresh", usersRolesGrouped, 600000); + + //set the stale cache for 60 minutes + await this.cacheManager.set("css-users-roles-stale", usersRolesGrouped, 3600000); + + return usersRolesGrouped; + } else { + throw new Error(`unable to get user roles from CSS or no roles found`); + } + }; + + getUserRoleMapping = async (): Promise => { + try { + // return fresh cache if available + const usersRolesGroupedFresh = await this.cacheManager.get("css-users-roles-fresh"); + if (usersRolesGroupedFresh) { + return usersRolesGroupedFresh; } + + // return the stale cache if available, but asyncronously refresh cache + const usersRolesGroupedStale = await this.cacheManager.get("css-users-roles-stale"); + if (usersRolesGroupedStale) { + // check if a refresh is already in progress, if not start a new refresh + const refreshingInProgress = await this.cacheManager.get("css-users-roles-refresh-status"); + if (!refreshingInProgress) { + this.fetchAndGroupUserRoles().catch((error) => { + this.logger.error(`exception: error: ${error}`); + }); + } + + return usersRolesGroupedStale; + } + + // wait for fresh data if no cache is available + return await this.fetchAndGroupUserRoles(); } catch (error) { this.logger.error(`exception: error: ${error}`); - return; } }; } diff --git a/backend/src/v1/case_file/case_file.service.spec.ts b/backend/src/v1/case_file/case_file.service.spec.ts index a0355b4f5..4b1bafc70 100644 --- a/backend/src/v1/case_file/case_file.service.spec.ts +++ b/backend/src/v1/case_file/case_file.service.spec.ts @@ -76,13 +76,14 @@ import { TeamService } from "../team/team.service"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; import { Team } from "../team/entities/team.entity"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("Testing: Case File Service", () => { let service: CaseFileService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [AutomapperModule], + imports: [AutomapperModule, CacheModule.register()], providers: [ AutomapperModule, { diff --git a/backend/src/v1/complaint/complaint.service.spec.ts b/backend/src/v1/complaint/complaint.service.spec.ts index b81827bc8..03a06e9a8 100644 --- a/backend/src/v1/complaint/complaint.service.spec.ts +++ b/backend/src/v1/complaint/complaint.service.spec.ts @@ -86,13 +86,14 @@ import { TeamService } from "../team/team.service"; import { Team } from "../team/entities/team.entity"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("Testing: Complaint Service", () => { let service: ComplaintService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [AutomapperModule], + imports: [AutomapperModule, CacheModule.register()], providers: [ AutomapperModule, { @@ -395,7 +396,7 @@ describe("Testing: Complaint Service", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [AutomapperModule], + imports: [AutomapperModule, CacheModule.register()], providers: [ AutomapperModule, { diff --git a/backend/src/v1/complaint/complaint.service.ts b/backend/src/v1/complaint/complaint.service.ts index f23832606..18233ba9c 100644 --- a/backend/src/v1/complaint/complaint.service.ts +++ b/backend/src/v1/complaint/complaint.service.ts @@ -185,14 +185,14 @@ export class ComplaintService { builder = this._allegationComplaintRepository .createQueryBuilder("allegation") .leftJoin("allegation.complaint_identifier", "complaint") - .addSelect(["complaint.complaint_identifier", "complaint.location_geometry_point"]) + .select(["complaint.complaint_identifier", "complaint.location_geometry_point"]) .leftJoin("allegation.violation_code", "violation_code"); break; case "GIR": builder = this._girComplaintRepository .createQueryBuilder("general") .leftJoin("general.complaint_identifier", "complaint") - .addSelect(["complaint.complaint_identifier", "complaint.location_geometry_point"]) + .select(["complaint.complaint_identifier", "complaint.location_geometry_point"]) .leftJoin("general.gir_type_code", "gir"); break; case "HWCR": @@ -200,7 +200,7 @@ export class ComplaintService { builder = this._wildlifeComplaintRepository .createQueryBuilder("wildlife") .leftJoin("wildlife.complaint_identifier", "complaint") - .addSelect(["complaint.complaint_identifier", "complaint.location_geometry_point"]) + .select(["complaint.complaint_identifier", "complaint.location_geometry_point"]) .leftJoin("wildlife.species_code", "species_code") .leftJoin("wildlife.hwcr_complaint_nature_code", "complaint_nature_code") .leftJoin("wildlife.attractant_hwcr_xref", "attractants", "attractants.active_ind = true") @@ -1065,11 +1065,8 @@ export class ComplaintService { } //-- search and count - // Workaround for the issue with getManyAndCount() returning the wrong count and results in complex queries - // introduced by adding an IN clause in a OrWhere statement: https://github.com/typeorm/typeorm/issues/320 - const [, total] = await builder.take(0).getManyAndCount(); // returns 0 results but the total count is correct + const [complaints, total] = await builder.skip(skip).take(pageSize).getManyAndCount(); results.totalCount = total; - const complaints = page && pageSize ? await builder.skip(skip).take(pageSize).getMany() : await builder.getMany(); switch (complaintType) { case "ERS": { @@ -1223,7 +1220,7 @@ export class ComplaintService { ); //-- run mapped query - const mappedComplaints = await complaintBuilder.getMany(); + const mappedComplaints = await complaintBuilder.getRawMany(); // convert to supercluster PointFeature array const points: Array> = mappedComplaints.map((item) => { @@ -1231,9 +1228,9 @@ export class ComplaintService { type: "Feature", properties: { cluster: false, - id: item.complaint_identifier.complaint_identifier, + id: item.complaint_complaint_identifier, }, - geometry: item.complaint_identifier.location_geometry_point, + geometry: item.complaint_location_geometry_point, } as PointFeature; }); diff --git a/backend/src/v1/document/document.controller.spec.ts b/backend/src/v1/document/document.controller.spec.ts index 07acea7ce..67257d580 100644 --- a/backend/src/v1/document/document.controller.spec.ts +++ b/backend/src/v1/document/document.controller.spec.ts @@ -78,6 +78,7 @@ import { Team } from "../team/entities/team.entity"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; import { TeamService } from "../team/team.service"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; +import { CacheModule } from "@nestjs/cache-manager"; describe("DocumentController", () => { let controller: DocumentController; @@ -85,6 +86,7 @@ describe("DocumentController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DocumentController], + imports: [CacheModule.register()], providers: [ AutomapperModule, { diff --git a/backend/src/v1/document/document.service.spec.ts b/backend/src/v1/document/document.service.spec.ts index 75fc30e07..f1bde9925 100644 --- a/backend/src/v1/document/document.service.spec.ts +++ b/backend/src/v1/document/document.service.spec.ts @@ -77,13 +77,14 @@ import { Team } from "../team/entities/team.entity"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; import { TeamService } from "../team/team.service"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; +import { CacheModule } from "@nestjs/cache-manager"; describe("DocumentService", () => { let service: DocumentService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [AutomapperModule], + imports: [AutomapperModule, CacheModule.register()], providers: [ AutomapperModule, { diff --git a/backend/src/v1/officer/officer.controller.spec.ts b/backend/src/v1/officer/officer.controller.spec.ts index 7e50f58af..d971df233 100644 --- a/backend/src/v1/officer/officer.controller.spec.ts +++ b/backend/src/v1/officer/officer.controller.spec.ts @@ -16,6 +16,7 @@ import { Team } from "../team/entities/team.entity"; import { TeamService } from "../team/team.service"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("OfficerController", () => { let controller: OfficerController; @@ -23,6 +24,7 @@ describe("OfficerController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [OfficerController], + imports: [CacheModule.register()], providers: [ OfficerService, { diff --git a/backend/src/v1/officer/officer.service.spec.ts b/backend/src/v1/officer/officer.service.spec.ts index 31ef3346b..56afbfba5 100644 --- a/backend/src/v1/officer/officer.service.spec.ts +++ b/backend/src/v1/officer/officer.service.spec.ts @@ -15,12 +15,14 @@ import { Team } from "../team/entities/team.entity"; import { TeamService } from "../team/team.service"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("OfficerService", () => { let service: OfficerService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [CacheModule.register()], providers: [ OfficerService, { diff --git a/backend/src/v1/officer/officer.service.v2.spec.ts b/backend/src/v1/officer/officer.service.v2.spec.ts index 38434614b..8ce44f6a8 100644 --- a/backend/src/v1/officer/officer.service.v2.spec.ts +++ b/backend/src/v1/officer/officer.service.v2.spec.ts @@ -22,12 +22,14 @@ import { Team } from "../team/entities/team.entity"; import { TeamService } from "../team/team.service"; import { OfficerTeamXrefService } from "../officer_team_xref/officer_team_xref.service"; import { OfficerTeamXref } from "../officer_team_xref/entities/officer_team_xref.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("Testing: OfficerService", () => { let service: OfficerService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [CacheModule.register()], providers: [ OfficerService, { diff --git a/backend/src/v1/team/team.controller.spec.ts b/backend/src/v1/team/team.controller.spec.ts index 822de1f37..9586e67b5 100644 --- a/backend/src/v1/team/team.controller.spec.ts +++ b/backend/src/v1/team/team.controller.spec.ts @@ -10,6 +10,7 @@ import { CssService } from "../../external_api/css/css.service"; import { ConfigurationService } from "../configuration/configuration.service"; import { Configuration } from "../configuration/entities/configuration.entity"; import { Officer } from "../officer/entities/officer.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("TeamController", () => { let controller: TeamController; @@ -17,6 +18,7 @@ describe("TeamController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [TeamController], + imports: [CacheModule.register()], providers: [ TeamService, { diff --git a/backend/src/v1/team/team.service.spec.ts b/backend/src/v1/team/team.service.spec.ts index 41f59632a..3daa7f972 100644 --- a/backend/src/v1/team/team.service.spec.ts +++ b/backend/src/v1/team/team.service.spec.ts @@ -9,12 +9,14 @@ import { CssService } from "../../external_api/css/css.service"; import { ConfigurationService } from "../configuration/configuration.service"; import { Configuration } from "../configuration/entities/configuration.entity"; import { Officer } from "..//officer/entities/officer.entity"; +import { CacheModule } from "@nestjs/cache-manager"; describe("TeamService", () => { let service: TeamService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [CacheModule.register()], providers: [ TeamService, { diff --git a/backend/test/mocks/mock-allegation-complaint-repository.ts b/backend/test/mocks/mock-allegation-complaint-repository.ts index b05d04c45..b0e8a0584 100644 --- a/backend/test/mocks/mock-allegation-complaint-repository.ts +++ b/backend/test/mocks/mock-allegation-complaint-repository.ts @@ -493,6 +493,7 @@ export const MockAllegationComplaintRepository = () => ({ orWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(manyItems), + getRawMany: jest.fn().mockResolvedValue(manyItems), getOne: jest.fn().mockResolvedValue(singleItem), getQuery: jest.fn(), select: jest.fn().mockReturnThis(), diff --git a/backend/test/mocks/mock-complaints-repositories.ts b/backend/test/mocks/mock-complaints-repositories.ts index 609ccba15..9a9cca93f 100644 --- a/backend/test/mocks/mock-complaints-repositories.ts +++ b/backend/test/mocks/mock-complaints-repositories.ts @@ -732,6 +732,7 @@ export const MockComplaintsRepository = () => ({ set: jest.fn().mockReturnThis(), execute: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(complaints), + getRawMany: jest.fn().mockResolvedValue(complaints), getCount: jest.fn().mockResolvedValue(complaints.length), getOne: jest.fn().mockResolvedValue(complaints[3]), update: jest.fn().mockResolvedValue({ affected: 1 }), @@ -752,6 +753,7 @@ export const MockComplaintsRepositoryV2 = () => ({ set: jest.fn().mockReturnThis(), execute: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(complaints), + getRawMany: jest.fn().mockResolvedValue(complaints), getCount: jest.fn().mockResolvedValue(complaints.length), getOne: jest.fn().mockResolvedValue(complaints[3]), update: jest.fn().mockResolvedValue({ affected: 1 }), diff --git a/backend/test/mocks/mock-general-incident-complaint-repository.ts b/backend/test/mocks/mock-general-incident-complaint-repository.ts index 3a964a232..d23e777e5 100644 --- a/backend/test/mocks/mock-general-incident-complaint-repository.ts +++ b/backend/test/mocks/mock-general-incident-complaint-repository.ts @@ -493,6 +493,7 @@ export const MockGeneralIncidentComplaintRepository = () => ({ orWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(manyItems), + getRawMany: jest.fn().mockResolvedValue(manyItems), getOne: jest.fn().mockResolvedValue(singleItem), getQuery: jest.fn(), select: jest.fn().mockReturnThis(), diff --git a/backend/test/mocks/mock-wildlife-conflict-complaint-repository.ts b/backend/test/mocks/mock-wildlife-conflict-complaint-repository.ts index 72a2fc3b0..7be61f57b 100644 --- a/backend/test/mocks/mock-wildlife-conflict-complaint-repository.ts +++ b/backend/test/mocks/mock-wildlife-conflict-complaint-repository.ts @@ -373,6 +373,7 @@ export const MockWildlifeConflictComplaintRepository = () => ({ orWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(manyItems), + getRawMany: jest.fn().mockResolvedValue(manyItems), getOne: jest.fn().mockResolvedValue(singleItem), getQuery: jest.fn(), select: jest.fn().mockReturnThis(), diff --git a/migrations/migrations/R__view-cos-geo-org-flat-vw-definition-update1.sql b/migrations/migrations/R__view-cos-geo-org-flat-vw-definition-update1.sql index 351f1ffef..600ea3435 100644 --- a/migrations/migrations/R__view-cos-geo-org-flat-vw-definition-update1.sql +++ b/migrations/migrations/R__view-cos-geo-org-flat-vw-definition-update1.sql @@ -1,43 +1 @@ --- As the repeatable scripts are always run after the DDL changes, update this script whenever the view definition changes. - -CREATE OR REPLACE VIEW public.cos_geo_org_unit_flat_vw -AS SELECT DISTINCT - gou.geo_organization_unit_code AS region_code, - gou.short_description AS region_name, - gou2.geo_organization_unit_code AS zone_code, - gou2.short_description AS zone_name, - gou3.geo_organization_unit_code AS offloc_code, - gou3.short_description AS offloc_name, - CAST(COALESCE(gou4.geo_organization_unit_code, NULL) AS VARCHAR(10)) AS area_code, - CAST(COALESCE(gou4.short_description, null) as VARCHAR(50)) AS area_name, - gou3.administrative_office_ind - -FROM - geo_org_unit_structure gos -JOIN - geo_organization_unit_code gou ON gos.parent_geo_org_unit_code::text = gou.geo_organization_unit_code::text -JOIN - geo_org_unit_structure gos2 ON gos2.parent_geo_org_unit_code::text = gou.geo_organization_unit_code::text -JOIN - geo_organization_unit_code gou2 ON gos2.child_geo_org_unit_code::text = gou2.geo_organization_unit_code::text -JOIN - geo_org_unit_structure gos3 ON gos3.parent_geo_org_unit_code::text = gou2.geo_organization_unit_code::text -JOIN - geo_organization_unit_code gou3 ON gos3.child_geo_org_unit_code::text = gou3.geo_organization_unit_code::text -LEFT JOIN - geo_org_unit_structure gos4 ON gos4.parent_geo_org_unit_code::text = gou3.geo_organization_unit_code::text -LEFT JOIN - geo_organization_unit_code gou4 ON gos4.child_geo_org_unit_code::text = gou4.geo_organization_unit_code::text -WHERE - gou.geo_org_unit_type_code = 'REGION' - AND gou2.geo_org_unit_type_code = 'ZONE' - AND gou3.geo_org_unit_type_code = 'OFFLOC' - AND (gou4.geo_org_unit_type_code = 'AREA' OR gou4.geo_org_unit_type_code IS NULL) - AND gos.agency_code = 'COS'; - - - - - - - +-- Deprecated \ No newline at end of file diff --git a/migrations/migrations/R__view-cos-geo-org-flat-vw-definition.sql b/migrations/migrations/R__view-cos-geo-org-flat-vw-definition.sql index d515475da..de9135700 100644 --- a/migrations/migrations/R__view-cos-geo-org-flat-vw-definition.sql +++ b/migrations/migrations/R__view-cos-geo-org-flat-vw-definition.sql @@ -1,41 +1,49 @@ -- As the repeatable scripts are always run after the DDL changes, update this script whenever the view definition changes. -CREATE OR REPLACE VIEW public.cos_geo_org_unit_flat_vw -AS SELECT DISTINCT - gou.geo_organization_unit_code AS region_code, +DROP MATERIALIZED VIEW IF EXISTS public.cos_geo_org_unit_flat_mvw; + +CREATE MATERIALIZED VIEW public.cos_geo_org_unit_flat_mvw +AS SELECT DISTINCT gou.geo_organization_unit_code AS region_code, gou.short_description AS region_name, gou2.geo_organization_unit_code AS zone_code, gou2.short_description AS zone_name, gou3.geo_organization_unit_code AS offloc_code, gou3.short_description AS offloc_name, - CAST(COALESCE(gou4.geo_organization_unit_code, NULL) AS VARCHAR(10)) AS area_code, - CAST(COALESCE(gou4.short_description, null) as VARCHAR(50)) AS area_name -FROM - geo_org_unit_structure gos -JOIN - geo_organization_unit_code gou ON gos.parent_geo_org_unit_code::text = gou.geo_organization_unit_code::text -JOIN - geo_org_unit_structure gos2 ON gos2.parent_geo_org_unit_code::text = gou.geo_organization_unit_code::text -JOIN - geo_organization_unit_code gou2 ON gos2.child_geo_org_unit_code::text = gou2.geo_organization_unit_code::text -JOIN - geo_org_unit_structure gos3 ON gos3.parent_geo_org_unit_code::text = gou2.geo_organization_unit_code::text -JOIN - geo_organization_unit_code gou3 ON gos3.child_geo_org_unit_code::text = gou3.geo_organization_unit_code::text -LEFT JOIN - geo_org_unit_structure gos4 ON gos4.parent_geo_org_unit_code::text = gou3.geo_organization_unit_code::text -LEFT JOIN - geo_organization_unit_code gou4 ON gos4.child_geo_org_unit_code::text = gou4.geo_organization_unit_code::text -WHERE - gou.geo_org_unit_type_code = 'REGION' - AND gou2.geo_org_unit_type_code = 'ZONE' - AND gou3.geo_org_unit_type_code = 'OFFLOC' - AND (gou4.geo_org_unit_type_code = 'AREA' OR gou4.geo_org_unit_type_code IS NULL) - AND gos.agency_code = 'COS'; - - + COALESCE(gou4.geo_organization_unit_code, NULL::character varying)::character varying(10) AS area_code, + COALESCE(gou4.short_description, NULL::character varying)::character varying(50) AS area_name, + gou3.administrative_office_ind + FROM geo_org_unit_structure gos + JOIN geo_organization_unit_code gou ON gos.parent_geo_org_unit_code::text = gou.geo_organization_unit_code::text + JOIN geo_org_unit_structure gos2 ON gos2.parent_geo_org_unit_code::text = gou.geo_organization_unit_code::text + JOIN geo_organization_unit_code gou2 ON gos2.child_geo_org_unit_code::text = gou2.geo_organization_unit_code::text + JOIN geo_org_unit_structure gos3 ON gos3.parent_geo_org_unit_code::text = gou2.geo_organization_unit_code::text + JOIN geo_organization_unit_code gou3 ON gos3.child_geo_org_unit_code::text = gou3.geo_organization_unit_code::text + LEFT JOIN geo_org_unit_structure gos4 ON gos4.parent_geo_org_unit_code::text = gou3.geo_organization_unit_code::text + LEFT JOIN geo_organization_unit_code gou4 ON gos4.child_geo_org_unit_code::text = gou4.geo_organization_unit_code::text + WHERE gou.geo_org_unit_type_code::text = 'REGION'::text + AND gou2.geo_org_unit_type_code::text = 'ZONE'::text + AND gou3.geo_org_unit_type_code::text = 'OFFLOC'::text + AND (gou4.geo_org_unit_type_code::text = 'AREA'::text OR gou4.geo_org_unit_type_code IS NULL) + AND gos.agency_code::text = 'COS'::text; + CREATE INDEX IF NOT EXISTS area_code + ON cos_geo_org_unit_flat_mvw (area_code); + + CREATE OR REPLACE FUNCTION cos_geo_org_unit_flat_mvw_refresh() + RETURNS TRIGGER AS $$ + BEGIN + REFRESH MATERIALIZED VIEW cos_geo_org_unit_flat_mvw; + RETURN NULL; + END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER geo_org_unit_structure_insert_update_refresh_mvw +AFTER INSERT OR UPDATE +ON geo_org_unit_structure +EXECUTE FUNCTION cos_geo_org_unit_flat_mvw_refresh(); - - +CREATE OR REPLACE TRIGGER geo_organization_unit_code_insert_update_refresh_mvw +AFTER INSERT OR UPDATE +ON geo_organization_unit_code +EXECUTE FUNCTION cos_geo_org_unit_flat_mvw_refresh(); diff --git a/migrations/migrations/V0.35.1__CE-1331.sql b/migrations/migrations/V0.35.1__CE-1331.sql new file mode 100644 index 000000000..708012eb4 --- /dev/null +++ b/migrations/migrations/V0.35.1__CE-1331.sql @@ -0,0 +1,2 @@ +DROP VIEW IF EXISTS public.cos_geo_org_unit_flat_vw; +CREATE INDEX "FK_hwcrcmplntguid" ON public.attractant_hwcr_xref USING btree (hwcr_complaint_guid); \ No newline at end of file From 90084e91896b8f17d027e46b5d99cec1095473e7 Mon Sep 17 00:00:00 2001 From: Ryan Rondeau Date: Mon, 20 Jan 2025 10:20:31 -0800 Subject: [PATCH 03/15] fix: CE-1331 view references (#883) --- backend/src/v1/complaint/complaint.service.ts | 2 +- .../v1/cos_geo_org_unit/entities/cos_geo_org_unit.entity.ts | 2 +- backend/src/v1/person/person.service.ts | 6 +++--- exports/ceeb_complaint_export.sql | 2 +- exports/cos_hwcr_complaint_export.sql | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/v1/complaint/complaint.service.ts b/backend/src/v1/complaint/complaint.service.ts index 18233ba9c..bea96c716 100644 --- a/backend/src/v1/complaint/complaint.service.ts +++ b/backend/src/v1/complaint/complaint.service.ts @@ -1129,7 +1129,7 @@ export class ComplaintService { try { //-- search for complaints - // Only these options require the cos_geo_org_unit_flat_vw view (cos_organization), which is very slow. + // Only these options require the cos_geo_org_unit_flat_mvw view (cos_organization), which is very slow. const includeCosOrganization: boolean = Boolean(query || filters.community || filters.zone || filters.region); let builder = this._generateMapQueryBuilder(complaintType, includeCosOrganization); diff --git a/backend/src/v1/cos_geo_org_unit/entities/cos_geo_org_unit.entity.ts b/backend/src/v1/cos_geo_org_unit/entities/cos_geo_org_unit.entity.ts index 360121feb..498387d1d 100644 --- a/backend/src/v1/cos_geo_org_unit/entities/cos_geo_org_unit.entity.ts +++ b/backend/src/v1/cos_geo_org_unit/entities/cos_geo_org_unit.entity.ts @@ -1,7 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { ViewEntity, Column, PrimaryColumn } from "typeorm"; -@ViewEntity("cos_geo_org_unit_flat_vw") +@ViewEntity("cos_geo_org_unit_flat_mvw") export class CosGeoOrgUnit { @ApiProperty({ example: "KTNY", description: "Human readable region code" }) @Column("character varying", { name: "region_code", length: 10 }) diff --git a/backend/src/v1/person/person.service.ts b/backend/src/v1/person/person.service.ts index 87e8ee35a..abfecfbbb 100644 --- a/backend/src/v1/person/person.service.ts +++ b/backend/src/v1/person/person.service.ts @@ -61,8 +61,8 @@ export class PersonService { .createQueryBuilder("person") .leftJoinAndSelect("person.officer", "officer") .leftJoinAndSelect("officer.office_guid", "office") - .leftJoinAndSelect("office.cos_geo_org_unit", "cos_geo_org_unit_flat_vw") - .where("cos_geo_org_unit_flat_vw.zone_code = :zone_code", { zone_code }); + .leftJoinAndSelect("office.cos_geo_org_unit", "cos_geo_org_unit_flat_mvw") + .where("cos_geo_org_unit_flat_mvw.zone_code = :zone_code", { zone_code }); return queryBuilder.getMany(); } @@ -71,7 +71,7 @@ export class PersonService { .createQueryBuilder("person") .leftJoinAndSelect("person.officer", "officer") .leftJoinAndSelect("officer.office_guid", "office") - .leftJoinAndSelect("office.cos_geo_org_unit", "cos_geo_org_unit_flat_vw") + .leftJoinAndSelect("office.cos_geo_org_unit", "cos_geo_org_unit_flat_mvw") .where("office.office_guid = :office_guid", { office_guid }); return queryBuilder.getMany(); } diff --git a/exports/ceeb_complaint_export.sql b/exports/ceeb_complaint_export.sql index 907b11100..5eae99e20 100644 --- a/exports/ceeb_complaint_export.sql +++ b/exports/ceeb_complaint_export.sql @@ -37,7 +37,7 @@ join join geo_organization_unit_code goc on goc.geo_organization_unit_code = cmp.geo_organization_unit_code join - cos_geo_org_unit_flat_vw gfv on gfv.area_code = goc.geo_organization_unit_code + cos_geo_org_unit_flat_mvw gfv on gfv.area_code = goc.geo_organization_unit_code left join comp_mthd_recv_cd_agcy_cd_xref cmrcacx on cmrcacx.comp_mthd_recv_cd_agcy_cd_xref_guid = cmp.comp_mthd_recv_cd_agcy_cd_xref_guid left join diff --git a/exports/cos_hwcr_complaint_export.sql b/exports/cos_hwcr_complaint_export.sql index 2f45f40ca..4877510be 100644 --- a/exports/cos_hwcr_complaint_export.sql +++ b/exports/cos_hwcr_complaint_export.sql @@ -32,7 +32,7 @@ join join geo_organization_unit_code goc on goc.geo_organization_unit_code = cmp.geo_organization_unit_code join - cos_geo_org_unit_flat_vw gfv on gfv.area_code = goc.geo_organization_unit_code + cos_geo_org_unit_flat_mvw gfv on gfv.area_code = goc.geo_organization_unit_code left join person_complaint_xref pcx on pcx.complaint_identifier = cmp.complaint_identifier and pcx.active_ind = true left join From d8606c9f295852d6654df9a5aa0d427db0f8f056 Mon Sep 17 00:00:00 2001 From: afwilcox Date: Mon, 20 Jan 2025 10:59:06 -0800 Subject: [PATCH 04/15] feat: last update date (#884) Co-authored-by: Ryan Rondeau --- .../maps/automapper-dto-to-entity-maps.ts | 16 +++ .../maps/automapper-entity-to-dto-maps.ts | 19 ++- .../src/middleware/maps/dto-to-table-map.ts | 6 + .../equipment/delete-equipment-dto.ts | 1 + .../models/complaints/update-complaint.dto.ts | 7 ++ .../src/v1/case_file/case_file.controller.ts | 8 ++ backend/src/v1/case_file/case_file.service.ts | 110 ++++++++++++++---- .../src/v1/complaint/complaint.controller.ts | 6 + backend/src/v1/complaint/complaint.service.ts | 57 ++++++--- .../v1/complaint/entities/complaint.entity.ts | 10 ++ .../person_complaint_xref.controller.spec.ts | 6 + .../person_complaint_xref.module.ts | 5 +- .../person_complaint_xref.service.spec.ts | 6 + .../person_complaint_xref.service.ts | 45 ++++--- .../staging_complaint.service.ts | 4 +- frontend/src/app/common/attachment-utils.ts | 39 ++++--- .../details/complaint-details-create.tsx | 9 +- .../details/complaint-details-edit.tsx | 9 +- .../complaints/details/complaint-header.tsx | 2 +- .../allegation-complaint-list-item.tsx | 2 +- .../general-complaint-list-item.tsx | 2 +- .../wildlife-complaint-list-item.tsx | 2 +- .../authorization-outcome-form.tsx | 2 +- .../authorization-outcome.tsx | 2 +- .../ceeb/ceeb-decision/decision-form.tsx | 2 +- .../hwcr-equipment/equipment-item.tsx | 9 +- .../outcomes/hwcr-outcome-by-animal-v2.tsx | 4 +- .../outcomes/outcome-attachments.tsx | 11 +- .../instances/delete-animal-outcome-modal.tsx | 8 +- .../modal/instances/delete-note-modal.tsx | 2 +- .../src/app/store/reducers/attachments.ts | 38 +++++- .../src/app/store/reducers/case-thunks.ts | 45 +++++-- frontend/src/app/store/reducers/complaints.ts | 18 +-- .../app/case-files/base-case-file-input.ts | 1 + ...__update_complaint_using_webeoc_update.sql | 12 +- ..._edit_complaint_using_webeoc_complaint.sql | 2 +- migrations/migrations/V0.35.2__CE-1335.sql | 6 + 37 files changed, 397 insertions(+), 136 deletions(-) create mode 100644 migrations/migrations/V0.35.2__CE-1335.sql diff --git a/backend/src/middleware/maps/automapper-dto-to-entity-maps.ts b/backend/src/middleware/maps/automapper-dto-to-entity-maps.ts index f32289983..bc71f7e15 100644 --- a/backend/src/middleware/maps/automapper-dto-to-entity-maps.ts +++ b/backend/src/middleware/maps/automapper-dto-to-entity-maps.ts @@ -153,6 +153,10 @@ export const mapComplaintDtoToComplaint = (mapper: Mapper) => { (dest) => dest.is_privacy_requested, mapFrom((src) => src.isPrivacyRequested), ), + forMember( + (dest) => dest.comp_last_upd_utc_timestamp, + mapFrom((src) => src.updatedOn), + ), ); }; @@ -341,6 +345,10 @@ export const mapWildlifeComplaintDtoToHwcrComplaint = (mapper: Mapper) => { (dest) => dest.complaint_identifier.is_privacy_requested, mapFrom((src) => src.isPrivacyRequested), ), + forMember( + (dest) => dest.complaint_identifier.comp_last_upd_utc_timestamp, + mapFrom((src) => src.updatedOn), + ), ); }; @@ -502,6 +510,10 @@ export const mapAllegationComplaintDtoToAllegationComplaint = (mapper: Mapper) = (dest) => dest.complaint_identifier.is_privacy_requested, mapFrom((src) => src.isPrivacyRequested), ), + forMember( + (dest) => dest.complaint_identifier.comp_last_upd_utc_timestamp, + mapFrom((src) => src.updatedOn), + ), ); }; @@ -609,6 +621,10 @@ export const mapGirComplaintDtoToGirComplaint = (mapper: Mapper) => { (dest) => dest.complaint_identifier.is_privacy_requested, mapFrom((src) => src.isPrivacyRequested), ), + forMember( + (dest) => dest.complaint_identifier.comp_last_upd_utc_timestamp, + mapFrom((src) => src.updatedOn), + ), ); }; diff --git a/backend/src/middleware/maps/automapper-entity-to-dto-maps.ts b/backend/src/middleware/maps/automapper-entity-to-dto-maps.ts index 20f3115a4..b2e27e4e7 100644 --- a/backend/src/middleware/maps/automapper-entity-to-dto-maps.ts +++ b/backend/src/middleware/maps/automapper-entity-to-dto-maps.ts @@ -264,6 +264,10 @@ export const complaintToComplaintDtoMap = (mapper: Mapper) => { (destination) => destination.isPrivacyRequested, mapFrom((source) => source.is_privacy_requested), ), + forMember( + (destination) => destination.updatedOn, + mapFrom((source) => source.comp_last_upd_utc_timestamp), + ), ); }; @@ -722,6 +726,10 @@ export const applyWildlifeComplaintMap = (mapper: Mapper) => { (destination) => destination.isPrivacyRequested, mapFrom((source) => source.complaint_identifier.is_privacy_requested), ), + forMember( + (destination) => destination.updatedOn, + mapFrom((source) => source.complaint_identifier.comp_last_upd_utc_timestamp), + ), ); }; @@ -949,6 +957,10 @@ export const applyAllegationComplaintMap = (mapper: Mapper) => { (destination) => destination.isPrivacyRequested, mapFrom((source) => source.complaint_identifier.is_privacy_requested), ), + forMember( + (destination) => destination.updatedOn, + mapFrom((source) => source.complaint_identifier.comp_last_upd_utc_timestamp), + ), ); }; export const applyGeneralInfomationComplaintMap = (mapper: Mapper) => { @@ -1152,6 +1164,10 @@ export const applyGeneralInfomationComplaintMap = (mapper: Mapper) => { (destination) => destination.isPrivacyRequested, mapFrom((source) => source.complaint_identifier.is_privacy_requested), ), + forMember( + (destination) => destination.updatedOn, + mapFrom((source) => source.complaint_identifier.comp_last_upd_utc_timestamp), + ), ); }; @@ -1519,7 +1535,7 @@ export const mapAllegationReport = (mapper: Mapper, tz: string = "America/Vancou ), forMember( (destination) => destination.updatedOn, - mapFrom((source) => source.complaint_identifier.update_utc_timestamp), + mapFrom((source) => source.complaint_identifier.comp_last_upd_utc_timestamp), ), forMember( (destination) => destination.officerAssigned, @@ -1732,7 +1748,6 @@ export const mapAllegationReport = (mapper: Mapper, tz: string = "America/Vancou } }), ), - //-- forMember( (destination) => destination.violationType, diff --git a/backend/src/middleware/maps/dto-to-table-map.ts b/backend/src/middleware/maps/dto-to-table-map.ts index 539f3de64..b26c258e1 100644 --- a/backend/src/middleware/maps/dto-to-table-map.ts +++ b/backend/src/middleware/maps/dto-to-table-map.ts @@ -112,6 +112,12 @@ export const mapComplaintDtoToComplaintTable = (mapper: Mapper) => { return src.isPrivacyRequested; }), ), + forMember( + (dest) => dest.comp_last_upd_utc_timestamp, + mapFrom((src) => { + return src.updatedOn; + }), + ), ); }; diff --git a/backend/src/types/models/case-files/equipment/delete-equipment-dto.ts b/backend/src/types/models/case-files/equipment/delete-equipment-dto.ts index 564a24341..24686c5cc 100644 --- a/backend/src/types/models/case-files/equipment/delete-equipment-dto.ts +++ b/backend/src/types/models/case-files/equipment/delete-equipment-dto.ts @@ -1,4 +1,5 @@ export interface DeleteEquipmentDto { id: string; updateUserId: string; + leadIdentifier: string; } diff --git a/backend/src/types/models/complaints/update-complaint.dto.ts b/backend/src/types/models/complaints/update-complaint.dto.ts index 6a28d5e11..a3858ea9e 100644 --- a/backend/src/types/models/complaints/update-complaint.dto.ts +++ b/backend/src/types/models/complaints/update-complaint.dto.ts @@ -179,4 +179,11 @@ export class UpdateComplaintDto { "flag to represent that the caller has asked for special care when handling their personal information", }) is_privacy_requested: string; + + @ApiProperty({ + example: "true", + description: + "The time the complaint was last updated, or null if the complaint has never been touched. This value might also be updated by business logic that touches sub-tables to indicate that the business object complaint has been updated.", + }) + comp_last_upd_utc_timestamp: Date; } diff --git a/backend/src/v1/case_file/case_file.controller.ts b/backend/src/v1/case_file/case_file.controller.ts index 8586aba84..68c4b6923 100644 --- a/backend/src/v1/case_file/case_file.controller.ts +++ b/backend/src/v1/case_file/case_file.controller.ts @@ -61,10 +61,12 @@ export class CaseFileController { @Token() token, @Query("id") id: string, @Query("updateUserId") userId: string, + @Query("leadIdentifier") leadIdentifier: string, ): Promise { const deleteEquipment = { id: id, updateUserId: userId, + leadIdentifier: leadIdentifier, }; return await this.service.deleteEquipment(token, deleteEquipment); } @@ -128,12 +130,14 @@ export class CaseFileController { async deleteNote( @Token() token, @Query("caseIdentifier") caseIdentifier: string, + @Query("leadIdentifier") leadIdentifier: string, @Query("actor") actor: string, @Query("updateUserId") updateUserId: string, @Query("actionId") actionId: string, ): Promise { const input = { caseIdentifier, + leadIdentifier, actor, updateUserId, actionId, @@ -159,12 +163,14 @@ export class CaseFileController { async deleteWildlife( @Token() token, @Query("caseIdentifier") caseIdentifier: string, + @Query("leadIdentifier") leadIdentifier: string, @Query("actor") actor: string, @Query("updateUserId") updateUserId: string, @Query("outcomeId") outcomeId: string, ): Promise { const input = { caseIdentifier, + leadIdentifier, actor, updateUserId, wildlifeId: outcomeId, @@ -213,11 +219,13 @@ export class CaseFileController { async deleteAuthorizationOutcome( @Token() token, @Query("caseIdentifier") caseIdentifier: string, + @Query("leadIdentifier") leadIdentifier: string, @Query("updateUserId") updateUserId: string, @Query("id") id: string, ): Promise { const input = { caseIdentifier, + leadIdentifier, updateUserId, id, }; diff --git a/backend/src/v1/case_file/case_file.service.ts b/backend/src/v1/case_file/case_file.service.ts index 2688d0c13..3f50192bb 100644 --- a/backend/src/v1/case_file/case_file.service.ts +++ b/backend/src/v1/case_file/case_file.service.ts @@ -188,7 +188,7 @@ export class CaseFileService { query: query, variables: model, }); - returnValue = await this.handleAPIResponse(result); + returnValue = await this.handleAPIResponse(result, complaintBeingLinkedId); // If the mutation succeeded, commit the pending transaction await queryRunner.commitTransaction(); } catch (err) { @@ -229,7 +229,8 @@ export class CaseFileService { query: query, variables: model, }); - returnValue = await this.handleAPIResponse(result); + + returnValue = await this.handleAPIResponse(result, modelAsAny.createAssessmentInput.leadIdentifier); } return returnValue?.createAssessment; @@ -283,7 +284,7 @@ export class CaseFileService { query: query, variables: model, }); - returnValue = await this.handleAPIResponse(result); + returnValue = await this.handleAPIResponse(result, modelAsAny.updateAssessmentInput.leadIdentifier); } return returnValue?.updateAssessment; }; @@ -296,7 +297,14 @@ export class CaseFileService { }`, variables: model, }); - const returnValue = await this.handleAPIResponse(result); + + // The model reaches this function in the shape { "reviewInput": {...CaseFlieDTO} } despite that property + // not existing in the CaseFileDTO type, which renders the CaseFile fields inside inaccessible in this scope. + // For example, leadIdentifier would be found in model.leadIdentifier by the type's definition, however in this + // scope it is at model.reviewInput.leadIdentifier, which errors due to type violation. + // This copies it into a new variable cast to any to allow access to the nested properties. + let modelAsAny: any = { ...model }; + const returnValue = await this.handleAPIResponse(result, modelAsAny.reviewInput.leadIdentifier); const caseFileDTO = returnValue.createReview as CaseFileDto; try { if (caseFileDTO.isReviewRequired) { @@ -319,7 +327,13 @@ export class CaseFileService { }`, variables: model, }); - const returnValue = await this.handleAPIResponse(result); + // The model reaches this function in the shape { "reviewInput": {...CaseFlieDTO} } despite that property + // not existing in the CaseFileDTO type, which renders the CaseFile fields inside inaccessible in this scope. + // For example, leadIdentifier would be found in model.leadIdentifier by the type's definition, however in this + // scope it is at model.reviewInput.leadIdentifier, which errors due to type violation. + // This copies it into a new variable cast to any to allow access to the nested properties. + let modelAsAny: any = { ...model }; + const returnValue = await this.handleAPIResponse(result, modelAsAny.reviewInput.leadIdentifier); const caseFileDTO = returnValue.updateReview as CaseFileDto; try { if (model.reviewInput.isReviewRequired) { @@ -348,7 +362,13 @@ export class CaseFileService { }`, variables: model, }); - const returnValue = await this.handleAPIResponse(result); + // The model reaches this function in the shape { "createPreventionInput": {...CaseFlieDTO} } despite that property + // not existing in the CaseFileDTO type, which renders the CaseFile fields inside inaccessible in this scope. + // For example, leadIdentifier would be found in model.leadIdentifier by the type's definition, however in this + // scope it is at model.createPreventionInput.leadIdentifier, which errors due to type violation. + // This copies it into a new variable cast to any to allow access to the nested properties. + let modelAsAny: any = { ...model }; + const returnValue = await this.handleAPIResponse(result, modelAsAny.createPreventionInput.leadIdentifier); return returnValue?.createPrevention; }; @@ -360,13 +380,27 @@ export class CaseFileService { }`, variables: model, }); - const returnValue = await this.handleAPIResponse(result); + // The model reaches this function in the shape { "updatePreventionInput": {...CaseFlieDTO} } despite that property + // not existing in the CaseFileDTO type, which renders the CaseFile fields inside inaccessible in this scope. + // For example, leadIdentifier would be found in model.leadIdentifier by the type's definition, however in this + // scope it is at model.updatePreventionInput.leadIdentifier, which errors due to type violation. + // This copies it into a new variable cast to any to allow access to the nested properties. + let modelAsAny: any = { ...model }; + const returnValue = await this.handleAPIResponse(result, modelAsAny.updatePreventionInput.leadIdentifier); return returnValue?.updatePrevention; }; - private handleAPIResponse = async (result: { response: AxiosResponse; error: AxiosError }): Promise => { + private readonly handleAPIResponse = async ( + result: { response: AxiosResponse; error: AxiosError }, + leadIdentifer: string, + ): Promise => { if (result?.response?.data?.data) { + // As per CE-1335 whenever the case data is updated we want to update the last updated date on the complaint table. + // All Case Actions should call this method so this should work here. const caseFileDto = result.response.data.data; + if (leadIdentifer) { + await this.complaintService.updateComplaintLastUpdatedDate(leadIdentifer); + } return caseFileDto; } else if (result?.response?.data?.errors) { this.logger.error(`Error occurred. ${JSON.stringify(result.response.data.errors)}`); @@ -391,8 +425,13 @@ export class CaseFileService { this.logger.debug(mutationQuery); const result = await post(token, mutationQuery); - - const returnValue = await this.handleAPIResponse(result); + // The model reaches this function in the shape { "createEquipmentInput": {...CaseFlieDTO} } despite that property + // not existing in the CaseFileDTO type, which renders the CaseFile fields inside inaccessible in this scope. + // For example, leadIdentifier would be found in model.leadIdentifier by the type's definition, however in this + // scope it is at model.createEquipmentInput.leadIdentifier, which errors due to type violation. + // This copies it into a new variable cast to any to allow access to the nested properties. + let modelAsAny: any = { ...model }; + const returnValue = await this.handleAPIResponse(result, modelAsAny.createEquipmentInput.leadIdentifier); return returnValue?.createEquipment; }; @@ -404,20 +443,28 @@ export class CaseFileService { }`, variables: model, }); - const returnValue = await this.handleAPIResponse(result); + // The model reaches this function in the shape { "updateEquipmentInput": {...CaseFlieDTO} } despite that property + // not existing in the CaseFileDTO type, which renders the CaseFile fields inside inaccessible in this scope. + // For example, leadIdentifier would be found in model.leadIdentifier by the type's definition, however in this + // scope it is at model.updateEquipmentInput.leadIdentifier, which errors due to type violation. + // This copies it into a new variable cast to any to allow access to the nested properties. + let modelAsAny: any = { ...model }; + const returnValue = await this.handleAPIResponse(result, modelAsAny.updateEquipmentInput.leadIdentifier); return returnValue?.updateEquipment; }; deleteEquipment = async (token: string, model: DeleteEquipmentDto): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation DeleteEquipment($deleteEquipmentInput: DeleteEquipmentInput!) { - deleteEquipment(deleteEquipmentInput: $deleteEquipmentInput) + deleteEquipment(deleteEquipmentInput: $deleteEquipmentInput) }`, variables: { deleteEquipmentInput: model, // Ensure that the key matches the name of the variable in your mutation }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue; }; @@ -432,11 +479,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, model.leadIdentifier); return returnValue?.createNote; }; updateNote = async (token: any, model: UpdateSupplementalNotesInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation UpdateNote($input: UpdateSupplementalNoteInput!) { updateNote(input: $input) { @@ -447,11 +496,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.updateNote; }; deleteNote = async (token: any, model: DeleteSupplementalNotesInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation DeleteNote($input: DeleteSupplementalNoteInput!) { deleteNote(input: $input) { @@ -461,8 +512,7 @@ export class CaseFileService { }`, variables: { input: model }, }); - - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.deleteNote; }; @@ -476,11 +526,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, model.leadIdentifier); return returnValue?.createWildlife; }; updateWildlife = async (token: any, model: UpdateWildlifeInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation updateWildlife($input: UpdateWildlifeInput!) { updateWildlife(input: $input) { @@ -490,11 +542,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.updateWildlife; }; deleteWildlife = async (token: any, model: DeleteWildlifeInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation DeleteWildlife($input: DeleteWildlifeInput!) { deleteWildlife(input: $input) { @@ -504,7 +558,7 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.deleteWildlife; }; @@ -526,11 +580,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadId); return returnValue?.createDecision; }; updateDecision = async (token: any, model: UpdateDecisionInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation updateDecision($input: UpdateDecisionInput!) { updateDecision(input: $input) { @@ -541,7 +597,7 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.updateDecision; }; @@ -556,11 +612,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, model.leadIdentifier); return returnValue?.createAuthorizationOutcome; }; updateAuthorizationOutcome = async (token: any, model: UpdateAuthorizationOutcomeInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation updateAuthorizationOutcome($input: UpdateAuthorizationOutcomeInput!) { updateAuthorizationOutcome(input: $input) { @@ -571,11 +629,13 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.updateAuthorizationOutcome; }; deleteAuthorizationOutcome = async (token: any, model: DeleteAuthorizationOutcomeInput): Promise => { + const leadIdentifier = model.leadIdentifier; + delete model.leadIdentifier; const result = await post(token, { query: `mutation deleteAuthorizationOutcome($input: DeleteAuthorizationOutcomeInput!) { deleteAuthorizationOutcome(input: $input) { @@ -586,7 +646,7 @@ export class CaseFileService { variables: { input: model }, }); - const returnValue = await this.handleAPIResponse(result); + const returnValue = await this.handleAPIResponse(result, leadIdentifier); return returnValue?.deleteAuthorizationOutcome; }; } diff --git a/backend/src/v1/complaint/complaint.controller.ts b/backend/src/v1/complaint/complaint.controller.ts index b2ec50888..bf9c521d9 100644 --- a/backend/src/v1/complaint/complaint.controller.ts +++ b/backend/src/v1/complaint/complaint.controller.ts @@ -108,6 +108,12 @@ export class ComplaintController { return await this.service.updateComplaintById(id, complaintType, model); } + @Patch("/update-date-by-id/:id") + @Roles(Role.COS, Role.CEEB) + async updateComplaintLastUpdatedDateById(@Param("id") id: string): Promise { + return await this.service.updateComplaintLastUpdatedDate(id); + } + @Get("/by-complaint-identifier/:complaintType/:id") @Roles(Role.COS, Role.CEEB) async findComplaintById( diff --git a/backend/src/v1/complaint/complaint.service.ts b/backend/src/v1/complaint/complaint.service.ts index bea96c716..6e13443c4 100644 --- a/backend/src/v1/complaint/complaint.service.ts +++ b/backend/src/v1/complaint/complaint.service.ts @@ -232,10 +232,6 @@ export class ComplaintService { case "ERS": builder = this._allegationComplaintRepository .createQueryBuilder("allegation") - .addSelect( - "GREATEST(complaint.update_utc_timestamp, allegation.update_utc_timestamp, COALESCE((SELECT MAX(update.update_utc_timestamp) FROM complaint_update update WHERE update.complaint_identifier = complaint.complaint_identifier), '1970-01-01'))", - "_update_utc_timestamp", - ) .leftJoinAndSelect("allegation.complaint_identifier", "complaint") .leftJoin("allegation.violation_code", "violation_code") .addSelect([ @@ -248,10 +244,6 @@ export class ComplaintService { case "GIR": builder = this._girComplaintRepository .createQueryBuilder("general") - .addSelect( - "GREATEST(complaint.update_utc_timestamp, general.update_utc_timestamp, COALESCE((SELECT MAX(update.update_utc_timestamp) FROM complaint_update update WHERE update.complaint_identifier = complaint.complaint_identifier), '1970-01-01'))", - "_update_utc_timestamp", - ) .leftJoinAndSelect("general.complaint_identifier", "complaint") .leftJoin("general.gir_type_code", "gir") .addSelect(["gir.gir_type_code", "gir.short_description", "gir.long_description"]); @@ -260,10 +252,6 @@ export class ComplaintService { default: builder = this._wildlifeComplaintRepository .createQueryBuilder("wildlife") //-- alias the hwcr_complaint - .addSelect( - "GREATEST(complaint.update_utc_timestamp, wildlife.update_utc_timestamp, COALESCE((SELECT MAX(update.update_utc_timestamp) FROM complaint_update update WHERE update.complaint_identifier = complaint.complaint_identifier), '1970-01-01'))", - "_update_utc_timestamp", - ) .leftJoinAndSelect("wildlife.complaint_identifier", "complaint") .leftJoin("wildlife.species_code", "species_code") .addSelect([ @@ -1007,7 +995,8 @@ export class ComplaintService { const skip = page && pageSize ? (page - 1) * pageSize : 0; const sortTable = this._getSortTable(sortBy); - const sortString = sortBy !== "update_utc_timestamp" ? `${sortTable}.${sortBy}` : "_update_utc_timestamp"; + const sortString = + sortBy !== "update_utc_timestamp" ? `${sortTable}.${sortBy}` : "complaint.comp_last_upd_utc_timestamp"; //-- generate initial query let builder = this._generateQueryBuilder(complaintType); @@ -1057,7 +1046,7 @@ export class ComplaintService { //-- apply sort if provided if (sortBy && orderBy) { builder - .orderBy(sortString, orderBy) + .orderBy(sortString, orderBy, "NULLS LAST") .addOrderBy( "complaint.incident_reported_utc_timestmp", sortBy === "incident_reported_utc_timestmp" ? orderBy : "DESC", @@ -1311,15 +1300,50 @@ export class ComplaintService { } }; + // There is specific business logic around when a complaint is considered to be 'Updated'. + // This business logic doesn't align with the standard update audit date in a couple of areas + // - When a complaint is new it is considered untouched and never updated + // - When case data associated with a complaint is modified the complaint is considered updated + // - When attachments are uploaded to a complaint or case the complaint is considered updated + // - Updates from webEOC are considered Updates, however Actions Taken are not + // As a result this method can be called whenever you need to set the complaint as 'Updated' + updateComplaintLastUpdatedDate = async (id: string): Promise => { + try { + const idir = getIdirFromRequest(this.request); + const timestamp = new Date(); + + const result = await this.complaintsRepository + .createQueryBuilder("complaint") + .update() + .set({ update_user_id: idir, comp_last_upd_utc_timestamp: timestamp }) + .where("complaint_identifier = :id", { id }) + .execute(); + + //-- check to make sure that only one record was updated + if (result.affected === 1) { + return true; + } else { + this.logger.error(`Unable to update complaint: ${id}`); + throw new HttpException(`Unable to update complaint: ${id}`, HttpStatus.UNPROCESSABLE_ENTITY); + } + } catch (error) { + this.logger.error(`An Error occured trying to update complaint: ${id}`); + this.logger.error(error.response); + + throw new HttpException(`Unable to update complaint: ${id}}`, HttpStatus.BAD_REQUEST); + } + }; + updateComplaintStatusById = async (id: string, status: string): Promise => { try { const idir = getIdirFromRequest(this.request); + const timestamp = new Date(); const statusCode = await this._codeTableService.getComplaintStatusCodeByStatus(status); const result = await this.complaintsRepository .createQueryBuilder("complaint") .update() - .set({ complaint_status_code: statusCode, update_user_id: idir }) + .set({ complaint_status_code: statusCode, update_user_id: idir, comp_last_upd_utc_timestamp: timestamp }) .where("complaint_identifier = :id", { id }) .execute(); @@ -1416,8 +1440,9 @@ export class ComplaintService { complaintTable.comp_mthd_recv_cd_agcy_cd_xref = xref; - //set the audit field + //set the audit fields complaintTable.update_user_id = idir; + complaintTable.comp_last_upd_utc_timestamp = new Date(); const complaintUpdateResult = await this.complaintsRepository .createQueryBuilder("complaint") diff --git a/backend/src/v1/complaint/entities/complaint.entity.ts b/backend/src/v1/complaint/entities/complaint.entity.ts index d7f713d62..5343da2f1 100644 --- a/backend/src/v1/complaint/entities/complaint.entity.ts +++ b/backend/src/v1/complaint/entities/complaint.entity.ts @@ -237,6 +237,14 @@ export class Complaint { @OneToMany(() => ActionTaken, (action_taken) => action_taken.complaintIdentifier) action_taken: ActionTaken[]; + @ApiProperty({ + example: "2003-04-12 04:05:06", + description: + "The time the complaint was last updated, or null if the complaint has never been touched. This value might also be updated by business logic that touches sub-tables to indicate that the business object complaint has been updated.", + }) + @Column({ nullable: true }) + comp_last_upd_utc_timestamp: Date; + constructor( detail_text?: string, caller_name?: string, @@ -269,6 +277,7 @@ export class Complaint { is_privacy_requested?: string, complaint_update?: ComplaintUpdate[], action_taken?: ActionTaken[], + comp_last_upd_utc_timestamp?: Date, ) { this.detail_text = detail_text; this.caller_name = caller_name; @@ -301,5 +310,6 @@ export class Complaint { this.is_privacy_requested = is_privacy_requested; this.complaint_update = complaint_update; this.action_taken = action_taken; + this.comp_last_upd_utc_timestamp = comp_last_upd_utc_timestamp; } } diff --git a/backend/src/v1/person_complaint_xref/person_complaint_xref.controller.spec.ts b/backend/src/v1/person_complaint_xref/person_complaint_xref.controller.spec.ts index 945ee1efb..839b9968a 100644 --- a/backend/src/v1/person_complaint_xref/person_complaint_xref.controller.spec.ts +++ b/backend/src/v1/person_complaint_xref/person_complaint_xref.controller.spec.ts @@ -5,6 +5,7 @@ import { PersonComplaintXref } from "./entities/person_complaint_xref.entity"; import { getRepositoryToken } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; import { dataSourceMockFactory } from "../../../test/mocks/datasource"; +import { ComplaintService } from "../complaint/complaint.service"; describe("PersonComplaintXrefController", () => { let controller: PersonComplaintXrefController; @@ -14,10 +15,15 @@ describe("PersonComplaintXrefController", () => { controllers: [PersonComplaintXrefController], providers: [ PersonComplaintXrefService, + ComplaintService, { provide: getRepositoryToken(PersonComplaintXref), useValue: {}, }, + { + provide: ComplaintService, + useFactory: dataSourceMockFactory, + }, { provide: DataSource, useFactory: dataSourceMockFactory, diff --git a/backend/src/v1/person_complaint_xref/person_complaint_xref.module.ts b/backend/src/v1/person_complaint_xref/person_complaint_xref.module.ts index e1a9106d0..1ec04f305 100644 --- a/backend/src/v1/person_complaint_xref/person_complaint_xref.module.ts +++ b/backend/src/v1/person_complaint_xref/person_complaint_xref.module.ts @@ -1,11 +1,12 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { PersonComplaintXrefService } from "./person_complaint_xref.service"; import { PersonComplaintXrefController } from "./person_complaint_xref.controller"; import { TypeOrmModule } from "@nestjs/typeorm"; import { PersonComplaintXref } from "./entities/person_complaint_xref.entity"; +import { ComplaintModule } from "../complaint/complaint.module"; @Module({ - imports: [TypeOrmModule.forFeature([PersonComplaintXref])], + imports: [TypeOrmModule.forFeature([PersonComplaintXref]), forwardRef(() => ComplaintModule)], controllers: [PersonComplaintXrefController], providers: [PersonComplaintXrefService], exports: [PersonComplaintXrefService], diff --git a/backend/src/v1/person_complaint_xref/person_complaint_xref.service.spec.ts b/backend/src/v1/person_complaint_xref/person_complaint_xref.service.spec.ts index 9fb4f40fe..22a5573fa 100644 --- a/backend/src/v1/person_complaint_xref/person_complaint_xref.service.spec.ts +++ b/backend/src/v1/person_complaint_xref/person_complaint_xref.service.spec.ts @@ -4,6 +4,7 @@ import { getRepositoryToken } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; import { dataSourceMockFactory } from "../../../test/mocks/datasource"; import { PersonComplaintXref } from "./entities/person_complaint_xref.entity"; +import { ComplaintService } from "../complaint/complaint.service"; describe("PersonComplaintXrefService", () => { let service: PersonComplaintXrefService; @@ -12,10 +13,15 @@ describe("PersonComplaintXrefService", () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PersonComplaintXrefService, + ComplaintService, { provide: getRepositoryToken(PersonComplaintXref), useValue: {}, }, + { + provide: ComplaintService, + useFactory: dataSourceMockFactory, + }, { provide: DataSource, useFactory: dataSourceMockFactory, diff --git a/backend/src/v1/person_complaint_xref/person_complaint_xref.service.ts b/backend/src/v1/person_complaint_xref/person_complaint_xref.service.ts index 8db568b6c..d14cef59b 100644 --- a/backend/src/v1/person_complaint_xref/person_complaint_xref.service.ts +++ b/backend/src/v1/person_complaint_xref/person_complaint_xref.service.ts @@ -1,17 +1,21 @@ -import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; import { CreatePersonComplaintXrefDto } from "./dto/create-person_complaint_xref.dto"; import { PersonComplaintXref } from "./entities/person_complaint_xref.entity"; import { InjectRepository } from "@nestjs/typeorm"; import { DataSource, QueryRunner, Repository } from "typeorm"; +import { ComplaintService } from "../complaint/complaint.service"; @Injectable() export class PersonComplaintXrefService { @InjectRepository(PersonComplaintXref) - private personComplaintXrefRepository: Repository; + private readonly personComplaintXrefRepository: Repository; private readonly logger = new Logger(PersonComplaintXrefService.name); - constructor(private dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + @Inject(forwardRef(() => ComplaintService)) private readonly _complaintService: ComplaintService, + ) {} async create(createPersonComplaintXrefDto: CreatePersonComplaintXrefDto): Promise { const newPersonComplaintXref = this.personComplaintXrefRepository.create(createPersonComplaintXrefDto); @@ -63,19 +67,32 @@ export class PersonComplaintXrefService { .getOne(); } - async update( - //queryRunner: QueryRunner, - person_complaint_xref_guid: any, - updatePersonComplaintXrefDto, - ): Promise { + async update(person_complaint_xref_guid: any, updatePersonComplaintXrefDto): Promise { const updatedValue = await this.personComplaintXrefRepository.update( person_complaint_xref_guid, updatePersonComplaintXrefDto, ); - //queryRunner.manager.save(updatedValue); return this.findOne(person_complaint_xref_guid); } + /** + * + * Update the complaint last updated date on the parent record + */ + async updateComplaintLastUpdatedDate( + complaintIdentifier: string, + newPersonComplaintXref: PersonComplaintXref, + queryRunner: QueryRunner, + ): Promise { + if (await this._complaintService.updateComplaintLastUpdatedDate(complaintIdentifier)) { + // save the transaction + await queryRunner.manager.save(newPersonComplaintXref); + this.logger.debug(`Successfully assigned person to complaint ${complaintIdentifier}`); + } else { + throw new BadRequestException(`Unable to assign person to complaint ${complaintIdentifier}`); + } + } + /** * Assigns an officer to a complaint. This will perform one of two operations. * If the existing complaint is not yet assigned to an officer, then this will create a new complaint/officer cross reference. @@ -110,8 +127,9 @@ export class PersonComplaintXrefService { newPersonComplaintXref = await this.create(createPersonComplaintXrefDto); this.logger.debug(`Updating assignment on complaint ${complaintIdentifier}`); - // save the transaction - await queryRunner.manager.save(newPersonComplaintXref); + // Update the complaint last updated date on the parent record + await this.updateComplaintLastUpdatedDate(complaintIdentifier, newPersonComplaintXref, queryRunner); + await queryRunner.commitTransaction(); this.logger.debug(`Successfully assigned person to complaint ${complaintIdentifier}`); } catch (err) { @@ -156,9 +174,8 @@ export class PersonComplaintXrefService { newPersonComplaintXref = await this.create(createPersonComplaintXrefDto); this.logger.debug(`Updating assignment on complaint ${complaintIdentifier}`); - // save the transaction - await queryRunner.manager.save(newPersonComplaintXref); - this.logger.debug(`Successfully assigned person to complaint ${complaintIdentifier}`); + // Update the complaint last updated date on the parent record + await this.updateComplaintLastUpdatedDate(complaintIdentifier, newPersonComplaintXref, queryRunner); } catch (err) { this.logger.error(err); this.logger.error(`Rolling back assignment on complaint ${complaintIdentifier}`); diff --git a/backend/src/v1/staging_complaint/staging_complaint.service.ts b/backend/src/v1/staging_complaint/staging_complaint.service.ts index 94512355d..2cf9cd5a5 100644 --- a/backend/src/v1/staging_complaint/staging_complaint.service.ts +++ b/backend/src/v1/staging_complaint/staging_complaint.service.ts @@ -107,8 +107,10 @@ export class StagingComplaintService { "back_number_of_days", "back_number_of_hours", "back_number_of_minutes", - "entrydate", "status", + "entrydate", // WebEOC will update the following 3 fields when entering an action taken + "positionname", + "username", ]; // Omit the attributes to ignore diff --git a/frontend/src/app/common/attachment-utils.ts b/frontend/src/app/common/attachment-utils.ts index 778af7085..1c56f72fd 100644 --- a/frontend/src/app/common/attachment-utils.ts +++ b/frontend/src/app/common/attachment-utils.ts @@ -1,5 +1,5 @@ import AttachmentEnum from "@constants/attachment-enum"; -import { deleteAttachments, getAttachments, saveAttachments } from "@store/reducers/attachments"; +import { deleteAttachments, saveAttachments } from "@store/reducers/attachments"; import { COMSObject } from "@apptypes/coms/object"; // used to update the state of attachments that are to be added to a complaint @@ -30,27 +30,36 @@ export const handleDeleteAttachments = ( } }; +interface PersistAttachmentsParams { + dispatch: any; + attachmentsToAdd: File[] | null; + attachmentsToDelete: COMSObject[] | null; + complaintIdentifier: string; + setAttachmentsToAdd: any; + setAttachmentsToDelete: any; + attachmentType: AttachmentEnum; + complaintType: string; +} + // Given a list of attachments to add/delete, call COMS to add/delete those attachments -export async function handlePersistAttachments( - dispatch: any, - attachmentsToAdd: File[] | null, - attachmentsToDelete: COMSObject[] | null, - complaintIdentifier: string, - setAttachmentsToAdd: any, - setAttachmentsToDelete: any, - attachmentType: AttachmentEnum, -) { +export async function handlePersistAttachments({ + dispatch, + attachmentsToAdd, + attachmentsToDelete, + complaintIdentifier, + setAttachmentsToAdd, + setAttachmentsToDelete, + attachmentType, + complaintType, +}: PersistAttachmentsParams) { if (attachmentsToDelete) { - await dispatch(deleteAttachments(attachmentsToDelete)); + dispatch(deleteAttachments(attachmentsToDelete, complaintIdentifier, complaintType, attachmentType)); } if (attachmentsToAdd) { - await dispatch(saveAttachments(attachmentsToAdd, complaintIdentifier, attachmentType)); + dispatch(saveAttachments(attachmentsToAdd, complaintIdentifier, complaintType, attachmentType)); } - // refresh store - await dispatch(getAttachments(complaintIdentifier, attachmentType)); - // Clear the attachments since they've been added or saved. setAttachmentsToAdd(null); setAttachmentsToDelete(null); diff --git a/frontend/src/app/components/containers/complaints/details/complaint-details-create.tsx b/frontend/src/app/components/containers/complaints/details/complaint-details-create.tsx index 071c1d3ea..49586a7f8 100644 --- a/frontend/src/app/components/containers/complaints/details/complaint-details-create.tsx +++ b/frontend/src/app/components/containers/complaints/details/complaint-details-create.tsx @@ -585,15 +585,16 @@ export const CreateComplaint: FC = () => { const handleComplaintProcessing = async (complaint: ComplaintAlias) => { let complaintId = await handleHwcrComplaint(complaint); if (complaintId) { - handlePersistAttachments( + handlePersistAttachments({ dispatch, attachmentsToAdd, attachmentsToDelete, - complaintId, + complaintIdentifier: complaintId, setAttachmentsToAdd, setAttachmentsToDelete, - AttachmentEnum.COMPLAINT_ATTACHMENT, - ); + attachmentType: AttachmentEnum.COMPLAINT_ATTACHMENT, + complaintType, + }); } setErrorNotificationClass("comp-complaint-error display-none"); diff --git a/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx b/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx index 14d44e175..1e562fc39 100644 --- a/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx +++ b/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx @@ -250,15 +250,16 @@ export const ComplaintDetailsEdit: FC = () => { setErrorNotificationClass("comp-complaint-error display-none"); setReadOnly(true); - handlePersistAttachments( + handlePersistAttachments({ dispatch, attachmentsToAdd, attachmentsToDelete, - id, + complaintIdentifier: id, setAttachmentsToAdd, setAttachmentsToDelete, - AttachmentEnum.COMPLAINT_ATTACHMENT, - ); + attachmentType: AttachmentEnum.COMPLAINT_ATTACHMENT, + complaintType, + }); window.scrollTo({ top: 0, behavior: "smooth" }); } else { diff --git a/frontend/src/app/components/containers/complaints/details/complaint-header.tsx b/frontend/src/app/components/containers/complaints/details/complaint-header.tsx index c08628b64..cff0f6172 100644 --- a/frontend/src/app/components/containers/complaints/details/complaint-header.tsx +++ b/frontend/src/app/components/containers/complaints/details/complaint-header.tsx @@ -316,7 +316,7 @@ export const ComplaintHeader: FC = ({ )} - {!lastUpdated && <>Not Available} + {!lastUpdated && <>N/A} diff --git a/frontend/src/app/components/containers/complaints/list-items/allegation-complaint-list-item.tsx b/frontend/src/app/components/containers/complaints/list-items/allegation-complaint-list-item.tsx index 187acdc15..71988401f 100644 --- a/frontend/src/app/components/containers/complaints/list-items/allegation-complaint-list-item.tsx +++ b/frontend/src/app/components/containers/complaints/list-items/allegation-complaint-list-item.tsx @@ -62,7 +62,7 @@ export const AllegationComplaintListItem: FC = ({ type, complaint }) => { }; const reportedOnDateTime = formatDateTime(reportedOn.toString()); - const updatedOnDateTime = formatDateTime(updatedOn.toString()); + const updatedOnDateTime = formatDateTime(updatedOn?.toString()); const statusButtonClass = `badge ${applyStatusClass(status)}`; diff --git a/frontend/src/app/components/containers/complaints/list-items/general-complaint-list-item.tsx b/frontend/src/app/components/containers/complaints/list-items/general-complaint-list-item.tsx index 1960e500c..fd7a734da 100644 --- a/frontend/src/app/components/containers/complaints/list-items/general-complaint-list-item.tsx +++ b/frontend/src/app/components/containers/complaints/list-items/general-complaint-list-item.tsx @@ -63,7 +63,7 @@ export const GeneralInformationComplaintListItem: FC = ({ type, complaint }; const reportedOnDateTime = formatDateTime(reportedOn.toString()); - const updatedOnDateTime = formatDateTime(updatedOn.toString()); + const updatedOnDateTime = formatDateTime(updatedOn?.toString()); const location = getLocationName(locationCode); diff --git a/frontend/src/app/components/containers/complaints/list-items/wildlife-complaint-list-item.tsx b/frontend/src/app/components/containers/complaints/list-items/wildlife-complaint-list-item.tsx index f6b4fb69f..afc248f96 100644 --- a/frontend/src/app/components/containers/complaints/list-items/wildlife-complaint-list-item.tsx +++ b/frontend/src/app/components/containers/complaints/list-items/wildlife-complaint-list-item.tsx @@ -65,7 +65,7 @@ export const WildlifeComplaintListItem: FC = ({ type, complaint }) => { }; const reportedOnDateTime = formatDateTime(reportedOn.toString()); - const updatedOnDateTime = formatDateTime(updatedOn.toString()); + const updatedOnDateTime = formatDateTime(updatedOn?.toString()); const natureCode = getNatureOfComplaint(natureOfComplaint); const species = getSpecies(speciesCode); diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome-form.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome-form.tsx index 5190f98fb..834c6d15b 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome-form.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome-form.tsx @@ -87,7 +87,7 @@ export const AuthoizationOutcomeForm: FC = ({ id, type, value, leadIdenti value: !unauthorized ? authorized : unauthorized, }; - dispatch(upsertAuthorizationOutcome(identifier, data)).then(async (response) => { + dispatch(upsertAuthorizationOutcome(identifier, leadIdentifier, data)).then(async (response) => { if (response === "success") { dispatch(getCaseFile(leadIdentifier)); diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx index 8be5d0a30..fddeb9605 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx @@ -53,7 +53,7 @@ export const AuthoizationOutcome: FC = () => { description: "Your changes will be lost.", confirmText: "delete authorization", deleteConfirmed: () => { - dispatch(deleteAuthorizationOutcome()).then(async (response) => { + dispatch(deleteAuthorizationOutcome(id)).then(async (response) => { if (response === "success") { dispatch(getCaseFile(id)); } diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx index bf272f5ba..2ebbbc399 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx @@ -200,7 +200,7 @@ export const DecisionForm: FC = ({ resetErrorMessages(); if (isValid()) { - dispatch(upsertDecisionOutcome(identifier, data)).then(async (response) => { + dispatch(upsertDecisionOutcome(identifier, leadIdentifier, data)).then(async (response) => { if (response === "success") { dispatch(getCaseFile(leadIdentifier)); diff --git a/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx b/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx index 3547f6879..85fc6082b 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx @@ -14,6 +14,7 @@ import { selectAllEquipmentDropdown } from "@store/reducers/code-table"; import { CASE_ACTION_CODE } from "@constants/case_actions"; import { deleteEquipment } from "@store/reducers/case-thunks"; import { CompLocationInfo } from "@components/common/comp-location-info"; +import { useParams } from "react-router-dom"; interface EquipmentItemProps { equipment: EquipmentDetailsDto; @@ -22,7 +23,7 @@ interface EquipmentItemProps { } export const EquipmentItem: FC = ({ equipment, isEditDisabled, onEdit }) => { const dispatch = useAppDispatch(); - + const { id = "" } = useParams<{ id: string }>(); const [showModal, setShowModal] = useState(false); const handleEdit = (equipment: EquipmentDetailsDto) => { if (equipment.id) { @@ -35,9 +36,9 @@ export const EquipmentItem: FC = ({ equipment, isEditDisable return equipmentTypeCodes.find((item) => item.value === equipment.typeCode); }; - const handleDeleteEquipment = (id: string | undefined) => { - if (id) { - dispatch(deleteEquipment(id)); + const handleDeleteEquipment = (equipmentId: string | undefined) => { + if (equipmentId) { + dispatch(deleteEquipment(id, equipmentId)); } }; diff --git a/frontend/src/app/components/containers/complaints/outcomes/hwcr-outcome-by-animal-v2.tsx b/frontend/src/app/components/containers/complaints/outcomes/hwcr-outcome-by-animal-v2.tsx index 42d545ddf..9d4d3befc 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/hwcr-outcome-by-animal-v2.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/hwcr-outcome-by-animal-v2.tsx @@ -71,7 +71,7 @@ export const HWCROutcomeByAnimalv2: FC = () => { modalSize: "md", modalType: DELETE_ANIMAL_OUTCOME, data: { - caseFileId: id, + leadId: id, outcomeId, //-- this is the id of the animal outcome thats being deleted title: "Delete animal outcome", description: "All the data in this section will be lost.", @@ -100,7 +100,7 @@ export const HWCROutcomeByAnimalv2: FC = () => { //-- when saving make sure that the outcome is successfully //-- saved before adding the outcome to the list of outcomes const handleUpdate = (item: AnimalOutcomeData) => { - dispatch(updateAnimalOutcome(caseId, item)).then((result) => { + dispatch(updateAnimalOutcome(caseId, id, item)).then((result) => { if (result === "success") { dispatch(getCaseFile(id)); setShowForm(false); diff --git a/frontend/src/app/components/containers/complaints/outcomes/outcome-attachments.tsx b/frontend/src/app/components/containers/complaints/outcomes/outcome-attachments.tsx index 405396240..eb90b11b8 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/outcome-attachments.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/outcome-attachments.tsx @@ -23,7 +23,7 @@ export const OutcomeAttachments: FC = ({ showAddButton = false }) => { complaintType: string; }; - const { id = "" } = useParams(); + const { id = "", complaintType = "" } = useParams(); const DISPLAY_STATE = 0; const EDIT_STATE = 1; @@ -91,15 +91,16 @@ export const OutcomeAttachments: FC = ({ showAddButton = false }) => { } if (!hasValidationErrors()) { - handlePersistAttachments( + handlePersistAttachments({ dispatch, attachmentsToAdd, attachmentsToDelete, - id, + complaintIdentifier: id, setAttachmentsToAdd, setAttachmentsToDelete, - AttachmentEnum.OUTCOME_ATTACHMENT, - ); + attachmentType: AttachmentEnum.OUTCOME_ATTACHMENT, + complaintType, + }); if (outcomeAttachmentCount === 0) { if (showAddButton) setIsCardVisible(false); setComponentState(EDIT_STATE); diff --git a/frontend/src/app/components/modal/instances/delete-animal-outcome-modal.tsx b/frontend/src/app/components/modal/instances/delete-animal-outcome-modal.tsx index eb84155d8..1212e0cc0 100644 --- a/frontend/src/app/components/modal/instances/delete-animal-outcome-modal.tsx +++ b/frontend/src/app/components/modal/instances/delete-animal-outcome-modal.tsx @@ -16,13 +16,13 @@ export const DeleteAnimalOutcomeModal: FC = ({ close, submit }) => { const { title, description, ok, cancel } = modalData; const handleSubmit = () => { - const { outcomeId, caseFileId } = modalData; + const { outcomeId, leadId } = modalData; - if (caseFileId) { - dispatch(deleteAnimalOutcome(outcomeId)) + if (leadId) { + dispatch(deleteAnimalOutcome(outcomeId, leadId)) .then((res) => { if (res === "success") { - dispatch(getCaseFile(caseFileId)); + dispatch(getCaseFile(leadId)); } }) .finally(() => { diff --git a/frontend/src/app/components/modal/instances/delete-note-modal.tsx b/frontend/src/app/components/modal/instances/delete-note-modal.tsx index c31bee99b..bb90c7bf0 100644 --- a/frontend/src/app/components/modal/instances/delete-note-modal.tsx +++ b/frontend/src/app/components/modal/instances/delete-note-modal.tsx @@ -15,7 +15,7 @@ export const DeleteNoteModal: FC = ({ close, submit }) => { const { title, description, ok, cancel, caseIdentifier: id } = modalData; const handleSubmit = () => { - dispatch(deleteNote()) + dispatch(deleteNote(id)) .then((res) => { if (res === "success") { dispatch(getCaseFile(id)); diff --git a/frontend/src/app/store/reducers/attachments.ts b/frontend/src/app/store/reducers/attachments.ts index b4378b7f6..995764d0e 100644 --- a/frontend/src/app/store/reducers/attachments.ts +++ b/frontend/src/app/store/reducers/attachments.ts @@ -1,6 +1,6 @@ import { createSlice } from "@reduxjs/toolkit"; import { RootState, AppThunk } from "@store/store"; -import { deleteMethod, generateApiParameters, get, putFile } from "@common/api"; +import { deleteMethod, generateApiParameters, get, patch, putFile } from "@common/api"; import { from } from "linq-to-typescript"; import { COMSObject } from "@apptypes/coms/object"; import { AttachmentsState } from "@apptypes/state/attachments-state"; @@ -14,6 +14,7 @@ import { import { ToggleError, ToggleSuccess } from "@common/toast"; import axios from "axios"; import AttachmentEnum from "@constants/attachment-enum"; +import { getComplaintById } from "./complaints"; const initialState: AttachmentsState = { complaintsAttachments: [], @@ -159,7 +160,12 @@ export const getAttachments = // delete attachments from objectstore export const deleteAttachments = - (attachments: COMSObject[]): AppThunk => + ( + attachments: COMSObject[], + complaint_identifier: string, + complaintType: string, + attachmentType: AttachmentEnum, + ): AppThunk => async (dispatch) => { if (attachments) { for (const attachment of attachments) { @@ -167,23 +173,38 @@ export const deleteAttachments = const parameters = generateApiParameters(`${config.COMS_URL}/object/${attachment.id}`); await deleteMethod(dispatch, parameters); - dispatch(removeAttachment(attachment.id)); // delete from store + const response = dispatch(removeAttachment(attachment.id)); // delete from store if (isImage(attachment.name)) { const thumbParameters = generateApiParameters(`${config.COMS_URL}/object/${attachment.imageIconId}`); await deleteMethod(dispatch, thumbParameters); } - ToggleSuccess(`Attachment ${decodeURIComponent(attachment.name)} has been removed`); + + if (response) { + const parameters = generateApiParameters( + `${config.API_BASE_URL}/v1/complaint/update-date-by-id/${complaint_identifier}`, + ); + await patch(dispatch, parameters); + ToggleSuccess(`Attachment ${decodeURIComponent(attachment.name)} has been removed`); + } } catch (error) { ToggleError(`Attachment ${decodeURIComponent(attachment.name)} could not be deleted`); } } + // refresh store + dispatch(getComplaintById(complaint_identifier, complaintType)); + dispatch(getAttachments(complaint_identifier, attachmentType)); } }; // save new attachment(s) to object store export const saveAttachments = - (attachments: File[], complaint_identifier: string, attachmentType: AttachmentEnum): AppThunk => + ( + attachments: File[], + complaint_identifier: string, + complaintType: string, + attachmentType: AttachmentEnum, + ): AppThunk => async (dispatch) => { if (!attachments) { return; @@ -233,11 +254,18 @@ export const saveAttachments = } if (response) { + const parameters = generateApiParameters( + `${config.API_BASE_URL}/v1/complaint/update-date-by-id/${complaint_identifier}`, + ); + await patch(dispatch, parameters); ToggleSuccess(`Attachment "${attachment.name}" saved`); } } catch (error) { handleError(attachment, error); } + // refresh store + dispatch(getComplaintById(complaint_identifier, complaintType)); + dispatch(getAttachments(complaint_identifier, attachmentType)); } }; diff --git a/frontend/src/app/store/reducers/case-thunks.ts b/frontend/src/app/store/reducers/case-thunks.ts index 5eeb7362e..83cf6a42f 100644 --- a/frontend/src/app/store/reducers/case-thunks.ts +++ b/frontend/src/app/store/reducers/case-thunks.ts @@ -610,6 +610,7 @@ export const upsertNote = const input: UpdateSupplementalNotesInput = { note, + leadIdentifier: id, caseIdentifier: caseId as UUID, actor, updateUserId: userId, @@ -655,7 +656,8 @@ export const upsertNote = }; export const deleteNote = - (): ThunkAction, RootState, unknown, Action> => async (dispatch, getState) => { + (id: string): ThunkAction, RootState, unknown, Action> => + async (dispatch, getState) => { const { officers: { officers }, app: { @@ -667,13 +669,15 @@ export const deleteNote = const _deleteNote = ( id: UUID, + leadIdentifer: string, actor: string, userId: string, actionId: string, ): ThunkAction, RootState, unknown, Action> => async (dispatch) => { const input: DeleteSupplementalNoteInput = { - caseIdentifier: caseId as UUID, + caseIdentifier: id, + leadIdentifier: leadIdentifer, actor, updateUserId: userId, actionId, @@ -689,7 +693,9 @@ export const deleteNote = } = currentNote; const officer = officers.find((item) => item.user_id === idir); - const result = await dispatch(_deleteNote(caseId as UUID, officer ? officer.officer_guid : "", idir, actionId)); + const result = await dispatch( + _deleteNote(caseId as UUID, id, officer ? officer.officer_guid : "", idir, actionId), + ); if (result !== null) { ToggleSuccess("Supplemental note deleted"); @@ -781,7 +787,7 @@ export const updateReview = //-- equipment thunks export const deleteEquipment = - (id: string): AppThunk => + (complaintId: string, id: string): AppThunk => async (dispatch, getState) => { if (!id) { return; @@ -794,6 +800,7 @@ export const deleteEquipment = const deleteEquipmentInput = { id: id, updateUserId: profile.idir_username, + leadIdentifier: complaintId, }; const parameters = generateApiParameters(`${config.API_BASE_URL}/v1/case/equipment`, deleteEquipmentInput); @@ -965,6 +972,7 @@ export const createAnimalOutcome = export const updateAnimalOutcome = ( id: UUID, + leadIdentifier: string, animalOutcome: AnimalOutcome, ): ThunkAction, RootState, unknown, Action> => async (dispatch, getState) => { @@ -1019,6 +1027,7 @@ export const updateAnimalOutcome = const input: UpdateAnimalOutcomeInput = { caseIdentifier: id, + leadIdentifier: leadIdentifier, updateUserId: idir, wildlife: { id: wildlifeId, @@ -1050,7 +1059,7 @@ export const updateAnimalOutcome = }; export const deleteAnimalOutcome = - (id: string): ThunkAction, RootState, unknown, Action> => + (id: string, leadIdentifier: string): ThunkAction, RootState, unknown, Action> => async (dispatch, getState) => { const { officers: { officers }, @@ -1063,12 +1072,14 @@ export const deleteAnimalOutcome = const _deleteAnimalOutcome = ( outcomeId: string, + leadIdentifier: string, actor: string, userId: string, ): ThunkAction, RootState, unknown, Action> => async (dispatch) => { const input: DeleteAnimalOutcomeInput = { caseIdentifier: caseId as UUID, + leadIdentifier: leadIdentifier, actor, updateUserId: userId, outcomeId, @@ -1079,7 +1090,7 @@ export const deleteAnimalOutcome = }; const officer = officers.find((item) => item.user_id === idir); - const result = await dispatch(_deleteAnimalOutcome(id, officer ? officer.officer_guid : "", idir)); + const result = await dispatch(_deleteAnimalOutcome(id, leadIdentifier, officer ? officer.officer_guid : "", idir)); if (result) { const { caseIdentifier } = result; @@ -1094,7 +1105,11 @@ export const deleteAnimalOutcome = }; export const upsertDecisionOutcome = - (id: string, decision: Decision): ThunkAction, RootState, unknown, Action> => + ( + id: string, + leadIdentifier: string, + decision: Decision, + ): ThunkAction, RootState, unknown, Action> => async (dispatch, getState) => { const { app: { @@ -1128,6 +1143,7 @@ export const upsertDecisionOutcome = const input: UpdateDecisionInput = { caseIdentifier: id, + leadIdentifier: leadIdentifier, agencyCode: "EPO", caseCode: "ERS", actor: assignedTo, @@ -1167,7 +1183,11 @@ export const upsertDecisionOutcome = }; export const upsertAuthorizationOutcome = - (id: string, input: PermitSite): ThunkAction, RootState, unknown, Action> => + ( + id: string, + leadIdentifier: string, + input: PermitSite, + ): ThunkAction, RootState, unknown, Action> => async (dispatch, getState) => { const { app: { @@ -1192,10 +1212,11 @@ export const upsertAuthorizationOutcome = }; const _update = - (id: string, site: PermitSite): ThunkAction, RootState, unknown, Action> => + (id: string, leadIdentifier: string, site: PermitSite): ThunkAction, RootState, unknown, Action> => async (dispatch) => { const input: UpdateAuthorizationOutcomeInput = { caseIdentifier: id, + leadIdentifier: leadIdentifier, updateUserId: idir, input: site, }; @@ -1210,7 +1231,7 @@ export const upsertAuthorizationOutcome = result = await dispatch(_create(id, input)); } else { const update = { ...input, id: current.id }; - result = await dispatch(_update(id, update)); + result = await dispatch(_update(id, leadIdentifier, update)); } const { authorization } = result; @@ -1226,7 +1247,8 @@ export const upsertAuthorizationOutcome = }; export const deleteAuthorizationOutcome = - (): ThunkAction, RootState, unknown, Action> => async (dispatch, getState) => { + (leadIdentifier: string): ThunkAction, RootState, unknown, Action> => + async (dispatch, getState) => { const { app: { profile: { idir_username: idir }, @@ -1238,6 +1260,7 @@ export const deleteAuthorizationOutcome = const { id } = authorization; const input: DeleteAuthorizationOutcomeInput = { caseIdentifier: caseId, + leadIdentifier: leadIdentifier, updateUserId: idir, id, }; diff --git a/frontend/src/app/store/reducers/complaints.ts b/frontend/src/app/store/reducers/complaints.ts index 3e6c58f84..57920ed1d 100644 --- a/frontend/src/app/store/reducers/complaints.ts +++ b/frontend/src/app/store/reducers/complaints.ts @@ -134,9 +134,9 @@ export const complaintSlice = createSlice({ const index = wildlife.findIndex(({ hwcrId }) => hwcrId === updatedComplaint.hwcrId); if (index !== -1) { - const { status, delegates } = updatedComplaint; + const { status, delegates, updatedOn } = updatedComplaint; - let complaint = { ...wildlife[index], status, delegates }; + let complaint = { ...wildlife[index], status, delegates, updatedOn }; const update = [...wildlife]; update[index] = complaint; @@ -154,9 +154,9 @@ export const complaintSlice = createSlice({ const index = general.findIndex(({ girId }) => girId === updatedComplaint.girId); if (index !== -1) { - const { status, delegates } = updatedComplaint; + const { status, delegates, updatedOn } = updatedComplaint; - let complaint = { ...general[index], status, delegates }; + let complaint = { ...general[index], status, delegates, updatedOn }; const update = [...general]; update[index] = complaint; @@ -174,9 +174,9 @@ export const complaintSlice = createSlice({ const index = allegations.findIndex(({ ersId }) => ersId === updatedComplaint.ersId); if (index !== -1) { - const { status, delegates } = updatedComplaint; + const { status, delegates, updatedOn } = updatedComplaint; - let complaint = { ...allegations[index], status, delegates }; + let complaint = { ...allegations[index], status, delegates, updatedOn }; const update = [...allegations]; update[index] = complaint; @@ -194,9 +194,9 @@ export const complaintSlice = createSlice({ const index = general.findIndex(({ girId }) => girId === updatedComplaint.girId); if (index !== -1) { - const { status, delegates } = updatedComplaint; + const { status, delegates, updatedOn } = updatedComplaint; - let complaint = { ...general[index], status, delegates }; + let complaint = { ...general[index], status, delegates, updatedOn }; const update = [...general]; update[index] = complaint; @@ -960,7 +960,7 @@ export const selectComplaintHeader = result = { loggedDate: loggedDate.toString(), createdBy, - lastUpdated: lastUpdated.toString(), + lastUpdated: lastUpdated?.toString(), status, statusCode, zone, diff --git a/frontend/src/app/types/app/case-files/base-case-file-input.ts b/frontend/src/app/types/app/case-files/base-case-file-input.ts index d64c7df0c..ab6161107 100644 --- a/frontend/src/app/types/app/case-files/base-case-file-input.ts +++ b/frontend/src/app/types/app/case-files/base-case-file-input.ts @@ -10,6 +10,7 @@ export interface BaseCaseFileCreateInput { export interface BaseCaseFileUpdateInput { caseIdentifier: UUID | string; + leadIdentifier: string; actor?: string; updateUserId: string; actionId?: string; diff --git a/migrations/migrations/R__0.19.3__update_complaint_using_webeoc_update.sql b/migrations/migrations/R__0.19.3__update_complaint_using_webeoc_update.sql index e82845579..36fc56ac4 100644 --- a/migrations/migrations/R__0.19.3__update_complaint_using_webeoc_update.sql +++ b/migrations/migrations/R__0.19.3__update_complaint_using_webeoc_update.sql @@ -170,10 +170,9 @@ BEGIN -- the update caused an edit, set the audit fields if (update_edit_ind) then - update complaint - set update_user_id = _update_userid, update_utc_timestamp = _update_utc_timestamp - where complaint_identifier = _complaint_identifier; - + update complaint + set update_user_id = _update_userid, update_utc_timestamp = _update_utc_timestamp, comp_last_upd_utc_timestamp = _update_utc_timestamp + where complaint_identifier = _complaint_identifier; end if; if (_parent_report_type = 'HWCR') then @@ -236,6 +235,11 @@ BEGIN where complaint_identifier = _complaint_identifier; end if; end if; + + -- We always want to update the complaint last updated field to indicate an update was received. + update complaint + set comp_last_upd_utc_timestamp = _update_utc_timestamp + where complaint_identifier = _complaint_identifier; END; $function$ ; diff --git a/migrations/migrations/R__0.20.0_edit_complaint_using_webeoc_complaint.sql b/migrations/migrations/R__0.20.0_edit_complaint_using_webeoc_complaint.sql index b4b3f5e34..51dd3102b 100644 --- a/migrations/migrations/R__0.20.0_edit_complaint_using_webeoc_complaint.sql +++ b/migrations/migrations/R__0.20.0_edit_complaint_using_webeoc_complaint.sql @@ -255,7 +255,7 @@ BEGIN -- the update caused an edit, set the audit fields if (update_edit_ind) then update complaint - set update_user_id = _edit_update_userid, update_utc_timestamp = _edit_update_utc_timestamp + set update_user_id = _edit_update_userid, update_utc_timestamp = _edit_update_utc_timestamp, comp_last_upd_utc_timestamp = _edit_update_utc_timestamp where complaint_identifier = _complaint_identifier; end if; diff --git a/migrations/migrations/V0.35.2__CE-1335.sql b/migrations/migrations/V0.35.2__CE-1335.sql new file mode 100644 index 000000000..57f8fbd2b --- /dev/null +++ b/migrations/migrations/V0.35.2__CE-1335.sql @@ -0,0 +1,6 @@ +-- +-- alter complaint table - add +-- +ALTER TABLE complaint ADD comp_last_upd_utc_timestamp TIMESTAMP; + +comment on column complaint.comp_last_upd_utc_timestamp is 'The time the complaint was last updated, or null if the complaint has never been touched. This value might also be updated by business logic that touches sub-tables to indicate that the business object complaint has been updated.'; \ No newline at end of file From c03cdf1a55f20d6451db62e8333b145620db72bc Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Mon, 20 Jan 2025 18:40:17 -0500 Subject: [PATCH 05/15] chore(ci): actions moved to bcgov org (#881) Co-authored-by: afwilcox --- .github/workflows/.dbdeployer.yml | 2 +- .github/workflows/analysis.yml | 2 +- .github/workflows/merge-hotfix.yml | 2 +- .github/workflows/pr-open.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/.dbdeployer.yml b/.github/workflows/.dbdeployer.yml index 7517562e3..db709e91d 100644 --- a/.github/workflows/.dbdeployer.yml +++ b/.github/workflows/.dbdeployer.yml @@ -88,7 +88,7 @@ jobs: uses: redhat-actions/openshift-tools-installer@v1 with: oc: "4.14.37" - - uses: bcgov-nr/action-diff-triggers@v0.2.0 + - uses: bcgov/action-diff-triggers@v0.2.0 id: triggers with: triggers: ${{ inputs.triggers }} diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 3c634a375..70dd7d1c0 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -40,7 +40,7 @@ jobs: - dir: frontend token: SONAR_TOKEN_FRONTEND steps: - - uses: bcgov-nr/action-test-and-analyse@v1.2.1 + - uses: bcgov/action-test-and-analyse@v1.2.1 with: commands: | npm ci diff --git a/.github/workflows/merge-hotfix.yml b/.github/workflows/merge-hotfix.yml index 81e24bff9..de3618cc5 100644 --- a/.github/workflows/merge-hotfix.yml +++ b/.github/workflows/merge-hotfix.yml @@ -32,7 +32,7 @@ jobs: # Get PR number for squash merges to main - name: PR Number id: pr - uses: bcgov-nr/action-get-pr@v0.0.1 + uses: bcgov/action-get-pr@v0.0.1 # https://github.com/bcgov/quickstart-openshift-helpers deploy-hotfix: diff --git a/.github/workflows/pr-open.yml b/.github/workflows/pr-open.yml index 4ae4a5a87..9d011fb53 100644 --- a/.github/workflows/pr-open.yml +++ b/.github/workflows/pr-open.yml @@ -16,7 +16,7 @@ concurrency: cancel-in-progress: true jobs: - # https://github.com/bcgov-nr/action-builder-ghcr + # https://github.com/bcgov/action-builder-ghcr builds: name: Builds runs-on: ubuntu-22.04 @@ -25,7 +25,7 @@ jobs: package: [backend, frontend, migrations, webeoc] timeout-minutes: 10 steps: - - uses: bcgov-nr/action-builder-ghcr@v2.2.0 + - uses: bcgov/action-builder-ghcr@v2.2.0 with: package: ${{ matrix.package }} tag: ${{ github.event.number }} From 50031687eae45001383c5072da3eab30780fa805 Mon Sep 17 00:00:00 2001 From: afwilcox Date: Tue, 21 Jan 2025 09:10:25 -0800 Subject: [PATCH 06/15] fix: Don't set Complaint Last Update Date when complaint is created in NatCom (#887) --- backend/src/v1/complaint/complaint.service.ts | 1 + frontend/cypress/e2e/hwcr-outcome-equipment.cy.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/v1/complaint/complaint.service.ts b/backend/src/v1/complaint/complaint.service.ts index 6e13443c4..8c119acaa 100644 --- a/backend/src/v1/complaint/complaint.service.ts +++ b/backend/src/v1/complaint/complaint.service.ts @@ -1613,6 +1613,7 @@ export class ComplaintService { entity.update_user_id = idir; entity.complaint_identifier = complaintId; entity.owned_by_agency_code = agencyCode; + entity.comp_last_upd_utc_timestamp = null; // do not want to set this value on a create const xref = await this._compMthdRecvCdAgcyCdXrefService.findByComplaintMethodReceivedCodeAndAgencyCode( model.complaintMethodReceivedCode, diff --git a/frontend/cypress/e2e/hwcr-outcome-equipment.cy.ts b/frontend/cypress/e2e/hwcr-outcome-equipment.cy.ts index 48091195a..9077dd039 100644 --- a/frontend/cypress/e2e/hwcr-outcome-equipment.cy.ts +++ b/frontend/cypress/e2e/hwcr-outcome-equipment.cy.ts @@ -60,7 +60,7 @@ describe("HWCR Outcome Equipment", () => { officer: "TestAcct, ENV", date: "01", toastText: "Equipment has been updated", - equipmentType: "Neck snare", + equipmentType: "Snare", }; cy.get("#equipment-copy-address-button").click(); cy.get("#copy-coordinates-button").click(); From 2887d2bfbf1d79690506706f647088539d2a50c8 Mon Sep 17 00:00:00 2001 From: Dmitri <108112696+dk-bcps@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:42:19 -0800 Subject: [PATCH 07/15] feat: CE-518-Prevent-user-from-editing-closed-complaints (#882) Co-authored-by: dmitri-korin-bcps <108112696+dmitri-korin-bcps@users.noreply.github.com> --- .github/workflows/.dbdeployer.yml | 99 +++++++++---------- .github/workflows/pr-close.yml | 26 ++--- backend/dataloader/bulk-data-loader.js | 1 - .../src/app/common/validation-date-picker.tsx | 3 + .../src/app/common/validation-textarea.tsx | 3 + .../components/common/attachment-upload.tsx | 5 +- .../common/attachments-carousel.tsx | 9 +- .../details/complaint-details-edit.tsx | 3 + .../complaints/details/complaint-header.tsx | 7 +- .../list-items/complaint-action-items.tsx | 1 + .../authorization-outcome-form.tsx | 6 ++ .../authorization-outcome.tsx | 5 + .../ceeb/ceeb-decision/decision-form.tsx | 11 +++ .../outcomes/ceeb/ceeb-decision/decision.tsx | 4 + .../outcomes/external-file-reference.tsx | 13 ++- .../outcomes/hwcr-complaint-assessment.tsx | 8 ++ .../hwcr-equipment/equipment-item.tsx | 5 + .../outcomes/hwcr-equipment/index.tsx | 4 +- .../complaints/outcomes/hwcr-file-review.tsx | 7 +- .../outcomes/hwcr-outcome-by-animal-v2.tsx | 4 +- .../outcomes/hwcr-prevention-education.tsx | 4 + .../oucome-by-animal/animal-outcome.tsx | 5 +- .../outcomes/outcome-attachments.tsx | 7 ++ .../complaints/outcomes/supplemental-note.tsx | 3 + .../supplemental-notes-item.tsx | 4 + frontend/src/app/store/migrations.ts | 2 + .../src/app/store/migrations/migration-26.ts | 14 +++ frontend/src/app/store/reducers/complaints.ts | 24 ++++- frontend/src/app/store/store.ts | 2 +- .../src/app/types/state/complaint-state.ts | 5 + 30 files changed, 219 insertions(+), 75 deletions(-) create mode 100644 frontend/src/app/store/migrations/migration-26.ts diff --git a/.github/workflows/.dbdeployer.yml b/.github/workflows/.dbdeployer.yml index db709e91d..e69f21960 100644 --- a/.github/workflows/.dbdeployer.yml +++ b/.github/workflows/.dbdeployer.yml @@ -5,12 +5,12 @@ on: inputs: ### Required directory: description: Crunchy Chart directory - default: 'charts/crunchy' + default: "charts/crunchy" required: false type: string oc_server: default: https://api.silver.devops.gov.bc.ca:6443 - description: 'OpenShift server' + description: "OpenShift server" required: false type: string environment: @@ -21,29 +21,29 @@ on: description: Cluster environment name, should be dev,test,prod required: false type: string - default: 'dev' + default: "dev" s3_enabled: description: Enable S3 backups required: false default: true type: boolean values: - description: 'Values file' - default: 'values.yaml' + description: "Values file" + default: "values.yaml" required: false type: string app_values: - description: 'App specific values file which is present inside charts/app' - default: 'values.yaml' + description: "App specific values file which is present inside charts/app" + default: "values.yaml" required: false type: string enabled: - description: 'Enable the deployment of the crunchy database, easy switch to turn it on/off' + description: "Enable the deployment of the crunchy database, easy switch to turn it on/off" default: true required: false type: boolean timeout-minutes: - description: 'Timeout minutes' + description: "Timeout minutes" default: 20 required: false type: number @@ -52,8 +52,8 @@ on: required: false type: string params: - description: 'Extra parameters to pass to helm upgrade' - default: '' + description: "Extra parameters to pass to helm upgrade" + default: "" required: false type: string secrets: @@ -146,47 +146,44 @@ jobs: shell: bash if: github.event_name == 'pull_request' run: | - echo 'Adding PR specific user to Crunchy DB' - NEW_USER='{"databases":["app-${{ github.event.number }}"],"name":"app-${{ github.event.number }}"}' - CURRENT_USERS=$(oc get PostgresCluster/postgres-crunchy-${{ inputs.cluster_environment }} -o json | jq '.spec.users') - echo "${CURRENT_USERS}" + echo 'Adding PR specific user to Crunchy DB' + NEW_USER='{"databases":["app-${{ github.event.number }}"],"name":"app-${{ github.event.number }}"}' + CURRENT_USERS=$(oc get PostgresCluster/postgres-crunchy-${{ inputs.cluster_environment }} -o json | jq '.spec.users') + echo "${CURRENT_USERS}" - # check if current_users already contains the new_user - if echo "${CURRENT_USERS}" | jq -e ".[] | select(.name == \"app-${{ github.event.number }}\")" > /dev/null; then - echo "User already exists" - exit 0 - fi - - UPDATED_USERS=$(echo "$CURRENT_USERS" | jq --argjson NEW_USER "$NEW_USER" '. + [$NEW_USER]') - echo "$UPDATED_USERS" - PATCH_JSON=$(jq -n --argjson users "$UPDATED_USERS" '{"spec": {"users": $users}}') - echo "$PATCH_JSON" - oc patch PostgresCluster/postgres-crunchy-${{ inputs.cluster_environment }} --type=merge -p "${PATCH_JSON}" - - # wait for sometime as it takes time to create the user, query the secret and check if it is created, otherwise wait in a loop for 10 rounds - for i in {1..10}; do - if oc get secret postgres-crunchy-${{ inputs.cluster_environment }}-pguser-app-${{ github.event.number }} -o jsonpath='{.metadata.name}' > /dev/null; then - echo "Secret created" - break - else - echo "Secret not created, waiting for 60 seconds" - sleep 60 - fi - done + # check if current_users already contains the new_user + if echo "${CURRENT_USERS}" | jq -e ".[] | select(.name == \"app-${{ github.event.number }}\")" > /dev/null; then + echo "User already exists" + exit 0 + fi - # Add public schema and grant to PR user - # get primary crunchy pod and remove the role and db - CRUNCHY_PG_PRIMARY_POD_NAME=$(oc get pods -l postgres-operator.crunchydata.com/role=master -o json | jq -r '.items[0].metadata.name') - echo "${CRUNCHY_PG_PRIMARY_POD_NAME}" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "CREATE SCHEMA IF NOT EXISTS public;" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO \"app-${{ github.event.number }}\";" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"app-${{ github.event.number }}\";" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"app-${{ github.event.number }}\";" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \"app-${{ github.event.number }}\";" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO \"app-${{ github.event.number }}\";" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO \"app-${{ github.event.number }}\";" - oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO \"app-${{ github.event.number }}\";" - # TODO: remove these + UPDATED_USERS=$(echo "$CURRENT_USERS" | jq --argjson NEW_USER "$NEW_USER" '. + [$NEW_USER]') + echo "$UPDATED_USERS" + PATCH_JSON=$(jq -n --argjson users "$UPDATED_USERS" '{"spec": {"users": $users}}') + echo "$PATCH_JSON" + oc patch PostgresCluster/postgres-crunchy-${{ inputs.cluster_environment }} --type=merge -p "${PATCH_JSON}" - + # wait for sometime as it takes time to create the user, query the secret and check if it is created, otherwise wait in a loop for 10 rounds + for i in {1..10}; do + if oc get secret postgres-crunchy-${{ inputs.cluster_environment }}-pguser-app-${{ github.event.number }} -o jsonpath='{.metadata.name}' > /dev/null; then + echo "Secret created" + break + else + echo "Secret not created, waiting for 60 seconds" + sleep 60 + fi + done + # Add public schema and grant to PR user + # get primary crunchy pod and remove the role and db + CRUNCHY_PG_PRIMARY_POD_NAME=$(oc get pods -l postgres-operator.crunchydata.com/role=master -o json | jq -r '.items[0].metadata.name') + echo "${CRUNCHY_PG_PRIMARY_POD_NAME}" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "CREATE SCHEMA IF NOT EXISTS public;" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO \"app-${{ github.event.number }}\";" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"app-${{ github.event.number }}\";" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"app-${{ github.event.number }}\";" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \"app-${{ github.event.number }}\";" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO \"app-${{ github.event.number }}\";" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO \"app-${{ github.event.number }}\";" + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -d "app-${{ github.event.number }}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO \"app-${{ github.event.number }}\";" + # TODO: remove these diff --git a/.github/workflows/pr-close.yml b/.github/workflows/pr-close.yml index f7ff62e4e..7e0783a9e 100644 --- a/.github/workflows/pr-close.yml +++ b/.github/workflows/pr-close.yml @@ -28,7 +28,7 @@ jobs: oc_token: ${{ secrets.OC_TOKEN }} with: cleanup: label - + cleanup-pvcs: name: Cleanup Project PVCs runs-on: ubuntu-22.04 @@ -60,32 +60,32 @@ jobs: oc project ${{ secrets.OC_NAMESPACE }} # Safeguard! - run: | # check if postgres-crunchy exists or else exit - oc get PostgresCluster/postgres-crunchy || exit 0 + oc get PostgresCluster/postgres-crunchy-dev || exit 0 # Remove the user from the crunchy cluster yaml and apply the changes USER_TO_REMOVE='{"databases":["app-${{ github.event.number }}"],"name":"app-${{ github.event.number }}"}' - + echo 'getting current users from crunchy' - CURRENT_USERS=$(oc get PostgresCluster/postgres-crunchy -o json | jq '.spec.users') + CURRENT_USERS=$(oc get PostgresCluster/postgres-crunchy-dev -o json | jq '.spec.users') echo "${CURRENT_USERS}" - + # Remove the user from the list, UPDATED_USERS=$(echo "$CURRENT_USERS" | jq --argjson user "$USER_TO_REMOVE" 'map(select(. != $user))') PATCH_JSON=$(jq -n --argjson users "$UPDATED_USERS" '{"spec": {"users": $users}}') - oc patch PostgresCluster/postgres-crunchy --type=merge -p "$PATCH_JSON" - + oc patch PostgresCluster/postgres-crunchy-dev --type=merge -p "$PATCH_JSON" + # get primary crunchy pod and remove the role and db CRUNCHY_PG_PRIMARY_POD_NAME=$(oc get pods -l postgres-operator.crunchydata.com/role=master -o json | jq -r '.items[0].metadata.name') - + echo "${CRUNCHY_PG_PRIMARY_POD_NAME}" # Terminate all connections to the database before trying terminate oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'app-${{ github.event.number }}' AND pid <> pg_backend_pid();" - + # Drop the database and role oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -c "DROP DATABASE \"app-${{ github.event.number }}\" --cascade" - + oc exec "${CRUNCHY_PG_PRIMARY_POD_NAME}" -- psql -c "DROP ROLE \"app-${{ github.event.number }}\" --cascade" - + echo "Database and Role for PR is cleaned." - - exit 0 \ No newline at end of file + + exit 0 diff --git a/backend/dataloader/bulk-data-loader.js b/backend/dataloader/bulk-data-loader.js index 7f18661e0..7a55a92c5 100644 --- a/backend/dataloader/bulk-data-loader.js +++ b/backend/dataloader/bulk-data-loader.js @@ -1,6 +1,5 @@ // Instruction for running: from backend directory: node dataloader/bulk-data-loader.js // Ensure parameters at the bottom of this file are updated as required - require('dotenv').config(); const faker = require('faker'); const db = require('pg-promise')(); diff --git a/frontend/src/app/common/validation-date-picker.tsx b/frontend/src/app/common/validation-date-picker.tsx index c0d1e63b7..bb37b9ecc 100644 --- a/frontend/src/app/common/validation-date-picker.tsx +++ b/frontend/src/app/common/validation-date-picker.tsx @@ -11,6 +11,7 @@ interface ValidationDatePickerProps { id: string; classNamePrefix: string; errMsg: string; + isDisabled?: boolean; } export const ValidationDatePicker: FC = ({ @@ -23,6 +24,7 @@ export const ValidationDatePicker: FC = ({ id, classNamePrefix, errMsg, + isDisabled, }) => { const handleDateChange = (date: Date) => { onChange(date); @@ -48,6 +50,7 @@ export const ValidationDatePicker: FC = ({ autoComplete="false" monthsShown={2} showPreviousMonths + disabled={isDisabled} />
{errMsg}
diff --git a/frontend/src/app/common/validation-textarea.tsx b/frontend/src/app/common/validation-textarea.tsx index 9d6a3df3a..1b6f6e77a 100644 --- a/frontend/src/app/common/validation-textarea.tsx +++ b/frontend/src/app/common/validation-textarea.tsx @@ -9,6 +9,7 @@ interface ValidationTextAreaProps { rows: number; maxLength?: number; placeholderText?: string; + disabled?: boolean; } export const ValidationTextArea: FC = ({ @@ -20,6 +21,7 @@ export const ValidationTextArea: FC = ({ rows, maxLength, placeholderText, + disabled, }) => { const errClass = errMsg === "" ? "" : "error-message"; const calulatedClass = errMsg === "" ? className : className + " error-border"; @@ -34,6 +36,7 @@ export const ValidationTextArea: FC = ({ onChange={(e) => onChange(e.target.value)} maxLength={maxLength} placeholder={placeholderText} + disabled={disabled} />
{errMsg}
diff --git a/frontend/src/app/components/common/attachment-upload.tsx b/frontend/src/app/components/common/attachment-upload.tsx index 504d4e72e..e3e2839ba 100644 --- a/frontend/src/app/components/common/attachment-upload.tsx +++ b/frontend/src/app/components/common/attachment-upload.tsx @@ -3,9 +3,10 @@ import { BsPlus } from "react-icons/bs"; type Props = { onFileSelect: (selectedFile: FileList) => void; + disabled?: boolean | null; }; -export const AttachmentUpload: FC = ({ onFileSelect }) => { +export const AttachmentUpload: FC = ({ onFileSelect, disabled }) => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files) { onFileSelect(event.target.files); @@ -40,6 +41,8 @@ export const AttachmentUpload: FC = ({ onFileSelect }) => { className="comp-attachment-upload-btn" tabIndex={0} onClick={handleDivClick} + disabled={disabled ?? false} + style={disabled ?? false ? { cursor: "default" } : {}} >
diff --git a/frontend/src/app/components/common/attachments-carousel.tsx b/frontend/src/app/components/common/attachments-carousel.tsx index 9fe567585..6f9949e73 100644 --- a/frontend/src/app/components/common/attachments-carousel.tsx +++ b/frontend/src/app/components/common/attachments-carousel.tsx @@ -21,6 +21,7 @@ type Props = { onFileDeleted?: (attachments: COMSObject) => void; onSlideCountChange?: (count: number) => void; setCancelPendingUpload?: (isCancelUpload: boolean) => void | null; + disabled?: boolean | null; }; export const AttachmentsCarousel: FC = ({ @@ -33,6 +34,7 @@ export const AttachmentsCarousel: FC = ({ onFileDeleted, onSlideCountChange, setCancelPendingUpload, + disabled, }) => { const dispatch = useAppDispatch(); @@ -207,7 +209,12 @@ export const AttachmentsCarousel: FC = ({ className="comp-carousel" > - {allowUpload && } + {allowUpload && ( + + )} {slides?.map((item, index) => ( { const data = useAppSelector(selectComplaint); const privacyDropdown = useAppSelector(selectPrivacyDropdown); const enablePrivacyFeature = useAppSelector(isFeatureActive(FEATURE_TYPES.PRIV_REQ)); + const isReadOnly = useAppSelector(selectComplaintViewMode); const { details, @@ -741,6 +743,7 @@ export const ComplaintDetailsEdit: FC = () => { size="sm" id="details-screen-edit-button" onClick={editButtonClick} + disabled={isReadOnly} > Edit Complaint diff --git a/frontend/src/app/components/containers/complaints/details/complaint-header.tsx b/frontend/src/app/components/containers/complaints/details/complaint-header.tsx index cff0f6172..fbb0d1bfb 100644 --- a/frontend/src/app/components/containers/complaints/details/complaint-header.tsx +++ b/frontend/src/app/components/containers/complaints/details/complaint-header.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { Link } from "react-router-dom"; import COMPLAINT_TYPES, { complaintTypeToName } from "@apptypes/app/complaint-types"; import { useAppDispatch, useAppSelector } from "@hooks/hooks"; -import { selectComplaintHeader } from "@store/reducers/complaints"; +import { selectComplaintHeader, selectComplaintViewMode } from "@store/reducers/complaints"; import { applyStatusClass, formatDate, formatTime, getAvatarInitials } from "@common/methods"; import { Badge, Button, Dropdown } from "react-bootstrap"; @@ -46,6 +46,7 @@ export const ComplaintHeader: FC = ({ girType, } = useAppSelector(selectComplaintHeader(complaintType)); const showExperimentalFeature = useAppSelector(isFeatureActive(FEATURE_TYPES.EXPERIMENTAL_FEATURE)); + const isReadOnly = useAppSelector(selectComplaintViewMode); const dispatch = useAppDispatch(); const assignText = officerAssigned === "Not Assigned" ? "Assign" : "Reassign"; @@ -175,6 +176,7 @@ export const ComplaintHeader: FC = ({ Quick close @@ -183,6 +185,7 @@ export const ComplaintHeader: FC = ({ {assignText} @@ -211,6 +214,7 @@ export const ComplaintHeader: FC = ({ title="Quick close" variant="outline-light" onClick={openQuickCloseModal} + disabled={isReadOnly} > Quick close @@ -221,6 +225,7 @@ export const ComplaintHeader: FC = ({ title="Assign to officer" variant="outline-light" onClick={openAsignOfficerModal} + disabled={isReadOnly} > {assignText} diff --git a/frontend/src/app/components/containers/complaints/list-items/complaint-action-items.tsx b/frontend/src/app/components/containers/complaints/list-items/complaint-action-items.tsx index 9ab5be6c7..f225fdc70 100644 --- a/frontend/src/app/components/containers/complaints/list-items/complaint-action-items.tsx +++ b/frontend/src/app/components/containers/complaints/list-items/complaint-action-items.tsx @@ -108,6 +108,7 @@ export const ComplaintActionItems: FC = ({ = ({ id, type, value, leadIdenti const dispatch = useAppDispatch(); const caseId = useAppSelector(selectCaseId); + const isReadOnly = useAppSelector(selectComplaintViewMode); const [authorized, setAuthorized] = useState(""); const [unauthorized, setUnauthorized] = useState(""); @@ -154,6 +156,7 @@ export const AuthoizationOutcomeForm: FC = ({ id, type, value, leadIdenti handleUpdateSiteChange("permit", value); }} + disabled={isReadOnly} />
@@ -183,6 +186,7 @@ export const AuthoizationOutcomeForm: FC = ({ id, type, value, leadIdenti handleUpdateSiteChange("site", value); }} + disabled={isReadOnly} /> @@ -193,6 +197,7 @@ export const AuthoizationOutcomeForm: FC = ({ id, type, value, leadIdenti id="outcome-decision-cancel-button" title="Cancel Decision" onClick={handleCancelButtonClick} + disabled={isReadOnly} > Cancel @@ -201,6 +206,7 @@ export const AuthoizationOutcomeForm: FC = ({ id, type, value, leadIdenti id="outcome-decision-save-button" title="Save Decision" onClick={() => handleSaveButtonClick()} + disabled={isReadOnly} > Save diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx index fddeb9605..01c08f51b 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/authorization-outcome/authorization-outcome.tsx @@ -11,6 +11,7 @@ import { AuthoizationOutcomeItem } from "./authorization-outcome-item"; import { openModal } from "@store/reducers/app"; import { DELETE_CONFIRM } from "@apptypes/modal/modal-types"; import { deleteAuthorizationOutcome, getCaseFile } from "@store/reducers/case-thunks"; +import { selectComplaintViewMode } from "@/app/store/reducers/complaints"; export const AuthoizationOutcome: FC = () => { const { id = "" } = useParams(); @@ -26,6 +27,8 @@ export const AuthoizationOutcome: FC = () => { const cases = useAppSelector((state) => state.cases); const hasAuthorization = !cases.authorization; + const isReadOnly = useAppSelector(selectComplaintViewMode); + useEffect(() => { if (!hasAuthorization && editable) { dispatch(setIsInEdit({ site: false })); @@ -79,6 +82,7 @@ export const AuthoizationOutcome: FC = () => { onClick={() => { toggleEdit(); }} + disabled={isReadOnly} > Edit @@ -88,6 +92,7 @@ export const AuthoizationOutcome: FC = () => { variant="outline-primary" size="sm" onClick={handleDeleteButtonClick} + disabled={isReadOnly} > Delete diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx index 2ebbbc399..b23e229af 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision-form.tsx @@ -23,6 +23,7 @@ import { selectCaseId } from "@store/reducers/case-selectors"; import { UUID } from "crypto"; import { ValidationTextArea } from "@common/validation-textarea"; import { getDropdownOption } from "@/app/common/methods"; +import { selectComplaintViewMode } from "@/app/store/reducers/complaints"; type props = { leadIdentifier: string; @@ -71,6 +72,7 @@ export const DecisionForm: FC = ({ const decisionTypeOptions = useAppSelector(selectDecisionTypeDropdown); const leadAgencyOptions = useAppSelector(selectLeadAgencyDropdown); const scheduleSectorType = useAppSelector(selectScheduleSectorXref); + const isReadOnly = useAppSelector(selectComplaintViewMode); //-- error messgaes const [scheduleErrorMessage, setScheduleErrorMessage] = useState(""); @@ -293,6 +295,7 @@ export const DecisionForm: FC = ({ handleScheduleChange(evt.value); } }} + isDisabled={isReadOnly} value={getDropdownOption(data.schedule, schedulesOptions)} /> @@ -316,6 +319,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("sector", evt?.value); }} + isDisabled={isReadOnly} value={getDropdownOption(data.sector, sectorsOptions) || { value: "", label: "" }} /> @@ -339,6 +343,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("discharge", evt?.value); }} + isDisabled={isReadOnly} value={getDropdownOption(data.discharge, dischargesOptions)} /> @@ -362,6 +367,7 @@ export const DecisionForm: FC = ({ const action = evt?.value ? evt?.value : ""; handleActionTakenChange(action); }} + isDisabled={isReadOnly} value={getDropdownOption(data.actionTaken, decisionTypeOptions)} /> @@ -432,6 +438,7 @@ export const DecisionForm: FC = ({ onChange={(evt) => { updateModel("nonCompliance", evt?.value); }} + isDisabled={isReadOnly} value={getDropdownOption(data.nonCompliance, nonComplianceOptions)} /> @@ -450,6 +457,7 @@ export const DecisionForm: FC = ({ errMsg={""} maxLength={4000} onChange={handleRationaleChange} + disabled={isReadOnly} /> @@ -468,6 +476,7 @@ export const DecisionForm: FC = ({ classNamePrefix="comp-select" // Adjust class as needed errMsg={dateActionTakenErrorMessage} // Pass error message if any maxDate={new Date()} + isDisabled={isReadOnly} /> @@ -478,6 +487,7 @@ export const DecisionForm: FC = ({ id="outcome-decision-cancel-button" title="Cancel Decision" onClick={handleCancelButtonClick} + disabled={isReadOnly} > Cancel @@ -486,6 +496,7 @@ export const DecisionForm: FC = ({ id="outcome-decision-save-button" title="Save Decision" onClick={() => handleSaveButtonClick()} + disabled={isReadOnly} > Save diff --git a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision.tsx b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision.tsx index 04e6173bd..c3da9c2c1 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/ceeb/ceeb-decision/decision.tsx @@ -8,6 +8,7 @@ import { setIsInEdit } from "@store/reducers/cases"; import { DecisionForm } from "./decision-form"; import { DecisionItem } from "./decision-item"; import { BsExclamationCircleFill } from "react-icons/bs"; +import { selectComplaintViewMode } from "@/app/store/reducers/complaints"; export const CeebDecision: FC = () => { const { id = "" } = useParams(); @@ -23,6 +24,8 @@ export const CeebDecision: FC = () => { const cases = useAppSelector((state) => state.cases); const hasDecision = !cases.decision; + const isReadOnly = useAppSelector(selectComplaintViewMode); + useEffect(() => { if (!hasDecision && editable) { dispatch(setIsInEdit({ decision: false })); @@ -56,6 +59,7 @@ export const CeebDecision: FC = () => { onClick={() => { toggleEdit(); }} + disabled={isReadOnly} > Edit diff --git a/frontend/src/app/components/containers/complaints/outcomes/external-file-reference.tsx b/frontend/src/app/components/containers/complaints/outcomes/external-file-reference.tsx index 19a9bb9b2..b9b3ef7f9 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/external-file-reference.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/external-file-reference.tsx @@ -4,13 +4,19 @@ import { useAppSelector, useAppDispatch } from "@hooks/hooks"; import { CompInput } from "@components/common/comp-input"; import { openModal } from "@store/reducers/app"; import { CANCEL_CONFIRM, DELETE_CONFIRM } from "@apptypes/modal/modal-types"; -import { getComplaintById, selectComplaint, updateComplaintById } from "@store/reducers/complaints"; +import { + getComplaintById, + selectComplaint, + selectComplaintViewMode, + updateComplaintById, +} from "@store/reducers/complaints"; import { getComplaintType } from "@common/methods"; export const ExternalFileReference: FC = () => { const dispatch = useAppDispatch(); const complaintData = useAppSelector(selectComplaint); + const isReadOnly = useAppSelector(selectComplaintViewMode); const [isEditable, setIsEditable] = useState(true); const [referenceNumber, setReferenceNumber] = useState(""); @@ -134,6 +140,7 @@ export const ExternalFileReference: FC = () => { size="sm" id="external-file-reference-edit-button" onClick={(e) => setIsEditable(true)} + disabled={isReadOnly} > Edit @@ -143,6 +150,7 @@ export const ExternalFileReference: FC = () => { variant="outline-primary" id="external-file-reference-delete-button" onClick={() => handleExternalFileReferenceDelete()} + disabled={isReadOnly} > Delete @@ -172,6 +180,7 @@ export const ExternalFileReference: FC = () => { } = evt; handleExternalFileReferenceChange(value); }} + disabled={isReadOnly} />
@@ -180,6 +189,7 @@ export const ExternalFileReference: FC = () => { id="external-file-reference-cancel-button" title="Cancel" onClick={handleExternalFileReferenceCancel} + disabled={isReadOnly} > Cancel @@ -188,6 +198,7 @@ export const ExternalFileReference: FC = () => { id="external-file-reference-save-button" title="Save" onClick={handleExternalFileReferenceSave} + disabled={isReadOnly} > Save diff --git a/frontend/src/app/components/containers/complaints/outcomes/hwcr-complaint-assessment.tsx b/frontend/src/app/components/containers/complaints/outcomes/hwcr-complaint-assessment.tsx index e3c6015f1..8fdb364a1 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/hwcr-complaint-assessment.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/hwcr-complaint-assessment.tsx @@ -13,6 +13,7 @@ import { selectComplaintLargeCarnivoreInd, selectLinkedComplaints, getLinkedComplaints, + selectComplaintViewMode, } from "@store/reducers/complaints"; import { selectAssessmentCat1Dropdown, @@ -103,6 +104,7 @@ export const HWCRComplaintAssessment: FC = ({ const assessmentCat1Options = useAppSelector(selectAssessmentCat1Dropdown); const isLargeCarnivore = useAppSelector(selectComplaintLargeCarnivoreInd); const validationResults = useValidateComplaint(); + const isReadOnly = useAppSelector(selectComplaintViewMode); const hasAssessment = Object.keys(cases.assessment).length > 0; const showSectionErrors = @@ -586,6 +588,7 @@ export const HWCRComplaintAssessment: FC = ({ variant="outline-primary" size="sm" onClick={toggleEdit} + disabled={isReadOnly} > Edit @@ -631,6 +634,7 @@ export const HWCRComplaintAssessment: FC = ({ value={selectedActionRequired} placeholder="Select" onChange={(e) => handleActionRequiredChange(e)} + isDisabled={isReadOnly} /> )}
@@ -856,6 +860,7 @@ export const HWCRComplaintAssessment: FC = ({ value={selectedOfficer} placeholder="Select " onChange={(officer: any) => setSelectedOfficer(officer)} + isDisabled={isReadOnly} /> @@ -874,6 +879,7 @@ export const HWCRComplaintAssessment: FC = ({ classNamePrefix="comp-select" // Adjust class as needed errMsg={assessmentDateErrorMessage} // Pass error message if any maxDate={new Date()} + isDisabled={isReadOnly} /> @@ -883,6 +889,7 @@ export const HWCRComplaintAssessment: FC = ({ id="outcome-cancel-button" title="Cancel Outcome" onClick={quickClose ? handleClose : cancelButtonClick} + disabled={isReadOnly} > Cancel @@ -891,6 +898,7 @@ export const HWCRComplaintAssessment: FC = ({ id="outcome-save-button" title="Save Outcome" onClick={saveButtonClick} + disabled={isReadOnly} > {quickClose ? "Save and Close" : "Save"} diff --git a/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx b/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx index 85fc6082b..0660e1156 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/equipment-item.tsx @@ -14,6 +14,7 @@ import { selectAllEquipmentDropdown } from "@store/reducers/code-table"; import { CASE_ACTION_CODE } from "@constants/case_actions"; import { deleteEquipment } from "@store/reducers/case-thunks"; import { CompLocationInfo } from "@components/common/comp-location-info"; +import { selectComplaintViewMode } from "@/app/store/reducers/complaints"; import { useParams } from "react-router-dom"; interface EquipmentItemProps { @@ -69,6 +70,8 @@ export const EquipmentItem: FC = ({ equipment, isEditDisable : null; const isInEdit = useAppSelector((state) => state.cases.isInEdit); + const isReadOnly = useAppSelector(selectComplaintViewMode); + const showSectionErrors = !removedEquipmentDate && getValue("equipment")?.value !== "SIGNG" && @@ -117,6 +120,7 @@ export const EquipmentItem: FC = ({ equipment, isEditDisable title="Edit equipment details" id="equipment-edit-button" onClick={() => handleEdit(equipment)} + disabled={isReadOnly} > Edit @@ -127,6 +131,7 @@ export const EquipmentItem: FC = ({ equipment, isEditDisable title="Delete equipment" id="equipment-delete-button" onClick={() => setShowModal(true)} + disabled={isReadOnly} > Delete diff --git a/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/index.tsx b/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/index.tsx index 79a272932..1c065bbcd 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/index.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/hwcr-equipment/index.tsx @@ -4,7 +4,7 @@ import { EquipmentForm } from "./equipment-form"; import { EquipmentItem } from "./equipment-item"; import { useAppDispatch, useAppSelector } from "@hooks/hooks"; import { selectEquipment } from "@store/reducers/case-selectors"; -import { selectComplaintAssignedBy } from "@store/reducers/complaints"; +import { selectComplaintAssignedBy, selectComplaintViewMode } from "@store/reducers/complaints"; import "@assets/sass/hwcr-equipment.scss"; import { setIsInEdit } from "@store/reducers/cases"; @@ -13,6 +13,7 @@ export const HWCREquipment: FC = memo(() => { const assigned = useAppSelector(selectComplaintAssignedBy); // used to indicate which equipment's guid is in edit mode (only one can be edited at a time const equipmentList = useAppSelector(selectEquipment); + const isReadOnly = useAppSelector(selectComplaintViewMode); const [showEquipmentForm, setShowEquipmentForm] = useState(false); const [editingGuid, setEditingGuid] = useState(""); @@ -81,6 +82,7 @@ export const HWCREquipment: FC = memo(() => { id="outcome-report-add-equipment" title="Add equipment" onClick={() => setShowEquipmentForm(true)} + disabled={isReadOnly} > Add equipment diff --git a/frontend/src/app/components/containers/complaints/outcomes/hwcr-file-review.tsx b/frontend/src/app/components/containers/complaints/outcomes/hwcr-file-review.tsx index 1c22fd2b9..378259357 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/hwcr-file-review.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/hwcr-file-review.tsx @@ -6,7 +6,7 @@ import { useAppSelector, useAppDispatch } from "@hooks/hooks"; import { openModal, profileDisplayName } from "@store/reducers/app"; import { formatDate } from "@common/methods"; import { BsExclamationCircleFill } from "react-icons/bs"; -import { getComplaintStatusById, selectComplaint } from "@store/reducers/complaints"; +import { getComplaintStatusById, selectComplaint, selectComplaintViewMode } from "@store/reducers/complaints"; import { CANCEL_CONFIRM } from "@apptypes/modal/modal-types"; import { createReview, updateReview } from "@store/reducers/case-thunks"; import COMPLAINT_TYPES from "@apptypes/app/complaint-types"; @@ -24,6 +24,7 @@ export const HWCRFileReview: FC = () => { const { officers } = useAppSelector((state) => state.officers); const displayName = useAppSelector(profileDisplayName); const isInEdit = useAppSelector((state) => state.cases.isInEdit); + const isReadOnly = useAppSelector(selectComplaintViewMode); const [componentState, setComponentState] = useState(REQUEST_REVIEW_STATE); const [reviewRequired, setReviewRequired] = useState(false); @@ -150,6 +151,7 @@ export const HWCRFileReview: FC = () => { onClick={(e) => { handleStateChange(EDIT_STATE); }} + disabled={isReadOnly} > Edit @@ -184,6 +186,7 @@ export const HWCRFileReview: FC = () => { type="checkbox" checked={reviewRequired} onChange={handleReviewRequiredClick} + disabled={isReadOnly} />