001/*
002 *  Copyright 2020 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.mobileapp.observer;
017
018import java.util.Collection;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.Set;
024import java.util.function.Function;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030
031import org.ametys.cms.ObservationConstants;
032import org.ametys.cms.data.RichText;
033import org.ametys.cms.data.RichTextHelper;
034import org.ametys.cms.data.type.ModelItemTypeConstants;
035import org.ametys.cms.repository.Content;
036import org.ametys.core.observation.AsyncObserver;
037import org.ametys.core.observation.Event;
038import org.ametys.core.right.AllowedUsers;
039import org.ametys.core.right.RightManager;
040import org.ametys.core.user.User;
041import org.ametys.core.user.UserIdentity;
042import org.ametys.core.user.UserManager;
043import org.ametys.core.user.population.UserPopulationDAO;
044import org.ametys.plugins.mobileapp.PushNotificationManager;
045import org.ametys.plugins.mobileapp.QueriesHelper;
046import org.ametys.plugins.mobileapp.UserPreferencesHelper;
047import org.ametys.plugins.queriesdirectory.Query;
048import org.ametys.plugins.repository.AmetysObjectResolver;
049import org.ametys.runtime.config.Config;
050import org.ametys.runtime.plugin.component.AbstractLogEnabled;
051import org.ametys.web.repository.content.WebContent;
052import org.ametys.web.repository.site.Site;
053
054/**
055 * On validation, test each query to notify impacted users
056 */
057public class ContentValidatedObserver extends AbstractLogEnabled implements AsyncObserver, Serviceable
058{
059    private static final String __DESCRIPTION_MAX_SIZE_CONF_ID = "plugin.mobileapp.push.description.richtext.max";
060    
061    private QueriesHelper _queryHelper;
062    private UserPreferencesHelper _userPreferencesHelper;
063    private PushNotificationManager _pushNotificationManager;
064    private UserManager _userManager;
065    private UserPopulationDAO _userPopulationDAO;
066    private RightManager _rightManager;
067    private RichTextHelper _richTextHelper;
068    private AmetysObjectResolver _resolver;
069
070    @Override
071    public void service(ServiceManager manager) throws ServiceException
072    {
073        _queryHelper = (QueriesHelper) manager.lookup(QueriesHelper.ROLE);
074        _userPreferencesHelper = (UserPreferencesHelper) manager.lookup(UserPreferencesHelper.ROLE);
075        _pushNotificationManager = (PushNotificationManager) manager.lookup(PushNotificationManager.ROLE);
076        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
077        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
078        _userPopulationDAO = (UserPopulationDAO) manager.lookup(UserPopulationDAO.ROLE);
079        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
080        _richTextHelper = (RichTextHelper) manager.lookup(RichTextHelper.ROLE);
081    }
082
083    public boolean supports(Event event)
084    {
085        return event.getId().equals(ObservationConstants.EVENT_CONTENT_VALIDATED);
086    }
087
088    public int getPriority(Event event)
089    {
090        return MIN_PRIORITY;
091    }
092
093    public void observe(Event event, Map<String, Object> transientVars) throws Exception
094    {
095        String contentId = (String) event.getArguments().get(ObservationConstants.ARGS_CONTENT_ID);
096        Content content = _resolver.resolveById(contentId);
097
098        if (!(content instanceof WebContent webContent))
099        {
100            getLogger().debug("We currently do not support push notifications for off-site content {}", content.getId());
101            return;
102        }
103        
104        // First find the queries that whose results contain the content
105        Site site = webContent.getSite();
106        Set<Query> queries = _queryHelper.getQueriesForResult(content.getId(), site);
107
108        getLogger().info("{} queries found for content {}", queries.size(), content.getId());
109        
110        if (queries.isEmpty())
111        {
112            return;
113        }
114
115        // Then collect users that have read access to the content
116        Set<UserIdentity> users;
117        AllowedUsers readAccessAllowedUsers = _rightManager.getReadAccessAllowedUsers(content);
118        if (readAccessAllowedUsers.isAnonymousAllowed() || readAccessAllowedUsers.isAnyConnectedUserAllowed())
119        {
120            List<String> userPopulationsIds = _userPopulationDAO.getUserPopulationsIds();
121            Collection<User> allUsers = _userManager.getUsersByPopulationIds(userPopulationsIds);
122            users = allUsers.stream().map(User::getIdentity).collect(Collectors.toSet());
123        }
124        else
125        {
126            users = readAccessAllowedUsers.resolveAllowedUsers(true);
127        }
128
129        Map<String, Query> queryMap = queries.stream().collect(Collectors.toMap(Query::getId, Function.identity()));
130        Set<String> feedIds = queryMap.keySet();
131
132        // Then find the users that have subscribed to the queries
133        Map<String, Map<UserIdentity, Set<String>>> notificationsNeeded = new HashMap<>();
134        for (UserIdentity user : users)
135        {
136            Map<String, Set<String>> tokensForUser = _userPreferencesHelper.getUserImpactedTokens(user, feedIds, site);
137            for (Entry<String, Set<String>> tokensByFeed : tokensForUser.entrySet())
138            {
139                Map<UserIdentity, Set<String>> tokens = notificationsNeeded.computeIfAbsent(tokensByFeed.getKey(), __ -> new HashMap<>());
140                tokens.put(user, tokensByFeed.getValue());
141            }
142        }
143
144        Map<String, String> data = _queryHelper.getDataForContent(content);
145        Map<String, String> sorts = queries.stream().collect(Collectors.toMap(Query::getId, q -> _queryHelper.getSortProperty(q, true).get(0).getField()));
146
147        // Finally send the notifications
148        for (Entry<String, Map<UserIdentity, Set<String>>> entry : notificationsNeeded.entrySet())
149        {
150            Map<String, Object> notificationData = new HashMap<>();
151            notificationData.putAll(data);
152            String feedId = entry.getKey();
153            notificationData.put("feed_id", feedId);
154            if (queryMap.containsKey(feedId))
155            {
156                notificationData.put("category_name", queryMap.get(feedId).getTitle());
157            }
158
159            String sortField = null;
160            if (sorts.containsKey(feedId))
161            {
162                sortField = sorts.get(feedId);
163            }
164            String isoDate = _queryHelper.getContentFormattedDate(content, sortField);
165            notificationData.put("date", isoDate);
166
167            String description = _getContentDescription(content);
168
169            _pushNotificationManager.pushNotifications(content.getTitle(), description, entry.getValue(), notificationData);
170        }
171    }
172
173    /**
174     * Get a description for the notification.
175     * It will first try to read a "abstract" value in the content, and if not available, will try to read a rich-text stored in "content" (and cut it down).
176     * If none is available, an empty String is returned.
177     * @param content The content to read
178     * @return a description for this content
179     */
180    protected String _getContentDescription(Content content)
181    {
182        Long maxSize = Config.getInstance().getValue(__DESCRIPTION_MAX_SIZE_CONF_ID);
183        String result = "";
184
185        if (content.hasValue("abstract") && org.ametys.runtime.model.type.ModelItemTypeConstants.STRING_TYPE_ID.equals(content.getDefinition("abstract").getType().getId()))
186        {
187            result = content.getValue("abstract", false, "");
188        }
189        else if (content.hasValue("content") && ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(content.getDefinition("content").getType().getId()))
190        {
191            RichText richText = content.getValue("content");
192            result = _richTextHelper.richTextToString(richText, maxSize.intValue());
193        }
194
195        return result;
196    }
197}