mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 02:25:01 +08:00
clean up
This commit is contained in:
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user