diff --git a/app/controllers/api/v2/alerts_controller.rb b/app/controllers/api/v2/alerts_controller.rb index 11b25b43a1..efc0227f0a 100644 --- a/app/controllers/api/v2/alerts_controller.rb +++ b/app/controllers/api/v2/alerts_controller.rb @@ -17,6 +17,17 @@ def index @alerts = @record.alerts end + def destroy + authorize! :remove_alert, @record + alert_id = params[:id] + alert = @record.alerts.find { |a| a.unique_id == alert_id } + if alert.present? + alert.destroy! + return + end + raise ActiveRecord::RecordNotFound + end + def index_action_message 'show_alerts' end diff --git a/app/javascript/components/internal-alert/component.jsx b/app/javascript/components/internal-alert/component.jsx index 8af0fd0e81..88c7d193a0 100644 --- a/app/javascript/components/internal-alert/component.jsx +++ b/app/javascript/components/internal-alert/component.jsx @@ -15,6 +15,7 @@ import SignalWifiOffIcon from "@material-ui/icons/SignalWifiOff"; import { generate } from "../notifier/utils"; import { useI18n } from "../i18n"; +import InternalAlertItem from "./components/item"; import { NAME, SEVERITY } from "./constants"; import { expansionPanelSummaryClasses } from "./theme"; import css from "./styles.css"; @@ -38,7 +39,9 @@ const Component = ({ title, items, severity, customIcon }) => { @@ -61,12 +64,16 @@ const Component = ({ title, items, severity, customIcon }) => { const renderTitle = () => { const titleMessage = - items?.size > 1 ? title || i18n.t("messages.alert_items", { items: items.size }) : items?.first()?.get("message"); + items?.size > 1 ? ( + title ||
{i18n.t("messages.alert_items", { items: items.size })}
+ ) : ( + + ); return ( <>
{customIcon || renderIcon()}
- {titleMessage} + {titleMessage} ); }; diff --git a/app/javascript/components/internal-alert/component.unit.test.js b/app/javascript/components/internal-alert/component.unit.test.js index 55c204d89f..be347092d9 100644 --- a/app/javascript/components/internal-alert/component.unit.test.js +++ b/app/javascript/components/internal-alert/component.unit.test.js @@ -1,7 +1,7 @@ // Copyright (c) 2014 - 2023 UNICEF. All rights reserved. import { fromJS } from "immutable"; -import { AccordionDetails, AccordionSummary } from "@material-ui/core"; +import { AccordionDetails, AccordionSummary, IconButton } from "@material-ui/core"; import { setupMountedComponent } from "../../test"; @@ -62,4 +62,30 @@ describe("", () => { expect(component.find(AccordionSummary).text()).to.be.equal(title); }); + + it("renders a dismiss button if onDismiss is on an item", () => { + const { component } = setupMountedComponent( + InternalAlert, + { + items: fromJS([{ message: "Alert Message 1", onDismiss: () => {} }]), + severity: "warning" + }, + {} + ); + + expect(component.find(IconButton)).to.have.lengthOf(1); + }); + + it("does not render a dismiss button if onDismiss is not on an item", () => { + const { component } = setupMountedComponent( + InternalAlert, + { + items: fromJS([{ message: "Alert Message 1" }]), + severity: "warning" + }, + {} + ); + + expect(component.find(IconButton)).to.have.lengthOf(0); + }); }); diff --git a/app/javascript/components/internal-alert/components/dismiss-button/component.jsx b/app/javascript/components/internal-alert/components/dismiss-button/component.jsx new file mode 100644 index 0000000000..ff40077461 --- /dev/null +++ b/app/javascript/components/internal-alert/components/dismiss-button/component.jsx @@ -0,0 +1,24 @@ +import { IconButton } from "@material-ui/core"; +import CloseIcon from "@material-ui/icons/Close"; +import PropTypes from "prop-types"; + +import css from "./styles.css"; + +const Component = ({ handler }) => { + const handlerWrapper = event => { + event.stopPropagation(); + handler(); + }; + + return ( + + + + ); +}; + +Component.displayName = "InternalAlertDismissButton"; +Component.propTypes = { + handler: PropTypes.func.isRequired +}; +export default Component; diff --git a/app/javascript/components/internal-alert/components/dismiss-button/index.js b/app/javascript/components/internal-alert/components/dismiss-button/index.js new file mode 100644 index 0000000000..b6e0586481 --- /dev/null +++ b/app/javascript/components/internal-alert/components/dismiss-button/index.js @@ -0,0 +1 @@ +export { default } from "./component"; diff --git a/app/javascript/components/internal-alert/components/dismiss-button/styles.css b/app/javascript/components/internal-alert/components/dismiss-button/styles.css new file mode 100644 index 0000000000..6626149cff --- /dev/null +++ b/app/javascript/components/internal-alert/components/dismiss-button/styles.css @@ -0,0 +1,3 @@ +.dismissButton { + pointer-events: auto; +} diff --git a/app/javascript/components/internal-alert/components/item/component.jsx b/app/javascript/components/internal-alert/components/item/component.jsx new file mode 100644 index 0000000000..ed347a2715 --- /dev/null +++ b/app/javascript/components/internal-alert/components/item/component.jsx @@ -0,0 +1,21 @@ +import PropTypes from "prop-types"; + +import InternalAlertDismissButton from "../dismiss-button"; + +import css from "./styles.css"; + +const Component = ({ item }) => { + return ( +
+ {item.get("message")} + {item.get("onDismiss") && InternalAlertDismissButton({ handler: item.get("onDismiss") })} +
+ ); +}; + +Component.displayName = "InternalAlertItem"; +Component.propTypes = { + item: PropTypes.object.isRequired +}; + +export default Component; diff --git a/app/javascript/components/internal-alert/components/item/index.js b/app/javascript/components/internal-alert/components/item/index.js new file mode 100644 index 0000000000..b6e0586481 --- /dev/null +++ b/app/javascript/components/internal-alert/components/item/index.js @@ -0,0 +1 @@ +export { default } from "./component"; diff --git a/app/javascript/components/internal-alert/components/item/styles.css b/app/javascript/components/internal-alert/components/item/styles.css new file mode 100644 index 0000000000..9c0774433a --- /dev/null +++ b/app/javascript/components/internal-alert/components/item/styles.css @@ -0,0 +1,7 @@ +.alertItemElement { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + flex-grow: 1; +} diff --git a/app/javascript/components/internal-alert/styles.css b/app/javascript/components/internal-alert/styles.css index 6907d7949c..155e775854 100644 --- a/app/javascript/components/internal-alert/styles.css +++ b/app/javascript/components/internal-alert/styles.css @@ -4,10 +4,11 @@ & svg { font-size: var(--fs-16); } + align-self: center; } .alertTitle { - display: inline-flex; + display: flex; align-items: center; width: 100%; @@ -73,3 +74,7 @@ } } } + +.accordionTitle { + align-self: center; +} diff --git a/app/javascript/components/permissions/constants.js b/app/javascript/components/permissions/constants.js index f51331d43e..4618be28cb 100644 --- a/app/javascript/components/permissions/constants.js +++ b/app/javascript/components/permissions/constants.js @@ -116,7 +116,8 @@ export const ACTIONS = { WORKFLOW_REPORT: "workflow_report", WRITE: "write", VIEW_FAMILY_RECORD: "view_family_record", - LINK_FAMILY_RECORD: "link_family_record" + LINK_FAMILY_RECORD: "link_family_record", + REMOVE_ALERT: "remove_alert" }; export const MANAGE = [ACTIONS.MANAGE]; @@ -343,3 +344,5 @@ export const CREATE_CASE_FROM_FAMILY = [...MANAGE, ACTIONS.CASE_FROM_FAMILY]; export const LINK_FAMILY_RECORD_FROM_CASE = [...MANAGE, ACTIONS.LINK_FAMILY_RECORD]; export const VIEW_FAMILY_RECORD_FROM_CASE = [...MANAGE, ACTIONS.VIEW_FAMILY_RECORD]; + +export const REMOVE_ALERT = [...MANAGE, ACTIONS.REMOVE_ALERT]; diff --git a/app/javascript/components/permissions/index.js b/app/javascript/components/permissions/index.js index 63264a058c..c309ea1fe4 100644 --- a/app/javascript/components/permissions/index.js +++ b/app/javascript/components/permissions/index.js @@ -50,7 +50,8 @@ export { VIEW_INCIDENTS_FROM_CASE, VIEW_KPIS, WRITE_RECORDS, - WRITE_REGISTRY_RECORD + WRITE_REGISTRY_RECORD, + REMOVE_ALERT } from "./constants"; export { checkPermissions } from "./utils"; diff --git a/app/javascript/components/record-form-alerts/component.jsx b/app/javascript/components/record-form-alerts/component.jsx index 9898d8d079..d10c6cebd2 100644 --- a/app/javascript/components/record-form-alerts/component.jsx +++ b/app/javascript/components/record-form-alerts/component.jsx @@ -2,24 +2,34 @@ import PropTypes from "prop-types"; import { fromJS, List } from "immutable"; +import { useDispatch } from "react-redux"; import { useI18n } from "../i18n"; import InternalAlert from "../internal-alert"; import useMemoizedSelector from "../../libs/use-memoized-selector"; -import { getRecordFormAlerts } from "../records"; +import { getRecordFormAlerts, getSelectedRecord, deleteAlertFromRecord } from "../records"; import { getSubformsDisplayName, getValidationErrors } from "../record-form"; import { getDuplicatedFields } from "../record-form/selectors"; +import { usePermissions, REMOVE_ALERT } from "../permissions"; import { getMessageData } from "./utils"; import { NAME } from "./constants"; -const Component = ({ form, recordType, attachmentForms }) => { +const Component = ({ form, recordType, attachmentForms, formMode }) => { const i18n = useI18n(); + const dispatch = useDispatch(); + const recordAlerts = useMemoizedSelector(state => getRecordFormAlerts(state, recordType, form.unique_id)); const validationErrors = useMemoizedSelector(state => getValidationErrors(state, form.unique_id)); const subformDisplayNames = useMemoizedSelector(state => getSubformsDisplayName(state, i18n.locale)); const duplicatedFields = useMemoizedSelector(state => getDuplicatedFields(state, recordType, form.unique_id)); + const selectedRecord = useMemoizedSelector(state => getSelectedRecord(state, recordType)); + const hasDismissPermission = usePermissions(recordType, REMOVE_ALERT); + + const showDismissButton = () => { + return hasDismissPermission && formMode.isShow; + }; const errors = validationErrors?.size && @@ -44,7 +54,12 @@ const Component = ({ form, recordType, attachmentForms }) => { message: i18n.t( `messages.alerts_for.${alert.get("alert_for")}`, getMessageData({ alert, form, duplicatedFields, i18n }) - ) + ), + onDismiss: showDismissButton() + ? () => { + dispatch(deleteAlertFromRecord(recordType, selectedRecord, alert.get("unique_id"))); + } + : null }) ); @@ -73,6 +88,7 @@ Component.defaultProps = { Component.propTypes = { attachmentForms: PropTypes.object, form: PropTypes.object.isRequired, + formMode: PropTypes.object, recordType: PropTypes.string.isRequired }; diff --git a/app/javascript/components/record-form/components/render-form-sections.js b/app/javascript/components/record-form/components/render-form-sections.js index 7e6a6a2bb5..99ec127f1f 100644 --- a/app/javascript/components/record-form/components/render-form-sections.js +++ b/app/javascript/components/record-form/components/render-form-sections.js @@ -114,7 +114,7 @@ const renderFormSections = {...titleProps} /> - + {renderFormFields( fs, form, diff --git a/app/javascript/components/records/action-creators.js b/app/javascript/components/records/action-creators.js index 79f386e453..350d3dc341 100644 --- a/app/javascript/components/records/action-creators.js +++ b/app/javascript/components/records/action-creators.js @@ -42,7 +42,9 @@ import { EXTERNAL_SYNC, OFFLINE_INCIDENT_FROM_CASE, CREATE_CASE_FROM_FAMILY_MEMBER, - CREATE_CASE_FROM_FAMILY_DETAIL + CREATE_CASE_FROM_FAMILY_DETAIL, + DELETE_ALERT_FROM_RECORD, + DELETE_ALERT_FROM_RECORD_SUCCESS } from "./actions"; const getSuccessCallback = ({ @@ -170,6 +172,20 @@ export const fetchRecordsAlerts = (recordType, recordId, asCallback = false) => } }); +export const deleteAlertFromRecord = (recordType, recordId, alertId) => ({ + type: `${recordType}/${DELETE_ALERT_FROM_RECORD}`, + api: { + path: `${recordType}/${recordId}/alerts/${alertId}`, + method: METHODS.DELETE, + skipDB: true, + performFromQueue: true, + successCallback: { + action: `${recordType}/${DELETE_ALERT_FROM_RECORD_SUCCESS}`, + payload: { alertId } + } + } +}); + export const saveRecord = ( recordType, saveMethod, diff --git a/app/javascript/components/records/action-creators.unit.test.js b/app/javascript/components/records/action-creators.unit.test.js index 0dd64f989c..ea5e6249fb 100644 --- a/app/javascript/components/records/action-creators.unit.test.js +++ b/app/javascript/components/records/action-creators.unit.test.js @@ -53,7 +53,8 @@ describe("records - Action Creators", () => { "setSelectedCasePotentialMatch", "setSelectedPotentialMatch", "setSelectedRecord", - "unMatchCaseForTrace" + "unMatchCaseForTrace", + "deleteAlertFromRecord" ].forEach(property => { expect(creators).to.have.property(property); expect(creators[property]).to.be.a("function"); @@ -537,4 +538,22 @@ describe("records - Action Creators", () => { expect(actionCreators.clearPotentialMatches()).be.deep.equals(expected); }); + + it("checks that 'deleteAlertFromRecord' action creator to return the correct object", () => { + const expected = { + type: `${RECORD_PATH.cases}/DELETE_ALERT_FROM_RECORD`, + api: { + path: `${RECORD_PATH.cases}/12345/alerts/12345-alert`, + method: METHODS.DELETE, + skipDB: true, + performFromQueue: true, + successCallback: { + action: `${RECORD_PATH.cases}/DELETE_ALERT_FROM_RECORD_SUCCESS`, + payload: { alertId: "12345-alert" } + } + } + }; + + expect(actionCreators.deleteAlertFromRecord(RECORD_PATH.cases, "12345", "12345-alert")).be.deep.equals(expected); + }); }); diff --git a/app/javascript/components/records/actions.js b/app/javascript/components/records/actions.js index 989aa3c185..1403280a36 100644 --- a/app/javascript/components/records/actions.js +++ b/app/javascript/components/records/actions.js @@ -30,6 +30,11 @@ export const FETCH_RECORD_ALERTS_FAILURE = "FETCH_RECORD_ALERTS_FAILURE"; export const FETCH_RECORD_ALERTS_FINISHED = "FETCH_RECORD_ALERTS_FINISHED"; export const FETCH_RECORD_ALERTS_STARTED = "FETCH_RECORD_ALERTS_STARTED"; export const FETCH_RECORD_ALERTS_SUCCESS = "FETCH_RECORD_ALERTS_SUCCESS"; +export const DELETE_ALERT_FROM_RECORD = "DELETE_ALERT_FROM_RECORD"; +export const DELETE_ALERT_FROM_RECORD_FAILURE = "DELETE_ALERT_FROM_RECORD_FAILURE"; +export const DELETE_ALERT_FROM_RECORD_FINISHED = "DELETE_ALERT_FROM_RECORD_FINISHED"; +export const DELETE_ALERT_FROM_RECORD_STARTED = "DELETE_ALERT_FROM_RECORD_STARTED"; +export const DELETE_ALERT_FROM_RECORD_SUCCESS = "DELETE_ALERT_FROM_RECORD_SUCCESS"; export const FETCH_INCIDENT_FROM_CASE = "FETCH_INCIDENT_FROM_CASE"; export const FETCH_INCIDENT_FROM_CASE_FAILURE = "FETCH_INCIDENT_FROM_CASE_FAILURE"; export const FETCH_INCIDENT_FROM_CASE_FINISHED = "FETCH_INCIDENT_FROM_CASE_FINISHED"; diff --git a/app/javascript/components/records/actions.unit.test.js b/app/javascript/components/records/actions.unit.test.js index b5ca26ee03..26aa5cb005 100644 --- a/app/javascript/components/records/actions.unit.test.js +++ b/app/javascript/components/records/actions.unit.test.js @@ -100,7 +100,12 @@ describe("records - Actions", () => { "CREATE_CASE_FROM_FAMILY_DETAIL_SUCCESS", "CREATE_CASE_FROM_FAMILY_DETAIL_STARTED", "CREATE_CASE_FROM_FAMILY_DETAIL_FAILURE", - "CREATE_CASE_FROM_FAMILY_DETAIL_FINISHED" + "CREATE_CASE_FROM_FAMILY_DETAIL_FINISHED", + "DELETE_ALERT_FROM_RECORD", + "DELETE_ALERT_FROM_RECORD_FAILURE", + "DELETE_ALERT_FROM_RECORD_FINISHED", + "DELETE_ALERT_FROM_RECORD_STARTED", + "DELETE_ALERT_FROM_RECORD_SUCCESS" ].forEach(property => { expect(cloneActions).to.have.property(property); expect(cloneActions[property]).to.be.a("string"); diff --git a/app/javascript/components/records/reducer.js b/app/javascript/components/records/reducer.js index 54d4f21542..d6c03eccb5 100644 --- a/app/javascript/components/records/reducer.js +++ b/app/javascript/components/records/reducer.js @@ -21,6 +21,7 @@ import { RECORD_FINISHED, SERVICE_REFERRED_SAVE, FETCH_RECORD_ALERTS_SUCCESS, + DELETE_ALERT_FROM_RECORD_SUCCESS, FETCH_INCIDENT_FROM_CASE_SUCCESS, CLEAR_METADATA, CLEAR_CASE_FROM_INCIDENT, @@ -178,6 +179,13 @@ export default namespace => return state.set("errors", true); case `${namespace}/${RECORD_FINISHED}`: return state.set("loading", false); + case `${namespace}/${DELETE_ALERT_FROM_RECORD_SUCCESS}`: + state.set("alert_count", state.get("alert_count") - 1); + + return state.set( + "recordAlerts", + state.get("recordAlerts").filter(alert => alert.get("unique_id") !== payload.alertId) + ); case `${namespace}/${FETCH_RECORD_ALERTS_SUCCESS}`: return state.set("recordAlerts", fromJS(payload.data)); case "user/LOGOUT_SUCCESS": diff --git a/app/javascript/components/records/reducer.unit.test.js b/app/javascript/components/records/reducer.unit.test.js index 5cfb0dfd0e..14d2c8cd03 100644 --- a/app/javascript/components/records/reducer.unit.test.js +++ b/app/javascript/components/records/reducer.unit.test.js @@ -812,4 +812,32 @@ describe(" - Reducers", () => { expect(newState).to.deep.equals(expected); }); + + it("should handle DELETE_ALERT_FROM_RECORD_SUCCESS", () => { + const initialState = fromJS({ + alert_count: 1, + recordAlerts: [ + { + alert_for: "field_change", + type: "formsection-stuff-with-subform-field-d6055c8", + date: "2023-10-16", + form_unique_id: "formsection-stuff-with-subform-field-d6055c8", + unique_id: "018a28dd-4af4-4521-91ed-636efed6228e" + } + ] + }); + const expected = fromJS({ + alert_count: 0, + recordAlerts: [] + }); + const action = { + type: "TestRecordType/DELETE_ALERT_FROM_RECORD_SUCCESS", + payload: { + alertId: "018a28dd-4af4-4521-91ed-636efed6228e" + } + }; + const newState = nsReducer(initialState, action); + + expect(newState).to.deep.equals(newState); + }); }); diff --git a/app/jobs/alert_notify_job.rb b/app/jobs/alert_notify_job.rb new file mode 100644 index 0000000000..00950fa89f --- /dev/null +++ b/app/jobs/alert_notify_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Job for enqueuing notifications about changes to the User account +class AlertNotifyJob < ApplicationJob + queue_as :mailer + + def perform(alert_id, user_id) + alert = Alert.find(alert_id) + user = User.find(user_id) + record = alert.record + # We never want to send an email to the user who made the change + return if record.last_updated_by == user.user_name + + ans = AlertNotificationService.new(record.id, alert_id, user.user_name) + RecordActionMailer.alert_notify(ans).deliver_now + RecordActionWebpushNotifier.alert_notify(ans) + end +end diff --git a/app/mailers/record_action_mailer.rb b/app/mailers/record_action_mailer.rb index 385c687b3b..e5675dbc36 100644 --- a/app/mailers/record_action_mailer.rb +++ b/app/mailers/record_action_mailer.rb @@ -44,6 +44,16 @@ def transfer_request(transfer_request_notification) mail(to: @transfer_request_notification&.transitioned_to&.email, subject: @transfer_request_notification.subject) end + def alert_notify(alert_notification) + @alert_notification = alert_notification + return unless assert_notifications_enabled(@alert_notification.user) + return if @alert_notification.user == @alert_notification.record.last_updated_by + + Rails.logger.info("Sending alert notification to #{@alert_notification.user.user_name}") + + mail(to: @alert_notification.user.email, subject: @alert_notification.subject, locale: @alert_notification.locale) + end + private def assert_notifications_enabled(user) diff --git a/app/models/alert.rb b/app/models/alert.rb index c2375906bf..add359c69c 100644 --- a/app/models/alert.rb +++ b/app/models/alert.rb @@ -8,9 +8,11 @@ class Alert < ApplicationRecord belongs_to :agency, optional: true belongs_to :user, optional: true validates :alert_for, presence: { message: 'errors.models.alerts.alert_for' } + attribute :send_email, :boolean, default: false before_create :generate_fields before_create :remove_duplicate_alert + after_create_commit :handle_send_email def generate_fields self.unique_id ||= SecureRandom.uuid @@ -26,4 +28,13 @@ def remove_duplicate_alert DuplicatedFieldAlertService.duplicate_alert(record, type)&.destroy! end + + def handle_send_email + return unless send_email + + users = record.associated_users + users.each do |user| + AlertNotifyJob.perform_later(id, user.id) + end + end end diff --git a/app/models/concerns/alertable.rb b/app/models/concerns/alertable.rb index 1425b8bac8..f4daafb2aa 100644 --- a/app/models/concerns/alertable.rb +++ b/app/models/concerns/alertable.rb @@ -14,6 +14,15 @@ module Alertable TRANSFER_REQUEST = 'transfer_request' INCIDENT_FROM_CASE = 'incident_from_case' + module AlertStrategy + # This sends email (and webpush) notifications to all + # users on the case other than the one making the change + ASSOCIATED_USERS = 'associated_users' + # This doesn't send email notifications to anyone, and only creates a yellow + # dot alert if the user making the change is not the owner + NOT_OWNER = 'not_owner' # this is the default + end + included do searchable do string :current_alert_types, multiple: true @@ -42,18 +51,38 @@ def remove_alert_on_save end def remove_field_change_alerts - alerts_on_change.each { |_, form_name| remove_alert(form_name) } + alerts_on_change.each do |_, conf_record| + next if conf_record.alert_strategy == AlertStrategy::ASSOCIATED_USERS + + remove_alert(conf_record.form_section_unique_id) + end end def add_alert_on_field_change - return unless owned_by != last_updated_by return unless alerts_on_change.present? changed_field_names = changes_to_save_for_record.keys - alerts_on_change.each do |field_name, form_name| + alerts_on_change.each do |field_name, conf_record| next unless changed_field_names.include?(field_name) - add_alert(alert_for: FIELD_CHANGE, date: Date.today, type: form_name, form_sidebar_id: form_name) + # remove any existing alerts of the same type + remove_alert(conf_record.form_section_unique_id) + add_field_alert(conf_record) + end + end + + def add_field_alert(conf_record) + case conf_record.alert_strategy + when AlertStrategy::ASSOCIATED_USERS + add_alert(alert_for: FIELD_CHANGE, date: Date.today, type: conf_record.form_section_unique_id, + form_sidebar_id: conf_record.form_section_unique_id, send_email: true) + + when AlertStrategy::NOT_OWNER + return if owned_by == last_updated_by + + add_alert(alert_for: FIELD_CHANGE, date: Date.today, type: conf_record.form_section_unique_id, + form_sidebar_id: conf_record.form_section_unique_id) + else raise "Unknown alert strategy #{conf_record.alert_strategy}" end end @@ -65,7 +94,8 @@ def add_alert(args = {}) date_alert = args[:date].presence || Date.today alert = Alert.new(type: args[:type], date: date_alert, form_sidebar_id: args[:form_sidebar_id], - alert_for: args[:alert_for], user_id: args[:user_id], agency_id: args[:agency_id]) + alert_for: args[:alert_for], user_id: args[:user_id], agency_id: args[:agency_id], + send_email: args[:send_email]) (alerts << alert) && alert end @@ -93,53 +123,60 @@ def add_approval_alert(approval_type, system_settings) def alerts_on_change @system_settings ||= SystemSettings.current - @system_settings&.changes_field_to_form - end - - # Class methods that indicate alerts for all permitted records for a user. - # TODO: This deserves its own service - module ClassMethods - def alert_count(current_user) - query_scope = current_user.record_query_scope(self.class)[:user] - if query_scope.blank? - open_enabled_records.distinct.count - elsif query_scope[Permission::AGENCY].present? - alert_count_agency(current_user) - elsif query_scope[Permission::GROUP].present? - alert_count_group(current_user) - else - alert_count_self(current_user) - end - end - - def remove_alert(type = nil) - alerts_to_delete = alerts.select do |alert| - type.present? && alert.type == type && [NEW_FORM, FIELD_CHANGE, TRANSFER_REQUEST].include?(alert.alert_for) - end + # changes field to form needs to be backwards compatible, so each of the + # values in the hash is either a string or a hash. If it's a string, it's + # the form section unique id. If it's a hash, it's the form section unique + # id and the alert strategy + ( + @system_settings&.changes_field_to_form&.map do |field_name, form_section_uid_or_hash| + [field_name, AlertConfigEntryService.new(form_section_uid_or_hash)] + end).to_h + end +end - alerts.destroy(*alerts_to_delete) +# Class methods that indicate alerts for all permitted records for a user. +# TODO: This deserves its own service +module ClassMethods + def alert_count(current_user) + query_scope = current_user.record_query_scope(self.class)[:user] + if query_scope.blank? + open_enabled_records.distinct.count + elsif query_scope[Permission::AGENCY].present? + alert_count_agency(current_user) + elsif query_scope[Permission::GROUP].present? + alert_count_group(current_user) + else + alert_count_self(current_user) end + end - def alert_count_agency(current_user) - agency_unique_id = current_user.agency.unique_id - open_enabled_records.where("data -> 'associated_user_agencies' ? :agency", agency: agency_unique_id) - .distinct.count + def remove_alert(type = nil) + alerts_to_delete = alerts.select do |alert| + type.present? && alert.type == type && [NEW_FORM, FIELD_CHANGE, TRANSFER_REQUEST].include?(alert.alert_for) end - def alert_count_group(current_user) - user_groups_unique_id = current_user.user_groups.pluck(:unique_id) - open_enabled_records.where( - "data -> 'associated_user_groups' ?& array[:group]", - group: user_groups_unique_id - ).distinct.count - end + alerts.destroy(*alerts_to_delete) + end - def alert_count_self(current_user) - open_enabled_records.owned_by(current_user.user_name).distinct.count - end + def alert_count_agency(current_user) + agency_unique_id = current_user.agency.unique_id + open_enabled_records.where("data -> 'associated_user_agencies' ? :agency", agency: agency_unique_id) + .distinct.count + end - def open_enabled_records - joins(:alerts).where('data @> ?', { record_state: true, status: Record::STATUS_OPEN }.to_json) - end + def alert_count_group(current_user) + user_groups_unique_id = current_user.user_groups.pluck(:unique_id) + open_enabled_records.where( + "data -> 'associated_user_groups' ?& array[:group]", + group: user_groups_unique_id + ).distinct.count + end + + def alert_count_self(current_user) + open_enabled_records.owned_by(current_user.user_name).distinct.count + end + + def open_enabled_records + joins(:alerts).where('data @> ?', { record_state: true, status: Record::STATUS_OPEN }.to_json) end end diff --git a/app/models/exporters/role_permissions_exporter.rb b/app/models/exporters/role_permissions_exporter.rb index fb06fa2d60..cb7eac9fac 100644 --- a/app/models/exporters/role_permissions_exporter.rb +++ b/app/models/exporters/role_permissions_exporter.rb @@ -12,7 +12,7 @@ class Exporters::RolePermissionsExporter CASE = %w[ referral transfer read create write enable_disable_record flag manage add_note reopen close change_log view_incident_from_case view_protection_concerns_filter list_case_names view_registry_record - add_registry_record view_family_record case_from_family link_family_record + add_registry_record view_family_record case_from_family link_family_record remove_alert ].freeze CASE_EXPORTS = %w[ export_list_view_csv export_csv export_xls export_photowall export_unhcr_csv export_pdf consent_override diff --git a/app/models/permission.rb b/app/models/permission.rb index f7e639bfcd..ddc2925c62 100644 --- a/app/models/permission.rb +++ b/app/models/permission.rb @@ -174,6 +174,7 @@ class Permission < ValueObject CASE_FROM_FAMILY = 'case_from_family' REFERRALS_TRANSFERS_REPORT = 'referrals_transfers_report' LINK_FAMILY_RECORD = 'link_family_record' + REMOVE_ALERT = 'remove_alert' RESOURCE_ACTIONS = { CASE => [ @@ -188,12 +189,12 @@ class Permission < ValueObject FIND_TRACING_MATCH, ASSIGN, ASSIGN_WITHIN_AGENCY, ASSIGN_WITHIN_USER_GROUP, REMOVE_ASSIGNED_USERS, TRANSFER, RECEIVE_TRANSFER, ACCEPT_OR_REJECT_TRANSFER, REFERRAL, RECEIVE_REFERRAL, RECEIVE_REFERRAL_DIFFERENT_MODULE, REOPEN, CLOSE, VIEW_PROTECTION_CONCERNS_FILTER, CHANGE_LOG, LIST_CASE_NAMES, VIEW_REGISTRY_RECORD, - ADD_REGISTRY_RECORD, VIEW_FAMILY_RECORD, CASE_FROM_FAMILY, LINK_FAMILY_RECORD, MANAGE + ADD_REGISTRY_RECORD, VIEW_FAMILY_RECORD, CASE_FROM_FAMILY, LINK_FAMILY_RECORD, REMOVE_ALERT, MANAGE ], INCIDENT => [ READ, CREATE, WRITE, ENABLE_DISABLE_RECORD, FLAG, EXPORT_LIST_VIEW, EXPORT_CSV, EXPORT_EXCEL, EXPORT_PDF, EXPORT_INCIDENT_RECORDER, EXPORT_JSON, EXPORT_CUSTOM, IMPORT, SYNC_MOBILE, CHANGE_LOG, EXPORT_MRM_VIOLATION_XLS, - MANAGE + REMOVE_ALERT, MANAGE ], TRACING_REQUEST => [ READ, CREATE, WRITE, ENABLE_DISABLE_RECORD, FLAG, EXPORT_LIST_VIEW, EXPORT_CSV, EXPORT_EXCEL, EXPORT_PDF, diff --git a/app/services/alert_config_entry_service.rb b/app/services/alert_config_entry_service.rb new file mode 100644 index 0000000000..ec0274ad62 --- /dev/null +++ b/app/services/alert_config_entry_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# This class is used for the members of changes_field_to_form in system_settings. +# It is used to store the form name, and the alert strategy (associated_users, owner, nobody) +class AlertConfigEntryService + attr_accessor :form_section_unique_id, :alert_strategy + + def initialize(args) + if args.is_a?(Hash) + @form_section_unique_id = args['form_section_unique_id'] + @alert_strategy = args['alert_strategy'] || Alertable::AlertStrategy::NOT_OWNER + + else + @form_section_unique_id = args + @alert_strategy = Alertable::AlertStrategy::NOT_OWNER + end + end +end diff --git a/app/services/alert_notification_service.rb b/app/services/alert_notification_service.rb new file mode 100644 index 0000000000..52c3545fb3 --- /dev/null +++ b/app/services/alert_notification_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# This service is for sending notifications when an alert with send_email: true is created. +class AlertNotificationService + attr_accessor :record_id, :alert_id, :user_name + + def initialize(record_id, alert_id, user_name) + self.record_id = record_id + self.alert_id = alert_id + self.user_name = user_name + end + + def key + # This should return alert_case or alert_incident or similar based on the record type. + "alert_#{record.class.parent_form}" + end + + def user + @user ||= User.find_by(user_name:) + log_not_found('User', user_name) if @user.blank? + @user + end + + def alert + @alert ||= Alert.find_by(id: alert_id) + log_not_found('Alert', alert_id) if @alert.blank? + @alert + end + + def record + @record ||= alert&.record + log_not_found('Record', alert&.record_id) if @record.blank? + @record + end + + def locale + @locale ||= user&.locale || I18n.locale + end + + def form_section + @form_section ||= FormSection.find_by(unique_id: alert.form_sidebar_id) + end + + def record_type_translated + I18n.t("forms.record_types.#{record.class.parent_form}", locale:) + end + + def form_section_name_translated + @form_section_name_translated ||= I18n.with_locale(locale) { form_section&.name } + end + + def send_notification? + # We may want to add some checks here if users can opt out of particular + # types of notifications in the future. + true + end + + # This is for the message structure in push notifications + alias type form_section_name_translated + + def subject + I18n.t( + 'email_notification.alert_subject', + record_type: record_type_translated, + id: record.short_id, + form_name: form_section_name_translated, + locale: + ) + end + + private + + def log_not_found(type, id) + Rails.logger.error( + "Notification not sent. #{type.capitalize} #{id} not found." + ) + end +end diff --git a/app/views/api/v2/alerts/_alert.json.jbuilder b/app/views/api/v2/alerts/_alert.json.jbuilder index 4219954890..fc59111c4c 100644 --- a/app/views/api/v2/alerts/_alert.json.jbuilder +++ b/app/views/api/v2/alerts/_alert.json.jbuilder @@ -6,3 +6,4 @@ json.alert_for alert.alert_for json.type alert.type json.date(alert.date&.iso8601) json.form_unique_id alert.form_sidebar_id +json.unique_id alert.unique_id diff --git a/app/views/record_action_mailer/alert_notify.html.erb b/app/views/record_action_mailer/alert_notify.html.erb new file mode 100644 index 0000000000..6430a5b79d --- /dev/null +++ b/app/views/record_action_mailer/alert_notify.html.erb @@ -0,0 +1,17 @@ + + + + + + +

+ <%= t("email_notification.alert", + record_type: @alert_notification.record_type_translated, + id: link_to(@alert_notification.record.short_id, url_for_v2(@alert_notification.record)), + form_name: @alert_notification.form_section_name_translated, + locale: @alert_notification.locale + ).html_safe %> +

+ + + \ No newline at end of file diff --git a/app/views/record_action_mailer/alert_notify.text.erb b/app/views/record_action_mailer/alert_notify.text.erb new file mode 100644 index 0000000000..2f0d039bca --- /dev/null +++ b/app/views/record_action_mailer/alert_notify.text.erb @@ -0,0 +1,8 @@ +<%= t("email_notification.alert", + record_type: @alert_notification.record_type_translated, + id: @alert_notification.record.short_id, + form_name: @alert_notification.form_section_name_translated, + locale: @alert_notification.locale +)%> + +<%= url_for_v2(@alert_notification.record) %> \ No newline at end of file diff --git a/app/webpush_notifiers/record_action_webpush_notifier.rb b/app/webpush_notifiers/record_action_webpush_notifier.rb index 58ea5fa41a..9d5e748d47 100644 --- a/app/webpush_notifiers/record_action_webpush_notifier.rb +++ b/app/webpush_notifiers/record_action_webpush_notifier.rb @@ -18,6 +18,10 @@ def self.manager_approval_response(approval_notification) RecordActionWebpushNotifier.new.manager_approval_response(approval_notification) end + def self.alert_notify(alert_notification) + RecordActionWebpushNotifier.new.alert_notify(alert_notification) + end + def self.transfer_request(transfer_request_notification) RecordActionWebpushNotifier.new.transfer_request(transfer_request_notification) end @@ -62,6 +66,16 @@ def transfer_request(transfer_request_notification) ) end + def alert_notify(alert_notification) + return unless alert_notification.send_notification? + return unless webpush_notifications_enabled?(alert_notification.user) + + WebpushService.send_notifications( + alert_notification.user, + message_structure(alert_notification) + ) + end + def message_structure(record_action_notification) { title: I18n.t("webpush_notification.#{record_action_notification.key}.title"), diff --git a/config/locales/en.yml b/config/locales/en.yml index a4aa750883..45a273ff20 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -841,6 +841,8 @@ en: assign_html: '%{user} has assigned the following %{record_type} to you: %{record_id}.' transfer_request_html: 'Primero user %{user} from %{agency} is requesting that you transfer ownership of this %{record_type} record %{record_id} so that they can provide %{record_type} management services for the individual in their area. If this transfer is acceptable to you, please click on the %{record_type} ID link in this email to open the %{record_type} in Primero and initiate the transfer.' transfer_request_notes_html: 'Notes from the individual making the request: %{request_transfer_notes}' + alert: '%{record_type} %{id} - %{form_name} has been updated. Please log in to Primero to review the changes.' + alert_subject: '%{record_type}: %{id} - %{form_name} Updated' webpush_notification: action_label: Go to Case approval_request: @@ -858,6 +860,9 @@ en: transfer: title: New Transfer body: You have received a new Case Transfer. + alert_case: + title: Case Updated + body: "%{type} has been updated on one of your cases." transfer_request: title: Transfer Request body: You have received a new Case Transfer Request @@ -2375,6 +2380,7 @@ en: workflow_team: Workflow - Teams cases write: Write activity_log: Activity Log + remove_alert: Remove Alert resource: kpi: actions: @@ -2688,6 +2694,12 @@ en: that this permission also allows the user to see a link to the incident which was created. label: Create an incident from a case + remove_alert: + explanation: This permission allows the user to remove an alert from + a case. Alerts are typically generated when a particular field is + edited, such as when notes are updated. Alerts are usually shown + with a yellow dot next to the case. + label: Remove alert from a case manage: explanation: Allows a user to perform all available actions on cases. The "Manage" permission essentially means "everything". @@ -3217,6 +3229,12 @@ en: skills and an in-depth knowledge of how data is formatted in Primero. Only performed from incident list page. label: Import + remove_alert: + explanation: This permission allows the user to remove an alert from + an incident. Alerts are typically generated when a particular field is + edited, such as when notes are updated. Alerts are usually shown + with a yellow dot next to the incident. + label: Remove alert from an incident manage: explanation: Allows a user to perform all available actions on incidents. The "Manage" permission essentially means "everything". diff --git a/config/routes.rb b/config/routes.rb index 72d540cdb4..e2b4b78fec 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,7 +39,7 @@ resources :children, as: :cases, path: :cases do resources :children_incidents, as: :incidents, path: :incidents, only: %i[index new] resources :flags, only: %i[index create update] - resources :alerts, only: [:index] + resources :alerts, only: %i[index destroy] resources :assigns, only: %i[index create] resources :referrals, only: %i[index create destroy update] resources :transfers, only: %i[index create update] @@ -62,7 +62,7 @@ resources :incidents do resources :flags, only: %i[index create update] - resources :alerts, only: [:index] + resources :alerts, only: %i[index destroy] resources :approvals, only: [:update] resources :attachments, only: %i[create destroy] post :flags, to: 'flags#create_bulk', on: :collection diff --git a/db/migrate/20230921124122_add_send_email_to_alert.rb b/db/migrate/20230921124122_add_send_email_to_alert.rb new file mode 100644 index 0000000000..c9dbf974c3 --- /dev/null +++ b/db/migrate/20230921124122_add_send_email_to_alert.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSendEmailToAlert < ActiveRecord::Migration[6.1] + def change + add_column :alerts, :send_email, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 9eff04d456..5fa1e2c863 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_09_20_000000) do +ActiveRecord::Schema.define(version: 2023_09_21_124122) do # These are extensions that must be enabled in order to support this database enable_extension "ltree" @@ -83,6 +83,7 @@ t.integer "agency_id" t.string "record_type" t.uuid "record_id" + t.boolean "send_email", default: false t.index ["agency_id"], name: "index_alerts_on_agency_id" t.index ["record_type", "record_id"], name: "index_alerts_on_record_type_and_record_id" t.index ["user_id"], name: "index_alerts_on_user_id" diff --git a/spec/jobs/alert_notify_spec.rb b/spec/jobs/alert_notify_spec.rb new file mode 100644 index 0000000000..f0ae8cd3e9 --- /dev/null +++ b/spec/jobs/alert_notify_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe AlertNotifyJob, type: :job do + include ActiveJob::TestHelper + + before do + clean_data(User, Role, PrimeroModule, PrimeroProgram, Field, FormSection, UserGroup, Agency, Alert) + role = create :role + @owner = create :user, role:, user_name: 'owner', full_name: 'Owner', email: 'owner@primero.dev' + @provider = create :user, role:, user_name: 'provider', full_name: 'Provider', email: 'provider@primero.dev' + @other = create :user, role:, user_name: 'other', full_name: 'Other', email: 'other@primero.dev' + @child = Child.new( + owned_by: @owner.user_name, + name: 'child', + module_id: PrimeroModule::CP, + case_id_display: '12345', + consent_for_services: true, + disclosure_other_orgs: true + ) + @child.save! + @child.assigned_user_names = [@provider.user_name] + @child.save! + # Force an update on associated_users + @child.associated_users(true) + end + + describe 'perform_later' do + it 'enqueues a job when an alert is created' do + ActiveJob::Base.queue_adapter = :test + @child.last_updated_by = @other.user_name + @child.update_history + @child.save! + alert = Alert.new(send_email: true, alert_for: Alertable::FIELD_CHANGE, record: @child, type: 'some_type') + alert.save! + alert_notify_jobs = enqueued_jobs.select { |j| j[:job] == AlertNotifyJob } + # There should be two jobs, one for the owner and one for the provider + expect(alert_notify_jobs.size).to eq(2) + end + it 'sends two notifications' do + @child.last_updated_by = @other.user_name + @child.update_history + @child.save! + alert = Alert.new(send_email: true, alert_for: Alertable::FIELD_CHANGE, record: @child, type: 'some_type') + alert.save! + perform_enqueued_jobs(only: AlertNotifyJob) + expect(ActionMailer::Base.deliveries.size).to eq(2) + end + it 'does not send a notification to the person who made the change' do + @child.last_updated_by = @provider.user_name + @child.update_history + @child.save! + alert = Alert.new(send_email: true, alert_for: Alertable::FIELD_CHANGE, record: @child, type: 'some_type') + alert.save! + perform_enqueued_jobs(only: AlertNotifyJob) + expect(ActionMailer::Base.deliveries.size).to eq(1) + end + end + + after :each do + clean_data(User, Role, PrimeroModule, PrimeroProgram, Field, FormSection, UserGroup, Agency, Alert) + end + + private + + def child_with_created_by(created_by, options = {}) + user = User.new(user_name: created_by) + child = Child.new_with_user user, options + child.save && child + end +end diff --git a/spec/mailers/record_action_mailer_spec.rb b/spec/mailers/record_action_mailer_spec.rb index bfd0e8df48..9f5974482e 100644 --- a/spec/mailers/record_action_mailer_spec.rb +++ b/spec/mailers/record_action_mailer_spec.rb @@ -7,7 +7,13 @@ describe RecordActionMailer, type: :mailer do before do clean_data(SystemSettings) - SystemSettings.create(default_locale: 'en', unhcr_needs_codes_mapping: {}, changes_field_to_form: {}) + SystemSettings.create(default_locale: 'en', unhcr_needs_codes_mapping: {}, + changes_field_to_form: { + 'email_alertable_field' => { + form_section_unique_id: 'some_formsection_name', + alert_strategy: Alertable::AlertStrategy::ASSOCIATED_USERS + } + }) end describe 'approvals' do @@ -397,8 +403,43 @@ end end - after do - clean_data(User, Role, PrimeroModule, PrimeroProgram, Field, FormSection, Lookup, UserGroup, Agency, Transition) + describe 'Emailable Alert' do + before do + clean_data(User, Role, PrimeroModule, PrimeroProgram, Field, FormSection, UserGroup, Agency) + FormSection.create!(unique_id: 'some_formsection_name', name: 'some_formsection_name', + name_en: 'Form Section Name', name_fr: 'Nom de la section du formulaire') + @owner = create :user, user_name: 'owner', full_name: 'Owner', email: 'owner@primero.dev' + @provider = create :user, user_name: 'provider', full_name: 'Provider', email: 'provider@primero.dev' + @child = Child.new_with_user(@owner, { name: 'child', module_id: PrimeroModule::CP, case_id_display: '12345' }) + @child.save! + @child.assigned_user_names = [@provider.user_name] + @child.save! + @child.associated_users(true) + @child.data = { 'email_alertable_field' => 'some_value' } + @child.save! + @alert_notification = AlertNotificationService.new(@child.id, @child.alerts.first.id, @owner.user_name) + end + + let(:mail) { RecordActionMailer.alert_notify(@alert_notification) } + + describe 'alert' do + it 'renders the headers' do + expect(mail.subject).to eq("Case: #{@child.short_id} - Form Section Name Updated") + expect(mail.to).to eq(['owner@primero.dev']) + end + + it 'renders the body' do + expect(mail.text_part.body.encoded).to match( + "Case #{@child.short_id} - Form Section Name has been updated. " \ + 'Please log in to Primero to review the changes.' + ) + end + end + + after do + clean_data(User, Role, PrimeroModule, PrimeroProgram, Field, FormSection, Lookup, UserGroup, Agency, Transition, + Alert, Child) + end end private diff --git a/spec/models/alert_spec.rb b/spec/models/alert_spec.rb index 0c3d4624a2..bbbbd794d3 100644 --- a/spec/models/alert_spec.rb +++ b/spec/models/alert_spec.rb @@ -23,4 +23,41 @@ expect(Alert.all.map { |alert| alert.record.id }).to match_array([@child2.id, @child3.id]) end end + + describe 'email alerts' do + before :each do + clean_data(User, Role, PrimeroModule, PrimeroProgram, Field, FormSection, UserGroup, Agency, Alert, + SystemSettings, Child) + ss = SystemSettings.create! + ss.changes_field_to_form = { + 'email_alertable_field' => { + form_section_unique_id: 'some_formsection_name1', + alert_strategy: Alertable::AlertStrategy::ASSOCIATED_USERS + } + } + ss.save! + @owner = create :user, user_name: 'owner', full_name: 'Owner', email: 'owner@primero.dev' + @provider = create :user, user_name: 'provider', full_name: 'Provider', email: 'provider@primero.dev' + end + it 'creates an email alert' do + child = Child.new(data: { 'email_alertable_field' => 'some_value' }) + child.save! + expect(Alert.count).to eq(1) + end + it 'does not create an email alert on other fields' do + child = Child.new(data: { 'some_other_field' => 'some_value' }) + child.save! + expect(Alert.count).to eq(0) + end + it 'deletes the old alert when a duplicate alert is created' do + child = Child.new(data: { 'email_alertable_field' => 'some_value' }) + child.save! + expect(Alert.count).to eq(1) + old_alert = Alert.first + child.data['email_alertable_field'] = 'some_other_value' + child.save! + expect(Alert.count).to eq(1) + expect(Alert.first.unique_id).not_to eq(old_alert.unique_id) + end + end end diff --git a/spec/requests/api/v2/alerts_controller_spec.rb b/spec/requests/api/v2/alerts_controller_spec.rb index 16768a0b41..1adb8e6d5f 100644 --- a/spec/requests/api/v2/alerts_controller_spec.rb +++ b/spec/requests/api/v2/alerts_controller_spec.rb @@ -199,6 +199,67 @@ end end + describe 'DELETE /api/v2//alerts/' do + it 'deletes an alert from a child' do + alert = @test_child.alerts.first + login_for_test( + permissions: [ + Permission.new( + resource: Permission::CASE, + actions: [Permission::REMOVE_ALERT] + ) + ] + ) + + delete "/api/v2/cases/#{@test_child.id}/alerts/#{alert.unique_id}" + expect(response).to have_http_status(204) + expect(@test_child.alerts.count).to eq(2) + end + + it 'deletes an alert from a incident' do + alert = @test_incident.alerts.first + login_for_test( + permissions: [ + Permission.new( + resource: Permission::INCIDENT, + actions: [Permission::REMOVE_ALERT] + ) + ] + ) + delete "/api/v2/incidents/#{@test_incident.id}/alerts/#{alert.unique_id}" + expect(response).to have_http_status(204) + expect(@test_incident.alerts.count).to eq(2) + end + it 'does not delete an alert from a incident if the user does not have remove_alert permission' do + alert = @test_incident.alerts.first + login_for_test( + permissions: [ + Permission.new( + resource: Permission::INCIDENT, + actions: [Permission::READ] + ) + ] + ) + delete "/api/v2/incidents/#{@test_incident.id}/alerts/#{alert.unique_id}" + expect(response).to have_http_status(403) + expect(@test_incident.alerts.count).to eq(3) + end + it 'does not delete an alert if the alert id does not correspond to the record' do + alert = @test_incident.alerts.first + login_for_test( + permissions: [ + Permission.new( + resource: Permission::CASE, + actions: [Permission::REMOVE_ALERT] + ) + ] + ) + delete "/api/v2/cases/#{@test_child.id}/alerts/#{alert.unique_id}" + expect(response).to have_http_status(404) + expect(@test_incident.alerts.count).to eq(3) + end + end + after do clear_enqueued_jobs clean_data(Alert, User, Incident, TracingRequest, Child, Role, Agency)