From 9d5f0ee367f2b9b54d258446d2db4fc27d0e625d Mon Sep 17 00:00:00 2001 From: afwilcox Date: Fri, 6 Dec 2024 14:18:43 -0800 Subject: [PATCH 1/5] chore: New PR for CE-1275 (#790) Co-authored-by: Mike Vesprini --- .../containers/complaints/complaint-list.tsx | 10 ++++++---- frontend/src/app/store/store.ts | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/containers/complaints/complaint-list.tsx b/frontend/src/app/components/containers/complaints/complaint-list.tsx index a3e543033..9b1e78170 100644 --- a/frontend/src/app/components/containers/complaints/complaint-list.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-list.tsx @@ -216,10 +216,12 @@ export const ComplaintList: FC = ({ type, searchQuery }) => { const renderNoComplaintsFound = () => { return ( - - - No complaints found using your current filters. Remove or change your filters to see complaints. - + + + + No complaints found using your current filters. Remove or change your filters to see complaints. + + ); }; diff --git a/frontend/src/app/store/store.ts b/frontend/src/app/store/store.ts index da398dbc0..caaa479f0 100644 --- a/frontend/src/app/store/store.ts +++ b/frontend/src/app/store/store.ts @@ -1,5 +1,15 @@ import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; -import { createMigrate, persistReducer, persistStore } from "redux-persist"; +import { + createMigrate, + persistReducer, + persistStore, + FLUSH, + REHYDRATE, + PAUSE, + PERSIST, + PURGE, + REGISTER, +} from "redux-persist"; import storage from "redux-persist/lib/storage"; import { rootReducer } from "./reducers"; import migration from "./migrations"; @@ -9,7 +19,7 @@ const persistConfig = { storage, blacklist: ["app"], whitelist: ["codeTables", "officers"], - version: 22, // This needs to be incremented every time a new migration is added + version: 23, // This needs to be incremented every time a new migration is added debug: true, migrate: createMigrate(migration, { debug: false }), }; @@ -18,6 +28,12 @@ const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = configureStore({ reducer: persistedReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), }); export type AppDispatch = typeof store.dispatch; From 2ff6c115023abeb8514c0eca6f0f2c19cdfbddb5 Mon Sep 17 00:00:00 2001 From: dmitri-korin-bcps <108112696+dmitri-korin-bcps@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:02:36 -0800 Subject: [PATCH 2/5] feat: CE-1261-add-warning-message-to-user-attempting-to-edit-the-outcome-or-outcome-date-in-a-hwc-file-v2 (#800) Co-authored-by: afwilcox --- .../oucome-by-animal/edit-outcome.tsx | 67 +++++++++++++++++-- .../standalone-cancel-confirm-modal.tsx | 22 ++++-- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/components/containers/complaints/outcomes/oucome-by-animal/edit-outcome.tsx b/frontend/src/app/components/containers/complaints/outcomes/oucome-by-animal/edit-outcome.tsx index 0c2eb3b52..1df54a955 100644 --- a/frontend/src/app/components/containers/complaints/outcomes/oucome-by-animal/edit-outcome.tsx +++ b/frontend/src/app/components/containers/complaints/outcomes/oucome-by-animal/edit-outcome.tsx @@ -45,6 +45,15 @@ const defaultAuthorization: DrugAuthorization = { date: new Date(), }; +type modalProps = { + title: string; + description: string; + confirmText: string; + cancelText: string; + confirm: () => void | null; + cancel: () => void | null; +}; + export const EditOutcome: FC = ({ id, index, outcome, assignedOfficer: officer, agency, update, toggle }) => { //-- select data from redux const speciesList = useAppSelector(selectSpeciesCodeDropdown); @@ -58,6 +67,18 @@ export const EditOutcome: FC = ({ id, index, outcome, assignedOfficer: of const showSectionErrors = isInEdit.showSectionErrors; const [showModal, setShowModal] = useState(false); + const [modalContent, setModalContent] = useState({ + title: "", + description: "", + confirmText: "", + cancelText: "", + confirm: () => { + return null; + }, + cancel: () => { + return null; + }, + }); //-- new input data const [data, applyData] = useState({ ...outcome }); @@ -318,6 +339,14 @@ export const EditOutcome: FC = ({ id, index, outcome, assignedOfficer: of }; const handleCancel = () => { + setModalContent({ + title: "Cancel changes?", + description: "Your changes will be lost.", + confirmText: "No, go back", + cancelText: "Yes, cancel changes", + confirm: close, + cancel: cancel, + }); setShowModal(true); }; const close = () => { @@ -329,16 +358,35 @@ export const EditOutcome: FC = ({ id, index, outcome, assignedOfficer: of toggle(""); }; + const showEditWarning = (onConfirm: Function, onCancel?: Function) => { + setModalContent({ + title: "Confirm changes?", + description: + "Editing the outcome or date of this report might affect public reporting statistics. Are you sure you want to continue?", + confirmText: "Yes", + cancelText: "No", + confirm: () => { + if (onConfirm) { + onConfirm(); + } + setShowModal(false); + }, + cancel: () => { + if (onCancel) { + onCancel(); + } + setShowModal(false); + }, + }); + setShowModal(true); + }; + return ( <> - = ({ id, index, outcome, assignedOfficer: of options={outcomes} enableValidation={false} placeholder={"Select"} + value={getDropdownOption(data.outcome, outcomes)} onChange={(evt) => { - updateModel("outcome", evt?.value); + showEditWarning(() => { + updateModel("outcome", evt?.value); + }); }} defaultOption={getDropdownOption(data.outcome, outcomes)} /> @@ -540,7 +591,9 @@ export const EditOutcome: FC = ({ id, index, outcome, assignedOfficer: of id="equipment-day-set" maxDate={new Date()} onChange={(input: Date) => { - handleOutcomeDateChange(input); + showEditWarning(() => { + handleOutcomeDateChange(input); + }); }} selectedDate={data?.date} placeholder={"Select"} diff --git a/frontend/src/app/components/modal/instances/standalone-cancel-confirm-modal.tsx b/frontend/src/app/components/modal/instances/standalone-cancel-confirm-modal.tsx index f62fff3c5..8466bd337 100644 --- a/frontend/src/app/components/modal/instances/standalone-cancel-confirm-modal.tsx +++ b/frontend/src/app/components/modal/instances/standalone-cancel-confirm-modal.tsx @@ -10,11 +10,21 @@ type props = { show: boolean; title: string; description: string; - close: () => void | null; - closeAndCancel: () => void | null; + confirmText: string; + cancelText: string; + confirm: () => void | null; + cancel: () => void | null; }; -export const StandaloneConfirmCancelModal: FC = ({ show, title, description, close, closeAndCancel }) => { +export const StandaloneConfirmCancelModal: FC = ({ + show, + title, + description, + confirmText, + cancelText, + confirm, + cancel, +}) => { return ( = ({ show, title, descripti - + ); From de00b072f1030adbf8642ded13533acd7aeb7fea Mon Sep 17 00:00:00 2001 From: afwilcox Date: Sat, 7 Dec 2024 10:47:23 -0800 Subject: [PATCH 3/5] feat: CE-1135-webeoc-polling (#798) --- .../src/v1/complaint/complaint.controller.ts | 8 +- charts/app/templates/secret.yaml | 4 +- .../webeoc/templates/deployment.yaml | 17 +- .../app/templates/webeoc/templates/pvc.yaml | 15 ++ charts/app/values.yaml | 2 +- docker-compose.yml | 3 + terraform/alerts_prod.tf | 16 ++ terraform/alerts_test.tf | 16 ++ terraform/channels.tf | 4 +- webeoc/.eslintrc.js | 22 -- webeoc/.gitignore | 6 +- webeoc/src/common/constants.ts | 7 + .../complaint-api.service.ts | 4 +- .../actions-taken-publisher.service.ts | 46 +++- .../complaints-publisher.service.ts | 10 +- .../staging-complaints-api-service.service.ts | 12 +- .../actions-taken-subscriber.service.ts | 113 ++++----- .../complaints-subscriber.service.ts | 26 +- .../webeoc-scheduler.service.ts | 231 ++++++++++++------ 19 files changed, 352 insertions(+), 210 deletions(-) create mode 100644 charts/app/templates/webeoc/templates/pvc.yaml delete mode 100644 webeoc/.eslintrc.js diff --git a/backend/src/v1/complaint/complaint.controller.ts b/backend/src/v1/complaint/complaint.controller.ts index dab2b327d..d96196c13 100644 --- a/backend/src/v1/complaint/complaint.controller.ts +++ b/backend/src/v1/complaint/complaint.controller.ts @@ -141,15 +141,15 @@ export class ComplaintController { @Public() @Post("/staging/action-taken") @UseGuards(ApiKeyGuard) - stageActionTaken(@Body() action: ActionTaken) { - this.stagingService.stageObject("ACTION-TAKEN", action); + async stageActionTaken(@Body() action: ActionTaken) { + return await this.stagingService.stageObject("ACTION-TAKEN", action); } @Public() @Post("/staging/action-taken-update") @UseGuards(ApiKeyGuard) - stageActionTakenUpdate(@Body() action: ActionTaken) { - this.stagingService.stageObject("ACTION-TAKEN-UPDATE", action); + async stageActionTakenUpdate(@Body() action: ActionTaken) { + return await this.stagingService.stageObject("ACTION-TAKEN-UPDATE", action); } @Public() diff --git a/charts/app/templates/secret.yaml b/charts/app/templates/secret.yaml index 262f6762a..65825fcc1 100644 --- a/charts/app/templates/secret.yaml +++ b/charts/app/templates/secret.yaml @@ -31,8 +31,8 @@ {{- $webeocPosition := (get $secretData "webeocPosition" | b64dec | default "") }} {{- $webeocIncident := (get $secretData "webeocIncident" | b64dec | default "") }} {{- $webeocUrl := (get $secretData "webeocUrl" | b64dec | default "") }} -{{- $webeocComplaintHistorySeconds := (get $secretData "webeocComplaintHistorySeconds" | b64dec | default "") }} {{- $webeocCronExpression := (get $secretData "webeocCronExpression" | b64dec | default "") }} +{{- $webeocLogPath := (get $secretData "webeocLogPath" | b64dec | default "") }} {{- $backupDir := (get $secretData "backupDir" | b64dec | default "") }} {{- $backupStrategy := (get $secretData "backupStrategy" | b64dec | default "") }} {{- $numBackups := (get $secretData "numBackups" | b64dec | default "") }} @@ -120,8 +120,8 @@ data: WEBEOC_POSITION: {{ $webeocPosition | b64enc | quote }} WEBEOC_INCIDENT: {{ $webeocIncident | b64enc | quote }} WEBEOC_URL: {{ $webeocUrl | b64enc | quote }} - WEBEOC_COMPLAINT_HISTORY_SECONDS: {{ $webeocComplaintHistorySeconds | b64enc | quote }} WEBEOC_CRON_EXPRESSION: {{ $webeocCronExpression | b64enc | quote }} + WEBEOC_LOG_PATH: {{ $webeocLogPath | b64enc | quote }} COMPLAINTS_API_KEY: {{ $caseManagementApiKey | b64enc | quote }} {{- end }} {{- if not (lookup "v1" "Secret" .Release.Namespace (printf "%s-flyway" .Release.Name)) }} diff --git a/charts/app/templates/webeoc/templates/deployment.yaml b/charts/app/templates/webeoc/templates/deployment.yaml index b46a97e63..309d07704 100644 --- a/charts/app/templates/webeoc/templates/deployment.yaml +++ b/charts/app/templates/webeoc/templates/deployment.yaml @@ -22,6 +22,10 @@ spec: labels: {{- include "webeoc.labels" . | nindent 8 }} spec: + volumes: + - name: {{ include "webeoc.fullname" . }} + persistentVolumeClaim: + claimName: {{ include "webeoc.fullname" . }} automountServiceAccountToken: false {{- if .Values.webeoc.podSecurityContext }} securityContext: @@ -33,20 +37,23 @@ spec: securityContext: {{- toYaml .Values.webeoc.securityContext | nindent 12 }} {{- end }} - image: "{{.Values.global.registry}}/{{.Values.global.repository}}/webeoc:{{ .Values.global.tag | default .Chart.AppVersion }}" + image: "{{ .Values.global.registry }}/{{ .Values.global.repository }}/webeoc:{{ .Values.global.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ default "Always" .Values.webeoc.imagePullPolicy }} envFrom: - secretRef: - name: {{.Release.Name}}-webeoc + name: {{ .Release.Name }}-webeoc env: - name: LOG_LEVEL value: info - name: NODE_TLS_REJECT_UNAUTHORIZED value: "0" - name: NATS_HOST - value: nats://{{.Release.Name}}-nats:4222 + value: nats://{{ .Release.Name }}-nats:4222 - name: COMPLAINTS_MANAGEMENT_API_URL - value: https://{{.Release.Name}}-frontend.apps.silver.devops.gov.bc.ca/api/v1 + value: https://{{ .Release.Name }}-frontend.apps.silver.devops.gov.bc.ca/api/v1 + volumeMounts: + - name: {{ include "webeoc.fullname" . }} + mountPath: /mnt/data ports: - name: http containerPort: {{ .Values.webeoc.service.targetPort }} @@ -69,9 +76,11 @@ spec: timeoutSeconds: 5 resources: # this is optional limits: + ephemeral-storage: "25Mi" cpu: 80m memory: 150Mi requests: + ephemeral-storage: "15Mi" cpu: 40m memory: 75Mi {{- with .Values.webeoc.nodeSelector }} diff --git a/charts/app/templates/webeoc/templates/pvc.yaml b/charts/app/templates/webeoc/templates/pvc.yaml new file mode 100644 index 000000000..15a885186 --- /dev/null +++ b/charts/app/templates/webeoc/templates/pvc.yaml @@ -0,0 +1,15 @@ +{{- if .Values.webeoc.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "webeoc.fullname" . }} + labels: + {{- include "webeoc.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: "25Mi" + storageClassName: netapp-file-standard +{{- end }} diff --git a/charts/app/values.yaml b/charts/app/values.yaml index 373f35ced..061c0bf8b 100644 --- a/charts/app/values.yaml +++ b/charts/app/values.yaml @@ -34,8 +34,8 @@ global: webeocPosition: ~ webeocIncident: ~ webeocUrl: ~ - webeocComplaintHistorySeconds: ~ webeocCronExpression: ~ + webeocLogPath: ~ backupDir: ~ backupStrategy: ~ numBackups: ~ diff --git a/docker-compose.yml b/docker-compose.yml index ebbe95737..0936e567c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,5 +117,8 @@ services: volumes: - ./webeoc:/app:z - /app/node_modules + - ./webeoc/logs:/mnt/data # this is just for the developer webeoc logging user: root working_dir: "/app" + + diff --git a/terraform/alerts_prod.tf b/terraform/alerts_prod.tf index 077bb8f06..841c72646 100644 --- a/terraform/alerts_prod.tf +++ b/terraform/alerts_prod.tf @@ -256,4 +256,20 @@ resource "sysdig_monitor_alert_v2_prometheus" "nr_database_prod_storage_usage" { service = "NatCom Database" app = "NatCom" } +} +resource "sysdig_monitor_alert_v2_prometheus" "nr_webeoc_prod_storage_usage" { + name = "Prod Webeoc Custom Log Storage Alert" + description = "Alert when the PVC storage usage is too high" + severity = "high" + query = "sysdig_fs_used_percent{kube_cluster_name=\"silver\",kube_namespace_name=\"c1c7ed-prod\",kube_deployment_name=\"nr-compliance-enforcement-prod-webeoc\"} > 70" + enabled = true + duration_seconds = 600 + notification_channels { + id = sysdig_monitor_notification_channel_email.prod_environment_alerts.id + renotify_every_minutes = 120 + } + labels = { + service = "NatCom Webeoc" + app = "NatCom" + } } \ No newline at end of file diff --git a/terraform/alerts_test.tf b/terraform/alerts_test.tf index 6d6cee20e..c2b2d5f3d 100644 --- a/terraform/alerts_test.tf +++ b/terraform/alerts_test.tf @@ -256,4 +256,20 @@ resource "sysdig_monitor_alert_v2_prometheus" "nr_database_test_storage_usage" { service = "NatCom Database" app = "NatCom" } +} +resource "sysdig_monitor_alert_v2_prometheus" "nr_webeoc_test_storage_usage" { + name = "Test Webeoc Custom Log Storage Alert" + description = "Alert when the PVC storage usage is too high" + severity = "high" + query = "sysdig_fs_used_percent{kube_cluster_name=\"silver\",kube_namespace_name=\"c1c7ed-test\",kube_deployment_name=\"nr-compliance-enforcement-test-webeoc\"} > 70" + enabled = true + duration_seconds = 600 + notification_channels { + id = sysdig_monitor_notification_channel_email.test_environment_alerts.id + renotify_every_minutes = 120 + } + labels = { + service = "NatCom Webeoc" + app = "NatCom" + } } \ No newline at end of file diff --git a/terraform/channels.tf b/terraform/channels.tf index 28799ca31..6082ee2cb 100644 --- a/terraform/channels.tf +++ b/terraform/channels.tf @@ -1,6 +1,6 @@ resource "sysdig_monitor_notification_channel_email" "test_environment_alerts" { name = "Team Wolverine - Test Environment Alerts" - recipients = ["jonathan.funk@gov.bc.ca"] + recipients = ["jonathan.funk@gov.bc.ca", "alec.2.wilcox@gov.bc.ca", "ryan.rondeau@gov.bc.ca", "mike.vesprini@gov.bc.ca", "scarlett.truong@gov.bc.ca", "dmitri.korin@gov.bc.ca"] enabled = true notify_when_ok = true notify_when_resolved = true @@ -9,7 +9,7 @@ resource "sysdig_monitor_notification_channel_email" "test_environment_alerts" { } resource "sysdig_monitor_notification_channel_email" "prod_environment_alerts" { name = "Team Wolverine - Prod Environment Alerts" - recipients = ["jonathan.funk@gov.bc.ca"] + recipients = ["jonathan.funk@gov.bc.ca", "alec.2.wilcox@gov.bc.ca", "ryan.rondeau@gov.bc.ca", "mike.vesprini@gov.bc.ca", "scarlett.truong@gov.bc.ca", "dmitri.korin@gov.bc.ca"] enabled = true notify_when_ok = true notify_when_resolved = true diff --git a/webeoc/.eslintrc.js b/webeoc/.eslintrc.js deleted file mode 100644 index 10e81aa6a..000000000 --- a/webeoc/.eslintrc.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - parser: "@typescript-eslint/parser", - parserOptions: { - project: "tsconfig.json", - tsconfigRootDir: __dirname, - sourceType: "module", - }, - plugins: ["@typescript-eslint/eslint-plugin"], - extends: ["plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], - root: true, - env: { - node: true, - jest: true, - }, - ignorePatterns: [".eslintrc.js"], - rules: { - "@typescript-eslint/interface-name-prefix": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-explicit-any": "off", - }, -}; diff --git a/webeoc/.gitignore b/webeoc/.gitignore index 22f55adc5..69b5f0175 100644 --- a/webeoc/.gitignore +++ b/webeoc/.gitignore @@ -3,14 +3,16 @@ /node_modules # Logs -logs -*.log npm-debug.log* pnpm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +# Include the log directory for local development but ignore all the logs inside of it +!/logs/ +logs/*.log + # OS .DS_Store diff --git a/webeoc/src/common/constants.ts b/webeoc/src/common/constants.ts index e2e250c8b..93445beeb 100644 --- a/webeoc/src/common/constants.ts +++ b/webeoc/src/common/constants.ts @@ -7,6 +7,13 @@ export const STREAMS = { ACTIONS_TAKEN: "actions", }; +export const OPERATIONS = { + COMPLAINT: "Complaint(s)", + COMPLAINT_UPDATE: "Complaint Update(s)", + ACTION_TAKEN: "Action(s) Taken", + ACTION_TAKEN_UPDATE: "Action Taken Update(s)", +}; + export const STREAM_TOPICS = { COMPLAINTS: "new_complaints", STAGING_COMPLAINTS: "new_staging_complaints", diff --git a/webeoc/src/complaint-api-service/complaint-api.service.ts b/webeoc/src/complaint-api-service/complaint-api.service.ts index d9f36abd3..8400e664e 100644 --- a/webeoc/src/complaint-api-service/complaint-api.service.ts +++ b/webeoc/src/complaint-api-service/complaint-api.service.ts @@ -19,7 +19,7 @@ export class ComplaintApiService { stageActionTaken = async (record: ActionTakenDto) => { try { const url = `${process.env.COMPLAINTS_MANAGEMENT_API_URL}/${STAGING_APIS.ACTION_TAKEN}`; - this.logger.debug(`Posting action-taken to staging. API URL: ${url}`); + this.logger.debug(`Posting action-taken for ${record.actionTakenId} to staging. API URL: ${url}`); await axios.post(url, record, this._apiConfig); } catch (error) { @@ -33,7 +33,7 @@ export class ComplaintApiService { stageActionTakenUpdate = async (record: ActionTakenDto) => { try { const url = `${process.env.COMPLAINTS_MANAGEMENT_API_URL}/${STAGING_APIS.UPDATE_ACTION_TAKEN}`; - this.logger.debug(`Posting action-taken-update to staging. API URL: ${url}`); + this.logger.debug(`Posting action-taken-update ${record.actionTakenId} to staging. API URL: ${url}`); await axios.post(url, record, this._apiConfig); } catch (error) { diff --git a/webeoc/src/publishers/actions-taken-publisher.service.ts b/webeoc/src/publishers/actions-taken-publisher.service.ts index 8b9e656ec..863bf9b82 100644 --- a/webeoc/src/publishers/actions-taken-publisher.service.ts +++ b/webeoc/src/publishers/actions-taken-publisher.service.ts @@ -45,11 +45,15 @@ export class ActionsTakenPublisherService { const ack = await this.jsClient.publish(STREAM_TOPICS.STAGE_ACTION_TAKEN, msg, { headers: natsHeaders }); if (!ack.duplicate) { this.logger.debug( - `Processing message: ${this._generateHeader("stage-new-action-taken", action, "ACTION-TAKEN")}`, + `Publishing new action taken for staging: ${this._generateHeader( + "stage-new-action-taken", + action, + "ACTION-TAKEN", + )}`, ); } else { this.logger.debug( - `Message processed: ${this._generateHeader("stage-new-action-taken", action, "ACTION-TAKEN")}`, + `Action taken already published: ${this._generateHeader("stage-new-action-taken", action, "ACTION-TAKEN")}`, ); } } catch (error) { @@ -72,11 +76,19 @@ export class ActionsTakenPublisherService { if (!ack.duplicate) { this.logger.debug( - `Processing message: ${this._generateHeader("stage-new-action-taken-update", action, "ACTION-TAKEN-UPDATE")}`, + `Publishing new action taken update for staging: ${this._generateHeader( + "stage-new-action-taken-update", + action, + "ACTION-TAKEN-UPDATE", + )}`, ); } else { this.logger.debug( - `Message processed: ${this._generateHeader("stage-new-action-taken-update", action, "ACTION-TAKEN-UPDATE")}`, + `Action taken already published: ${this._generateHeader( + "stage-new-action-taken-update", + action, + "ACTION-TAKEN-UPDATE", + )}`, ); } } catch (error) { @@ -109,10 +121,20 @@ export class ActionsTakenPublisherService { if (!ack.duplicate) { this.logger.debug( - `Processing message: ${this._generateHeader("promote-action-taken", action, "ACTION-TAKEN")}`, + `Action taken ready to be moved to operational tables: ${this._generateHeader( + "promote-action-taken", + action, + "ACTION-TAKEN", + )}`, ); } else { - this.logger.debug(`Message processed: ${this._generateHeader("promote-action-taken", action, "ACTION-TAKEN")}`); + this.logger.debug( + `Action taken already moved to operational tables: ${this._generateHeader( + "promote-action-taken", + action, + "ACTION-TAKEN", + )}`, + ); } } catch (error) { this.logger.error(`Unable to process request: ${error.message}`, error.stack); @@ -140,11 +162,19 @@ export class ActionsTakenPublisherService { if (!ack.duplicate) { this.logger.debug( - `Processing message: ${this._generateHeader("promote-action-taken-update", action, "ACTION-TAKEN-UPDATE")}`, + `Action taken update ready to be moved to operational tables: ${this._generateHeader( + "promote-action-taken-update", + action, + "ACTION-TAKEN-UPDATE", + )}`, ); } else { this.logger.debug( - `Message processed: ${this._generateHeader("promote-action-taken-update", action, "ACTION-TAKEN-UPDATE")}`, + `Action taken already moved to operational tables: ${this._generateHeader( + "promote-action-taken-update", + action, + "ACTION-TAKEN-UPDATE", + )}`, ); } } catch (error) { diff --git a/webeoc/src/publishers/complaints-publisher.service.ts b/webeoc/src/publishers/complaints-publisher.service.ts index 361d2aef6..028c103bb 100644 --- a/webeoc/src/publishers/complaints-publisher.service.ts +++ b/webeoc/src/publishers/complaints-publisher.service.ts @@ -33,7 +33,7 @@ export class ComplaintsPublisherService { natsHeaders.set("Nats-Msg-Id", `staged-${complaint.incident_number}-${complaint.created_by_datetime}`); const ack = await this.jsClient.publish(STREAM_TOPICS.COMPLAINTS, msg, { headers: natsHeaders }); if (!ack.duplicate) { - this.logger.debug(`New complaint: ${complaint.incident_number}`); + this.logger.debug(`Publishing new complaint for staging: ${complaint.incident_number}`); } else { this.logger.debug(`Complaint already published: ${complaint.incident_number}`); } @@ -59,12 +59,12 @@ export class ComplaintsPublisherService { ); const ack = await this.jsClient.publish(STREAM_TOPICS.COMPLAINT_UPDATE, jsonData, { headers: natsHeaders }); if (!ack.duplicate) { - this.logger.debug(`Complaint update: ${incidentNumber} ${updateNumber}`); + this.logger.debug(`Publishing new complaint update for staging: ${incidentNumber} ${updateNumber}`); } else { this.logger.debug(`Complaint update already published: ${incidentNumber}`); } } catch (error) { - this.logger.error(`Error publishing complaint: ${error.message}`, error.stack); + this.logger.error(`Error publishing complaint update: ${error.message}`, error.stack); throw error; } } @@ -84,7 +84,7 @@ export class ComplaintsPublisherService { if (!ack?.duplicate) { this.logger.debug(`Complaint ready to be moved to operational tables: ${incident_number}`); } else { - this.logger.debug(`Complaint already moved to operational: ${incident_number}`); + this.logger.debug(`Complaint already moved to operational tables: ${incident_number}`); } } catch (error) { this.logger.error(`Error saving complaint to staging: ${error.message}`, error.stack); @@ -119,7 +119,7 @@ export class ComplaintsPublisherService { `Complaint update ready to be moved to operational tables: ${incidentNumber} ${updateNumber}`, ); } else { - this.logger.debug(`Complaint update already moved to operational: ${incidentNumber} ${updateNumber}`); + this.logger.debug(`Complaint update already moved to operational tables: ${incidentNumber} ${updateNumber}`); } } catch (error) { this.logger.error(`Error saving complaint update to staging: ${error.message}`, error.stack); diff --git a/webeoc/src/staging-complaints-api-service/staging-complaints-api-service.service.ts b/webeoc/src/staging-complaints-api-service/staging-complaints-api-service.service.ts index dac85d395..36e684ff7 100644 --- a/webeoc/src/staging-complaints-api-service/staging-complaints-api-service.service.ts +++ b/webeoc/src/staging-complaints-api-service/staging-complaints-api-service.service.ts @@ -17,7 +17,7 @@ export class StagingComplaintsApiService { async createNewComplaintInStaging(complaintData: Complaint): Promise { try { const apiUrl = `${process.env.COMPLAINTS_MANAGEMENT_API_URL}/${STAGING_API_ENDPOINT_CREATES}`; - this.logger.debug(`createNewComplaintInStaging Posting new complaint to staging. API URL: ${apiUrl}`); + this.logger.debug(`Posting new complaint ${complaintData.incident_number} to staging. API URL: ${apiUrl}`); await axios.post(apiUrl, complaintData, this._apiConfig); } catch (error) { @@ -30,7 +30,9 @@ export class StagingComplaintsApiService { async createUpdateComplaintInStaging(complaintData: ComplaintUpdate): Promise { try { const apiUrl = `${process.env.COMPLAINTS_MANAGEMENT_API_URL}/${STAGING_API_ENDPOINT_UPDATES}`; - this.logger.debug(`createUpdateComplaintInStaging Posting new complaint to staging. API URL: ${apiUrl}`); + this.logger.debug( + `Posting new complaint update ${complaintData.parent_incident_number} to staging. API URL: ${apiUrl}`, + ); await axios.post(apiUrl, complaintData, this._apiConfig); } catch (error) { @@ -42,9 +44,8 @@ export class StagingComplaintsApiService { // create complaint based on complaint data in the staging table async createComplaintFromStaging(complaint_identifier: string): Promise { try { - this.logger.debug("createComplaintFromStaging Creating new complaint based on new complaint from webeoc."); const apiUrl = `${process.env.COMPLAINTS_MANAGEMENT_API_URL}/staging-complaint/process/${complaint_identifier}`; - this.logger.debug(`Posting new complaint. API URL: ${apiUrl}`); + this.logger.debug(`Moving complaint ${complaint_identifier} from staging to live tables. API URL: ${apiUrl}`); await axios.post(apiUrl, {}, this._apiConfig); } catch (error) { @@ -56,9 +57,8 @@ export class StagingComplaintsApiService { // create complaint update based on complaint data in the staging table async createComplaintUpdateFromStaging(complaint_identifier: string, update_number: string): Promise { try { - this.logger.debug("createComplaintUpdateFromStaging Creating new complaint based on new complaint from webeoc."); const apiUrl = `${process.env.COMPLAINTS_MANAGEMENT_API_URL}/staging-complaint/process/${complaint_identifier}/${update_number}`; - this.logger.debug(`Posting new complaint. API URL: ${apiUrl}`); + this.logger.debug(`Moving complaint ${complaint_identifier} from staging to live tables. API URL: ${apiUrl}`); await axios.post(apiUrl, {}, this._apiConfig); } catch (error) { diff --git a/webeoc/src/subscribers/actions-taken-subscriber.service.ts b/webeoc/src/subscribers/actions-taken-subscriber.service.ts index cd9602be5..6d990b872 100644 --- a/webeoc/src/subscribers/actions-taken-subscriber.service.ts +++ b/webeoc/src/subscribers/actions-taken-subscriber.service.ts @@ -91,66 +91,60 @@ export class ActionsTakenSubscriberService implements OnModuleInit { //-- added to the staging table //-- private stageActionTaken = async (message, action: ActionTaken) => { - this.logger.debug("Received action-taken:", action?.action_taken_guid); - const success = await message.ackAck(); - if (success) { - //-- reshape the action taken, only send the required data - const { - action_taken_guid: actionTakenId, - action_logged_by: loggedBy, - action_datetime: actionTimestamp, - action_details: details, - fk_table_345: webeocId, - dataid, - } = action; - - const record: ActionTakenDto = { - actionTakenId, - webeocId, - loggedBy, - actionTimestamp, - details, - isUpdate: false, - dataid, - }; - - this.logger.debug("post message to complaint api for staging"); - await this.service.stageActionTaken(record); - //-- this shouldn't happen here, it should be happening in the backend - await this.publisher.publishActionTaken(actionTakenId, webeocId, action); - } + this.logger.debug(`Received action-taken: ${action?.action_taken_guid}`); + //-- reshape the action taken, only send the required data + const { + action_taken_guid: actionTakenId, + action_logged_by: loggedBy, + action_datetime: actionTimestamp, + action_details: details, + fk_table_345: webeocId, + dataid, + } = action; + + const record: ActionTakenDto = { + actionTakenId, + webeocId, + loggedBy, + actionTimestamp, + details, + isUpdate: false, + dataid, + }; + + await this.service.stageActionTaken(record); + await message.ackAck(); //Message has been loaded into NatCom no need to retry. If NATS is unavailable there will be 'PENDING' row to process manually. + //-- this shouldn't happen here, it should be happening in the backend + await this.publisher.publishActionTaken(actionTakenId, webeocId, action); }; private stageActionTakenUpdate = async (message, action: ActionTaken) => { - this.logger.debug("Received action-taken-update:", action?.action_taken_guid); - const success = await message.ackAck(); - - if (success) { - //-- reshape the action taken, only send the required data - const { - action_taken_guid: actionTakenId, - action_logged_by: loggedBy, - action_datetime: actionTimestamp, - action_details: details, - fk_table_346: webeocId, - dataid, - } = action; - - const record: ActionTakenDto = { - actionTakenId, - webeocId, - loggedBy, - actionTimestamp, - details, - isUpdate: true, - dataid, - }; - - this.logger.debug("post message to complaint api for staging"); - await this.service.stageActionTakenUpdate(record); - //-- this shouldn't happen here, it should be happening in the backend - await this.publisher.publishActionTakenUpdate(actionTakenId, webeocId, action); - } + this.logger.debug(`Received action-taken-update: ${action?.action_taken_guid}`); + + //-- reshape the action taken, only send the required data + const { + action_taken_guid: actionTakenId, + action_logged_by: loggedBy, + action_datetime: actionTimestamp, + action_details: details, + fk_table_346: webeocId, + dataid, + } = action; + + const record: ActionTakenDto = { + actionTakenId, + webeocId, + loggedBy, + actionTimestamp, + details, + isUpdate: true, + dataid, + }; + + await this.service.stageActionTakenUpdate(record); + await message.ackAck(); //Message has been loaded into NatCom no need to retry. If NATS is unavailable there will be 'PENDING' row to process manually. + //-- this shouldn't happen here, it should be happening in the backend + await this.publisher.publishActionTakenUpdate(actionTakenId, webeocId, action); }; //-- @@ -158,7 +152,7 @@ export class ActionsTakenSubscriberService implements OnModuleInit { //-- published to the action-taken table //-- private publishActionTaken = async (message: JsMsg, payload: string) => { - this.logger.log("Process Staged action-taken:", payload); + this.logger.debug(`Process Staged action-taken: ${payload}`); this.service.publishActionTaken(JSON.parse(payload)); message.ackAck(); }; @@ -168,8 +162,7 @@ export class ActionsTakenSubscriberService implements OnModuleInit { //-- published to the action-taken table //-- private publishActionTakenUpdate = async (message: JsMsg, payload: string) => { - this.logger.log("Process Staged action-taken-update:", payload); - this.logger.warn(JSON.stringify(message.subject)); + this.logger.debug(`Process Staged action-taken-update: ${payload}`); this.service.publishActionTakenUpdate(JSON.parse(payload)); message.ackAck(); }; diff --git a/webeoc/src/subscribers/complaints-subscriber.service.ts b/webeoc/src/subscribers/complaints-subscriber.service.ts index 660ae6b7a..c5ddf38de 100644 --- a/webeoc/src/subscribers/complaints-subscriber.service.ts +++ b/webeoc/src/subscribers/complaints-subscriber.service.ts @@ -76,38 +76,34 @@ export class ComplaintsSubscriberService implements OnModuleInit { } } catch (error) { this.logger.error(`Error processing message from ${message.subject}`, error); - message.nak(10_000); // retry in 10 seconds + message.nak(60_000); // retry in 60 seconds } } } private async handleNewComplaint(message, complaintMessage: Complaint) { - this.logger.debug("Received complaint:", complaintMessage?.incident_number); - const success = await message.ackAck(); - if (success) { - await this.service.createNewComplaintInStaging(complaintMessage); - this.complaintsPublisherService.publishStagingComplaintInsertedMessage(complaintMessage.incident_number); - } + this.logger.debug(`Staging complaint: ${complaintMessage?.incident_number}`); + await this.service.createNewComplaintInStaging(complaintMessage); + await message.ackAck(); //Message has been loaded into NatCom no need to retry. If NATS is unavailable there will be 'PENDING' row to process manually. + this.complaintsPublisherService.publishStagingComplaintInsertedMessage(complaintMessage.incident_number); } private async handleUpdatedComplaint(message, complaintMessage: ComplaintUpdate) { - this.logger.debug("Received complaint update:", complaintMessage?.parent_incident_number); - const success = await message.ackAck(); - if (success) { - await this.service.createUpdateComplaintInStaging(complaintMessage); - this.complaintsPublisherService.publishStagingComplaintUpdateInsertedMessage(complaintMessage); - } + this.logger.debug(`Staging complaint update: ${complaintMessage?.parent_incident_number}`); + await this.service.createUpdateComplaintInStaging(complaintMessage); + await message.ackAck(); //Message has been loaded into NatCom no need to retry. If NATS is unavailable there will be 'PENDING' row to process manually. + this.complaintsPublisherService.publishStagingComplaintUpdateInsertedMessage(complaintMessage); } private async handleStagedComplaint(message, stagingData: string) { - this.logger.debug("Received staged complaint:", stagingData); + this.logger.debug(`Processing Staging complaint: ${stagingData}`); await this.service.createComplaintFromStaging(stagingData); message.ackAck(); } private async handleStagedComplaintUpdate(message, complaintUpdate: ComplaintUpdate) { const { parent_incident_number, update_number } = complaintUpdate; - this.logger.debug("Received staged complaint update:", parent_incident_number); + this.logger.debug(`Processing staged complaint update: ${parent_incident_number}`); await this.service.createComplaintUpdateFromStaging(parent_incident_number, update_number); message.ackAck(); } diff --git a/webeoc/src/webeoc-scheduler/webeoc-scheduler.service.ts b/webeoc/src/webeoc-scheduler/webeoc-scheduler.service.ts index f2ec5e7c0..4fa424ae9 100644 --- a/webeoc/src/webeoc-scheduler/webeoc-scheduler.service.ts +++ b/webeoc/src/webeoc-scheduler/webeoc-scheduler.service.ts @@ -7,10 +7,13 @@ import { CronJob } from "cron"; import { ComplaintUpdate } from "src/types/complaint-update-type"; import { toZonedTime, format } from "date-fns-tz"; import { WEBEOC_FLAGS } from "src/common/webeoc-flags"; +import { OPERATIONS } from "src/common/constants"; import { WEBEOC_API_PATHS } from "src/common/webeoc-api-paths"; import { ActionTaken } from "src/types/actions-taken/action-taken"; import { ActionsTakenPublisherService } from "src/publishers/actions-taken-publisher.service"; import { randomUUID } from "crypto"; +import * as path from "path"; +import * as fs from "fs"; @Injectable() export class WebEocScheduler { @@ -19,7 +22,7 @@ export class WebEocScheduler { private readonly logger = new Logger(WebEocScheduler.name); constructor( - private complaintsPublisherService: ComplaintsPublisherService, + private readonly complaintsPublisherService: ComplaintsPublisherService, private readonly _actionsTakenPublisherService: ActionsTakenPublisherService, ) {} @@ -27,26 +30,30 @@ export class WebEocScheduler { this.cronJob = new CronJob(this.getCronExpression(), async () => { //-- don't remove these items, these control complaints and complaint updates await this.fetchAndPublishComplaints( + OPERATIONS.COMPLAINT, WEBEOC_API_PATHS.COMPLAINTS, WEBEOC_FLAGS.COMPLAINTS, this.publishComplaint.bind(this), ); await this.fetchAndPublishComplaints( + OPERATIONS.COMPLAINT_UPDATE, WEBEOC_API_PATHS.COMPLAINT_UPDATES, WEBEOC_FLAGS.COMPLAINT_UPDATES, this.publishComplaintUpdate.bind(this), ); - // handle actions taken - await this._handleAction( - () => this._fetchDataFromWebEOC(WEBEOC_API_PATHS.ACTIONS_TAKEN), + await this.fetchAndPublishComplaints( + OPERATIONS.ACTION_TAKEN, + WEBEOC_API_PATHS.ACTIONS_TAKEN, + WEBEOC_FLAGS.ACTIONS_TAKEN, this._publishAction.bind(this), ); - // handle actions taken updates - await this._handleAction( - () => this._fetchDataFromWebEOC(WEBEOC_API_PATHS.ACTIONS_TAKEN_UPDATES), + await this.fetchAndPublishComplaints( + OPERATIONS.ACTION_TAKEN_UPDATE, + WEBEOC_API_PATHS.ACTIONS_TAKEN_UPDATES, + WEBEOC_FLAGS.ACTIONS_TAKEN_UPDATES, this._publishActionUpdate.bind(this), ); }); @@ -57,31 +64,153 @@ export class WebEocScheduler { private getCronExpression(): string { const defaultCron = CronExpression.EVERY_5_MINUTES; const envCronExpression = process.env.WEBEOC_CRON_EXPRESSION || defaultCron; - this.logger.debug(`Grabbing complaints from WebEOC as per cron schedule ${envCronExpression}`); + this.logger.debug(`Polling WebEOC as per cron schedule ${envCronExpression}`); return envCronExpression; } + // Method to write data to a file + private async logPollingActivity(operationType: string, timeStamp: string, counter: number): Promise { + // Set the fileName to be the current date (for easy pruning afterwards) + const fileName = `${operationType}_${timeStamp.substring(0, 10)}.log`; + + // Get the file path + const filePathEnv = process.env.WEBEOC_LOG_PATH || "/mnt/data"; // Default to '/mnt/data' if the env variable is not set + const filePath = path.join(filePathEnv, fileName); + + // Set the message + const message = `${timeStamp}: Logged ${counter} ${operationType}`; + + try { + await fs.promises.appendFile(filePath, message + "\n", "utf8"); + } catch (err) { + this.logger.error(`Error writing to file ${filePath}: ${err.message}`); + throw new Error("Failed to write data to file"); + } + } + + private getMostRecentFile(operationType: string, filePathEnv: string): string | null { + try { + const files = fs + .readdirSync(filePathEnv) + .filter((file) => file.startsWith(`${operationType}_`) && file.endsWith(".log")); + + if (files.length === 0) { + return null; + } + + const sortedFiles = files + .map((file) => { + // Extract the date from the file name + const datePart = file.substring(operationType.length + 1, file.length - 4); // Extract "YYYY-MM-DD" part + const fileDate = new Date(datePart); + return { file, fileDate }; + }) + .sort((a, b) => b.fileDate.getTime() - a.fileDate.getTime()); // Sort by date descending + + return sortedFiles[0].file; // Return the most recent file + } catch (err) { + this.logger.error(`Error reading files: ${err.message}`); + return null; + } + } + + private getLastTimestampFromFile(filePath: string, defaultDate: Date): Date { + try { + const data = fs.readFileSync(filePath, "utf8"); + const lines = data.trim().split("\n"); // Split by newlines and get the last line + + if (lines.length === 0) { + return defaultDate; // If the file is empty, return the default date + } + + const lastLine = lines[lines.length - 1]; // Get the last line + const timestampString = lastLine.substring(0, 19); // Extract the first 19 characters (ISO 8601 format) + + // Parse the timestamp string to a Date object + const timestamp = new Date(timestampString); + + if (isNaN(timestamp.getTime())) { + this.logger.error(`Invalid timestamp format: ${timestampString}`); + return defaultDate; // If the timestamp is invalid, return the default date + } + + return timestamp; // Return the Date object + } catch (err) { + this.logger.error(`Error reading file ${filePath}: ${err.message}`); + return defaultDate; // Return defaultDate if any error occurs + } + } + + private getLastPolledDate(operationType: string): Date { + const filePathEnv = process.env.WEBEOC_LOG_PATH || "/mnt/data"; // Default to '/mnt/data' + + // Failsafe in case anything goes wrong (5 minutes ago) + const defaultDate = new Date(); + defaultDate.setUTCMinutes(defaultDate.getUTCMinutes() - 5); + + try { + // Generate today's file name + const todayFileName = `${operationType}_${new Date().toISOString().substring(0, 10)}.log`; + const todayFilePath = path.join(filePathEnv, todayFileName); + + // Check if today's file exists, and if so, return the last polled date from it + if (fs.existsSync(todayFilePath)) { + return this.getLastTimestampFromFile(todayFilePath, defaultDate); + } + + // If today's file does not exist, look for the most recent file + const mostRecentFile = this.getMostRecentFile(operationType, filePathEnv); + + // If no files exist, return the default date + if (!mostRecentFile) { + return defaultDate; + } + + // Read the most recent file's content and return the last timestamp from it + const filePath = path.join(filePathEnv, mostRecentFile); + return this.getLastTimestampFromFile(filePath, defaultDate); + } catch (err) { + this.logger.error(`Error reading file: ${err.message}`); + return defaultDate; // Return defaultDate if any error occurs + } + } + private async fetchAndPublishComplaints( + operationType: string, urlPath: string, flagName: string, publishMethod: (data: any) => Promise, ) { try { + //This is the timestamp that will be written to the log. Going to start without any padding but we might need to subtract some time from this value if we find that we are losing complaints. + const timeStamp = this.formatDate(new Date()); + await this.authenticateWithWebEOC(); - const data = await this.fetchDataFromWebEOC(urlPath, flagName); - this.logger.debug(`Found ${data?.length} items from WebEOC`); + const data = await this.fetchDataFromWebEOC(urlPath, flagName, operationType); + + this.logger.debug(`Found ${data?.length} ${operationType} from WebEOC`); + let counter = 0; for (const item of data) { + counter++; await publishMethod(item); } + + this.logger.debug(`Published ${counter} ${operationType} from WebEOC`); + + if (data?.length != counter) { + this.logger.error("Error publishing some objects to NATS. Check logs for more detail."); + } + + await this.logPollingActivity(operationType, timeStamp, counter); } catch (error) { this.logger.error(`Unable to fetch data from WebEOC`, error); } } private async authenticateWithWebEOC(): Promise { - this.logger.debug(`Grabbing complaints from ${process.env.WEBEOC_URL}`); + this.logger.debug(`Authenticating with webEOC from ${process.env.WEBEOC_URL}`); const authUrl = `${process.env.WEBEOC_URL}/sessions`; const credentials = { @@ -121,12 +250,16 @@ export class WebEocScheduler { complaint.update_violation_type === "Waste" || complaint.update_violation_type === "Pesticide" ); + } else if (flagName === WEBEOC_FLAGS.ACTIONS_TAKEN) { + return complaint.flag_AT === "Yes"; + } else if (flagName === WEBEOC_FLAGS.ACTIONS_TAKEN_UPDATES) { + return complaint.flag_UAT === "Yes"; } }); } - private async fetchDataFromWebEOC(urlPath: string, flagName: string): Promise { - const dateFilter = this.getDateFilter(); + private async fetchDataFromWebEOC(urlPath: string, flagName: string, operationType: string): Promise { + const dateFilter = this.getDateFilter(operationType); const url = `${process.env.WEBEOC_URL}/${urlPath}`; const config: AxiosRequestConfig = { headers: { @@ -148,7 +281,6 @@ export class WebEocScheduler { try { const response = await axios.post(url, body, config); const complaints = response.data; - const filteredComplaints = this._filterComplaints(complaints, flagName); return filteredComplaints; @@ -158,22 +290,16 @@ export class WebEocScheduler { } } - private getDateFilter() { - const timeZone = "America/Los_Angeles"; // This timezone automatically handles PDT/PST - - // Get the current date in UTC - const currentUtcDate = new Date(); - // Convert the current date in UTC to the appropriate Pacific Time (PDT/PST) - const complaintsAsOfDate = toZonedTime(currentUtcDate, timeZone); - const complaintHistorySeconds = parseInt(process.env.WEBEOC_COMPLAINT_HISTORY_SECONDS || "600"); // default to 10 minutes (600 seconds) + private formatDate(date: Date): string { + return format(date, "yyyy-MM-dd HH:mm:ss"); + } - if (isNaN(complaintHistorySeconds)) { - throw new Error("WEBEOC_COMPLAINT_HISTORY_SECONDS is not a valid number"); - } - this.logger.debug(`Finding complaints less than ${complaintHistorySeconds} seconds old`); + private getDateFilter(operationType: string) { + const timeZone = "America/Los_Angeles"; // This timezone automatically handles PDT/PST + const lastPolledDate = this.getLastPolledDate(operationType); + const complaintsAsOfDate = toZonedTime(lastPolledDate, timeZone); - complaintsAsOfDate.setSeconds(complaintsAsOfDate.getSeconds() - complaintHistorySeconds); - this.logger.debug(`Finding complaints greater than ${complaintsAsOfDate.toISOString()}`); + this.logger.debug(`Finding ${operationType} greater than ${complaintsAsOfDate.toISOString()}`); const formattedDate = this.formatDate(complaintsAsOfDate); return { @@ -191,55 +317,6 @@ export class WebEocScheduler { await this.complaintsPublisherService.publishComplaintUpdatesFromWebEOC(complaintUpdate); } - private formatDate(date: Date): string { - return format(date, "yyyy-MM-dd HH:mm:ss"); - } - - private _fetchDataFromWebEOC = async (path: string): Promise => { - const dateFilter = this.getDateFilter(); - const url = `${process.env.WEBEOC_URL}/${path}`; - const config: AxiosRequestConfig = { - headers: { - Cookie: this.cookie, - }, - }; - - const body = { - customFilter: { - boolean: "and", - items: [dateFilter], - }, - }; - - try { - const response = await axios.post(url, body, config); - return response.data as Complaint[]; - } catch (error) { - this.logger.error(`Error fetching data from WebEOC at ${path}:`, error); - throw error; - } - }; - - private _handleAction = async ( - fetchMethod: () => Promise, - publishMethod: (item: ActionTaken) => Promise, - ) => { - try { - await this.authenticateWithWebEOC(); - const data = await fetchMethod(); - - if (data) { - this.logger.debug(`Found ${data.length} action taken from WebEOC`); - - for (const item of data) { - await publishMethod(item); - } - } - } catch (error) { - this.logger.error(`Unable to fetch data from WebEOC`, error); - } - }; - private _publishAction = async (action: ActionTaken) => { //-- apply an action_taken_guid action.action_taken_guid = randomUUID(); From 166e50ff5da9aa597e63a6b6868d769cef8e0ae8 Mon Sep 17 00:00:00 2001 From: Scarlett <35635257+Scarlett-Truong@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:05:38 -0800 Subject: [PATCH 4/5] feat: CE-1260 add Outcome Date filter (#794) Co-authored-by: afwilcox --- .../complaints/complaint-filter-parameters.ts | 2 + backend/src/v1/complaint/complaint.service.ts | 22 +++- .../src/app/components/common/filter-date.tsx | 97 +++++++++++++++ .../complaints/complaint-filter-bar.tsx | 23 +++- .../complaints/complaint-filter.tsx | 114 ++++++------------ .../containers/complaints/complaint-list.tsx | 4 + .../containers/complaints/complaint-map.tsx | 4 + .../src/app/constants/feature-flag-types.ts | 1 + .../providers/complaint-filter-provider.tsx | 17 +++ frontend/src/app/store/reducers/app.ts | 3 + frontend/src/app/store/reducers/complaints.ts | 8 ++ frontend/src/app/types/app/active-filters.ts | 1 + .../app/types/complaints/complaint-filters.ts | 2 + .../complaint-filters/complaint-filters.ts | 2 + frontend/src/assets/sass/complaint.scss | 7 ++ migrations/migrations/R__Create-Test-Data.sql | 44 +++++++ 16 files changed, 262 insertions(+), 89 deletions(-) create mode 100644 frontend/src/app/components/common/filter-date.tsx diff --git a/backend/src/types/models/complaints/complaint-filter-parameters.ts b/backend/src/types/models/complaints/complaint-filter-parameters.ts index e8cc04555..72c83b422 100644 --- a/backend/src/types/models/complaints/complaint-filter-parameters.ts +++ b/backend/src/types/models/complaints/complaint-filter-parameters.ts @@ -13,4 +13,6 @@ export interface ComplaintFilterParameters { complaintMethod?: string; actionTaken?: string; outcomeAnimal?: string; + outcomeAnimalStartDate?: Date; + outcomeAnimalEndDate?: Date; } diff --git a/backend/src/v1/complaint/complaint.service.ts b/backend/src/v1/complaint/complaint.service.ts index 133c2795f..114bf62fd 100644 --- a/backend/src/v1/complaint/complaint.service.ts +++ b/backend/src/v1/complaint/complaint.service.ts @@ -817,9 +817,11 @@ export class ComplaintService { private readonly _getComplaintsByOutcomeAnimal = async ( token: string, outcomeAnimalCode: string, + startDate: Date | undefined, + endDate: Date | undefined, ): Promise => { const { data, errors } = await get(token, { - query: `{getLeadsByOutcomeAnimal (outcomeAnimalCode: "${outcomeAnimalCode}")}`, + query: `{getLeadsByOutcomeAnimal (outcomeAnimalCode: "${outcomeAnimalCode}", startDate: "${startDate}" , endDate: "${endDate}")}`, }); if (errors) { this.logger.error("GraphQL errors:", errors); @@ -975,8 +977,13 @@ export class ComplaintService { } // -- filter by complaint identifiers returned by case management if outcome animal filter is present - if (agency === "COS" && filters.outcomeAnimal) { - const complaintIdentifiers = await this._getComplaintsByOutcomeAnimal(token, filters.outcomeAnimal); + if (agency === "COS" && (filters.outcomeAnimal || filters.outcomeAnimalStartDate)) { + const complaintIdentifiers = await this._getComplaintsByOutcomeAnimal( + token, + filters.outcomeAnimal, + filters.outcomeAnimalStartDate, + filters.outcomeAnimalEndDate, + ); builder.andWhere("complaint.complaint_identifier IN(:...complaint_identifiers)", { complaint_identifiers: complaintIdentifiers, @@ -1144,8 +1151,13 @@ export class ComplaintService { } // -- filter by complaint identifiers returned by case management if outcome animal filter is present - if (agency === "COS" && filters.outcomeAnimal) { - const complaintIdentifiers = await this._getComplaintsByOutcomeAnimal(token, filters.outcomeAnimal); + if (agency === "COS" && (filters.outcomeAnimal || filters.outcomeAnimalStartDate)) { + const complaintIdentifiers = await this._getComplaintsByOutcomeAnimal( + token, + filters.outcomeAnimal, + filters.outcomeAnimalStartDate, + filters.outcomeAnimalEndDate, + ); complaintBuilder.andWhere("complaint.complaint_identifier IN(:...complaint_identifiers)", { complaint_identifiers: complaintIdentifiers, }); diff --git a/frontend/src/app/components/common/filter-date.tsx b/frontend/src/app/components/common/filter-date.tsx new file mode 100644 index 000000000..2e547dda8 --- /dev/null +++ b/frontend/src/app/components/common/filter-date.tsx @@ -0,0 +1,97 @@ +import { FC } from "react"; +import DatePicker from "react-datepicker"; + +interface Props { + id: string; + label: string; + startDate: Date | undefined; + endDate: Date | undefined; + handleDateChange: (dates: [Date, Date]) => void; +} + +export const FilterDate: FC = ({ id, label, startDate, endDate, handleDateChange }) => { + // manual entry of date change listener. Looks for a date range format of {yyyy-mm-dd} - {yyyy-mm-dd} + const handleManualDateChange = (e: React.ChangeEvent) => { + if (e?.target?.value?.includes(" - ")) { + const [startDateStr, endDateStr] = e.target.value.split(" - "); + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + // Invalid date format + return [null, null]; + } else { + // add 1 to date because days start at 0 + startDate.setDate(startDate.getDate() + 1); + endDate.setDate(endDate.getDate() + 1); + + handleDateChange([startDate, endDate]); + } + } + return [null, null]; + }; + + return ( +
+ +
+ ( +
+ + + {monthDate.toLocaleString("en-US", { + month: "long", + year: "numeric", + })} + + +
+ )} + selected={startDate} + onChange={handleDateChange} + onChangeRaw={handleManualDateChange} + startDate={startDate} + endDate={endDate} + dateFormat="yyyy-MM-dd" + monthsShown={2} + selectsRange={true} + isClearable={true} + wrapperClassName="comp-filter-calendar-input" + showPreviousMonths + maxDate={new Date()} + /> +
+
+ ); +}; diff --git a/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx b/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx index 3f82e76c4..4fddf27b7 100644 --- a/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-filter-bar.tsx @@ -43,9 +43,11 @@ export const ComplaintFilterBar: FC = ({ complaintMethod, actionTaken, outcomeAnimal, + outcomeAnimalStartDate, + outcomeAnimalEndDate, } = state; - const dateRangeLabel = (): string | undefined => { + const dateRangeLabel = (startDate: Date | undefined | null, endDate: Date | undefined | null): string | undefined => { const currentDate = new Date().toLocaleDateString(); if (startDate !== null && endDate !== null) { return `${startDate?.toLocaleDateString()} - ${endDate?.toLocaleDateString()}`; @@ -58,7 +60,7 @@ export const ComplaintFilterBar: FC = ({ } }; - const hasDate = () => { + const hasDate = (startDate: Date | undefined | null, endDate: Date | undefined | null) => { if ((startDate === undefined || startDate === null) && (endDate === undefined || endDate === null)) { return false; } @@ -78,6 +80,10 @@ export const ComplaintFilterBar: FC = ({ dispatch(clearFilter("startDate")); dispatch(clearFilter("endDate")); break; + case "outcomeAnimalDateRange": + dispatch(clearFilter("outcomeAnimalStartDate")); + dispatch(clearFilter("outcomeAnimalEndDate")); + break; default: dispatch(clearFilter(name)); break; @@ -133,10 +139,10 @@ export const ComplaintFilterBar: FC = ({ /> )} - {hasDate() && ( + {hasDate(startDate, endDate) && ( @@ -240,6 +246,15 @@ export const ComplaintFilterBar: FC = ({ clear={removeFilter} /> )} + + {hasDate(outcomeAnimalStartDate, outcomeAnimalEndDate) && ( + + )} ); diff --git a/frontend/src/app/components/containers/complaints/complaint-filter.tsx b/frontend/src/app/components/containers/complaints/complaint-filter.tsx index 48f66d3fd..b4e01203f 100644 --- a/frontend/src/app/components/containers/complaints/complaint-filter.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-filter.tsx @@ -17,7 +17,6 @@ import { import { selectOfficersByAgencyDropdownUsingPersonGuid } from "@store/reducers/officer"; import { selectDecisionTypeDropdown } from "@store/reducers/code-table-selectors"; import COMPLAINT_TYPES from "@apptypes/app/complaint-types"; -import DatePicker from "react-datepicker"; import { CompSelect } from "@components/common/comp-select"; import { ComplaintFilterContext } from "@providers/complaint-filter-provider"; import { ComplaintFilterPayload, updateFilter } from "@store/reducers/complaint-filters"; @@ -25,6 +24,7 @@ import Option from "@apptypes/app/option"; import { listActiveFilters } from "@store/reducers/app"; import UserService from "@service/user-service"; import Roles from "@apptypes/app/roles"; +import { FilterDate } from "../../common/filter-date"; type Props = { type: string; @@ -47,6 +47,8 @@ export const ComplaintFilter: FC = ({ type }) => { complaintMethod, actionTaken, outcomeAnimal, + outcomeAnimalStartDate, + outcomeAnimalEndDate, }, dispatch, } = useContext(ComplaintFilterContext); @@ -96,24 +98,20 @@ export const ComplaintFilter: FC = ({ type }) => { setFilter("endDate", end); }; - // manual entry of date change listener. Looks for a date range format of {yyyy-mm-dd} - {yyyy-mm-dd} - const handleManualDateRangeChange = (e: React.ChangeEvent) => { - if (e?.target?.value?.includes(" - ")) { - const [startDateStr, endDateStr] = e.target.value.split(" - "); - const startDate = new Date(startDateStr); - const endDate = new Date(endDateStr); - - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - // Invalid date format - return [null, null]; - } else { - // add 1 to date because days start at 0 - startDate.setDate(startDate.getDate() + 1); - endDate.setDate(endDate.getDate() + 1); - handleDateRangeChange([startDate, endDate]); - } + const handleOutcomeDateRangeChange = (dates: [Date, Date]) => { + const [start, end] = dates; + //set the time to be end of day to ensure that we also search for records after the beginning of the selected day. + if (start) { + start.setHours(0, 0, 0); + start.setMilliseconds(0); + } + if (end) { + end.setHours(23, 59, 59); + end.setMilliseconds(999); } - return [null, null]; + + setFilter("outcomeAnimalStartDate", start); + setFilter("outcomeAnimalEndDate", end); }; ///-- @@ -221,67 +219,13 @@ export const ComplaintFilter: FC = ({ type }) => { )} {activeFilters.showDateFilter && ( -
- -
- ( -
- - - {monthDate.toLocaleString("en-US", { - month: "long", - year: "numeric", - })} - - -
- )} - selected={startDate} - onChange={handleDateRangeChange} - onChangeRaw={handleManualDateRangeChange} - startDate={startDate} - endDate={endDate} - dateFormat="yyyy-MM-dd" - monthsShown={2} - selectsRange={true} - isClearable={true} - wrapperClassName="comp-filter-calendar-input" - showPreviousMonths - maxDate={new Date()} - /> -
-
+ )} {activeFilters.showStatusFilter && ( @@ -364,7 +308,7 @@ export const ComplaintFilter: FC = ({ type }) => { setFilter("outcomeAnimal", option); }} classNames={{ - menu: () => "top-layer-select", + menu: () => "top-layer-select outcome-animal-select", }} options={outcomeAnimalTypes} placeholder="Select" @@ -375,6 +319,16 @@ export const ComplaintFilter: FC = ({ type }) => { )} + + {COMPLAINT_TYPES.HWCR === type && activeFilters.showOutcomeAnimalDateFilter && ( + + )} ); }; diff --git a/frontend/src/app/components/containers/complaints/complaint-list.tsx b/frontend/src/app/components/containers/complaints/complaint-list.tsx index 9b1e78170..e7558252f 100644 --- a/frontend/src/app/components/containers/complaints/complaint-list.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-list.tsx @@ -55,6 +55,8 @@ export const generateComplaintRequestPayload = ( complaintMethod, actionTaken, outcomeAnimal, + outcomeAnimalStartDate, + outcomeAnimalEndDate, } = filters; const common = { @@ -92,6 +94,8 @@ export const generateComplaintRequestPayload = ( speciesCodeFilter: species, natureOfComplaintFilter: natureOfComplaint, outcomeAnimalFilter: outcomeAnimal, + outcomeAnimalStartDateFilter: outcomeAnimalStartDate, + outcomeAnimalEndDateFilter: outcomeAnimalEndDate, } as ComplaintRequestPayload; } }; diff --git a/frontend/src/app/components/containers/complaints/complaint-map.tsx b/frontend/src/app/components/containers/complaints/complaint-map.tsx index e0f3b5aba..63c872f42 100644 --- a/frontend/src/app/components/containers/complaints/complaint-map.tsx +++ b/frontend/src/app/components/containers/complaints/complaint-map.tsx @@ -38,6 +38,8 @@ export const generateMapComplaintRequestPayload = ( complaintMethod, actionTaken, outcomeAnimal, + outcomeAnimalStartDate, + outcomeAnimalEndDate, } = filters; const common = { @@ -67,6 +69,8 @@ export const generateMapComplaintRequestPayload = ( speciesCodeFilter: species, natureOfComplaintFilter: natureOfComplaint, outcomeAnimalFilter: outcomeAnimal, + outcomeAnimalStartDateFilter: outcomeAnimalStartDate, + outcomeAnimalEndDateFilter: outcomeAnimalEndDate, } as ComplaintRequestPayload; } }; diff --git a/frontend/src/app/constants/feature-flag-types.ts b/frontend/src/app/constants/feature-flag-types.ts index 309fdfb78..1a93cfa6a 100644 --- a/frontend/src/app/constants/feature-flag-types.ts +++ b/frontend/src/app/constants/feature-flag-types.ts @@ -18,4 +18,5 @@ export const FEATURE_TYPES = { ENABLE_OFFICE: "ENBL_OFF", EXTERNAL_FILE_REFERENCE: "EXTRNALREF", OUTCOME_ANIMAL_FILTER: "OUT_A_FLTR", + OUTCOME_ANIMAL_DATE_FILTER: "OUT_D_FLTR", }; diff --git a/frontend/src/app/providers/complaint-filter-provider.tsx b/frontend/src/app/providers/complaint-filter-provider.tsx index 9f4678565..ab1229205 100644 --- a/frontend/src/app/providers/complaint-filter-provider.tsx +++ b/frontend/src/app/providers/complaint-filter-provider.tsx @@ -28,6 +28,8 @@ let initialState: ComplaintFilters = { complaintMethod: null, actionTaken: null, outcomeAnimal: null, + outcomeAnimalStartDate: undefined, + outcomeAnimalEndDate: undefined, }; const mapFilters = (complaintFilters: Partial) => { @@ -51,6 +53,8 @@ const mapFilters = (complaintFilters: Partial) => { speciesCodeFilter, natureOfComplaintFilter, outcomeAnimalFilter, + outcomeAnimalStartDateFilter, + outcomeAnimalEndDateFilter, } = complaintFilters; // Parse the start and end date filters into Date objects if they exist. @@ -63,6 +67,17 @@ const mapFilters = (complaintFilters: Partial) => { parsedEndDate = new Date(); } + //Parse outcomeAnimalDates + const parsedOutcomeAnimalStartDate = outcomeAnimalStartDateFilter + ? new Date(outcomeAnimalStartDateFilter) + : undefined; + let parsedOutcomeAnimalEndDate = undefined; + if (outcomeAnimalEndDateFilter) { + parsedOutcomeAnimalEndDate = new Date(outcomeAnimalEndDateFilter); + } else if (parsedOutcomeAnimalStartDate) { + parsedOutcomeAnimalEndDate = new Date(); + } + const allFilters: Partial = { region: regionCodeFilter, zone: zoneCodeFilter, @@ -78,6 +93,8 @@ const mapFilters = (complaintFilters: Partial) => { girType: girTypeFilter, actionTaken: actionTakenFilter, outcomeAnimal: outcomeAnimalFilter, + outcomeAnimalStartDate: parsedOutcomeAnimalStartDate, + outcomeAnimalEndDate: parsedOutcomeAnimalEndDate, }; // Only return filters that have a value set diff --git a/frontend/src/app/store/reducers/app.ts b/frontend/src/app/store/reducers/app.ts index b41c02a54..28a7d6feb 100644 --- a/frontend/src/app/store/reducers/app.ts +++ b/frontend/src/app/store/reducers/app.ts @@ -335,6 +335,9 @@ export const listActiveFilters = showOutcomeAnimalFilter: features.some( (feature: any) => feature.featureCode === FEATURE_TYPES.OUTCOME_ANIMAL_FILTER && feature.isActive === true, ), + showOutcomeAnimalDateFilter: features.some( + (feature: any) => feature.featureCode === FEATURE_TYPES.OUTCOME_ANIMAL_DATE_FILTER && feature.isActive === true, + ), }; return filters; }; diff --git a/frontend/src/app/store/reducers/complaints.ts b/frontend/src/app/store/reducers/complaints.ts index ab21563ce..8d9eb94a9 100644 --- a/frontend/src/app/store/reducers/complaints.ts +++ b/frontend/src/app/store/reducers/complaints.ts @@ -305,6 +305,8 @@ export const getComplaints = complaintMethodFilter, actionTakenFilter, outcomeAnimalFilter, + outcomeAnimalStartDateFilter, + outcomeAnimalEndDateFilter, page, pageSize, query, @@ -331,6 +333,8 @@ export const getComplaints = complaintMethod: complaintMethodFilter?.value, actionTaken: actionTakenFilter?.value, outcomeAnimal: outcomeAnimalFilter?.value, + outcomeAnimalStartDate: outcomeAnimalStartDateFilter, + outcomeAnimalEndDate: outcomeAnimalEndDateFilter, page: page, pageSize: pageSize, query: query, @@ -364,6 +368,8 @@ export const getMappedComplaints = complaintMethodFilter, actionTakenFilter, outcomeAnimalFilter, + outcomeAnimalStartDateFilter, + outcomeAnimalEndDateFilter, page, pageSize, query, @@ -389,6 +395,8 @@ export const getMappedComplaints = complaintMethod: complaintMethodFilter?.value, actionTaken: actionTakenFilter?.value, outcomeAnimal: outcomeAnimalFilter?.value, + outcomeAnimalStartDate: outcomeAnimalStartDateFilter, + outcomeAnimalEndDate: outcomeAnimalEndDateFilter, page: page, pageSize: pageSize, query: query, diff --git a/frontend/src/app/types/app/active-filters.ts b/frontend/src/app/types/app/active-filters.ts index 0ad55384f..6ebe6d993 100644 --- a/frontend/src/app/types/app/active-filters.ts +++ b/frontend/src/app/types/app/active-filters.ts @@ -12,4 +12,5 @@ export interface ActiveFilters { showViolationFilter: boolean; showZoneFilter: boolean; showOutcomeAnimalFilter: boolean; + showOutcomeAnimalDateFilter: boolean; } diff --git a/frontend/src/app/types/complaints/complaint-filters.ts b/frontend/src/app/types/complaints/complaint-filters.ts index 68100e079..5b1c08c86 100644 --- a/frontend/src/app/types/complaints/complaint-filters.ts +++ b/frontend/src/app/types/complaints/complaint-filters.ts @@ -17,6 +17,8 @@ export interface ComplaintFilters { complaintMethodFilter?: Option; actionTakenFilter?: Option; outcomeAnimalFilter?: Option; + outcomeAnimalStartDateFilter?: Date; + outcomeAnimalEndDateFilter?: Date; page?: number; pageSize?: number; query?: string; diff --git a/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts b/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts index 88f9b8d5d..5dfdb585c 100644 --- a/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts +++ b/frontend/src/app/types/complaints/complaint-filters/complaint-filters.ts @@ -22,6 +22,8 @@ export type ComplaintFilters = { actionTaken?: DropdownOption | null; outcomeAnimal?: DropdownOption | null; + outcomeAnimalStartDate?: Date; + outcomeAnimalEndDate?: Date; filters: Array; }; diff --git a/frontend/src/assets/sass/complaint.scss b/frontend/src/assets/sass/complaint.scss index 874b921ba..f8092e479 100644 --- a/frontend/src/assets/sass/complaint.scss +++ b/frontend/src/assets/sass/complaint.scss @@ -1363,6 +1363,13 @@ p { z-index: 7000 !important; } +.top-layer-select.outcome-animal-select { + max-height: 250px !important; + .comp-select__menu-list { + max-height: 250px !important; + } +} + .comp-details-edit-container { display: flex; } diff --git a/migrations/migrations/R__Create-Test-Data.sql b/migrations/migrations/R__Create-Test-Data.sql index 76576b0c0..7f603cd0d 100644 --- a/migrations/migrations/R__Create-Test-Data.sql +++ b/migrations/migrations/R__Create-Test-Data.sql @@ -4347,6 +4347,30 @@ SELECT now() ON CONFLICT DO NOTHING; +INSERT INTO + feature_code ( + feature_code, + short_description, + long_description, + display_order, + active_ind, + create_user_id, + create_utc_timestamp, + update_user_id, + update_utc_timestamp + ) +SELECT + 'OUT_D_FLTR', + 'Outcome Animal Date Filter', + 'Outcome Animal Date Filter', + 190, + 'Y', + user, + now(), + user, + now() ON CONFLICT +DO NOTHING; + ------------------------- -- Insert Feature / Agency XREF ------------------------- @@ -4370,6 +4394,26 @@ SELECT now() ON CONFLICT DO NOTHING; +INSERT INTO + feature_agency_xref ( + feature_code, + agency_code, + active_ind, + create_user_id, + create_utc_timestamp, + update_user_id, + update_utc_timestamp + ) +SELECT + 'OUT_D_FLTR', + 'COS', + 'Y', + user, + now(), + user, + now() ON CONFLICT +DO NOTHING; + INSERT INTO feature_agency_xref ( feature_code, From 174ef7fa042bf9357991a680ff35658851dc7440 Mon Sep 17 00:00:00 2001 From: Mike Vesprini <11034827+mikevespi@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:08:22 -0800 Subject: [PATCH 5/5] chore: CE-443 Automate COMS access requests (#801) Co-authored-by: afwilcox --- .../src/auth/decorators/token.decorator.ts | 10 +++++- backend/src/auth/decorators/user.decorator.ts | 8 +++++ backend/src/auth/jwtrole.guard.ts | 7 ++-- backend/src/helpers/axios-api.ts | 5 +++ .../src/v1/officer/entities/officer.entity.ts | 7 ++++ backend/src/v1/officer/officer.controller.ts | 10 +++++- backend/src/v1/officer/officer.service.ts | 36 +++++++++++++++++++ charts/app/templates/secret.yaml | 8 ++++- charts/app/values.yaml | 3 ++ frontend/src/app/store/migrations.ts | 2 ++ .../src/app/store/migrations/migration-24.ts | 12 +++++++ frontend/src/app/store/reducers/app.ts | 14 +++++++- frontend/src/app/store/store.ts | 2 +- frontend/src/app/types/app/profile.ts | 1 + frontend/src/app/types/person/person.ts | 1 + migrations/migrations/V0.32.0__CE-443.sql | 7 ++++ 16 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 backend/src/auth/decorators/user.decorator.ts create mode 100644 frontend/src/app/store/migrations/migration-24.ts create mode 100644 migrations/migrations/V0.32.0__CE-443.sql diff --git a/backend/src/auth/decorators/token.decorator.ts b/backend/src/auth/decorators/token.decorator.ts index 5d19a3c11..1297b3246 100644 --- a/backend/src/auth/decorators/token.decorator.ts +++ b/backend/src/auth/decorators/token.decorator.ts @@ -3,5 +3,13 @@ import { createParamDecorator, ExecutionContext } from "@nestjs/common"; export const Token = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { //Extract token from request const request = ctx.switchToHttp().getRequest(); - return request.token; + if (request.token) { + return request.token; + } + // If the token is not directly accessible in the request object, take it from the headers + let token = request.headers.authorization; + if (token && token.indexOf("Bearer ") === 0) { + token = token.substring(7); + } + return token; }); diff --git a/backend/src/auth/decorators/user.decorator.ts b/backend/src/auth/decorators/user.decorator.ts new file mode 100644 index 000000000..6d7f1d1aa --- /dev/null +++ b/backend/src/auth/decorators/user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +// Returns the user off of the request object. +// Sample usage: foo(@User() user) {...} +export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/backend/src/auth/jwtrole.guard.ts b/backend/src/auth/jwtrole.guard.ts index 48d39d570..365959941 100644 --- a/backend/src/auth/jwtrole.guard.ts +++ b/backend/src/auth/jwtrole.guard.ts @@ -12,6 +12,9 @@ import { Role } from "../enum/role.enum"; import { ROLES_KEY } from "./decorators/roles.decorator"; import { IS_PUBLIC_KEY } from "./decorators/public.decorator"; +// A list of routes that are exceptions to the READ_ONLY role only being allowed to make get requests +const READ_ONLY_EXCEPTIONS = ["/api/v1/officer/request-coms-access/:officer_guid"]; + @Injectable() /** * An API guard used to authorize controller methods. This guard checks for othe @Roles decorator, and compares it against the role_names of the authenticated user's jwt. @@ -57,8 +60,8 @@ export class JwtRoleGuard extends AuthGuard("jwt") implements CanActivate { // Check if the user has the readonly role const hasReadOnlyRole = userRoles.includes(Role.READ_ONLY); - // If the user has readonly role, allow only GET requests - if (hasReadOnlyRole) { + // If the user has readonly role, allow only GET requests unless the route is in the list of exceptions + if (hasReadOnlyRole && !READ_ONLY_EXCEPTIONS.includes(request.route.path)) { if (request.method !== "GET") { this.logger.debug(`User with readonly role attempted ${request.method} method`); throw new ForbiddenException("Access denied: Read-only users cannot perform this action"); diff --git a/backend/src/helpers/axios-api.ts b/backend/src/helpers/axios-api.ts index b922d4cc8..28c2dbb82 100644 --- a/backend/src/helpers/axios-api.ts +++ b/backend/src/helpers/axios-api.ts @@ -27,3 +27,8 @@ export const post = async (apiToken: string, url: string, data: any, headers?: a const config = generateConfig(apiToken, headers); return axios.post(url, data, config); }; + +export const put = async (apiToken: string, url: string, data: any, headers?: any) => { + const config = generateConfig(apiToken, headers); + return axios.put(url, data, config); +}; diff --git a/backend/src/v1/officer/entities/officer.entity.ts b/backend/src/v1/officer/entities/officer.entity.ts index 60a0a5418..64b050523 100644 --- a/backend/src/v1/officer/entities/officer.entity.ts +++ b/backend/src/v1/officer/entities/officer.entity.ts @@ -72,6 +72,13 @@ export class Officer { @Column() auth_user_guid: UUID; + @ApiProperty({ + example: false, + description: "Indicates whether an officer has been enrolled in COMS", + }) + @Column() + coms_enrolled_ind: boolean; + user_roles: string[]; @AfterLoad() updateUserRoles() { diff --git a/backend/src/v1/officer/officer.controller.ts b/backend/src/v1/officer/officer.controller.ts index b0fd26e27..12fc38cc7 100644 --- a/backend/src/v1/officer/officer.controller.ts +++ b/backend/src/v1/officer/officer.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from "@nestjs/common"; +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Put } from "@nestjs/common"; import { OfficerService } from "./officer.service"; import { CreateOfficerDto } from "./dto/create-officer.dto"; import { UpdateOfficerDto } from "./dto/update-officer.dto"; @@ -7,6 +7,8 @@ import { Role } from "../../enum/role.enum"; import { JwtRoleGuard } from "../../auth/jwtrole.guard"; import { ApiTags } from "@nestjs/swagger"; import { UUID } from "crypto"; +import { User } from "../../auth/decorators/user.decorator"; +import { Token } from "../../auth/decorators/token.decorator"; @ApiTags("officer") @UseGuards(JwtRoleGuard) @@ -65,6 +67,12 @@ export class OfficerController { return this.officerService.update(id, updateOfficerDto); } + @Put("/request-coms-access/:officer_guid") + @Roles(Role.CEEB, Role.COS_OFFICER, Role.READ_ONLY) + requestComsAccess(@Token() token, @Param("officer_guid") officer_guid: UUID, @User() user) { + return this.officerService.requestComsAccess(token, officer_guid, user); + } + @Delete(":id") @Roles(Role.COS_OFFICER) remove(@Param("id") id: string) { diff --git a/backend/src/v1/officer/officer.service.ts b/backend/src/v1/officer/officer.service.ts index e29709dce..b3c7dca16 100644 --- a/backend/src/v1/officer/officer.service.ts +++ b/backend/src/v1/officer/officer.service.ts @@ -11,6 +11,8 @@ import { PersonService } from "../person/person.service"; import { OfficeService } from "../office/office.service"; import { UUID } from "crypto"; import { CssService } from "../../external_api/css/css.service"; +import { Role } from "../../enum/role.enum"; +import { put } from "../../helpers/axios-api"; @Injectable() export class OfficerService { @@ -172,6 +174,40 @@ export class OfficerService { return this.findOne(officer_guid); } + /** + * This function requests the appropriate level of access to the storage bucket in COMS. + * If successful, the officer's record in the officer table has its `coms_enrolled_ind` indicator set to true. + * @param requestComsAccessDto An object containing the officer guid + * @returns the updated record of the officer who was granted access to COMS + */ + async requestComsAccess(token: string, officer_guid: UUID, user: any): Promise { + try { + const currentRoles = user.client_roles; + const permissions = currentRoles.includes(Role.READ_ONLY) ? ["READ"] : ["READ", "CREATE", "UPDATE", "DELETE"]; + const comsPayload = { + accessKeyId: process.env.OBJECTSTORE_ACCESS_KEY, + bucket: process.env.OBJECTSTORE_BUCKET, + bucketName: process.env.OBJECTSTORE_BUCKET_NAME, + key: process.env.OBJECTSTORE_KEY, + endpoint: process.env.OBJECTSTORE_HTTPS_URL, + secretAccessKey: process.env.OBJECTSTORE_SECRET_KEY, + permCodes: permissions, + }; + const comsUrl = `${process.env.OBJECTSTORE_API_URL}/bucket`; + await put(token, comsUrl, comsPayload); + const officerRes = await this.officerRepository + .createQueryBuilder("officer") + .update() + .set({ coms_enrolled_ind: true }) + .where({ officer_guid: officer_guid }) + .returning("*") + .execute(); + return officerRes.raw[0]; + } catch (error) { + this.logger.error("An error occurred while requesting COMS access.", error); + } + } + remove(id: number) { return `This action removes a #${id} officer`; } diff --git a/charts/app/templates/secret.yaml b/charts/app/templates/secret.yaml index 65825fcc1..1a37b2a28 100644 --- a/charts/app/templates/secret.yaml +++ b/charts/app/templates/secret.yaml @@ -44,8 +44,11 @@ {{- $objectstoreUrl := (get $secretData "objectstoreUrl" | b64dec | default "") }} {{- $objectstoreHttpsUrl := (get $secretData "objectstoreHttpsUrl" | b64dec | default "") }} {{- $objectstoreBackupDirectory := (get $secretData "objectstoreBackupDirectory" | b64dec | default "") }} +{{- $objectstoreKey := (get $secretData "objectstoreKey" | b64dec | default "") }} {{- $objectstoreBucket := (get $secretData "objectstoreBucket" | b64dec | default "") }} +{{- $objectstoreBucketName := (get $secretData "objectstoreBucketName" | b64dec | default "") }} {{- $objectstoreSecretKey := (get $secretData "objectstoreSecretKey" | b64dec | default "") }} +{{- $objectstoreApiUrl := (get $secretData "objectstoreApiUrl" | b64dec | default "") }} {{- $jwksUri := (get $secretData "jwksUri" | b64dec | default "") }} {{- $jwtIssuer := (get $secretData "jwtIssuer" | b64dec | default "") }} {{- $keycloakClientId := (get $secretData "keycloakClientId" | b64dec | default "") }} @@ -99,8 +102,11 @@ data: OBJECTSTORE_URL: {{ $objectstoreUrl | b64enc | quote }} OBJECTSTORE_HTTPS_URL: {{ $objectstoreHttpsUrl | b64enc | quote }} OBJECTSTORE_BACKUP_DIRECTORY: {{ $objectstoreBackupDirectory | b64enc | quote }} + OBJECTSTORE_KEY: {{ $objectstoreKey | b64enc | quote }} OBJECTSTORE_BUCKET: {{ $objectstoreBucket | b64enc | quote }} + OBJECTSTORE_BUCKET_NAME: {{ $objectstoreBucketName | b64enc | quote }} OBJECTSTORE_SECRET_KEY: {{ $objectstoreSecretKey | b64enc | quote }} + OBJECTSTORE_API_URL: {{ $objectstoreApiUrl | b64enc | quote }} {{- end }} {{- if not (lookup "v1" "Secret" .Release.Namespace (printf "%s-webeoc" .Release.Name)) }} --- @@ -156,4 +162,4 @@ data: password: {{ $databasePassword | quote }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/app/values.yaml b/charts/app/values.yaml index 061c0bf8b..30f58da09 100644 --- a/charts/app/values.yaml +++ b/charts/app/values.yaml @@ -46,8 +46,11 @@ global: objectstoreAccessKey: ~ objectstoreUrl: ~ objectstoreBackupDirectory: ~ + objectstoreKey: ~ objectstoreBucket: ~ + objectstoreBucketName: ~ objectstoreSecretKey: ~ + objectstoreApiUrl: ~ jwksUri: ~ jwtIssuer: ~ keycloakClientId: ~ diff --git a/frontend/src/app/store/migrations.ts b/frontend/src/app/store/migrations.ts index 1dae28cc8..dca1ae84d 100644 --- a/frontend/src/app/store/migrations.ts +++ b/frontend/src/app/store/migrations.ts @@ -20,6 +20,7 @@ import { DrugAdministeredChanges } from "./migrations/migration-20"; import { AddCat1TypeAndLocationType } from "./migrations/migration-21"; import { AddActiveComplaintsViewType } from "./migrations/migration-22"; import { AssessmentTypeUpdates } from "./migrations/migration-23"; +import { AddComsEnrolledInd } from "./migrations/migration-24"; const BaseMigration = { 0: (state: any) => { @@ -54,6 +55,7 @@ migration = { ...AddCat1TypeAndLocationType, ...AddActiveComplaintsViewType, ...AssessmentTypeUpdates, + ...AddComsEnrolledInd, }; export default migration; diff --git a/frontend/src/app/store/migrations/migration-24.ts b/frontend/src/app/store/migrations/migration-24.ts new file mode 100644 index 000000000..347bf8a54 --- /dev/null +++ b/frontend/src/app/store/migrations/migration-24.ts @@ -0,0 +1,12 @@ +// Refresh the profile for coms access indicator +export const AddComsEnrolledInd = { + 24: (state: any) => { + return { + ...state, + app: { + ...state.app, + profile: {}, + }, + }; + }, +}; diff --git a/frontend/src/app/store/reducers/app.ts b/frontend/src/app/store/reducers/app.ts index 28a7d6feb..6cdfc9e20 100644 --- a/frontend/src/app/store/reducers/app.ts +++ b/frontend/src/app/store/reducers/app.ts @@ -6,7 +6,7 @@ import Profile from "@apptypes/app/profile"; import { UUID } from "crypto"; import { Officer } from "@apptypes/person/person"; import config from "@/config"; -import { generateApiParameters, get, patch } from "@common/api"; +import { generateApiParameters, get, patch, put } from "@common/api"; import { AUTH_TOKEN, getUserAgency } from "@service/user-service"; import { DropdownOption } from "@apptypes/app/drop-down-option"; @@ -370,6 +370,7 @@ export const getTokenProfile = (): AppThunk => async (dispatch) => { let zoneDescription = ""; let agency = ""; let personGuid = ""; + let comsEnrolledInd = response.coms_enrolled_ind; if (response.office_guid !== null) { const { @@ -385,6 +386,14 @@ export const getTokenProfile = (): AppThunk => async (dispatch) => { personGuid = person_guid; } + if (!comsEnrolledInd) { + const requestComsAccessParams = generateApiParameters( + `${config.API_BASE_URL}/v1/officer/request-coms-access/${response.officer_guid}`, + ); + const res = await put(dispatch, requestComsAccessParams); + comsEnrolledInd = res.coms_enrolled_ind; + } + const profile: Profile = { givenName: given_name, surName: family_name, @@ -397,6 +406,7 @@ export const getTokenProfile = (): AppThunk => async (dispatch) => { zoneDescription: zoneDescription, agency, personGuid, + comsEnrolledInd, }; dispatch(setTokenProfile(profile)); @@ -531,6 +541,7 @@ const initialState: AppState = { zoneDescription: "", agency: "", personGuid: "", + comsEnrolledInd: null, }, isSidebarOpen: true, @@ -585,6 +596,7 @@ const reducer = (state: AppState = initialState, action: any): AppState => { zoneDescription: payload.zoneDescription, agency: payload.agency, personGuid: payload.personGuid, + comsEnrolledInd: payload.comsEnrolledInd, }; return { ...state, profile }; diff --git a/frontend/src/app/store/store.ts b/frontend/src/app/store/store.ts index caaa479f0..78085adba 100644 --- a/frontend/src/app/store/store.ts +++ b/frontend/src/app/store/store.ts @@ -19,7 +19,7 @@ const persistConfig = { storage, blacklist: ["app"], whitelist: ["codeTables", "officers"], - version: 23, // This needs to be incremented every time a new migration is added + version: 24, // This needs to be incremented every time a new migration is added debug: true, migrate: createMigrate(migration, { debug: false }), }; diff --git a/frontend/src/app/types/app/profile.ts b/frontend/src/app/types/app/profile.ts index a01b5ed9d..838a4a581 100644 --- a/frontend/src/app/types/app/profile.ts +++ b/frontend/src/app/types/app/profile.ts @@ -12,4 +12,5 @@ export default interface Profile { zoneDescription: string; agency: string; personGuid: string; + comsEnrolledInd: boolean | null; } diff --git a/frontend/src/app/types/person/person.ts b/frontend/src/app/types/person/person.ts index 216b46d20..8e2fc4823 100644 --- a/frontend/src/app/types/person/person.ts +++ b/frontend/src/app/types/person/person.ts @@ -25,6 +25,7 @@ export interface Officer { office_guid: OfficeGUID; person_guid: Person; user_roles: string[]; + coms_enrolled_ind: boolean; } export interface OfficeGUID { diff --git a/migrations/migrations/V0.32.0__CE-443.sql b/migrations/migrations/V0.32.0__CE-443.sql new file mode 100644 index 000000000..2afec71e7 --- /dev/null +++ b/migrations/migrations/V0.32.0__CE-443.sql @@ -0,0 +1,7 @@ +-- +-- alter officer table - add large_carnivore_ind +-- +ALTER TABLE officer ADD coms_enrolled_ind boolean DEFAULT false; + +comment on column species_code.large_carnivore_ind is + 'A boolean indicator representing if an officer has been enrolled in COMS';