This commit is contained in:
Philipinho
2026-03-30 20:20:51 +01:00
parent 0f22e44d29
commit a6d2140197
5 changed files with 149 additions and 43 deletions
@@ -4,8 +4,8 @@ import type { Redis } from 'ioredis';
const KEY_PREFIX = 'page-update:emails:'; const KEY_PREFIX = 'page-update:emails:';
const DIGEST_PREFIX = 'page-update:digest:'; const DIGEST_PREFIX = 'page-update:digest:';
const TTL_SECONDS = 86400; // 24 hours const TTL_SECONDS = 28800; // 8 hours
const MAX_IMMEDIATE_EMAILS = 10; const MAX_IMMEDIATE_EMAILS = 4;
@Injectable() @Injectable()
export class PageUpdateEmailRateLimiter { export class PageUpdateEmailRateLimiter {
@@ -186,8 +186,10 @@ export class PageNotificationService {
const candidateIds = watcherIds.filter((id) => !actorSet.has(id)); const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
if (candidateIds.length === 0) return; if (candidateIds.length === 0) return;
const afterPrefs = await this.getEligiblePageUpdateUserIds(candidateIds); const eligibleUsers = await this.getEligiblePageUpdateUsers(candidateIds);
if (afterPrefs.length === 0) return; if (eligibleUsers.size === 0) return;
const afterPrefs = [...eligibleUsers.keys()];
const recentlyNotified = const recentlyNotified =
await this.notificationRepo.getRecentlyNotifiedUserIds( await this.notificationRepo.getRecentlyNotifiedUserIds(
@@ -237,6 +239,7 @@ export class PageNotificationService {
notification.id, notification.id,
`${actor.name} updated ${pageTitle}`, `${actor.name} updated ${pageTitle}`,
PageUpdateEmail({ PageUpdateEmail({
userName: eligibleUsers.get(userId) ?? '',
actorName: actor.name, actorName: actor.name,
pageTitle, pageTitle,
pageUrl: basePageUrl, pageUrl: basePageUrl,
@@ -255,37 +258,38 @@ export class PageNotificationService {
} }
} }
private async getEligiblePageUpdateUserIds( private async getEligiblePageUpdateUsers(
userIds: string[], userIds: string[],
): Promise<string[]> { ): Promise<Map<string, string>> {
if (userIds.length === 0) return []; if (userIds.length === 0) return new Map();
const users = await this.db const users = await this.db
.selectFrom('users') .selectFrom('users')
.select(['id', 'settings']) .select(['id', 'name', 'settings'])
.where('id', 'in', userIds) .where('id', 'in', userIds)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null) .where('deactivatedAt', 'is', null)
.execute(); .execute();
return users const eligible = new Map<string, string>();
.filter((u) => { for (const u of users) {
const settings = u.settings as any; const settings = u.settings as any;
return settings?.notifications?.['page.updated'] !== false; if (settings?.notifications?.['page.updated'] !== false) {
}) eligible.set(u.id, u.name);
.map((u) => u.id); }
}
return eligible;
} }
private async scheduleDigest( private async scheduleDigest(
userId: string, userId: string,
workspaceId: string, workspaceId: string,
): Promise<void> { ): Promise<void> {
const jobId = `page-update-digest:${userId}`;
await this.notificationQueue await this.notificationQueue
.add( .add(
QueueJob.PAGE_UPDATE_DIGEST, QueueJob.PAGE_UPDATE_DIGEST,
{ userId, workspaceId }, { userId, workspaceId },
{ jobId, delay: DIGEST_DELAY_MS }, { delay: DIGEST_DELAY_MS, removeOnComplete: true },
) )
.catch((err) => { .catch((err) => {
this.logger.error( this.logger.error(
@@ -298,40 +302,102 @@ export class PageNotificationService {
const notificationIds = await this.rateLimiter.popDigest(userId); const notificationIds = await this.rateLimiter.popDigest(userId);
if (notificationIds.length === 0) return; if (notificationIds.length === 0) return;
const notifications = await this.db const [user, notifications] = await Promise.all([
.selectFrom('notifications') this.db
.select(['id', 'pageId']) .selectFrom('users')
.where('id', 'in', notificationIds) .select(['id', 'name'])
.execute(); .where('id', '=', userId)
.executeTakeFirst(),
this.db
.selectFrom('notifications')
.select(['id', 'pageId', 'actorId'])
.where('id', 'in', notificationIds)
.execute(),
]);
if (notifications.length === 0) return; if (!user || notifications.length === 0) return;
const pageIds = [...new Set(notifications.map((n) => n.pageId).filter(Boolean))]; const pageIds = [
...new Set(notifications.map((n) => n.pageId).filter(Boolean)),
];
const actorIds = [
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
];
const pages = await this.db const allPages = await this.db
.selectFrom('pages') .selectFrom('pages')
.innerJoin('spaces', 'spaces.id', 'pages.spaceId') .innerJoin('spaces', 'spaces.id', 'pages.spaceId')
.select([ .select([
'pages.id', 'pages.id',
'pages.title', 'pages.title',
'pages.slugId', 'pages.slugId',
'pages.spaceId',
'spaces.slug as spaceSlug', 'spaces.slug as spaceSlug',
]) ])
.where('pages.id', 'in', pageIds) .where('pages.id', 'in', pageIds)
.execute(); .execute();
if (allPages.length === 0) return;
const spaceIds = [...new Set(allPages.map((p) => p.spaceId))];
const accessibleSpaceIds = new Set<string>();
for (const spaceId of spaceIds) {
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess([userId], spaceId);
if (usersWithAccess.has(userId)) accessibleSpaceIds.add(spaceId);
}
const spaceFilteredPages = allPages.filter((p) =>
accessibleSpaceIds.has(p.spaceId),
);
if (spaceFilteredPages.length === 0) return;
const accessiblePageIds = new Set<string>();
for (const p of spaceFilteredPages) {
const hasAccess = await this.pagePermissionRepo.getUserIdsWithPageAccess(
p.id,
[userId],
);
if (hasAccess.includes(userId)) accessiblePageIds.add(p.id);
}
const pages = spaceFilteredPages.filter((p) => accessiblePageIds.has(p.id));
if (pages.length === 0) return;
const actors = actorIds.length > 0
? await this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', 'in', actorIds)
.execute()
: [];
const actorMap = new Map(actors.map((a) => [a.id, a.name]));
const pageActors = new Map<string, Set<string>>();
for (const n of notifications) {
if (!n.pageId || !n.actorId) continue;
const names = pageActors.get(n.pageId) ?? new Set();
const name = actorMap.get(n.actorId);
if (name) names.add(name);
pageActors.set(n.pageId, names);
}
const pageUpdates = pages.map((p) => ({ const pageUpdates = pages.map((p) => ({
title: getPageTitle(p.title), title: getPageTitle(p.title),
url: `${appUrl}/s/${p.spaceSlug}/p/${p.slugId}`, url: `${appUrl}/s/${p.spaceSlug}/p/${p.slugId}`,
updatedBy: [...(pageActors.get(p.id) ?? [])],
})); }));
if (pageUpdates.length === 0) return;
await this.notificationService.queueEmail( await this.notificationService.queueEmail(
userId, userId,
notificationIds[0], notificationIds[0],
`${pageUpdates.length} pages were updated`, `Your digest: ${pageUpdates.length} page updates`,
PageUpdateDigestEmail({ pageUpdates }), PageUpdateDigestEmail({
userName: user.name,
pageUpdates,
totalUpdates: pageUpdates.length,
}),
NotificationType.PAGE_UPDATED, NotificationType.PAGE_UPDATED,
); );
} }
@@ -1,42 +1,76 @@
import { Link, Section, Text } from '@react-email/components'; import { Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { content, link, paragraph } from '../css/styles'; import { content, link, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { getGreetingName, MailBody } from '../partials/partials';
interface PageUpdate { interface PageUpdate {
title: string; title: string;
url: string; url: string;
updatedBy: string[];
} }
interface Props { interface Props {
userName: string;
pageUpdates: PageUpdate[]; pageUpdates: PageUpdate[];
totalUpdates: number;
} }
export const PageUpdateDigestEmail = ({ pageUpdates }: Props) => { export const PageUpdateDigestEmail = ({
userName,
pageUpdates,
totalUpdates,
}: Props) => {
return ( return (
<MailBody> <MailBody>
<Section style={content}> <Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}> <Text style={paragraph}>
The following {pageUpdates.length} pages you watch were updated: Hi {getGreetingName(userName)},
</Text> </Text>
<Text style={paragraph}>
There {totalUpdates === 1 ? 'has' : 'have'} been{' '}
<strong>
{totalUpdates} update{totalUpdates === 1 ? '' : 's'}
</strong>{' '}
since your last update.
</Text>
{pageUpdates.map((page, i) => ( {pageUpdates.map((page, i) => (
<Text key={i} style={listItem}> <Section key={i} style={pageCard}>
{'• '} <Text style={pageTitle}>
<Link href={page.url} style={link}> <Link href={page.url} style={link}>
{page.title} {page.title}
</Link> </Link>
</Text> </Text>
{page.updatedBy.length > 0 && (
<Text style={updatedByText}>
{page.updatedBy.join(', ')} made edits
</Text>
)}
</Section>
))} ))}
</Section> </Section>
</MailBody> </MailBody>
); );
}; };
const listItem = { const pageCard = {
borderLeft: '3px solid #e8e5ef',
paddingLeft: '12px',
marginBottom: '12px',
};
const pageTitle = {
...paragraph, ...paragraph,
margin: '4px 0', margin: '0 0 2px 0',
lineHeight: 1.4, fontSize: 14,
fontWeight: 'bold' as const,
};
const updatedByText = {
...paragraph,
margin: '0',
fontSize: 13,
color: '#666',
}; };
export default PageUpdateDigestEmail; export default PageUpdateDigestEmail;
@@ -1,15 +1,17 @@
import { Link, Section, Text } from '@react-email/components'; import { Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { content, link, paragraph } from '../css/styles'; import { content, link, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials'; import { EmailButton, getGreetingName, MailBody } from '../partials/partials';
interface Props { interface Props {
userName: string;
actorName: string; actorName: string;
pageTitle: string; pageTitle: string;
pageUrl: string; pageUrl: string;
} }
export const PageUpdateEmail = ({ export const PageUpdateEmail = ({
userName,
actorName, actorName,
pageTitle, pageTitle,
pageUrl, pageUrl,
@@ -17,7 +19,7 @@ export const PageUpdateEmail = ({
return ( return (
<MailBody> <MailBody>
<Section style={content}> <Section style={content}>
<Text style={paragraph}>Hi there,</Text> <Text style={paragraph}>Hi {getGreetingName(userName)},</Text>
<Text style={paragraph}> <Text style={paragraph}>
<strong>{actorName}</strong> updated{' '} <strong>{actorName}</strong> updated{' '}
<Link href={pageUrl} style={link}> <Link href={pageUrl} style={link}>
@@ -87,3 +87,7 @@ export function MailFooter() {
</Section> </Section>
); );
} }
export function getGreetingName(name?: string): string {
return name?.split(' ')[0] || 'there';
}