diff --git a/component/api/src/main/java/org/exoplatform/social/core/identity/model/Profile.java b/component/api/src/main/java/org/exoplatform/social/core/identity/model/Profile.java index 776efcf22a9..dd725220eec 100644 --- a/component/api/src/main/java/org/exoplatform/social/core/identity/model/Profile.java +++ b/component/api/src/main/java/org/exoplatform/social/core/identity/model/Profile.java @@ -58,6 +58,14 @@ public class Profile { /** TimeZone key. */ public static final String TIME_ZONE = "timeZone"; + /** + * The organization profile attribute holding the timezone the browser of the + * user lives in, for example Europe/Paris. It is the source of truth for + * everything sent to the user on a schedule, kept up to date on every page + * load by the platform timezone synchronization. + */ + public static final String USER_TIME_ZONE = "user.timeZone"; + /** TimeZone DayLight savings key. */ public static final String TIME_ZONE_DST_SAVINGS = "timeZoneDSTSavings"; diff --git a/component/core/src/main/java/io/meeds/social/digest/service/DigestCategoryLabelResolver.java b/component/core/src/main/java/io/meeds/social/digest/service/DigestCategoryLabelResolver.java new file mode 100644 index 00000000000..c65c8f3feaf --- /dev/null +++ b/component/core/src/main/java/io/meeds/social/digest/service/DigestCategoryLabelResolver.java @@ -0,0 +1,85 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.digest.service; + +import java.util.List; +import java.util.Locale; +import java.util.ResourceBundle; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import org.exoplatform.commons.api.notification.plugin.config.PluginConfig; +import org.exoplatform.commons.api.notification.service.setting.PluginSettingService; +import org.exoplatform.services.resources.ResourceBundleService; +import io.meeds.commons.digest.plugin.DigestCategoryProvider; + +/** + * Translates the label of a digest category. The categories of the addons are + * translated in the language of the user here and not in the browser, because + * the settings page only loads the resource bundles of the platform: the labels + * of an addon are read from the bundle its own notification plugins already + * use, so an addon has nothing more to declare than what it declares today. + */ +@Component +public class DigestCategoryLabelResolver { + + private final PluginSettingService pluginSettingService; + + private final ResourceBundleService resourceBundleService; + + public DigestCategoryLabelResolver(PluginSettingService pluginSettingService, ResourceBundleService resourceBundleService) { + this.pluginSettingService = pluginSettingService; + this.resourceBundleService = resourceBundleService; + } + + /** + * @param category the category to display + * @param locale the language to translate the label in, the request one: + * these REST services are served by the social webapp, which doesn't + * fill the portal locale of the thread + * @return the label of the category in the language of the user, its + * identifier when no bundle holds it + */ + public String getLabel(DigestCategoryProvider category, Locale locale) { + Locale userLocale = locale == null ? Locale.ENGLISH : locale; + String labelKey = category.getLabelKey(); + List pluginIds = category.getPluginIds(); + if (StringUtils.isBlank(labelKey) || pluginIds == null) { + return category.getId(); + } + for (String pluginId : pluginIds) { + String label = getLabel(labelKey, pluginId, userLocale); + if (label != null) { + return label; + } + } + return category.getId(); + } + + private String getLabel(String labelKey, String pluginId, Locale userLocale) { + PluginConfig pluginConfig = pluginSettingService.getPluginConfig(pluginId); + if (pluginConfig == null || StringUtils.isBlank(pluginConfig.getBundlePath())) { + return null; + } + ResourceBundle bundle = resourceBundleService.getResourceBundle(pluginConfig.getBundlePath(), userLocale); + return bundle != null && bundle.containsKey(labelKey) ? bundle.getString(labelKey) : null; + } + +} diff --git a/component/core/src/main/java/io/meeds/social/timezone/service/UserTimeZoneService.java b/component/core/src/main/java/io/meeds/social/timezone/service/UserTimeZoneService.java new file mode 100644 index 00000000000..5ce510c38a9 --- /dev/null +++ b/component/core/src/main/java/io/meeds/social/timezone/service/UserTimeZoneService.java @@ -0,0 +1,129 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.timezone.service; + +import java.time.DateTimeException; +import java.time.ZoneId; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import org.exoplatform.services.listener.ListenerService; +import org.exoplatform.services.organization.OrganizationService; +import org.exoplatform.services.organization.UserProfile; +import org.exoplatform.services.organization.UserProfileHandler; +import org.exoplatform.social.core.identity.model.Profile; + +/** + * Holds the timezone of each user, the one his browser lives in, under the + * profile attribute {@link Profile#USER_TIME_ZONE}. The browser of the user + * keeps it up to date on every page load, so that everything sent to him on a + * schedule, starting with the digest mail notifications, goes out at the right + * local hour. This synchronization used to live in the agenda addon and is now + * owned by the platform, so that it works whatever the installed addons; + * agenda delegates its own copy here. + */ +@Service +public class UserTimeZoneService { + + /** + * Event broadcast when the timezone of a user is saved, with the username as + * source and the timezone as data. A profile listener can't be used for + * that: it never fires when this attribute changes. + */ + public static final String USER_TIME_ZONE_SAVED_EVENT = "social.timeZone.saved"; + + private static final Logger LOG = LoggerFactory.getLogger(UserTimeZoneService.class); + + private final OrganizationService organizationService; + + private final ListenerService listenerService; + + public UserTimeZoneService(OrganizationService organizationService, ListenerService listenerService) { + this.organizationService = organizationService; + this.listenerService = listenerService; + } + + /** + * @param username the user to read the timezone of + * @return the timezone of the user, null when it is unknown: a user who never + * loaded a page has no timezone yet. The caller falls back to the + * server timezone. + */ + public String getUserTimeZone(String username) { + if (StringUtils.isBlank(username)) { + return null; + } + try { + UserProfile userProfile = organizationService.getUserProfileHandler().findUserProfileByName(username); + return userProfile == null ? null : userProfile.getAttribute(Profile.USER_TIME_ZONE); + } catch (Exception e) { + // An unknown timezone only makes what is sent on a schedule go out on + // the server hour, it must never break the caller + LOG.warn("Can't read the timezone of user {}, the server timezone will be used instead", username, e); + return null; + } + } + + /** + * Saves the timezone the browser of the user lives in, then broadcasts + * {@link #USER_TIME_ZONE_SAVED_EVENT} so that whatever keeps a copy of the + * timezone, like the digest work list, can refresh it. + * + * @param username the user to save the timezone of + * @param zoneId the timezone identifier, for example Europe/Paris + * @throws IllegalArgumentException when the timezone is not a known one + * @throws IllegalStateException when the profile of the user can't be saved + */ + public void saveUserTimeZone(String username, String zoneId) { + if (StringUtils.isBlank(username)) { + throw new IllegalArgumentException("Username is mandatory"); + } + try { + ZoneId.of(zoneId); + } catch (DateTimeException | NullPointerException e) { + throw new IllegalArgumentException("Unknown timezone " + zoneId, e); + } + try { + UserProfileHandler userProfileHandler = organizationService.getUserProfileHandler(); + UserProfile userProfile = userProfileHandler.findUserProfileByName(username); + if (userProfile == null) { + userProfile = userProfileHandler.createUserProfileInstance(username); + } + userProfile.setAttribute(Profile.USER_TIME_ZONE, zoneId); + userProfileHandler.saveUserProfile(userProfile, true); + } catch (Exception e) { + throw new IllegalStateException("Can't save the timezone of user " + username, e); + } + broadcast(username, zoneId); + } + + private void broadcast(String username, String zoneId) { + try { + listenerService.broadcast(USER_TIME_ZONE_SAVED_EVENT, username, zoneId); + } catch (Exception e) { + // The timezone is saved, a listener failure must not undo that for the + // user: whoever keeps a copy refreshes it at worst on his next save + LOG.warn("Error broadcasting the timezone change of user {}", username, e); + } + } + +} diff --git a/component/core/src/test/java/io/meeds/social/digest/service/DigestCategoryLabelResolverTest.java b/component/core/src/test/java/io/meeds/social/digest/service/DigestCategoryLabelResolverTest.java new file mode 100644 index 00000000000..83caec38419 --- /dev/null +++ b/component/core/src/test/java/io/meeds/social/digest/service/DigestCategoryLabelResolverTest.java @@ -0,0 +1,128 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.digest.service; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Locale; +import java.util.ListResourceBundle; +import java.util.ResourceBundle; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import org.exoplatform.commons.api.notification.plugin.config.PluginConfig; +import org.exoplatform.commons.api.notification.service.setting.PluginSettingService; +import org.exoplatform.services.resources.ResourceBundleService; + +import io.meeds.commons.digest.plugin.DigestCategoryProvider; + +@RunWith(MockitoJUnitRunner.class) +public class DigestCategoryLabelResolverTest { + + private static final String BUNDLE_PATH = "locale.notification.template.Notification"; + + private static final String LABEL_KEY = "digest.category.spaces"; + + @Mock + private PluginSettingService pluginSettingService; + + @Mock + private ResourceBundleService resourceBundleService; + + private DigestCategoryLabelResolver labelResolver; + + @Before + public void setUp() { + labelResolver = new DigestCategoryLabelResolver(pluginSettingService, resourceBundleService); + PluginConfig pluginConfig = new PluginConfig(); + pluginConfig.setBundlePath(BUNDLE_PATH); + lenient().when(pluginSettingService.getPluginConfig("SpaceInvitationPlugin")).thenReturn(pluginConfig); + lenient().when(resourceBundleService.getResourceBundle(BUNDLE_PATH, Locale.ENGLISH)).thenReturn(bundle("Spaces")); + lenient().when(resourceBundleService.getResourceBundle(BUNDLE_PATH, Locale.FRENCH)).thenReturn(bundle("Espaces")); + } + + /** + * The digest REST services are served by the social webapp, which doesn't + * fill the portal locale of the thread: reading the language anywhere else + * than in the request would serve English to everybody. + */ + @Test + public void testLabelIsTranslatedInTheLanguageOfTheUser() { + assertEquals("Espaces", labelResolver.getLabel(category(), Locale.FRENCH)); + assertEquals("Spaces", labelResolver.getLabel(category(), Locale.ENGLISH)); + } + + @Test + public void testLabelFallsBackToEnglishWithoutLanguage() { + assertEquals("Spaces", labelResolver.getLabel(category(), null)); + } + + @Test + public void testLabelFallsBackToTheCategoryIdWhenNoPluginIsRegistered() { + when(pluginSettingService.getPluginConfig("SpaceInvitationPlugin")).thenReturn(null); + assertEquals("spaces", labelResolver.getLabel(category(), Locale.FRENCH)); + } + + @Test + public void testLabelFallsBackToTheCategoryIdWhenNoBundleHoldsIt() { + when(resourceBundleService.getResourceBundle(BUNDLE_PATH, Locale.FRENCH)).thenReturn(bundle(null)); + assertEquals("spaces", labelResolver.getLabel(category(), Locale.FRENCH)); + } + + private ResourceBundle bundle(String label) { + return new ListResourceBundle() { + @Override + protected Object[][] getContents() { + return label == null ? new Object[0][0] : new Object[][] { { LABEL_KEY, label } }; + } + }; + } + + private DigestCategoryProvider category() { + return new DigestCategoryProvider() { + @Override + public String getId() { + return "spaces"; + } + + @Override + public String getLabelKey() { + return LABEL_KEY; + } + + @Override + public int getOrder() { + return 10; + } + + @Override + public List getPluginIds() { + return List.of("SpaceInvitationPlugin"); + } + }; + } + +} diff --git a/component/core/src/test/java/io/meeds/social/timezone/service/UserTimeZoneServiceTest.java b/component/core/src/test/java/io/meeds/social/timezone/service/UserTimeZoneServiceTest.java new file mode 100644 index 00000000000..207d9ecfe68 --- /dev/null +++ b/component/core/src/test/java/io/meeds/social/timezone/service/UserTimeZoneServiceTest.java @@ -0,0 +1,139 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.timezone.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import org.exoplatform.services.listener.ListenerService; +import org.exoplatform.services.organization.OrganizationService; +import org.exoplatform.services.organization.UserProfile; +import org.exoplatform.services.organization.UserProfileHandler; +import org.exoplatform.social.core.identity.model.Profile; + +@RunWith(MockitoJUnitRunner.class) +public class UserTimeZoneServiceTest { + + private static final String USERNAME = "ayoub"; + + private static final String TIME_ZONE = "Africa/Tunis"; + + @Mock + private OrganizationService organizationService; + + @Mock + private ListenerService listenerService; + + @Mock + private UserProfileHandler userProfileHandler; + + @Mock + private UserProfile userProfile; + + private UserTimeZoneService userTimeZoneService; + + @Before + public void setUp() { + userTimeZoneService = new UserTimeZoneService(organizationService, listenerService); + lenient().when(organizationService.getUserProfileHandler()).thenReturn(userProfileHandler); + } + + @Test + public void testGetUserTimeZone() throws Exception { + when(userProfileHandler.findUserProfileByName(USERNAME)).thenReturn(userProfile); + when(userProfile.getAttribute(Profile.USER_TIME_ZONE)).thenReturn(TIME_ZONE); + + assertEquals(TIME_ZONE, userTimeZoneService.getUserTimeZone(USERNAME)); + } + + @Test + public void testNoTimeZoneBeforeTheFirstPageLoad() throws Exception { + when(userProfileHandler.findUserProfileByName(USERNAME)).thenReturn(null); + assertNull(userTimeZoneService.getUserTimeZone(USERNAME)); + assertNull(userTimeZoneService.getUserTimeZone(null)); + } + + @Test + public void testAProfileReadFailureFallsBackToTheServerTimeZone() throws Exception { + when(userProfileHandler.findUserProfileByName(USERNAME)).thenThrow(new IllegalStateException("Store is down")); + assertNull(userTimeZoneService.getUserTimeZone(USERNAME)); + } + + @Test + public void testSaveUserTimeZone() throws Exception { + when(userProfileHandler.findUserProfileByName(USERNAME)).thenReturn(userProfile); + + userTimeZoneService.saveUserTimeZone(USERNAME, TIME_ZONE); + + verify(userProfile).setAttribute(Profile.USER_TIME_ZONE, TIME_ZONE); + verify(userProfileHandler).saveUserProfile(userProfile, true); + // Whoever keeps a copy of the timezone, like the digest work list, + // refreshes it on this event: a profile listener never fires for it + verify(listenerService).broadcast(UserTimeZoneService.USER_TIME_ZONE_SAVED_EVENT, USERNAME, TIME_ZONE); + } + + @Test + public void testSaveCreatesTheProfileOfANeverSeenUser() throws Exception { + when(userProfileHandler.findUserProfileByName(USERNAME)).thenReturn(null); + when(userProfileHandler.createUserProfileInstance(USERNAME)).thenReturn(userProfile); + + userTimeZoneService.saveUserTimeZone(USERNAME, TIME_ZONE); + + verify(userProfile).setAttribute(Profile.USER_TIME_ZONE, TIME_ZONE); + verify(userProfileHandler).saveUserProfile(userProfile, true); + } + + @Test + public void testAnUnknownTimeZoneIsRefused() throws Exception { + assertThrows(IllegalArgumentException.class, () -> userTimeZoneService.saveUserTimeZone(USERNAME, "Mars/Olympus")); + assertThrows(IllegalArgumentException.class, () -> userTimeZoneService.saveUserTimeZone(USERNAME, null)); + assertThrows(IllegalArgumentException.class, () -> userTimeZoneService.saveUserTimeZone(null, TIME_ZONE)); + verify(userProfileHandler, never()).saveUserProfile(any(), any(Boolean.class).booleanValue()); + verify(listenerService, never()).broadcast(any(String.class), any(), any()); + } + + @Test + public void testABroadcastFailureDoesNotUndoTheSave() throws Exception { + when(userProfileHandler.findUserProfileByName(USERNAME)).thenReturn(userProfile); + doBroadcastFailure(); + + userTimeZoneService.saveUserTimeZone(USERNAME, TIME_ZONE); + + verify(userProfileHandler).saveUserProfile(userProfile, true); + } + + private void doBroadcastFailure() throws Exception { + org.mockito.Mockito.doThrow(new IllegalStateException("Listener failure")) + .when(listenerService) + .broadcast(any(String.class), any(), any()); + } + +} diff --git a/component/core/src/test/java/org/exoplatform/social/core/test/NoContainerTestSuite.java b/component/core/src/test/java/org/exoplatform/social/core/test/NoContainerTestSuite.java index 64f4d7555cf..06345766dcc 100644 --- a/component/core/src/test/java/org/exoplatform/social/core/test/NoContainerTestSuite.java +++ b/component/core/src/test/java/org/exoplatform/social/core/test/NoContainerTestSuite.java @@ -76,6 +76,8 @@ import io.meeds.social.space.template.storage.SpaceTemplateStorageTest; import io.meeds.social.upgrade.SpaceNavigationIconUpgradePluginTest; import io.meeds.social.user.plugin.UserAclPluginTest; +import io.meeds.social.digest.service.DigestCategoryLabelResolverTest; +import io.meeds.social.timezone.service.UserTimeZoneServiceTest; import io.meeds.social.identity.permission.listener.UserPermissionGroupListenerTest; import io.meeds.social.identity.permission.listener.UserPermissionMembershipListenerTest; import io.meeds.social.identity.permission.listener.UserPermissionNestedMembershipListenerTest; @@ -90,6 +92,8 @@ @RunWith(Suite.class) @SuiteClasses({ + DigestCategoryLabelResolverTest.class, + UserTimeZoneServiceTest.class, ReactionServiceImplTest.class, ReactionStorageTest.class, ReactionLikeDeletedListenerTest.class, diff --git a/component/service/src/main/java/io/meeds/social/digest/rest/DigestRest.java b/component/service/src/main/java/io/meeds/social/digest/rest/DigestRest.java index 845be46e9f1..3f655ba10c0 100644 --- a/component/service/src/main/java/io/meeds/social/digest/rest/DigestRest.java +++ b/component/service/src/main/java/io/meeds/social/digest/rest/DigestRest.java @@ -18,17 +18,31 @@ */ package io.meeds.social.digest.rest; +import java.util.List; +import java.util.Locale; + import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.annotation.Secured; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import io.meeds.commons.digest.DigestService; +import io.meeds.commons.digest.model.DigestUserSettings; +import io.meeds.commons.digest.plugin.DigestCategoryProvider; +import io.meeds.social.digest.rest.model.DigestCategoryEntity; import io.meeds.social.digest.rest.model.DigestSettingsEntity; +import io.meeds.social.digest.rest.model.DigestUserSettingsEntity; +import io.meeds.social.digest.service.DigestCategoryLabelResolver; +import io.meeds.social.timezone.service.UserTimeZoneService; + +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -42,18 +56,63 @@ public class DigestRest { @Autowired - private DigestService digestService; + private DigestService digestService; + + @Autowired + private UserTimeZoneService userTimeZoneService; + + @Autowired + private DigestCategoryLabelResolver labelResolver; @GetMapping("settings") @Secured("users") @Operation(summary = "Retrieves the digest mail notification settings", method = "GET", - description = "Retrieves the digest mail notification settings, holding the platform-wide administrator switch state") + description = "Retrieves the platform-wide administrator switch state, the categories offered by the installed addons and the choices of the current user") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Request fulfilled"), @ApiResponse(responseCode = "403", description = "Forbidden"), }) - public DigestSettingsEntity getSettings() { - return new DigestSettingsEntity(digestService.isDigestAllowed()); + public DigestSettingsEntity getSettings(HttpServletRequest request) { + boolean digestAllowed = digestService.isDigestAllowed(); + if (!digestAllowed) { + // Nothing to display, the categories and the user choices are useless + return new DigestSettingsEntity(false, List.of(), false, List.of(), false, List.of()); + } + DigestUserSettings userSettings = digestService.getUserSettings(request.getRemoteUser()); + return new DigestSettingsEntity(true, + toCategoryEntities(digestService.getCategories(), request.getLocale()), + userSettings.isDaily(), + userSettings.getDailyCategories(), + userSettings.isWeekly(), + userSettings.getWeeklyCategories()); + } + + @PatchMapping(path = "settings", consumes = MediaType.APPLICATION_JSON_VALUE) + @Secured("users") + @Operation(summary = "Saves the digest mail notification choices of the current user", method = "PATCH", + description = "Saves the frequencies and the categories the current user chose, and enrolls him in the digest sending") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Request fulfilled"), + @ApiResponse(responseCode = "400", description = "Invalid query input"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + }) + public void saveUserSettings(HttpServletRequest request, + @RequestBody + DigestUserSettingsEntity settings) { + if (!digestService.isDigestAllowed()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Digest mail notifications are not allowed"); + } + String username = request.getRemoteUser(); + try { + digestService.saveUserSettings(username, + new DigestUserSettings(settings.isDaily(), + settings.getDailyCategories(), + settings.isWeekly(), + settings.getWeeklyCategories()), + userTimeZoneService.getUserTimeZone(username)); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } } @PatchMapping(path = "allowed", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @@ -70,4 +129,10 @@ public void saveDigestAllowed(@Parameter(description = "true to allow users to s digestService.saveDigestAllowed(allowed); } + private List toCategoryEntities(List categories, Locale locale) { + return categories.stream() + .map(category -> new DigestCategoryEntity(category.getId(), labelResolver.getLabel(category, locale))) + .toList(); + } + } diff --git a/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestCategoryEntity.java b/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestCategoryEntity.java new file mode 100644 index 00000000000..431e44422fb --- /dev/null +++ b/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestCategoryEntity.java @@ -0,0 +1,39 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.digest.rest.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A category the user can pick in the digest settings drawer. Only the + * categories of the installed addons are sent. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class DigestCategoryEntity { + + private String id; + + /** Already translated in the language of the user */ + private String label; + +} diff --git a/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestSettingsEntity.java b/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestSettingsEntity.java index d77994826de..eea62423488 100644 --- a/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestSettingsEntity.java +++ b/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestSettingsEntity.java @@ -18,15 +18,32 @@ */ package io.meeds.social.digest.rest.model; +import java.util.List; + import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +/** + * Everything the digest settings drawer needs in one call: whether the + * administrator allows the digest, the categories the installed addons offer, + * and the choices of the current user. + */ @Data @AllArgsConstructor @NoArgsConstructor public class DigestSettingsEntity { - private boolean digestAllowed; + private boolean digestAllowed; + + private List categories; + + private boolean daily; + + private List dailyCategories; + + private boolean weekly; + + private List weeklyCategories; } diff --git a/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestUserSettingsEntity.java b/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestUserSettingsEntity.java new file mode 100644 index 00000000000..32726601348 --- /dev/null +++ b/component/service/src/main/java/io/meeds/social/digest/rest/model/DigestUserSettingsEntity.java @@ -0,0 +1,43 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.digest.rest.model; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The digest choices sent by the settings drawer. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class DigestUserSettingsEntity { + + private boolean daily; + + private List dailyCategories; + + private boolean weekly; + + private List weeklyCategories; + +} diff --git a/component/service/src/main/java/io/meeds/social/timezone/rest/TimeZoneRest.java b/component/service/src/main/java/io/meeds/social/timezone/rest/TimeZoneRest.java new file mode 100644 index 00000000000..c48574e86bf --- /dev/null +++ b/component/service/src/main/java/io/meeds/social/timezone/rest/TimeZoneRest.java @@ -0,0 +1,66 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.social.timezone.rest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.meeds.social.timezone.service.UserTimeZoneService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; + +@RestController +@RequestMapping("timezone") +@Tag(name = "/social/rest/timezone", description = "Keeps the timezone of the current user up to date") +public class TimeZoneRest { + + @Autowired + private UserTimeZoneService userTimeZoneService; + + @PostMapping(consumes = MediaType.TEXT_PLAIN_VALUE) + @Secured("users") + @Operation(summary = "Saves the timezone the browser of the current user lives in", method = "POST", + description = "Saves the timezone the browser of the current user lives in, so that everything sent to him on a schedule goes out at the right local hour") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Request fulfilled"), + @ApiResponse(responseCode = "400", description = "Unknown timezone"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + }) + public void saveUserTimeZone(HttpServletRequest request, + @RequestBody + String timeZone) { + try { + userTimeZoneService.saveUserTimeZone(request.getRemoteUser(), timeZone); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + +} diff --git a/webapp/src/main/resources/locale/notification/template/Notification_en.properties b/webapp/src/main/resources/locale/notification/template/Notification_en.properties index f2bc5a1fec0..e543a574493 100644 --- a/webapp/src/main/resources/locale/notification/template/Notification_en.properties +++ b/webapp/src/main/resources/locale/notification/template/Notification_en.properties @@ -348,3 +348,7 @@ UINotification.title.DlpUserRestoredItemPlugin=Item has been validated Notification.title.DlpUserRestoredItemPlugin=Item has been validated Notification.subject.DlpUserRestoredItemPlugin=Item has been validated Notification.intranet.message.DlpUserRestoredItemPlugin=The item named {0} has been analyzed during a quarantine phase. It has been validated and restored in his initial location. + +# Digest mail notifications: labels of the categories social contributes +digest.category.spaces=Spaces +digest.category.feed=Feed diff --git a/webapp/src/main/resources/locale/portlet/social/UserSettings_en.properties b/webapp/src/main/resources/locale/portlet/social/UserSettings_en.properties index 0b1a87ede29..2ae49a056e7 100644 --- a/webapp/src/main/resources/locale/portlet/social/UserSettings_en.properties +++ b/webapp/src/main/resources/locale/portlet/social/UserSettings_en.properties @@ -70,6 +70,12 @@ UserSettings.drawer.label.mutedSpaces=Muted spaces UserSettings.drawer.spaceSuggesterPlaceholder=Choose a space UserSettings.drawer.spaceSuggesterNoData=No space found UserSettings.button.tooltip.unmute=Unmute space notifications +UserSettings.drawer.title.digest=Set Digest Mail Notifications +UserSettings.drawer.label.dailyDigest=Receive daily digest notification +UserSettings.drawer.label.weeklyDigest=Receive weekly digest notification +UserSettings.digest.success.save=Your digest mail notifications have been saved +UserSettings.digest.error.save=Your digest mail notifications could not be saved +UserSettings.digest.error.load=Your digest mail notifications could not be loaded UserSettings.label.currentPassword=Current Password UserSettings.label.newPassword=New password diff --git a/webapp/src/main/webapp/WEB-INF/conf/social-extension/social/notification-plugins-configuration.xml b/webapp/src/main/webapp/WEB-INF/conf/social-extension/social/notification-plugins-configuration.xml index 81b4fca0fc6..45dd8f4fcb9 100644 --- a/webapp/src/main/webapp/WEB-INF/conf/social-extension/social/notification-plugins-configuration.xml +++ b/webapp/src/main/webapp/WEB-INF/conf/social-extension/social/notification-plugins-configuration.xml @@ -884,4 +884,59 @@ Notifies platform administrators on account deactivation request + + + + io.meeds.commons.digest.DigestCategoryRegistry + + spaces + addCategoryProvider + io.meeds.commons.digest.plugin.DigestCategoryPlugin + The space life notifications: invitations and join requests + + + id + spaces + + + labelKey + digest.category.spaces + + + order + 10 + + + pluginIds + SpaceInvitationPlugin + RequestJoinSpacePlugin + + + + + feed + addCategoryProvider + io.meeds.commons.digest.plugin.DigestCategoryPlugin + What happens on the activity streams the user follows + + + id + feed + + + labelKey + digest.category.feed + + + order + 20 + + + pluginIds + PostActivitySpaceStreamPlugin + ActivityCommentWatchPlugin + + + + diff --git a/webapp/src/main/webapp/WEB-INF/gatein-resources.xml b/webapp/src/main/webapp/WEB-INF/gatein-resources.xml index 5e96900a6c0..47ce8851775 100644 --- a/webapp/src/main/webapp/WEB-INF/gatein-resources.xml +++ b/webapp/src/main/webapp/WEB-INF/gatein-resources.xml @@ -3223,6 +3223,14 @@ + + userTimeZoneSync + baseGRP + + + bodyScrollListener baseGRP diff --git a/webapp/src/main/webapp/groovy/social/webui/UISocialPortalApplicationHead.gtmpl b/webapp/src/main/webapp/groovy/social/webui/UISocialPortalApplicationHead.gtmpl index b1cc1a3da68..a42c7564435 100644 --- a/webapp/src/main/webapp/groovy/social/webui/UISocialPortalApplicationHead.gtmpl +++ b/webapp/src/main/webapp/groovy/social/webui/UISocialPortalApplicationHead.gtmpl @@ -14,6 +14,10 @@ import org.exoplatform.social.core.search.SearchConnector; import org.exoplatform.commons.utils.PropertyManager; import org.exoplatform.portal.branding.BrandingService; + import org.exoplatform.services.organization.OrganizationService; + import org.exoplatform.services.organization.UserProfile; + import org.exoplatform.social.core.identity.model.Profile; + import java.time.ZoneId; import java.util.Set; def rcontext = _ctx.getRequestContext(); @@ -32,6 +36,24 @@ String ownerIdentityId = Utils.getOwnerIdentityId(); ProfilePropertyService profilePropertyService = uicomponent.getApplicationComponent(ProfilePropertyService.class); ProfilePropertySetting profilePropertySetting = profilePropertyService.getProfileSettingByName("manager"); + // The timezone the platform knows for the user: the userTimeZoneSync script + // compares it to the browser one and refreshes it when they differ. The + // module only runs because it is required here. + OrganizationService organizationService = uicomponent.getApplicationComponent(OrganizationService.class); + UserProfile organizationUserProfile = userName == null ? null : organizationService.getUserProfileHandler().findUserProfileByName(userName); + String userTimezone = organizationUserProfile == null ? null : organizationUserProfile.getAttribute(Profile.USER_TIME_ZONE); + try { + // Values saved before this synchronization existed were never validated: + // an unknown one must not end up in the script block below + if (userTimezone != null) { + ZoneId.of(userTimezone); + } + } catch (Exception e) { + userTimezone = null; + } + if (userName != null) { + rcontext.getJavascriptManager().require("SHARED/userTimeZoneSync"); + } %> diff --git a/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestDrawer.vue b/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestDrawer.vue new file mode 100644 index 00000000000..e70b7aa11c8 --- /dev/null +++ b/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestDrawer.vue @@ -0,0 +1,165 @@ + + + + diff --git a/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestEntry.vue b/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestEntry.vue index 0d977bbb583..382a73f9740 100644 --- a/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestEntry.vue +++ b/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingDigestEntry.vue @@ -36,7 +36,7 @@ :aria-label="$t('UserSettings.title.digest')" small icon - @click="openDigestDrawer"> + @click="$emit('open')"> fa-edit @@ -47,15 +47,3 @@ - - diff --git a/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingNotifications.vue b/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingNotifications.vue index 777b27291ee..d83bc65f46d 100644 --- a/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingNotifications.vue +++ b/webapp/src/main/webapp/vue-apps/notification-user-settings/components/UserSettingNotifications.vue @@ -17,7 +17,9 @@ - +