Files
speckle-server/packages/server/modules/notifications/services/handlers/activityDigest.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

459 lines
14 KiB
TypeScript

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<boolean>
): Promise<boolean | null> => {
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 = `
<h1>${heading}</h1>
<p>${facts.join('')}</p>
`
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 = `
<h1>${heading}</h1>
<p>${fact}</p>
`
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: `<p>${mentionFact}</p>`,
sources: mentionActions
}
}
export const farewell = () => {
return {
text: "That's it for this week, see you next time!",
html: "<p>That's it for this week, see you next time!</p>",
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 = `<h1>${heading}</h1>`
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 += `<p><a style="font-weight:bold" href="${streamUrl}">${a.stream.name}</a>`
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} <a href="${streamUrl}/comments">comments</a>`
text += `. It also got ${commentCount} comments. Check them at ${streamUrl}/comments`
}
html += `.<p/>`
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 = `<p>${fact}<ul>`
if (commitCount) {
const factText = `Your streams received a total of ${commitCount} new commits.`
text += `- ${factText}\n`
html += `<li>${factText}</li>`
}
if (commentCount) {
const factText = `${commentCount} comments were created.`
text += `- ${factText}\n`
html += `<li>${factText}</li>`
}
if (receiveCount) {
const factText = `Commits were received ${receiveCount} times.`
text += `- ${factText}\n`
html += `<li>${factText}</li>`
}
return {
text,
html,
sources: []
}
}
const countByActivityType = (
activities: StreamScopeActivity[],
actionType: AllStreamActivityTypes
): number => activities.filter((a) => a.actionType === actionType).length
const sortedByActivityCount = (
activities: StreamActivitySummary[]
): StreamActivitySummary[] =>
activities.slice().sort((a, b) => b.activity.length - a.activity.length)
const flattenActivities = (
activitySummaries: StreamActivitySummary[]
): StreamScopeActivity[] => {
const allActivity: StreamScopeActivity[] = []
activitySummaries.map((str) => allActivity.push(...str.activity))
return allActivity
}
type PrepareSummaryEmailDeps = {
renderEmail: typeof renderEmail
}
export const prepareSummaryEmailFactory =
(deps: PrepareSummaryEmailDeps) =>
async (digest: Digest, serverInfo: ServerInfo): Promise<EmailInput> => {
const body = await renderEmailBody(digest, serverInfo)
const cta = {
title: 'Check activities',
url: serverInfo.canonicalUrl
}
const subject = 'Speckle weekly digest'
const { text, html } = await deps.renderEmail(
{ mjml: { bodyStart: body.mjml }, text: { bodyStart: body.text }, cta },
serverInfo,
digest.user
)
return { to: digest.user.email, subject, text, html }
}
export const renderEmailBody = async (
digest: Digest,
serverInfo: ServerInfo
): Promise<EmailBody> => {
let text = `
Hello ${digest.user.name}!\n
Here's a summary of what happened in the past week
on the Speckle server: ${serverInfo.name}\n\n
`
// digest.topics.map((t) => (bodyStart += `${t.html}<br />`))
digest.topics.map((t) => (text += `${t.text} \n\n`))
let mjml = `
<mj-text>
<h3>Hello ${digest.user.name}!</h3>
<p>Here's a summary of what happened in the past week
on the Speckle server: ${serverInfo.name} ✨ </p>
</mj-text>
`
const topicPath = path.resolve(
packageRoot,
'assets/emails/templates/components/digestTopic.ejs'
)
const mjmlTopics = await Promise.all(
digest.topics.map(
async (params) =>
await ejs.renderFile(
topicPath,
{ params },
{ cache: true, outputFunctionName: 'print' }
)
)
)
mjml += mjmlTopics.join('\n')
return { text, mjml }
}
const digestNotificationEmailHandler = digestNotificationEmailHandlerFactory({
getUserNotificationPreferences: getUserNotificationPreferencesFactory({
getSavedUserNotificationPreferences: getSavedUserNotificationPreferencesFactory({
db
})
}),
createActivitySummary: createActivitySummaryFactory({
getStream: getStreamFactory({ db }),
getActivity: geUserStreamActivityFactory({ db }),
getUser: getUserFactory({ db })
}),
getServerInfo: getServerInfoFactory({ db }),
renderEmail
})
export const handler: NotificationHandler<ActivityDigestMessage> = async (msg) => {
const {
targetUserId,
data: { streamIds, start, end }
} = msg
await digestNotificationEmailHandler(targetUserId, streamIds, start, end, sendEmail)
}
export default handler