001/*
002 *  Copyright 2021 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 */
016
017package org.ametys.plugins.workspaces.project.notification;
018
019import java.io.ByteArrayOutputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.time.ZonedDateTime;
023import java.util.ArrayList;
024import java.util.Date;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Set;
030import java.util.stream.Collectors;
031
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.cocoon.components.ContextHelper;
035import org.apache.cocoon.environment.Request;
036import org.apache.commons.lang3.StringUtils;
037import org.apache.excalibur.source.Source;
038import org.apache.excalibur.source.SourceResolver;
039import org.apache.excalibur.source.SourceUtil;
040import org.quartz.JobExecutionContext;
041
042import org.ametys.core.right.RightManager;
043import org.ametys.core.user.User;
044import org.ametys.core.util.DateUtils;
045import org.ametys.core.util.I18nUtils;
046import org.ametys.core.util.mail.SendMailHelper;
047import org.ametys.plugins.core.impl.schedule.AbstractStaticSchedulable;
048import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
049import org.ametys.plugins.repository.AmetysObjectIterable;
050import org.ametys.plugins.repository.events.DefaultEventType;
051import org.ametys.plugins.repository.events.EventTypeExtensionPoint;
052import org.ametys.plugins.repository.query.expression.AndExpression;
053import org.ametys.plugins.repository.query.expression.DateExpression;
054import org.ametys.plugins.repository.query.expression.Expression;
055import org.ametys.plugins.repository.query.expression.Expression.Operator;
056import org.ametys.plugins.repository.query.expression.StringExpression;
057import org.ametys.plugins.workspaces.events.WorkspacesEventType;
058import org.ametys.plugins.workspaces.members.ProjectMemberManager;
059import org.ametys.plugins.workspaces.members.ProjectMemberManager.ProjectMember;
060import org.ametys.plugins.workspaces.project.ProjectManager;
061import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
062import org.ametys.plugins.workspaces.project.notification.preferences.NotificationPreferencesHelper;
063import org.ametys.plugins.workspaces.project.notification.preferences.NotificationPreferencesHelper.Frequency;
064import org.ametys.plugins.workspaces.project.objects.Project;
065import org.ametys.runtime.i18n.I18nizable;
066import org.ametys.web.WebConstants;
067import org.ametys.web.renderingcontext.RenderingContext;
068import org.ametys.web.renderingcontext.RenderingContextHandler;
069import org.ametys.web.repository.site.Site;
070import org.ametys.web.repository.site.SiteManager;
071
072/**
073 * Abstract Class to send a mail with the summary of all notification for a time period
074 */
075public abstract class AbstractSendNotificationSummarySchedulable extends AbstractStaticSchedulable
076{
077    /** The project manager */
078    protected ProjectManager _projectManager;
079    /** The notofication helper */
080    protected NotificationPreferencesHelper _notificationPrefHelper;
081    
082    private EventTypeExtensionPoint _eventTypeEP;
083    private I18nUtils _i18nUtils;
084    private ProjectMemberManager _projectMemberManager;
085    private RenderingContextHandler _renderingContextHandler;
086    private RightManager _rightManager;
087    private SiteManager _siteManager;
088    private SourceResolver _srcResolver;
089
090
091    @Override
092    public void service(ServiceManager manager) throws ServiceException
093    {
094        super.service(manager);
095        _eventTypeEP = (EventTypeExtensionPoint) manager.lookup(EventTypeExtensionPoint.ROLE);
096        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
097        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
098        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
099        _renderingContextHandler = (RenderingContextHandler) manager.lookup(RenderingContextHandler.ROLE);
100        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
101        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
102        _srcResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
103        _notificationPrefHelper = (NotificationPreferencesHelper) manager.lookup(NotificationPreferencesHelper.ROLE);
104    }
105
106    @Override
107    public void execute(JobExecutionContext context) throws Exception
108    {
109        List<Map<String, Object>> eventsInTimeFrame = _getAggregatedEventsInTimeFrame();
110        if (eventsInTimeFrame.size() == 0)
111        {
112            return;
113        }
114        Set<User> userToNotify = _getUserToNotify();
115        
116        for (User user : userToNotify)
117        {
118            Map<String, Map<String, List<Map<String, Object>>>> userEventsInTimeFrame = _getEventsByProject(user, eventsInTimeFrame);
119            if (userEventsInTimeFrame.isEmpty())
120            {
121                continue;
122            }
123            // here we select the first language we find in the events to notify
124            // this is not ideal at all. But as there is no localisation by user we don't really have a better choice
125            String lang = _projectManager.getProject(userEventsInTimeFrame.keySet().iterator().next()).getSites().iterator().next().getSitemaps().iterator().next().getName();
126            
127            String mailSubject = _i18nUtils.translate(getI18nSubject(), lang);
128            String mailBody = _getMailBody(user, userEventsInTimeFrame, lang);
129            
130            SendMailHelper.newMail()
131                          .withRecipient(user.getEmail())
132                          .withSubject(mailSubject)
133                          .withHTMLBody(mailBody)
134                          .withInlineCSS(false)
135                          .withAsync(true)
136                          .sendMail();
137        }
138    }
139
140    /**
141     * Get all event that happened in the time frame.
142     * Event are sorted by project
143     * @return the events, sorted by project
144     */
145    private List<Map<String, Object>> _getAggregatedEventsInTimeFrame()
146    {
147        Expression projectExpr = new StringExpression("projectName", Operator.NE, "");
148                
149        ZonedDateTime frameLimit = getTimeFrameLimit();
150        Expression dateExpr = new DateExpression("date", Operator.GE, DateUtils.asDate(frameLimit));
151                
152        Expression finalExpr = new AndExpression(projectExpr, dateExpr);
153        
154        List<Map<String, Object>> events = _eventTypeEP.getEvents(finalExpr);
155        
156        return _aggregateEvents(events);
157    }
158
159    private List<Map<String, Object>> _aggregateEvents(List<Map<String, Object>> events)
160    {
161        List<Map<String, Object>> aggregatedEvents = new ArrayList<>();
162        for (Map<String, Object> event : events)
163        {
164            // We retrieve all the event that are similar
165            List<Map<String, Object>> sameEvents = aggregatedEvents.stream().filter(e -> _isAlreadyPresent(event, e)).collect(Collectors.toList());
166            if (sameEvents.size() == 0)
167            {
168                // None are find, we just add the new event
169                aggregatedEvents.add(event);
170            }
171            else
172            {
173                // We removed all the similar events
174                aggregatedEvents.removeAll(sameEvents);
175                // Find the one we want to store
176                Map<String, Object> finalEvent = event;
177                ZonedDateTime finalEventDate = DateUtils.asZonedDateTime((Date) finalEvent.get(DefaultEventType.DATE));
178                boolean multipleAuthor = false;
179                int nbOfOccurrence = 1;
180                // There should never be more than one at a time still
181                for (Map<String, Object> sameEvent : sameEvents)
182                {
183                    if (sameEvent.get("nbOfOccurence") != null)
184                    {
185                        nbOfOccurrence += (int) sameEvent.get("nbOfOccurrence");
186                    }
187                    else
188                    {
189                        nbOfOccurrence++;
190                    }
191                    // If we find similar event with different author we will have to change the author
192                    if (!multipleAuthor && !finalEvent.get(DefaultEventType.AUTHOR).equals(sameEvent.get(DefaultEventType.AUTHOR)))
193                    {
194                        multipleAuthor = true;
195                    }
196                    ZonedDateTime eventDate = DateUtils.asZonedDateTime((Date) sameEvent.get(DefaultEventType.DATE));
197                    if (eventDate.isAfter(finalEventDate))
198                    {
199                        finalEvent = sameEvent;
200                        finalEventDate = eventDate;
201                    }
202                }
203                if (multipleAuthor)
204                {
205                    finalEvent.remove(DefaultEventType.AUTHOR);
206                }
207                finalEvent.put("nbOfOccurrence", nbOfOccurrence);
208                aggregatedEvents.add(finalEvent);
209            }
210        }
211        return aggregatedEvents;
212    }
213    
214    private boolean _isAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
215    {
216        if (!event.get(DefaultEventType.TYPE).equals(event2.get(DefaultEventType.TYPE)))
217        {
218            return false;
219        }
220       
221        String type = (String) event.get(DefaultEventType.TYPE);
222        switch (StringUtils.substringBefore(type, "."))
223        {
224            case "calendar":
225                return _calendarEventAlreadyPresent(event, event2);
226            case "member":
227                return _memberEventAlreadyPresent(event, event2);
228            case "task":
229                return _taskEventAlreadyPresent(event, event2);
230            case "thread":
231                return _threadEventAlreadyPresent(event, event2);
232            case "resource":
233                return _resourceEventAlreadyPresent(event, event2);
234            case "wallcontent":
235                return _wallContentEventAlreadyPresent(event, event2);
236            default:
237                return false;
238        }
239    }
240
241    private boolean _wallContentEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
242    {
243        if (event.get("contentId") != null && event2.get("contentId") != null)
244        {
245            return event.get("contentId").equals(event2.get("contentId"));
246        }
247        return false;
248    }
249
250    private boolean _resourceEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
251    {
252        if (event.get("file") != null && event2.get("file") != null)
253        {
254            @SuppressWarnings("unchecked")
255            Map<String, Object> file = (Map<String, Object>) event.get("file");
256            @SuppressWarnings("unchecked")
257            Map<String, Object> file2 = (Map<String, Object>) event2.get("file");
258            if (file.get("id") != null && file2.get("id") != null)
259            {
260                return file.get("id").equals(file2.get("id"));
261            }
262        }
263        return false;
264    }
265
266    private boolean _threadEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
267    {
268        if (StringUtils.startsWith((String) event.get(DefaultEventType.TYPE), "thread.post"))
269        {
270            return false;
271        }
272        else if (event.get("threadId") != null && event2.get("threadId") != null)
273        {
274            return event.get("threadId").equals(event2.get("threadId"));
275        }
276        return false;
277    }
278
279    private boolean _taskEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
280    {
281        if (event.get("taskId") != null && event2.get("taskId") != null)
282        {
283            return event.get("taskId").equals(event2.get("taskId"));
284        }
285        return false;
286    }
287
288    private boolean _memberEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
289    {
290        if (event.get("identity") != null && event2.get("identity") != null)
291        {
292            return event.get("identity").equals(event2.get("identity"));
293        }
294        return false;
295    }
296
297    private boolean _calendarEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2)
298    {
299        if (event.get("eventId") != null && event2.get("eventId") != null)
300        {
301            return event.get("eventId").equals(event2.get("eventId"));
302        }
303        return false;
304    }
305
306    /**
307     * Get the earliest event's date we want to retrieve
308     * @return the date after which we want to retrieve event
309     */
310    protected abstract ZonedDateTime getTimeFrameLimit();
311
312    /**
313     *  Get all user who have this time frame set in there userPrefs
314     * @return the list of user to notify
315     */
316    private Set<User> _getUserToNotify()
317    {
318        Set<User> users = new HashSet<>();
319        AmetysObjectIterable<Project> projects = _projectManager.getProjects();
320        
321        // Get all members of projects
322        for (Project project : projects)
323        {
324            Set<ProjectMember> projectMembers = _projectMemberManager.getProjectMembers(project, true);
325            Set<User> projectUsers = projectMembers.stream().map(ProjectMember::getUser).collect(Collectors.toSet());
326            users.addAll(projectUsers);
327        }
328    
329        return users.stream()
330                    // if the user has no mail then no need to compute notification.
331                    .filter(user -> StringUtils.isNotEmpty(user.getEmail()))
332                    // ensure that the user has this frequency somewhere in his preference
333                    .filter(user -> _notificationPrefHelper.hasFrequencyInPreferences(user, getFrequency()))
334                    .collect(Collectors.toSet());
335    }
336
337    /**
338     * Get the notification frequency
339     * @return the frequency
340     */
341    protected abstract Frequency getFrequency();
342
343    private Map<String, Map<String, List<Map<String, Object>>>> _getEventsByProject(User user, List<Map<String, Object>> eventsInTimeFrame)
344    {
345        Set<String> projectNames = _notificationPrefHelper.getUserProjectsWithFrequency(user, getFrequency());
346        
347        Map<String, Map<String, List<Map<String, Object>>>> userEvents = new HashMap<>();
348        for (String projectName : projectNames)
349        {
350            Set<String> allowedType = _getAllowedEventTypes(user, projectName);
351            Map<String, List<Map<String, Object>>> userEventsByType = _getUserProjectEvents(projectName, allowedType, eventsInTimeFrame);
352            if (!userEventsByType.isEmpty())
353            {
354                userEvents.put(projectName, userEventsByType);
355            }
356        }
357        return userEvents;
358    }
359
360    private Set<String> _getAllowedEventTypes(User user, String projectName)
361    {
362        Project project = _projectManager.getProject(projectName);
363        if (project == null)
364        {
365            // project does not exist anymore, ignore all events
366            return Set.of();
367        }
368        
369        Set<String> allowedTypes = new HashSet<>();
370        for (WorkspaceModule module : _projectManager.getModules(project))
371        {
372            ModifiableResourceCollection moduleRoot = module.getModuleRoot(project, false);
373            if (moduleRoot != null && _rightManager.hasReadAccess(user.getIdentity(), moduleRoot))
374            {
375                allowedTypes.addAll(module.getAllowedEventTypes());
376            }
377        }
378        
379        return allowedTypes;
380    }
381
382    private Map<String, List<Map<String, Object>>> _getUserProjectEvents(String projectName, Set<String> allowedType, List<Map<String, Object>> eventsInTimeFrame)
383    {
384        Map<String, List<Map<String, Object>>> userEventsByType = new HashMap<>();
385        for (Map<String, Object> eventJSON : eventsInTimeFrame)
386        {
387            String eventType = (String) eventJSON.get(DefaultEventType.TYPE);
388            if (allowedType.contains(eventType) && eventJSON.get(WorkspacesEventType.PROJECT_NAME).equals(projectName))
389            {
390                if (!userEventsByType.containsKey(eventType))
391                {
392                    userEventsByType.put(eventType, new ArrayList<>());
393                }
394                // TODO we should implement here if we send all notification or only those concerning the user
395                userEventsByType.get(eventType).add(eventJSON);
396            }
397        }
398        return userEventsByType;
399    }
400
401    /**
402     * Get the subject of the mail
403     * @return the subject of the mail
404     */
405    protected abstract I18nizable getI18nSubject();
406
407    private String _getMailBody(User user, Map<String, Map<String, List<Map<String, Object>>>> events, String lang)
408    {
409        
410        Source source = null;
411        RenderingContext currentContext = _renderingContextHandler.getRenderingContext();
412        Request request = ContextHelper.getRequest(_context);
413        
414        try
415        {
416            // Force rendering context.FRONT to resolve URI
417            _renderingContextHandler.setRenderingContext(RenderingContext.FRONT);
418            
419            request.setAttribute("forceAbsoluteUrl", true);
420            request.setAttribute("forceBase64Encoding", true); // for image using uri resolver
421            
422            request.setAttribute("lang", lang);
423            String siteName = _projectManager.getCatalogSiteName();
424            Site site = _siteManager.getSite(siteName);
425    
426            request.setAttribute(WebConstants.REQUEST_ATTR_SITE, site);
427            request.setAttribute(WebConstants.REQUEST_ATTR_SITE_NAME, siteName);
428            request.setAttribute(WebConstants.REQUEST_ATTR_SKIN_ID, site.getSkinId());
429            
430            source = _srcResolver.resolveURI("cocoon://_plugins/workspaces/notification-mail-summary.html",
431                                             null,
432                                             Map.of("frequency", getFrequency().name(), "events", events, "user", user));
433            
434            try (InputStream is = source.getInputStream())
435            {
436                ByteArrayOutputStream bos = new ByteArrayOutputStream();
437                SourceUtil.copy(is, bos);
438                
439                return bos.toString("UTF-8");
440            }
441        }
442        catch (IOException e)
443        {
444            throw new RuntimeException("Failed to get mail body for workspaces notifications", e);
445        }
446        finally
447        {
448            request.removeAttribute("forceBase64Encoding");
449            _renderingContextHandler.setRenderingContext(currentContext);
450            
451            if (source != null)
452            {
453                _srcResolver.release(source);
454            }
455        }
456    }
457}