diff --git a/doc/release-notes/7492_muting_notifications.md b/doc/release-notes/7492_muting_notifications.md new file mode 100644 index 00000000000..18c82b3a106 --- /dev/null +++ b/doc/release-notes/7492_muting_notifications.md @@ -0,0 +1,9 @@ +This release has a new feature that allows users to control which notifications they receive. How to enable this feature and which options can be set by the admins is described in the [Letting users manage receiving notifications section of the Admin Guide](https://guides.dataverse.org/en/latest/admin/user-administration.html#letting-users-manage-receiving-notifications). See also #7492. + +In addition, the existing API endpoint for listing notifications has been enhanced to show the subject, text, and timestamp of notifications. See also #8487. + +## New DB Settings + +- :ShowMuteOptions +- :AlwaysMuted +- :NeverMuted diff --git a/doc/sphinx-guides/source/admin/user-administration.rst b/doc/sphinx-guides/source/admin/user-administration.rst index df9a9f61aaa..608a8ab2b72 100644 --- a/doc/sphinx-guides/source/admin/user-administration.rst +++ b/doc/sphinx-guides/source/admin/user-administration.rst @@ -75,3 +75,50 @@ Using the API token 7ae33670-be21-491d-a244-008149856437 as an example: ``delete from apitoken where tokenstring = '7ae33670-be21-491d-a244-008149856437';`` You should expect the output ``DELETE 1`` after issuing the command above. + +.. _mute-notifications: + +Letting Users Manage Notifications +----------------------------------- + +See :ref:`account-notifications` in the User Guide for how notifications are described to end users. + +You can let users manage which notification types they wish to receive by setting :ref:`:ShowMuteOptions` to "true": + +``curl -X PUT -d 'true' http://localhost:8080/api/admin/settings/:ShowMuteOptions`` + +This enables additional settings for each user in the notifications tab of their account page. The users can select which in-app notifications and/or e-mails they wish to receive out of the following list: + +* ``APIGENERATED`` API token is generated +* ``ASSIGNROLE`` Role is assigned +* ``CHECKSUMFAIL`` Checksum validation failed +* ``CHECKSUMIMPORT`` Dataset had file checksums added via a batch job +* ``CONFIRMEMAIL`` Email Verification +* ``CREATEACC`` Account is created +* ``CREATEDS`` Your dataset is created +* ``CREATEDV`` Dataverse collection is created +* ``DATASETCREATED`` Dataset was created by user +* ``FILESYSTEMIMPORT`` Dataset has been successfully uploaded and verified +* ``GRANTFILEACCESS`` Access to file is granted +* ``INGESTCOMPLETEDWITHERRORS`` Ingest completed with errors +* ``INGESTCOMPLETED`` Ingest is completed +* ``PUBLISHEDDS`` Dataset is published +* ``PUBLISHFAILED_PIDREG`` Publish has failed +* ``REJECTFILEACCESS`` Access to file is rejected +* ``REQUESTFILEACCESS`` Access to file is requested +* ``RETURNEDDS`` Returned from review +* ``REVOKEROLE`` Role is revoked +* ``STATUSUPDATED`` Status of dataset has been updated +* ``SUBMITTEDDS`` Submitted for review +* ``WORKFLOW_FAILURE`` External workflow run has failed +* ``WORKFLOW_SUCCESS`` External workflow run has succeeded + +After enabling this feature, all notifications are enabled by default, until this is changed by the user. + +You can shorten this list by configuring some notification types (e.g., ``ASSIGNROLE`` and ``REVOKEROLE``) to be always muted for everyone and not manageable by users (not visible in the user interface) with the :ref:`:AlwaysMuted` setting: + +``curl -X PUT -d 'ASSIGNROLE,REVOKEROLE' http://localhost:8080/api/admin/settings/:AlwaysMuted`` + +Finally, you can set some notifications (e.g., ``REQUESTFILEACCESS``, ``GRANTFILEACCESS`` and ``REJECTFILEACCESS``) as always enabled for everyone and not manageable by users (grayed out in the user interface) with the :ref:`:NeverMuted` setting: + +``curl -X PUT -d 'REQUESTFILEACCESS,GRANTFILEACCESS,REJECTFILEACCESS' http://localhost:8080/api/admin/settings/:NeverMuted`` diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index fe11a8c6947..5cf90359001 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -2836,13 +2836,90 @@ Show Info About Single Metadata Block Notifications ------------- +See :ref:`account-notifications` in the User Guide for an overview. For a list of all the notification types mentioned below (e.g. ASSIGNROLE), see :ref:`mute-notifications` in the Admin Guide. + Get All Notifications by User ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Each user can get a dump of their notifications by passing in their API token:: +Each user can get a dump of their notifications by passing in their API token: + +.. code-block:: bash + + curl -H "X-Dataverse-key:$API_TOKEN" $SERVER_URL/api/notifications/all + +Delete Notification by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can delete notifications by passing in their API token and specifying notification ID (e.g., 555): + +.. code-block:: bash + + export NOTIFICATION_ID=555 + + curl -H X-Dataverse-key:$API_TOKEN -X DELETE "$SERVER_URL/api/notifications/$NOTIFICATION_ID" + +Get All Muted In-app Notifications by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can get a list of their muted in-app notification types by passing in their API token: + +.. code-block:: bash + + curl -H X-Dataverse-key:$API_TOKEN -X GET "$SERVER_URL/api/notifications/mutedNotifications" + +Mute In-app Notification by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can mute in-app notifications by passing in their API token and specifying notification type to be muted (e.g., ASSIGNROLE): + +.. code-block:: bash + + export NOTIFICATION_TYPE=ASSIGNROLE + + curl -H X-Dataverse-key:$API_TOKEN -X PUT "$SERVER_URL/api/notifications/mutedNotifications/$NOTIFICATION_TYPE" + +Unmute In-app Notification by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can unmute in-app notifications by passing in their API token and specifying notification type to be unmuted (e.g., ASSIGNROLE): + +.. code-block:: bash + + export NOTIFICATION_TYPE=ASSIGNROLE + + curl -H X-Dataverse-key:$API_TOKEN -X DELETE "$SERVER_URL/api/notifications/mutedNotifications/$NOTIFICATION_TYPE" + +Get All Muted Email Notifications by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can get a list of their muted email notification types by passing in their API token: + +.. code-block:: bash + + curl -H X-Dataverse-key:$API_TOKEN -X GET "$SERVER_URL/api/notifications/mutedEmails" + +Mute Email Notification by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can mute email notifications by passing in their API token and specifying notification type to be muted (e.g., ASSIGNROLE): + +.. code-block:: bash + + export NOTIFICATION_TYPE=ASSIGNROLE + + curl -H X-Dataverse-key:$API_TOKEN -X PUT "$SERVER_URL/api/notifications/mutedEmails/$NOTIFICATION_TYPE" + +Unmute Email Notification by User +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each user can unmute email notifications by passing in their API token and specifying notification type to be unmuted (e.g., ASSIGNROLE): + +.. code-block:: bash + + export NOTIFICATION_TYPE=ASSIGNROLE + + curl -H X-Dataverse-key:$API_TOKEN -X DELETE "$SERVER_URL/api/notifications/mutedEmails/$NOTIFICATION_TYPE" - curl -H "X-Dataverse-key:$API_TOKEN" $SERVER_URL/api/notifications/all - .. _User Information: User Information diff --git a/doc/sphinx-guides/source/developers/sql-upgrade-scripts.rst b/doc/sphinx-guides/source/developers/sql-upgrade-scripts.rst index c76ddab0c09..bace682b1b8 100644 --- a/doc/sphinx-guides/source/developers/sql-upgrade-scripts.rst +++ b/doc/sphinx-guides/source/developers/sql-upgrade-scripts.rst @@ -26,7 +26,7 @@ How to Create a SQL Upgrade Script We assume you have already read the :doc:`version-control` section and have been keeping your feature branch up to date with the "develop" branch. -Create a new file called something like ``V4.11.0.1__5565-sanitize-directory-labels.sql`` in the ``src/main/resources/db/migration`` directory. Use a version like "4.11.0.1" in the example above where the previously released version was 4.11, ensuring that the version number is unique. Note that this is not the version that you expect the code changes to be included in (4.12 in this example). For the "description" you should the name of your branch, which should include the GitHub issue you are working on, as in the example above. To read more about Flyway file naming conventions, see https://flywaydb.org/documentation/migrations#naming +Create a new file called something like ``V4.11.0.1__5565-sanitize-directory-labels.sql`` in the ``src/main/resources/db/migration`` directory. Use a version like "4.11.0.1" in the example above where the previously released version was 4.11, ensuring that the version number is unique. Note that this is not the version that you expect the code changes to be included in (4.12 in this example). When the previously released version is a patch version (e.g. 5.10.1), use "5.10.1.1" for the first SQL script version (rather than "5.10.1.0.1"). For the "description" you should the name of your branch, which should include the GitHub issue you are working on, as in the example above. To read more about Flyway file naming conventions, see https://flywaydb.org/documentation/migrations#naming The SQL migration script you wrote will be part of the war file and executed when the war file is deployed. To see a history of Flyway database migrations that have been applied, look at the ``flyway_schema_history`` table. diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index cd40221d7fc..509fef4b951 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -698,6 +698,10 @@ The image below indicates that the page layout consists of three main blocks: a |dvPageBlocks| +.. To edit, use dvBrandingCustBlocks.drawio with https://app.diagrams.net +.. |dvPageBlocks| image:: ./img/dvBrandingCustBlocks.png + :class: img-responsive + Installation Name/Brand Name ++++++++++++++++++++++++++++ @@ -2760,6 +2764,29 @@ To remove the override and go back to the default list: ``curl -X PUT -d '' http://localhost:8080/api/admin/settings/:FileCategories`` -.. To edit, use dvBrandingCustBlocks.drawio with https://app.diagrams.net -.. |dvPageBlocks| image:: ./img/dvBrandingCustBlocks.png - :class: img-responsive +.. _:ShowMuteOptions: + +:ShowMuteOptions +++++++++++++++++ + +Allows users to mute notifications by showing additional configuration options in the Notifications tab of the account page (see :ref:`account-notifications` in the User Guide). By default, this setting is "false" and users cannot mute any notifications (this feature is not shown in the user interface). + +For configuration details, see :ref:`mute-notifications`. + +.. _:AlwaysMuted: + +:AlwaysMuted +++++++++++++ + +Overrides the default empty list of always muted notifications. Always muted notifications cannot be unmuted by the users. Always muted notifications are not shown in the notification settings for the users. + +For configuration details, see :ref:`mute-notifications`. + +.. _:NeverMuted: + +:NeverMuted ++++++++++++ + +Overrides the default empty list of never muted notifications. Never muted notifications cannot be muted by the users. Always muted notifications are grayed out and are not adjustable by the user. + +For configuration details, see :ref:`mute-notifications`. diff --git a/doc/sphinx-guides/source/user/account.rst b/doc/sphinx-guides/source/user/account.rst index 4c343ff85d4..12cc54c7fde 100755 --- a/doc/sphinx-guides/source/user/account.rst +++ b/doc/sphinx-guides/source/user/account.rst @@ -155,19 +155,27 @@ The My Data section of your account page displays a listing of all the Dataverse You can use the Add Data button to create a new Dataverse collection or dataset. By default, the new Dataverse collection or dataset will be created in the root Dataverse collection, but from the create form you can use the Host Dataverse collection dropdown menu to choose a different Dataverse collection, for which you have the proper access privileges. However, you will not be able to change this selection after you create your Dataverse collection or dataset. +.. _account-notifications: + Notifications ------------- -Notifications appear in the notifications tab on your account page and are also displayed as a number next to your account name. +Notifications appear in the notifications tab on your account page and are also displayed as a number next to your account name. You also receive notifications via email. + +If your admin has enabled the option to change the notification settings, you will find an overview of the notification and email settings in the notifications tab. There, you can select which notifications and/or emails you wish to receive. If certain notification or email options are greyed out, you can’t change the setting for this notification because the admin has set these as never to be muted by the user. You control the in-app and the email notifications separately in the two lists. -You will receive a notification when: +You will typically receive a notification or email when: - You've created your account. - You've created a Dataverse collection or added a dataset. -- Another Dataverse installation user has requested access to restricted files in a dataset that you published. (If you submitted your dataset for review and it was published by a curator, the curators of the Dataverse collection that contains your dataset will get a notification about requests to access your restricted files.) +- Another Dataverse installation user has requested access to restricted files in a dataset that you published. (If you submitted your dataset for review, and it was published by a curator, the curators of the Dataverse collection that contains your dataset will get a notification about requests to access your restricted files.) - A file in one of your datasets has finished the ingest process. -Notifications will only be emailed one time even if you haven't read the notification on the Dataverse installation. +There are other notification types that you can receive, e.g., notification on granted roles, API key generation, etc. These types of notifications are less common and are not described here. Some other notifications are limited to specific roles. For example, if the installation has a curation workflow, reviewers get notified when a new dataset is submitted for review. + +Notifications will only be emailed once, even if you haven't read the in-app notification. + +It's possible to manage notifications via API. See :ref:`notifications` in the API Guide. API Token --------- diff --git a/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java index bd5f27b9e83..f39fb8b0a32 100644 --- a/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java @@ -613,7 +613,7 @@ public String getMessageTextBasedOnNotification(UserNotification userNotificatio return ""; } - private Object getObjectOfNotification (UserNotification userNotification){ + public Object getObjectOfNotification (UserNotification userNotification){ switch (userNotification.getType()) { case ASSIGNROLE: case REVOKEROLE: diff --git a/src/main/java/edu/harvard/iq/dataverse/SettingsWrapper.java b/src/main/java/edu/harvard/iq/dataverse/SettingsWrapper.java index bf0b873d512..7dd308e54c7 100644 --- a/src/main/java/edu/harvard/iq/dataverse/SettingsWrapper.java +++ b/src/main/java/edu/harvard/iq/dataverse/SettingsWrapper.java @@ -13,6 +13,7 @@ import edu.harvard.iq.dataverse.util.MailUtil; import edu.harvard.iq.dataverse.util.StringUtil; import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.UserNotification.Type; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -23,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.logging.Logger; +import java.util.Set; import javax.ejb.EJB; import javax.faces.application.FacesMessage; @@ -102,6 +104,10 @@ public class SettingsWrapper implements java.io.Serializable { private Boolean customLicenseAllowed = null; + private Set alwaysMuted = null; + + private Set neverMuted = null; + public String get(String settingKey) { if (settingsMap == null) { initSettingsMap(); @@ -176,6 +182,40 @@ private void initSettingsMap() { } } + private void initAlwaysMuted() { + alwaysMuted = UserNotification.Type.tokenizeToSet(getValueForKey(Key.AlwaysMuted)); + } + + private void initNeverMuted() { + neverMuted = UserNotification.Type.tokenizeToSet(getValueForKey(Key.NeverMuted)); + } + + public Set getAlwaysMutedSet() { + if (alwaysMuted == null) { + initAlwaysMuted(); + } + return alwaysMuted; + } + + public Set getNeverMutedSet() { + if (neverMuted == null) { + initNeverMuted(); + } + return neverMuted; + } + + public boolean isAlwaysMuted(Type type) { + return getAlwaysMutedSet().contains(type); + } + + public boolean isNeverMuted(Type type) { + return getNeverMutedSet().contains(type); + } + + public boolean isShowMuteOptions() { + return isTrueForKey(Key.ShowMuteOptions, false); + } + public String getGuidesBaseUrl() { if (guidesBaseUrl == null) { diff --git a/src/main/java/edu/harvard/iq/dataverse/UserNotification.java b/src/main/java/edu/harvard/iq/dataverse/UserNotification.java index 58152f6673e..5714a879527 100644 --- a/src/main/java/edu/harvard/iq/dataverse/UserNotification.java +++ b/src/main/java/edu/harvard/iq/dataverse/UserNotification.java @@ -1,11 +1,17 @@ package edu.harvard.iq.dataverse; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.DateUtil; import java.io.Serializable; import java.sql.Timestamp; import java.text.SimpleDateFormat; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.Collections; +import java.util.HashSet; +import java.util.stream.Collectors; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Enumerated; @@ -26,11 +32,37 @@ @Table(indexes = {@Index(columnList="user_id")}) public class UserNotification implements Serializable { + // Keep in sync with list at admin/user-administration.rst public enum Type { ASSIGNROLE, REVOKEROLE, CREATEDV, CREATEDS, CREATEACC, SUBMITTEDDS, RETURNEDDS, PUBLISHEDDS, REQUESTFILEACCESS, GRANTFILEACCESS, REJECTFILEACCESS, FILESYSTEMIMPORT, CHECKSUMIMPORT, CHECKSUMFAIL, CONFIRMEMAIL, APIGENERATED, INGESTCOMPLETED, INGESTCOMPLETEDWITHERRORS, - PUBLISHFAILED_PIDREG, WORKFLOW_SUCCESS, WORKFLOW_FAILURE, STATUSUPDATED, DATASETCREATED + PUBLISHFAILED_PIDREG, WORKFLOW_SUCCESS, WORKFLOW_FAILURE, STATUSUPDATED, DATASETCREATED; + + public String getDescription() { + return BundleUtil.getStringFromBundle("notification.typeDescription." + this.name()); + } + + public boolean hasDescription() { + final String description = getDescription(); + return description != null && !description.isEmpty(); + } + + public static Set tokenizeToSet(String tokens) { + if (tokens == null || tokens.isEmpty()) { + return new HashSet<>(); + } + return Collections.list(new StringTokenizer(tokens, ",")).stream() + .map(token -> Type.valueOf(((String) token).trim())) + .collect(Collectors.toSet()); + } + + public static String toStringValue(Set typesSet) { + if (typesSet == null || typesSet.isEmpty()) { + return null; + } + return String.join(",", typesSet.stream().map(x -> x.name()).collect(Collectors.toList())); + } }; private static final long serialVersionUID = 1L; @@ -93,6 +125,10 @@ public String getSendDate() { return new SimpleDateFormat("MMMM d, yyyy h:mm a z").format(sendDate); } + public Timestamp getSendDateTimestamp() { + return sendDate; + } + public void setSendDate(Timestamp sendDate) { this.sendDate = sendDate; } diff --git a/src/main/java/edu/harvard/iq/dataverse/UserNotificationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/UserNotificationServiceBean.java index 071805d3d26..6792a7bedc7 100644 --- a/src/main/java/edu/harvard/iq/dataverse/UserNotificationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/UserNotificationServiceBean.java @@ -8,6 +8,9 @@ import edu.harvard.iq.dataverse.UserNotification.Type; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; + import java.sql.Timestamp; import java.util.List; import java.util.logging.Logger; @@ -15,6 +18,7 @@ import javax.ejb.Stateless; import javax.ejb.TransactionAttribute; import javax.ejb.TransactionAttributeType; +import javax.inject.Inject; import javax.inject.Named; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; @@ -35,6 +39,9 @@ public class UserNotificationServiceBean { MailServiceBean mailService; @PersistenceContext(unitName = "VDCNet-ejbPU") private EntityManager em; + + @EJB + SettingsServiceBean settingsService; public List findByUser(Long userId) { TypedQuery query = em.createQuery("select un from UserNotification un where un.user.id =:userId order by un.sendDate desc", UserNotification.class); @@ -110,12 +117,36 @@ public void sendNotification(AuthenticatedUser dataverseUser, Timestamp sendDate userNotification.setObjectId(objectId); userNotification.setRequestor(requestor); - if (mailService.sendNotificationEmail(userNotification, comment, requestor, isHtmlContent)) { + if (!isEmailMuted(userNotification) && mailService.sendNotificationEmail(userNotification, comment, requestor, isHtmlContent)) { logger.fine("email was sent"); userNotification.setEmailed(true); } else { logger.fine("email was not sent"); } - save(userNotification); + if (!isNotificationMuted(userNotification)) { + save(userNotification); + } + } + + public boolean isEmailMuted(UserNotification userNotification) { + final Type type = userNotification.getType(); + final AuthenticatedUser user = userNotification.getUser(); + final boolean alwaysMuted = settingsService.containsCommaSeparatedValueForKey(Key.AlwaysMuted, type.name()); + final boolean neverMuted = settingsService.containsCommaSeparatedValueForKey(Key.NeverMuted, type.name()); + if (alwaysMuted && neverMuted) { + logger.warning("Both; AlwaysMuted and NeverMuted are set for " + type.name() + ", email is muted"); + } + return alwaysMuted || (!neverMuted && user.hasEmailMuted(type)); + } + + public boolean isNotificationMuted(UserNotification userNotification) { + final Type type = userNotification.getType(); + final AuthenticatedUser user = userNotification.getUser(); + final boolean alwaysMuted = settingsService.containsCommaSeparatedValueForKey(Key.AlwaysMuted, type.name()); + final boolean neverMuted = settingsService.containsCommaSeparatedValueForKey(Key.NeverMuted, type.name()); + if (alwaysMuted && neverMuted) { + logger.warning("Both; AlwaysMuted and NeverMuted are set for " + type.name() + ", notification is muted"); + } + return alwaysMuted || (!neverMuted && user.hasNotificationMuted(type)); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java index 9ec0527a318..2d8ecf64f76 100644 --- a/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java @@ -1,4 +1,5 @@ package edu.harvard.iq.dataverse; +import edu.harvard.iq.dataverse.UserNotification.Type; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.userdata.UserUtil; @@ -144,6 +145,9 @@ private AuthenticatedUser createAuthenticatedUserForView (Object[] dbRowValues, user.setDeactivated((Boolean)(dbRowValues[13])); user.setDeactivatedTime(UserUtil.getTimestampOrNull(dbRowValues[14])); + user.setMutedEmails(Type.tokenizeToSet((String) dbRowValues[15])); + user.setMutedNotifications(Type.tokenizeToSet((String) dbRowValues[15])); + user.setRoles(roles); return user; } @@ -415,7 +419,8 @@ private List getUserListCore(String searchTerm, qstr += " u.position,"; qstr += " u.createdtime, u.lastlogintime, u.lastapiusetime, "; qstr += " prov.id, prov.factoryalias, "; - qstr += " u.deactivated, u.deactivatedtime "; + qstr += " u.deactivated, u.deactivatedtime, "; + qstr += " u.mutedEmails, u.mutedNotifications "; qstr += " FROM authenticateduser u,"; qstr += " authenticateduserlookup prov_lookup,"; qstr += " authenticationproviderrow prov"; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Notifications.java b/src/main/java/edu/harvard/iq/dataverse/api/Notifications.java index 4067e61a31c..c477788cae6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Notifications.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Notifications.java @@ -1,26 +1,40 @@ package edu.harvard.iq.dataverse.api; +import edu.harvard.iq.dataverse.MailServiceBean; import edu.harvard.iq.dataverse.UserNotification; import edu.harvard.iq.dataverse.UserNotification.Type; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.workflows.WorkflowUtil; import java.util.List; +import java.util.Optional; +import java.util.Set; + +import javax.ejb.EJB; +import javax.ejb.Stateless; import javax.json.Json; import javax.json.JsonArrayBuilder; import javax.json.JsonObjectBuilder; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.PUT; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.core.Response; + +import edu.harvard.iq.dataverse.util.MailUtil; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; -import javax.json.JsonValue; +@Stateless @Path("notifications") public class Notifications extends AbstractApiBean { + @EJB + MailServiceBean mailService; + @GET - @Path("all") + @Path("/all") public Response getAllNotificationsForUser() { User user; try { @@ -54,6 +68,14 @@ public Response getAllNotificationsForUser() { notificationObjectBuilder.add("reasonsForReturn", reasonsForReturn); } */ + Object objectOfNotification = mailService.getObjectOfNotification(notification); + if (objectOfNotification != null){ + String subjectText = MailUtil.getSubjectTextBasedOnNotification(notification, objectOfNotification); + String messageText = mailService.getMessageTextBasedOnNotification(notification, objectOfNotification, null, notification.getRequestor()); + notificationObjectBuilder.add("subjectText", subjectText); + notificationObjectBuilder.add("messageText", messageText); + } + notificationObjectBuilder.add("sentTimestamp", notification.getSendDateTimestamp()); jsonArrayBuilder.add(notificationObjectBuilder); } JsonObjectBuilder result = Json.createObjectBuilder().add("notifications", jsonArrayBuilder); @@ -65,4 +87,208 @@ private JsonArrayBuilder getReasonsForReturn(UserNotification notification) { return WorkflowUtil.getAllWorkflowComments(datasetVersionSvc.find(objectId)); } + @DELETE + @Path("/{id}") + public Response deleteNotificationForUser(@PathParam("id") long id) { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + Long userId = authenticatedUser.getId(); + Optional notification = userNotificationSvc.findByUser(userId).stream().filter(x -> x.getId().equals(id)).findFirst(); + + if (notification.isPresent()) { + userNotificationSvc.delete(notification.get()); + return ok("Notification " + id + " deleted."); + } + + return notFound("Notification " + id + " not found."); + } + + @GET + @Path("/mutedEmails") + public Response getMutedEmailsForUser() { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + JsonArrayBuilder mutedEmails = Json.createArrayBuilder(); + authenticatedUser.getMutedEmails().stream().forEach( + x -> mutedEmails.add(jsonObjectBuilder().add("name", x.name()).add("description", x.getDescription())) + ); + JsonObjectBuilder result = Json.createObjectBuilder().add("mutedEmails", mutedEmails); + return ok(result); + } + + @PUT + @Path("/mutedEmails/{typeName}") + public Response muteEmailsForUser(@PathParam("typeName") String typeName) { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + UserNotification.Type notificationType; + try { + notificationType = UserNotification.Type.valueOf(typeName); + } catch (Exception ignore) { + return notFound("Notification type " + typeName + " not found."); + } + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + Set mutedEmails = authenticatedUser.getMutedEmails(); + mutedEmails.add(notificationType); + authenticatedUser.setMutedEmails(mutedEmails); + authSvc.update(authenticatedUser); + return ok("Notification emails of type " + typeName + " muted."); + } + + @DELETE + @Path("/mutedEmails/{typeName}") + public Response unmuteEmailsForUser(@PathParam("typeName") String typeName) { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + UserNotification.Type notificationType; + try { + notificationType = UserNotification.Type.valueOf(typeName); + } catch (Exception ignore) { + return notFound("Notification type " + typeName + " not found."); + } + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + Set mutedEmails = authenticatedUser.getMutedEmails(); + mutedEmails.remove(notificationType); + authenticatedUser.setMutedEmails(mutedEmails); + authSvc.update(authenticatedUser); + return ok("Notification emails of type " + typeName + " unmuted."); + } + + @GET + @Path("/mutedNotifications") + public Response getMutedNotificationsForUser() { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + JsonArrayBuilder mutedNotifications = Json.createArrayBuilder(); + authenticatedUser.getMutedNotifications().stream().forEach( + x -> mutedNotifications.add(jsonObjectBuilder().add("name", x.name()).add("description", x.getDescription())) + ); + JsonObjectBuilder result = Json.createObjectBuilder().add("mutedNotifications", mutedNotifications); + return ok(result); + } + + @PUT + @Path("/mutedNotifications/{typeName}") + public Response muteNotificationsForUser(@PathParam("typeName") String typeName) { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + UserNotification.Type notificationType; + try { + notificationType = UserNotification.Type.valueOf(typeName); + } catch (Exception ignore) { + return notFound("Notification type " + typeName + " not found."); + } + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + Set mutedNotifications = authenticatedUser.getMutedNotifications(); + mutedNotifications.add(notificationType); + authenticatedUser.setMutedNotifications(mutedNotifications); + authSvc.update(authenticatedUser); + return ok("Notification of type " + typeName + " muted."); + } + + @DELETE + @Path("/mutedNotifications/{typeName}") + public Response unmuteNotificationsForUser(@PathParam("typeName") String typeName) { + User user; + try { + user = findUserOrDie(); + } catch (WrappedResponse ex) { + return error(Response.Status.UNAUTHORIZED, "You must supply an API token."); + } + if (user == null) { + return error(Response.Status.BAD_REQUEST, "A user could not be found based on the API token."); + } + if (!(user instanceof AuthenticatedUser)) { + // It's unlikely we'll reach this error. A Guest doesn't have an API token and would have been blocked above. + return error(Response.Status.BAD_REQUEST, "Only an AuthenticatedUser can have notifications."); + } + + UserNotification.Type notificationType; + try { + notificationType = UserNotification.Type.valueOf(typeName); + } catch (Exception ignore) { + return notFound("Notification type " + typeName + " not found."); + } + AuthenticatedUser authenticatedUser = (AuthenticatedUser) user; + Set mutedNotifications = authenticatedUser.getMutedNotifications(); + mutedNotifications.remove(notificationType); + authenticatedUser.setMutedNotifications(mutedNotifications); + authSvc.update(authenticatedUser); + return ok("Notification of type " + typeName + " unmuted."); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/DataverseUserPage.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/DataverseUserPage.java index 0660de18bcb..142420bc7d9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/DataverseUserPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/DataverseUserPage.java @@ -17,6 +17,7 @@ import edu.harvard.iq.dataverse.SettingsWrapper; import edu.harvard.iq.dataverse.validation.UserNameValidator; import edu.harvard.iq.dataverse.UserNotification; +import edu.harvard.iq.dataverse.UserNotification.Type; import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.authorization.AuthUtil; @@ -46,8 +47,10 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.HashSet; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import javax.ejb.EJB; import javax.faces.application.FacesMessage; import javax.faces.component.UIComponent; @@ -57,6 +60,7 @@ import javax.faces.view.ViewScoped; import javax.inject.Inject; import javax.inject.Named; + import org.apache.commons.lang3.StringUtils; import org.hibernate.validator.constraints.NotBlank; import org.primefaces.event.TabChangeEvent; @@ -134,6 +138,12 @@ public enum EditMode { private String username; boolean nonLocalLoginEnabled; private List passwordErrors; + + + private List notificationTypeList; + private Set mutedEmails; + private Set mutedNotifications; + private Set disabledNotifications; public String init() { @@ -161,6 +171,13 @@ public String init() { setCurrentUser((AuthenticatedUser) session.getUser()); userAuthProvider = authenticationService.lookupProvider(currentUser); notificationsList = userNotificationService.findByUser(currentUser.getId()); + notificationTypeList = Arrays.asList(Type.values()).stream() + .filter(x -> !Type.CONFIRMEMAIL.equals(x) && x.hasDescription() && !settingsWrapper.isAlwaysMuted(x)) + .collect(Collectors.toList()); + mutedEmails = new HashSet<>(currentUser.getMutedEmails()); + mutedNotifications = new HashSet<>(currentUser.getMutedNotifications()); + disabledNotifications = new HashSet<>(settingsWrapper.getAlwaysMutedSet()); + disabledNotifications.addAll(settingsWrapper.getNeverMutedSet()); switch (selectTab) { case "notifications": @@ -334,7 +351,7 @@ public String save() { */ userNotificationService.sendNotification(au, new Timestamp(new Date().getTime()), - UserNotification.Type.CREATEACC, null); + Type.CREATEACC, null); // go back to where user came from @@ -368,6 +385,8 @@ public String save() { logger.info("Redirecting"); return permissionsWrapper.notAuthorized() + "faces-redirect=true"; }else { + currentUser.setMutedEmails(mutedEmails); + currentUser.setMutedNotifications(mutedNotifications); String emailBeforeUpdate = currentUser.getEmail(); AuthenticatedUser savedUser = authenticationService.updateAuthenticatedUser(currentUser, userDisplayInfo); String emailAfterUpdate = savedUser.getEmail(); @@ -702,4 +721,41 @@ public String getRequestorEmail(UserNotification notification) { if(notification.getRequestor() == null) return BundleUtil.getStringFromBundle("notification.email.info.unavailable");; return notification.getRequestor().getEmail() != null ? notification.getRequestor().getEmail() : BundleUtil.getStringFromBundle("notification.email.info.unavailable"); } + + public List getNotificationTypeList() { + return notificationTypeList; + } + + public void setNotificationTypeList(List notificationTypeList) { + this.notificationTypeList = notificationTypeList; + } + + public Set getToReceiveEmails() { + return notificationTypeList.stream().filter( + x -> isDisabled(x) ? !settingsWrapper.isAlwaysMuted(x) && settingsWrapper.isNeverMuted(x) : !mutedEmails.contains(x) + ).collect(Collectors.toSet()); + } + + public void setToReceiveEmails(Set toReceiveEmails) { + this.mutedEmails = notificationTypeList.stream().filter( + x -> !isDisabled(x) && !toReceiveEmails.contains(x) + ).collect(Collectors.toSet()); + } + + public Set getToReceiveNotifications() { + return notificationTypeList.stream().filter( + x -> isDisabled(x) ? !settingsWrapper.isAlwaysMuted(x) && settingsWrapper.isNeverMuted(x) : !mutedNotifications.contains(x) + ).collect(Collectors.toSet()); + } + + public void setToReceiveNotifications(Set toReceiveNotifications) { + this.mutedNotifications = notificationTypeList.stream().filter( + x -> !isDisabled(x) && !toReceiveNotifications.contains(x) + ).collect(Collectors.toSet()); + } + + public boolean isDisabled(Type t) { + return disabledNotifications.contains(t); + } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java b/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java index 5cd974d443a..b2b5fa92e76 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.Cart; import edu.harvard.iq.dataverse.DatasetLock; +import edu.harvard.iq.dataverse.UserNotification.Type; import edu.harvard.iq.dataverse.UserNotification; import edu.harvard.iq.dataverse.validation.ValidateEmail; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; @@ -11,6 +12,7 @@ import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibAuthenticationProvider; import edu.harvard.iq.dataverse.util.BundleUtil; +import edu.harvard.iq.dataverse.util.json.JsonPrinter; import static edu.harvard.iq.dataverse.util.StringUtil.nonEmpty; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import java.io.Serializable; @@ -18,6 +20,8 @@ import java.util.Date; import java.util.List; import java.util.Objects; +import java.util.Set; + import javax.json.Json; import javax.json.JsonObjectBuilder; import javax.persistence.CascadeType; @@ -30,6 +34,8 @@ import javax.persistence.NamedQuery; import javax.persistence.OneToMany; import javax.persistence.OneToOne; +import javax.persistence.PostLoad; +import javax.persistence.PrePersist; import javax.persistence.Transient; import javax.validation.constraints.NotNull; import org.hibernate.validator.constraints.NotBlank; @@ -122,6 +128,30 @@ public class AuthenticatedUser implements User, Serializable { @Column(nullable=true) private Timestamp deactivatedTime; + @Column(columnDefinition="TEXT", nullable=true) + private String mutedEmails; + + @Column(columnDefinition="TEXT", nullable=true) + private String mutedNotifications; + + @Transient + private Set mutedEmailsSet; + + @Transient + private Set mutedNotificationsSet; + + @PrePersist + void prePersist() { + mutedNotifications = Type.toStringValue(mutedNotificationsSet); + mutedEmails = Type.toStringValue(mutedEmailsSet); + } + + @PostLoad + void postLoad() { + mutedNotificationsSet = Type.tokenizeToSet(mutedNotifications); + mutedEmailsSet = Type.tokenizeToSet(mutedEmails); + } + /** * @todo Consider storing a hash of *all* potentially interesting Shibboleth * attribute key/value pairs, not just the Identity Provider (IdP). @@ -397,6 +427,8 @@ public JsonObjectBuilder toJson() { authenicatedUserJson.add("deactivated", this.deactivated); authenicatedUserJson.add("deactivatedTime", UserUtil.getTimestampStringOrNull(this.deactivatedTime)); + authenicatedUserJson.add("mutedEmails", JsonPrinter.enumsToJson(this.mutedEmailsSet)); + authenicatedUserJson.add("mutedNotifications", JsonPrinter.enumsToJson(this.mutedNotificationsSet)); return authenicatedUserJson; } @@ -500,4 +532,36 @@ public Cart getCart() { public void setCart(Cart cart) { this.cart = cart; } + + public Set getMutedEmails() { + return mutedEmailsSet; + } + + public void setMutedEmails(Set mutedEmails) { + this.mutedEmailsSet = mutedEmails; + this.mutedEmails = Type.toStringValue(mutedEmails); + } + + public Set getMutedNotifications() { + return mutedNotificationsSet; + } + + public void setMutedNotifications(Set mutedNotifications) { + this.mutedNotificationsSet = mutedNotifications; + this.mutedNotifications = Type.toStringValue(mutedNotifications); + } + + public boolean hasEmailMuted(Type type) { + if (this.mutedEmailsSet == null || type == null) { + return false; + } + return this.mutedEmailsSet.contains(type); + } + + public boolean hasNotificationMuted(Type type) { + if (this.mutedNotificationsSet == null || type == null) { + return false; + } + return this.mutedNotificationsSet.contains(type); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java index e13ea806dc7..12ae777f3f8 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java @@ -21,12 +21,14 @@ import org.json.JSONObject; import java.io.StringReader; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.StringTokenizer; import java.util.logging.Level; import java.util.logging.Logger; @@ -505,7 +507,22 @@ Whether Harvesting (OAI) service is enabled /* * Include "Custom Terms" as an item in the license drop-down or not. */ - AllowCustomTermsOfUse + AllowCustomTermsOfUse, + /* + * Allow users to mute notifications or not. + */ + ShowMuteOptions, + /* + * List (comma separated, e.g., "ASSIGNROLE,REVOKEROLE", extra whitespaces are trimmed such that "ASSIGNROLE, REVOKEROLE" + * would also work) of always muted notifications that cannot be turned on by the users. + */ + AlwaysMuted, + /* + * List (comma separated, e.g., "ASSIGNROLE,REVOKEROLE", extra whitespaces are trimmed such that "ASSIGNROLE, REVOKEROLE" + * would also work) of never muted notifications that cannot be turned off by the users. AlwaysMuted setting overrides + * Nevermuted setting warning is logged. + */ + NeverMuted ; @Override @@ -715,6 +732,15 @@ public boolean isTrueForKey( Key key, boolean defaultValue ) { public boolean isFalseForKey( Key key, boolean defaultValue ) { return ! isTrue( key.toString(), defaultValue ); } + + public boolean containsCommaSeparatedValueForKey(Key key, String value) { + final String tokens = getValueForKey(key); + if (tokens == null || tokens.isEmpty()) { + return false; + } + return Collections.list(new StringTokenizer(tokens, ",")).stream() + .anyMatch(token -> ((String) token).trim().equals(value)); + } public void deleteValueForKey( Key name ) { delete( name.toString() ); diff --git a/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java b/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java index 1ec17ac5928..8ab07cbc1a1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java @@ -5,7 +5,11 @@ */ package edu.harvard.iq.dataverse.userdata; +import edu.harvard.iq.dataverse.UserNotification.Type; import java.sql.Timestamp; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * @@ -65,5 +69,4 @@ public static Timestamp getTimestampOrNull(Object dbResult){ } return (Timestamp)dbResult; } - } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index f6dcf3d5821..a2becb20d7d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -292,6 +292,15 @@ public MailDomainGroup parseMailDomainGroup(JsonObject obj) throws JsonParseExce return grp; } + public static > List parseEnumsFromArray(JsonArray enumsArray, Class enumClass) throws JsonParseException { + final List enums = new LinkedList<>(); + + for (String name : enumsArray.getValuesAs(JsonString::getString)) { + enums.add(Enum.valueOf(enumClass, name)); + } + return enums; + } + public DatasetVersion parseDatasetVersion(JsonObject obj) throws JsonParseException { return parseDatasetVersion(obj, new DatasetVersion()); } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index ed3460b6759..91f1ac2cfbc 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -22,15 +22,11 @@ import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.DatasetFieldWalker; -import edu.harvard.iq.dataverse.util.StringUtil; import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; -import edu.harvard.iq.dataverse.util.SystemConfig; import edu.harvard.iq.dataverse.workflow.Workflow; import edu.harvard.iq.dataverse.workflow.step.WorkflowStepData; -import java.math.BigDecimal; -import java.net.URISyntaxException; import java.util.*; import javax.json.Json; import javax.json.JsonArrayBuilder; @@ -204,6 +200,14 @@ public static JsonArrayBuilder rolesToJson(List role) { return bld; } + public static JsonArrayBuilder enumsToJson(Collection collection) { + JsonArrayBuilder arr = Json.createArrayBuilder(); + for (E entry : collection) { + arr.add(entry.name()); + } + return arr; + } + public static JsonObjectBuilder json(DataverseRole role) { JsonObjectBuilder bld = jsonObjectBuilder() .add("alias", role.getAlias()) diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index 4245f8a45fc..15dce707478 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -235,6 +235,37 @@ notification.mail.import.filesystem=Dataset {2} ({0}/dataset.xhtml?persistentId= notification.import.filesystem=Dataset {1} has been successfully uploaded and verified. notification.import.checksum={1}, dataset had file checksums added via a batch job. removeNotification=Remove Notification + +# These are the labels of the options where the muted notifications can be selected by the users +notification.muteOptions=Notification settings +notification.mutedEmails=Select the email notifications you wish to receive: +notification.mutedNotifications=Select the in-app notifications you wish to receive: + +# Notification types descriptions as presentend to the user. Leave the description empty or leave it out entirely in order to make it not selectable for muting by the user. +notification.typeDescription.ASSIGNROLE=Role is assigned +notification.typeDescription.REVOKEROLE=Role is revoked +notification.typeDescription.CREATEDV=Dataverse collection is created +notification.typeDescription.CREATEDS=Your dataset is created +notification.typeDescription.CREATEACC=Account is created +notification.typeDescription.SUBMITTEDDS=Submitted for review +notification.typeDescription.RETURNEDDS=Returned from review +notification.typeDescription.PUBLISHEDDS=Dataset is published +notification.typeDescription.REQUESTFILEACCESS=Access to file is requested +notification.typeDescription.GRANTFILEACCESS=Access to file is granted +notification.typeDescription.REJECTFILEACCESS=Access to file is rejected +notification.typeDescription.FILESYSTEMIMPORT=Dataset has been successfully uploaded and verified +notification.typeDescription.CHECKSUMIMPORT=Dataset had file checksums added via a batch job +notification.typeDescription.CHECKSUMFAIL=Checksum validation failed +notification.typeDescription.CONFIRMEMAIL=Email Verification +notification.typeDescription.APIGENERATED=API token is generated +notification.typeDescription.INGESTCOMPLETED=Ingest is completed +notification.typeDescription.INGESTCOMPLETEDWITHERRORS=Ingest completed with errors +notification.typeDescription.PUBLISHFAILED_PIDREG=Publish has failed +notification.typeDescription.WORKFLOW_SUCCESS=External workflow run has succeeded +notification.typeDescription.WORKFLOW_FAILURE=External workflow run has failed +notification.typeDescription.STATUSUPDATED=Status of dataset has been updated +notification.typeDescription.DATASETCREATED=Dataset was created by user + groupAndRoles.manageTips=Here is where you can access and manage all the groups you belong to, and the roles you have been assigned. user.message.signup.label=Create Account user.message.signup.tip=Why have a Dataverse account? To create your own dataverse and customize it, add datasets, or request access to restricted files. diff --git a/src/main/resources/db/migration/V5.10.1.0.1__8533-semantic-updates.sql b/src/main/resources/db/migration/V5.10.1.1__8533-semantic-updates.sql similarity index 55% rename from src/main/resources/db/migration/V5.10.1.0.1__8533-semantic-updates.sql rename to src/main/resources/db/migration/V5.10.1.1__8533-semantic-updates.sql index 7186adbee3e..b42aebc3ff6 100644 --- a/src/main/resources/db/migration/V5.10.1.0.1__8533-semantic-updates.sql +++ b/src/main/resources/db/migration/V5.10.1.1__8533-semantic-updates.sql @@ -4,7 +4,7 @@ BEGIN BEGIN ALTER TABLE datasetfieldtype ADD CONSTRAINT datasetfieldtype_name_key UNIQUE(name); EXCEPTION - WHEN duplicate_object THEN RAISE NOTICE 'Table unique constraint datasetfieldtype_name_key already exists'; + WHEN duplicate_table THEN RAISE NOTICE 'Table unique constraint datasetfieldtype_name_key already exists'; END; END $$; diff --git a/src/main/resources/db/migration/V5.10.1.2__7492-muting-notifications.sql b/src/main/resources/db/migration/V5.10.1.2__7492-muting-notifications.sql new file mode 100644 index 00000000000..7f3944b57ff --- /dev/null +++ b/src/main/resources/db/migration/V5.10.1.2__7492-muting-notifications.sql @@ -0,0 +1,5 @@ +ALTER TABLE authenticateduser +ADD COLUMN IF NOT EXISTS mutedemails TEXT; + +ALTER TABLE authenticateduser +ADD COLUMN IF NOT EXISTS mutednotifications TEXT; \ No newline at end of file diff --git a/src/main/webapp/dataverseuser.xhtml b/src/main/webapp/dataverseuser.xhtml index ff6596ded34..97d00f7c326 100644 --- a/src/main/webapp/dataverseuser.xhtml +++ b/src/main/webapp/dataverseuser.xhtml @@ -53,12 +53,50 @@ - + +
+ +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+ +
+
+
+
diff --git a/src/test/java/edu/harvard/iq/dataverse/api/NotificationsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/NotificationsIT.java new file mode 100644 index 00000000000..09a14e2d6ad --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/NotificationsIT.java @@ -0,0 +1,64 @@ +package edu.harvard.iq.dataverse.api; + +import com.jayway.restassured.RestAssured; +import com.jayway.restassured.path.json.JsonPath; +import com.jayway.restassured.response.Response; +import java.util.logging.Logger; +import static javax.ws.rs.core.Response.Status.CREATED; +import static javax.ws.rs.core.Response.Status.OK; +import static org.hamcrest.CoreMatchers.equalTo; +import org.junit.BeforeClass; +import org.junit.Test; + +public class NotificationsIT { + + private static final Logger logger = Logger.getLogger(NotificationsIT.class.getCanonicalName()); + + @BeforeClass + public static void setUpClass() { + RestAssured.baseURI = UtilIT.getRestAssuredBaseUri(); + } + + @Test + public void testNotifications() { + + Response createAuthor = UtilIT.createRandomUser(); + createAuthor.prettyPrint(); + createAuthor.then().assertThat() + .statusCode(OK.getStatusCode()); + String authorUsername = UtilIT.getUsernameFromResponse(createAuthor); + String authorApiToken = UtilIT.getApiTokenFromResponse(createAuthor); + + // Some API calls don't generate a notification: https://github.com/IQSS/dataverse/issues/1342 + Response createDataverseResponse = UtilIT.createRandomDataverse(authorApiToken); + createDataverseResponse.prettyPrint(); + createDataverseResponse.then().assertThat() + .statusCode(CREATED.getStatusCode()); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + // Some API calls don't generate a notification: https://github.com/IQSS/dataverse/issues/1342 + Response createDataset = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, authorApiToken); + createDataset.prettyPrint(); + createDataset.then().assertThat() + .statusCode(CREATED.getStatusCode()); + Response getNotifications = UtilIT.getNotifications(authorApiToken); + getNotifications.prettyPrint(); + getNotifications.then().assertThat() + .body("data.notifications[0].type", equalTo("CREATEACC")) + .body("data.notifications[1]", equalTo(null)) + .statusCode(OK.getStatusCode()); + + long id = JsonPath.from(getNotifications.getBody().asString()).getLong("data.notifications[0].id"); + + Response deleteNotification = UtilIT.deleteNotification(id, authorApiToken); + deleteNotification.prettyPrint(); + deleteNotification.then().assertThat().statusCode(OK.getStatusCode()); + + Response getNotifications2 = UtilIT.getNotifications(authorApiToken); + getNotifications2.prettyPrint(); + getNotifications2.then().assertThat() + .body("data.notifications[0]", equalTo(null)) + .statusCode(OK.getStatusCode()); + + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 7b9b5f3b129..19b94f34db7 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1294,6 +1294,15 @@ static Response getNotifications(String apiToken) { return requestSpecification.get("/api/notifications/all"); } + static Response deleteNotification(long id, String apiToken) { + RequestSpecification requestSpecification = given(); + if (apiToken != null) { + requestSpecification = given() + .header(UtilIT.API_TOKEN_HTTP_HEADER, apiToken); + } + return requestSpecification.delete("/api/notifications/" + id); + } + static Response nativeGetUsingPersistentId(String persistentId, String apiToken) { Response response = given() .header(API_TOKEN_HTTP_HEADER, apiToken) diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUserTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUserTest.java index a756d7cd69e..5606bbe6aa3 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUserTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUserTest.java @@ -5,24 +5,43 @@ */ package edu.harvard.iq.dataverse.authorization.users; -import edu.harvard.iq.dataverse.DatasetLock; +import edu.harvard.iq.dataverse.UserNotification; +import edu.harvard.iq.dataverse.UserNotification.Type; +import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserLookup; import edu.harvard.iq.dataverse.mocks.MocksFactory; +import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import java.sql.Timestamp; import java.util.Date; -import java.util.List; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; + import org.junit.Test; import static org.junit.Assert.*; import org.junit.Before; +import javax.json.JsonObject; +import javax.json.JsonString; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + /** * Tested class: AuthenticatedUser.java * * @author bsilverstein */ +@RunWith(MockitoJUnitRunner.class) public class AuthenticatedUserTest { + @Mock + private SettingsServiceBean settingsServiceBean; + @InjectMocks + private UserNotificationServiceBean userNotificationService; + public AuthenticatedUserTest() { } @@ -30,6 +49,7 @@ public AuthenticatedUserTest() { public static Timestamp expResult; public static Timestamp loginTime = Timestamp.valueOf("2000-01-01 00:00:00.0"); public static final String IDENTIFIER_PREFIX = "@"; + public static final Set mutedTypes = EnumSet.of(Type.ASSIGNROLE, Type.REVOKEROLE); @Before public void setUp() { @@ -320,6 +340,100 @@ public void testHashCode() { int result = instance.hashCode(); assertEquals(expResult, result); } + + @Test + public void testMutingEmails() { + System.out.println("setMutedEmails"); + testUser.setMutedEmails(mutedTypes); + assertEquals(mutedTypes, testUser.getMutedEmails()); + } + + @Test + public void testMutingNotifications() { + System.out.println("setMutedNotifications"); + testUser.setMutedNotifications(mutedTypes); + assertEquals(mutedTypes, testUser.getMutedNotifications()); + } + + @Test + public void testMutingInJson() { + testUser.setMutedEmails(mutedTypes); + testUser.setMutedNotifications(mutedTypes); + System.out.println("toJson"); + JsonObject jObject = testUser.toJson().build(); + + Set mutedEmails = new HashSet<>(jObject.getJsonArray("mutedEmails").getValuesAs(JsonString::getString)); + assertTrue("Set contains two elements", mutedEmails.size() == 2); + assertTrue("Set contains REVOKEROLE", mutedEmails.contains("REVOKEROLE")); + assertTrue("Set contains ASSIGNROLE", mutedEmails.contains("ASSIGNROLE")); + + Set mutedNotifications = new HashSet<>(jObject.getJsonArray("mutedNotifications").getValuesAs(JsonString::getString)); + assertTrue("Set contains two elements", mutedNotifications.size() == 2); + assertTrue("Set contains REVOKEROLE", mutedNotifications.contains("REVOKEROLE")); + assertTrue("Set contains ASSIGNROLE", mutedNotifications.contains("ASSIGNROLE")); + } + + @Test + public void testHasEmailMuted() { + testUser.setMutedEmails(mutedTypes); + System.out.println("hasEmailMuted"); + assertEquals(true, testUser.hasEmailMuted(Type.ASSIGNROLE)); + assertEquals(true, testUser.hasEmailMuted(Type.REVOKEROLE)); + assertEquals(false, testUser.hasEmailMuted(Type.CREATEDV)); + assertEquals(false, testUser.hasEmailMuted(null)); + } + + @Test + public void testHasNotificationsMutedMuted() { + testUser.setMutedNotifications(mutedTypes); + System.out.println("hasNotificationMuted"); + assertEquals(true, testUser.hasNotificationMuted(Type.ASSIGNROLE)); + assertEquals(true, testUser.hasNotificationMuted(Type.REVOKEROLE)); + assertEquals(false, testUser.hasNotificationMuted(Type.CREATEDV)); + assertEquals(false, testUser.hasNotificationMuted(null)); + } + + @Test + public void testTypeTokenizer() { + final Set typeSet = Type.tokenizeToSet( + Type.toStringValue( + Type.tokenizeToSet(" ASSIGNROLE , CREATEDV,REVOKEROLE ") + ) + ); + assertTrue("typeSet contains 3 elements", typeSet.size() == 3); + assertTrue("typeSet contains ASSIGNROLE", typeSet.contains(Type.ASSIGNROLE)); + assertTrue("typeSet contains CREATEDV", typeSet.contains(Type.CREATEDV)); + assertTrue("typeSet contains REVOKEROLE", typeSet.contains(Type.REVOKEROLE)); + } + + @Test + public void testIsEmailMuted() { + testUser.setMutedEmails(mutedTypes); + UserNotification userNotification = new UserNotification(); + userNotification.setUser(testUser); + userNotification.setSendDate(null); + userNotification.setObjectId(null); + userNotification.setRequestor(null); + userNotification.setType(Type.ASSIGNROLE); // muted + assertTrue(userNotificationService.isEmailMuted(userNotification)); + userNotification.setType(Type.APIGENERATED); // not muted + assertFalse(userNotificationService.isEmailMuted(userNotification)); + } + + @Test + public void isNotificationMuted() { + testUser.setMutedNotifications(mutedTypes); + UserNotification userNotification = new UserNotification(); + userNotification.setUser(testUser); + userNotification.setSendDate(null); + userNotification.setObjectId(null); + userNotification.setRequestor(null); + userNotification.setType(Type.ASSIGNROLE); // muted + assertTrue(userNotificationService.isNotificationMuted(userNotification)); + userNotification.setType(Type.APIGENERATED); // not muted + assertFalse(userNotificationService.isNotificationMuted(userNotification)); + } + /** * All commented tests below have only been generated / are not complete for * AuthenticatedUser.java The tests above should all run fine, due to time diff --git a/src/test/java/edu/harvard/iq/dataverse/util/json/JsonParserTest.java b/src/test/java/edu/harvard/iq/dataverse/util/json/JsonParserTest.java index d7af861d701..579711d63c3 100644 --- a/src/test/java/edu/harvard/iq/dataverse/util/json/JsonParserTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/util/json/JsonParserTest.java @@ -15,6 +15,7 @@ import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.DataverseTheme.Alignment; import edu.harvard.iq.dataverse.FileMetadata; +import edu.harvard.iq.dataverse.UserNotification.Type; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.IpGroup; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.IpGroupProvider; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; @@ -704,4 +705,15 @@ public boolean isTrueForKey(Key key, boolean safeDefaultIfKeyNotFound) { } } + @Test + public void testEnum() throws JsonParseException { + JsonArrayBuilder arr = Json.createArrayBuilder(); + for (Type entry : Arrays.asList(Type.REVOKEROLE, Type.ASSIGNROLE)) { + arr.add(entry.name()); + } + Set typesSet = new HashSet<>(JsonParser.parseEnumsFromArray(arr.build(), Type.class)); + assertTrue("Set contains two elements", typesSet.size() == 2); + assertTrue("Set contains REVOKEROLE", typesSet.contains(Type.REVOKEROLE)); + assertTrue("Set contains ASSIGNROLE", typesSet.contains(Type.ASSIGNROLE)); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java b/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java index d0ebbcc2c3d..cbefd3be0ad 100644 --- a/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java @@ -8,6 +8,7 @@ import edu.harvard.iq.dataverse.mocks.MockDatasetFieldSvc; import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import edu.harvard.iq.dataverse.UserNotification.Type; import java.time.LocalDate; import java.util.ArrayList; @@ -17,12 +18,16 @@ import java.util.LinkedList; import java.util.List; import java.util.Set; + +import javax.json.JsonArrayBuilder; import javax.json.JsonObject; import javax.json.JsonObjectBuilder; +import javax.json.JsonString; import org.junit.Test; import org.junit.Before; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class JsonPrinterTest { @@ -305,4 +310,13 @@ public boolean isTrueForKey(SettingsServiceBean.Key key, boolean defaultValue) { } + @Test + public void testEnum() throws JsonParseException { + JsonArrayBuilder arr = JsonPrinter.enumsToJson(Arrays.asList(Type.REVOKEROLE, Type.ASSIGNROLE)); + Set typesSet = new HashSet<>(arr.build().getValuesAs(JsonString::getString)); + assertTrue(typesSet.size() == 2); + assertTrue(typesSet.contains("REVOKEROLE")); + assertTrue(typesSet.contains("ASSIGNROLE")); + } + } diff --git a/tests/integration-tests.txt b/tests/integration-tests.txt index b0c601ac269..71ba38e0aae 100644 --- a/tests/integration-tests.txt +++ b/tests/integration-tests.txt @@ -1 +1 @@ -DataversesIT,DatasetsIT,SwordIT,AdminIT,BuiltinUsersIT,UsersIT,UtilIT,ConfirmEmailIT,FileMetadataIT,FilesIT,SearchIT,InReviewWorkflowIT,HarvestingServerIT,MoveIT,MakeDataCountApiIT,FileTypeDetectionIT,EditDDIIT,ExternalToolsIT,AccessIT,DuplicateFilesIT,DownloadFilesIT,LinkIT,DeleteUsersIT,DeactivateUsersIT,AuxiliaryFilesIT,InvalidCharactersIT,LicensesIT +DataversesIT,DatasetsIT,SwordIT,AdminIT,BuiltinUsersIT,UsersIT,UtilIT,ConfirmEmailIT,FileMetadataIT,FilesIT,SearchIT,InReviewWorkflowIT,HarvestingServerIT,MoveIT,MakeDataCountApiIT,FileTypeDetectionIT,EditDDIIT,ExternalToolsIT,AccessIT,DuplicateFilesIT,DownloadFilesIT,LinkIT,DeleteUsersIT,DeactivateUsersIT,AuxiliaryFilesIT,InvalidCharactersIT,LicensesIT,NotificationsIT