import { ActivityDigestMessage, NotificationHandler } from '@/modules/notifications/helpers/types' import { StreamActionTypes, StreamActivityRecord, AllStreamActivityTypes, StreamScopeActivity } from '@/modules/activitystream/helpers/types' import { ServerInfo, UserRecord } from '@/modules/core/helpers/types' import { sendEmail, SendEmailParams } from '@/modules/emails/services/sending' import { groupBy } from 'lodash-es' import { packageRoot } from '@/bootstrap' import path from 'path' import * as ejs from 'ejs' import { renderEmail } from '@/modules/emails/services/emailRendering' import { getUserNotificationPreferencesFactory } from '@/modules/notifications/services/notificationPreferences' import { getSavedUserNotificationPreferencesFactory } from '@/modules/notifications/repositories' import { db } from '@/db/knex' import { GetUserNotificationPreferences } from '@/modules/notifications/domain/operations' import { CreateActivitySummary } from '@/modules/activitystream/domain/operations' import { ActivitySummary, StreamActivitySummary } from '@/modules/activitystream/domain/types' import { createActivitySummaryFactory } from '@/modules/activitystream/services/summary' import { geUserStreamActivityFactory } from '@/modules/activitystream/repositories' import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' import { GetServerInfo } from '@/modules/core/domain/server/operations' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { EmailBody, EmailInput } from '@/modules/emails/domain/operations' const digestNotificationEmailHandlerFactory = ( deps: { getUserNotificationPreferences: GetUserNotificationPreferences createActivitySummary: CreateActivitySummary getServerInfo: GetServerInfo } & PrepareSummaryEmailDeps ) => async ( userId: string, streamIds: string[], start: Date, end: Date, emailSender: (params: SendEmailParams) => Promise ): Promise => { const wantDigests = (await deps.getUserNotificationPreferences(userId)).activityDigest?.email !== false const activitySummary = await deps.createActivitySummary({ userId, streamIds, start, end }) // if there are no activities stop early if (!wantDigests || !activitySummary || !activitySummary.streamActivities.length) return null const serverInfo = await deps.getServerInfo() const digest = digestSummaryData(activitySummary, serverInfo) if (!digest) return null const emailInput = await prepareSummaryEmailFactory(deps)(digest, serverInfo) return await emailSender(emailInput) } /** * Organize the activity summary into topics. * The order of topics should be by relevance. */ export const digestSummaryData = ( activitySummary: ActivitySummary, serverInfo: ServerInfo, topicDigesters: TopicDigesterFunction[] = [ digestMostActiveStream, digestActiveStreams, mostActiveComment, commentMentionSummary, closingOverview ] ): Digest | null => { const maybeTopics = topicDigesters.map((dig) => dig(activitySummary, serverInfo)) const topics = maybeTopics.filter((topic): topic is DigestTopic => topic !== null) // if there are no topics, do not return a digest if (!topics.length) return null topics.push(farewell()) return { user: activitySummary.user, topics } } export type Digest = { user: UserRecord topics: DigestTopic[] } export type DigestTopic = { text: string html: string cta?: { url: string title: string altTitle?: string } sources: StreamActivityRecord[] } type TopicDigesterFunction = ( activitySummary: ActivitySummary, serverInfo: ServerInfo ) => DigestTopic | null export const digestMostActiveStream: TopicDigesterFunction = ( activitySummary, serverInfo ) => { // if there are less than 2 streams with activity, there is not reason to highlight it const minStreamCount = 1 if (activitySummary.streamActivities.length <= minStreamCount) return null const orderedActivities = sortedByActivityCount(activitySummary.streamActivities) // we know, there are items in the array cause of the guardrail above // so its save to cast away from undefined let mostActive = orderedActivities.shift() as StreamActivitySummary while (mostActive.stream === null) { const activity = orderedActivities.shift() if (!activity) break mostActive = activity } // all the active streams were deleted, shouldn't send this topic if (!mostActive.stream) return null const commitCount = countByActivityType( mostActive.activity, StreamActionTypes.Commit.Create ) const commentCount = countByActivityType(mostActive.activity, StreamActionTypes.Comment.Create) + countByActivityType(mostActive.activity, StreamActionTypes.Comment.Reply) const receives = mostActive.activity.filter( (a) => a.actionType === StreamActionTypes.Commit.Receive ) const numReceiveUsers = new Set(receives.map((a) => a.userId)).size const heading = `Your most active stream was ${mostActive.stream.name}!` const facts: string[] = [] if (commitCount) facts.push(`${commitCount} new commits were created`) if (commentCount) { if (facts.length) facts.push(' and') facts.push(`${facts.length ? ' u' : 'U'}sers added ${commentCount} new comments`) } if (facts.length) facts.push('.\n') if (receives.length) facts.push( `The commits were received ${receives.length} times by ${numReceiveUsers} users.` ) const text = `${heading}\n\n${facts.join('')}` const html = `

${heading}

${facts.join('')}

` const topic: DigestTopic = { text, html, sources: mostActive.activity, cta: { url: `${serverInfo.canonicalUrl}/streams/${mostActive.stream.id}`, title: 'Check it out here!' } } return topic } export const mostActiveComment: TopicDigesterFunction = ( activitySummary, serverInfo ) => { const activities = flattenActivities(activitySummary.streamActivities) const replyActions = activities.filter( (a) => a.actionType === StreamActionTypes.Comment.Reply ) if (!replyActions.length) return null const parentCommentGroups = groupBy(replyActions, (a) => { const info = a.info as { input: { parentComment: string } } return info.input.parentComment }) const replies = Object.entries(parentCommentGroups).reduce((currentLongest, curr) => curr[1].length > currentLongest[1].length ? curr : currentLongest )[1] const streamActivity = activitySummary.streamActivities.find( (a) => a.stream?.id === replies[0].streamId ) // the stream was deleted since if (!streamActivity || !streamActivity.stream) return null const heading = 'Most active comment' const fact = `The most active comment was on ${streamActivity.stream.name} stream. It received ${replies.length} replies.` const text = `${heading}\n\n${fact}` const html = `

${heading}

${fact}

` return { text, html, cta: { url: `${serverInfo.canonicalUrl}/streams/${streamActivity.stream.id}/comments`, title: `Open stream comments` }, sources: replyActions } } export const commentMentionSummary: TopicDigesterFunction = (activitySummary) => { const activities = flattenActivities(activitySummary.streamActivities) const mentionActions = activities.filter( (a) => a.actionType === StreamActionTypes.Comment.Mention ) const mentionFact = mentionActions.length ? `You have been mentioned in ${mentionActions.length} comments. Make sure to follow up on them.` : null if (!mentionFact) return null return { text: mentionFact, html: `

${mentionFact}

`, sources: mentionActions } } export const farewell = () => { return { text: "That's it for this week, see you next time!", html: "

That's it for this week, see you next time!

", sources: [] } } export const digestActiveStreams: TopicDigesterFunction = ( activitySummary, serverInfo ) => { const minStreamCount = 2 if (activitySummary.streamActivities.length <= minStreamCount) return null const orderedActivities = sortedByActivityCount(activitySummary.streamActivities) // i know it sucks, but i have to drop the most active stream here, cause its been // part of digests elsewhere... const activities = orderedActivities.slice(1, 4) const heading = 'Notable active streams' let html = `

${heading}

` let text = `${heading}\n` activities.map((a) => { //The stream was deleted if (!a.stream) return const commitCount = countByActivityType(a.activity, StreamActionTypes.Commit.Create) const commentCount = countByActivityType(a.activity, StreamActionTypes.Comment.Create) + countByActivityType(a.activity, StreamActionTypes.Comment.Reply) const receiveCount = countByActivityType( a.activity, StreamActionTypes.Commit.Receive ) const streamUrl = `${serverInfo.canonicalUrl}/streams/${a.stream.id}` html += `

${a.stream.name}` text += `${a.stream.name} ${streamUrl}` if (commitCount) { html += ` had ${commitCount} new commits` text += ` had ${commitCount} new commits` } if (receiveCount) { html += ` which were received ${receiveCount} times` text += ` which were received ${receiveCount} times` } if (commentCount) { html += `. It also got ${commentCount} comments` text += `. It also got ${commentCount} comments. Check them at ${streamUrl}/comments` } html += `.

` text += '.\n' }) return { text, html, sources: flattenActivities(activities) } } export const closingOverview: TopicDigesterFunction = (activitySummary) => { const activities = flattenActivities(activitySummary.streamActivities) const commitCount = activities.filter( (a) => a.actionType === StreamActionTypes.Commit.Create ).length const commentCount = activities.filter((a) => { const actions: AllStreamActivityTypes[] = [ StreamActionTypes.Comment.Create, StreamActionTypes.Comment.Reply ] return a.actionType && actions.includes(a.actionType) }).length const receiveCount = activities.filter( (a) => a.actionType === StreamActionTypes.Commit.Receive ).length const factCount = [commitCount, commentCount, receiveCount].filter((f) => f > 0) if (factCount.length < 2) return null const fact = 'Before you leave, a quick overview:' let text = `${fact}\n\n` let html = `

${fact}