mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: page update notifications (#2074)
* feat: watchers notification and email preferences * fix: email copy * digests * clean up * fix * clean up * move backlinks queue-up to history processor * fix * fix keys * feat: group notifications * filter * adjust email digest window
This commit is contained in:
@@ -18,12 +18,10 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import {
|
||||
extractMentions,
|
||||
extractPageMentions,
|
||||
extractUserMentions,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import {
|
||||
IPageBacklinkJob,
|
||||
IPageHistoryJob,
|
||||
IPageMentionNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
@@ -43,7 +41,6 @@ export class PersistenceExtension implements Extension {
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
@@ -165,13 +162,6 @@ export class PersistenceExtension implements Extension {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
const pageMentions = extractPageMentions(mentions);
|
||||
|
||||
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
|
||||
pageId: pageId,
|
||||
workspaceId: page.workspaceId,
|
||||
mentions: pageMentions,
|
||||
} as IPageBacklinkJob);
|
||||
|
||||
const userMentions = extractUserMentions(mentions);
|
||||
const oldMentions = page.content ? extractMentions(page.content) : [];
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
|
||||
import {
|
||||
IPageBacklinkJob,
|
||||
IPageHistoryJob,
|
||||
IPageUpdateNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import {
|
||||
extractMentions,
|
||||
extractPageMentions,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
@@ -18,6 +27,8 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
private readonly watcherService: WatcherService,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -47,8 +58,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||
) {
|
||||
const contributorIds =
|
||||
await this.collabHistory.popContributors(pageId);
|
||||
const contributorIds = await this.collabHistory.popContributors(pageId);
|
||||
|
||||
try {
|
||||
await this.watcherService.addPageWatchers(
|
||||
@@ -61,12 +71,39 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||
this.logger.debug(`History created for page: ${pageId}`);
|
||||
} catch (err) {
|
||||
await this.collabHistory.addContributors(
|
||||
pageId,
|
||||
contributorIds,
|
||||
);
|
||||
await this.collabHistory.addContributors(pageId, contributorIds);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const mentions = extractMentions(page.content);
|
||||
const pageMentions = extractPageMentions(mentions);
|
||||
|
||||
await this.generalQueue
|
||||
.add(QueueJob.PAGE_BACKLINKS, {
|
||||
pageId,
|
||||
workspaceId: page.workspaceId,
|
||||
mentions: pageMentions,
|
||||
} as IPageBacklinkJob)
|
||||
.catch((err) => {
|
||||
this.logger.error(
|
||||
`Failed to queue backlinks for ${pageId}: ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (contributorIds.length > 0 && lastHistory?.content) {
|
||||
await this.notificationQueue
|
||||
.add(QueueJob.PAGE_UPDATED, {
|
||||
pageId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
actorIds: contributorIds,
|
||||
} as IPageUpdateNotificationJob)
|
||||
.catch((err) => {
|
||||
this.logger.error(
|
||||
`Failed to queue page update notification for ${pageId}: ${err.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
export class NotificationIdDto {
|
||||
@IsUUID()
|
||||
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
|
||||
@IsOptional()
|
||||
notificationIds?: string[];
|
||||
}
|
||||
|
||||
export class ListNotificationsDto extends PaginationOptions {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['direct', 'updates', 'all'])
|
||||
type?: 'direct' | 'updates' | 'all' = 'all';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,45 @@ export const NotificationType = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
PAGE_UPDATED: 'page.updated',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
(typeof NotificationType)[keyof typeof NotificationType];
|
||||
|
||||
export type NotificationSettingKey =
|
||||
| 'page.updated'
|
||||
| 'page.userMention'
|
||||
| 'comment.userMention'
|
||||
| 'comment.created'
|
||||
| 'comment.resolved';
|
||||
|
||||
export const NotificationTypeToSettingKey: Partial<
|
||||
Record<NotificationType, NotificationSettingKey>
|
||||
> = {
|
||||
[NotificationType.PAGE_UPDATED]: 'page.updated',
|
||||
[NotificationType.PAGE_USER_MENTION]: 'page.userMention',
|
||||
[NotificationType.COMMENT_USER_MENTION]: 'comment.userMention',
|
||||
[NotificationType.COMMENT_CREATED]: 'comment.created',
|
||||
[NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
|
||||
};
|
||||
|
||||
export type NotificationTab = 'direct' | 'updates' | 'all';
|
||||
|
||||
export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
|
||||
NotificationType.COMMENT_USER_MENTION,
|
||||
NotificationType.COMMENT_CREATED,
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
NotificationType.PAGE_PERMISSION_GRANTED,
|
||||
];
|
||||
|
||||
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
|
||||
NotificationType.PAGE_UPDATED,
|
||||
];
|
||||
|
||||
export function getTypesForTab(tab: NotificationTab): NotificationType[] | undefined {
|
||||
if (tab === 'direct') return DIRECT_NOTIFICATION_TYPES;
|
||||
if (tab === 'updates') return UPDATES_NOTIFICATION_TYPES;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
import { NotificationService } from './notification.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { MarkNotificationsReadDto } from './dto/notification.dto';
|
||||
import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
@@ -21,10 +20,10 @@ export class NotificationController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
async getNotifications(
|
||||
@Body() pagination: PaginationOptions,
|
||||
@Body() dto: ListNotificationsDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.notificationService.findByUserId(user.id, pagination);
|
||||
return this.notificationService.findByUserId(user.id, dto, dto.type);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
|
||||
NotificationProcessor,
|
||||
CommentNotificationService,
|
||||
PageNotificationService,
|
||||
PageUpdateEmailRateLimiter,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
@@ -35,6 +36,7 @@ export class NotificationProcessor
|
||||
| ICommentNotificationJob
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob
|
||||
| IPageUpdateNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
void
|
||||
>,
|
||||
@@ -76,6 +78,20 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_UPDATED: {
|
||||
await this.pageNotificationService.processPageUpdate(
|
||||
job.data as IPageUpdateNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_UPDATE_DIGEST: {
|
||||
const { userId } = job.data as unknown as { userId: string };
|
||||
await this.pageNotificationService.processDigest(userId, appUrl);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { WsGateway } from '../../ws/ws.gateway';
|
||||
import { MailService } from '../../integrations/mail/mail.service';
|
||||
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -13,12 +15,23 @@ export class NotificationService {
|
||||
|
||||
constructor(
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly wsGateway: WsGateway,
|
||||
private readonly mailService: MailService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async create(data: InsertableNotification) {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id'])
|
||||
.where('id', '=', data.userId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const notification = await this.notificationRepo.insert(data);
|
||||
|
||||
this.wsGateway.server
|
||||
@@ -28,8 +41,35 @@ export class NotificationService {
|
||||
return notification;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
||||
return this.notificationRepo.findByUserId(userId, pagination);
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
type: NotificationTab = 'all',
|
||||
) {
|
||||
const result = await this.notificationRepo.findByUserId(
|
||||
userId,
|
||||
pagination,
|
||||
type,
|
||||
);
|
||||
|
||||
const pageIds = result.items
|
||||
.map((n: any) => n.pageId)
|
||||
.filter(Boolean);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
const accessiblePageIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds,
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessiblePageIds);
|
||||
|
||||
result.items = result.items.filter(
|
||||
(n: any) => !n.pageId || accessibleSet.has(n.pageId),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string) {
|
||||
@@ -53,17 +93,27 @@ export class NotificationService {
|
||||
notificationId: string,
|
||||
subject: string,
|
||||
template: any,
|
||||
type?: NotificationType,
|
||||
) {
|
||||
try {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.select(['email', 'settings'])
|
||||
.where('id', '=', userId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user?.email) return;
|
||||
|
||||
if (type) {
|
||||
const settingKey = NotificationTypeToSettingKey[type];
|
||||
if (settingKey) {
|
||||
const settings = user.settings as any;
|
||||
if (settings?.notifications?.[settingKey] === false) return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
subject,
|
||||
|
||||
@@ -86,12 +86,14 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
`${actor.name} mentioned you in a comment`,
|
||||
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_USER_MENTION,
|
||||
);
|
||||
|
||||
notifiedUserIds.add(userId);
|
||||
@@ -110,12 +112,14 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
recipientId,
|
||||
notification.id,
|
||||
`${actor.name} commented on ${pageTitle}`,
|
||||
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_CREATED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +175,7 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) return;
|
||||
|
||||
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
||||
|
||||
@@ -179,6 +184,7 @@ export class CommentNotificationService {
|
||||
notification.id,
|
||||
subject,
|
||||
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
const KEY_PREFIX = 'page-update:emails:';
|
||||
const DIGEST_PREFIX = 'page-update:digest:';
|
||||
const TTL_SECONDS = 86400; // 24 hours
|
||||
const MAX_IMMEDIATE_EMAILS = 4;
|
||||
|
||||
@Injectable()
|
||||
export class PageUpdateEmailRateLimiter {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
async canSendEmail(userId: string): Promise<boolean> {
|
||||
const key = KEY_PREFIX + userId;
|
||||
const count = await this.redis.incr(key);
|
||||
await this.redis.expire(key, TTL_SECONDS, 'NX');
|
||||
return count <= MAX_IMMEDIATE_EMAILS;
|
||||
}
|
||||
|
||||
async addToDigest(userId: string, notificationId: string): Promise<boolean> {
|
||||
const key = DIGEST_PREFIX + userId;
|
||||
const len = await this.redis.rpush(key, notificationId);
|
||||
await this.redis.expire(key, TTL_SECONDS);
|
||||
return len === 1;
|
||||
}
|
||||
|
||||
async popDigest(userId: string): Promise<string[]> {
|
||||
const key = DIGEST_PREFIX + userId;
|
||||
const [ids] = await this.redis
|
||||
.multi()
|
||||
.lrange(key, 0, -1)
|
||||
.del(key)
|
||||
.exec();
|
||||
|
||||
return (ids?.[1] as string[]) ?? [];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,25 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { PageUpdateEmailRateLimiter } from './page-update-email-rate-limiter';
|
||||
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
||||
import { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
|
||||
import { PageUpdateDigestEmail } from '@docmost/transactional/emails/page-update-digest-email';
|
||||
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
|
||||
const PAGE_UPDATE_COOLDOWN_HOURS = 7;
|
||||
const DIGEST_DELAY_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
@Injectable()
|
||||
export class PageNotificationService {
|
||||
private readonly logger = new Logger(PageNotificationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly rateLimiter: PageUpdateEmailRateLimiter,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
) {}
|
||||
|
||||
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||
@@ -41,10 +59,9 @@ export class PageNotificationService {
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
[...usersWithSpaceAccess],
|
||||
);
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...usersWithSpaceAccess,
|
||||
]);
|
||||
const usersWithAccess = new Set(usersWithPageAccess);
|
||||
|
||||
const accessibleMentions = newMentions.filter((m) =>
|
||||
@@ -97,6 +114,7 @@ export class PageNotificationService {
|
||||
spaceId,
|
||||
data: { mentionId },
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const pageUrl = `${basePageUrl}`;
|
||||
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
||||
@@ -106,6 +124,7 @@ export class PageNotificationService {
|
||||
notification.id,
|
||||
subject,
|
||||
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +158,7 @@ export class PageNotificationService {
|
||||
spaceId,
|
||||
data: { role },
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
|
||||
|
||||
@@ -156,6 +176,232 @@ export class PageNotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
|
||||
const { pageId, spaceId, workspaceId, actorIds } = data;
|
||||
|
||||
const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
|
||||
if (watcherIds.length === 0) return;
|
||||
|
||||
const actorSet = new Set(actorIds);
|
||||
const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
|
||||
if (candidateIds.length === 0) return;
|
||||
|
||||
const eligibleUsers = await this.getEligiblePageUpdateUsers(candidateIds);
|
||||
if (eligibleUsers.size === 0) return;
|
||||
|
||||
const afterPrefs = [...eligibleUsers.keys()];
|
||||
|
||||
const recentlyNotified =
|
||||
await this.notificationRepo.getRecentlyNotifiedUserIds(
|
||||
afterPrefs,
|
||||
pageId,
|
||||
NotificationType.PAGE_UPDATED,
|
||||
PAGE_UPDATE_COOLDOWN_HOURS,
|
||||
);
|
||||
const afterCooldown = afterPrefs.filter((id) => !recentlyNotified.has(id));
|
||||
if (afterCooldown.length === 0) return;
|
||||
|
||||
const usersWithSpaceAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
afterCooldown,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...usersWithSpaceAccess,
|
||||
]);
|
||||
if (usersWithPageAccess.length === 0) return;
|
||||
|
||||
const recipientIds = new Set(usersWithPageAccess);
|
||||
const actorId = actorIds[0];
|
||||
|
||||
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, basePageUrl } = context;
|
||||
|
||||
for (const userId of recipientIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_UPDATED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const canSend = await this.rateLimiter.canSendEmail(userId);
|
||||
if (canSend) {
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
`${actor.name} updated ${pageTitle}`,
|
||||
PageUpdateEmail({
|
||||
userName: eligibleUsers.get(userId) ?? '',
|
||||
actorName: actor.name,
|
||||
pageTitle,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
NotificationType.PAGE_UPDATED,
|
||||
);
|
||||
} else {
|
||||
const isFirst = await this.rateLimiter.addToDigest(
|
||||
userId,
|
||||
notification.id,
|
||||
);
|
||||
if (isFirst) {
|
||||
await this.scheduleDigest(userId, workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getEligiblePageUpdateUsers(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const users = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'settings'])
|
||||
.where('id', 'in', userIds)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
const eligible = new Map<string, string>();
|
||||
for (const u of users) {
|
||||
const settings = u.settings as any;
|
||||
if (settings?.notifications?.['page.updated'] !== false) {
|
||||
eligible.set(u.id, u.name);
|
||||
}
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private async scheduleDigest(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.notificationQueue
|
||||
.add(
|
||||
QueueJob.PAGE_UPDATE_DIGEST,
|
||||
{ userId, workspaceId },
|
||||
{ delay: DIGEST_DELAY_MS, removeOnComplete: true },
|
||||
)
|
||||
.catch((err) => {
|
||||
this.logger.error(
|
||||
`Failed to schedule digest for ${userId}: ${err.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async processDigest(userId: string, appUrl: string): Promise<void> {
|
||||
const notificationIds = await this.rateLimiter.popDigest(userId);
|
||||
if (notificationIds.length === 0) return;
|
||||
|
||||
const [user, notifications] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('id', '=', userId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('notifications')
|
||||
.select(['id', 'pageId', 'actorId'])
|
||||
.where('id', 'in', notificationIds)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
if (!user || notifications.length === 0) return;
|
||||
|
||||
const pageIds = [
|
||||
...new Set(notifications.map((n) => n.pageId).filter(Boolean)),
|
||||
];
|
||||
const actorIds = [
|
||||
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
|
||||
];
|
||||
|
||||
const allPages = await this.db
|
||||
.selectFrom('pages')
|
||||
.innerJoin('spaces', 'spaces.id', 'pages.spaceId')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.title',
|
||||
'pages.slugId',
|
||||
'pages.spaceId',
|
||||
'spaces.slug as spaceSlug',
|
||||
])
|
||||
.where('pages.id', 'in', pageIds)
|
||||
.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) => ({
|
||||
title: getPageTitle(p.title),
|
||||
url: `${appUrl}/s/${p.spaceSlug}/p/${p.slugId}`,
|
||||
updatedBy: [...(pageActors.get(p.id) ?? [])],
|
||||
}));
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notificationIds[0],
|
||||
`Your digest: ${pageUpdates.length} page ${pageUpdates.length === 1 ? 'update' : 'updates'}`,
|
||||
PageUpdateDigestEmail({
|
||||
userName: user.name,
|
||||
pageUpdates,
|
||||
totalUpdates: pageUpdates.length,
|
||||
}),
|
||||
NotificationType.PAGE_UPDATED,
|
||||
);
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
|
||||
@@ -35,4 +35,24 @@ export class UpdateUserDto extends PartialType(
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationPageUpdates: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationPageUserMention: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationCommentUserMention: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationCommentCreated: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notificationCommentResolved: boolean;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { NotificationSettingKey } from '../notification/notification.constants';
|
||||
import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { validateSsoEnforcement } from '../auth/auth.util';
|
||||
@@ -60,6 +61,24 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
const notificationSettings: Record<string, NotificationSettingKey> = {
|
||||
notificationPageUpdates: 'page.updated',
|
||||
notificationPageUserMention: 'page.userMention',
|
||||
notificationCommentUserMention: 'comment.userMention',
|
||||
notificationCommentCreated: 'comment.created',
|
||||
notificationCommentResolved: 'comment.resolved',
|
||||
};
|
||||
|
||||
for (const [dtoField, settingKey] of Object.entries(notificationSettings)) {
|
||||
if (typeof updateUserDto[dtoField] !== 'undefined') {
|
||||
return this.userRepo.updateNotificationSetting(
|
||||
userId,
|
||||
settingKey,
|
||||
updateUserDto[dtoField],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userBefore = { name: user.name, email: user.email, locale: user.locale };
|
||||
|
||||
if (updateUserDto.name) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/***
|
||||
import {
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
@@ -16,12 +14,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { WatcherPageDto } from './dto/watcher.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -29,7 +22,7 @@ export class WatcherController {
|
||||
constructor(
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -44,10 +37,7 @@ export class WatcherController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
await this.watcherService.watchPage(
|
||||
user.id,
|
||||
@@ -67,10 +57,7 @@ export class WatcherController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
await this.watcherService.unwatchPage(user.id, page.id);
|
||||
|
||||
@@ -85,15 +72,10 @@ export class WatcherController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
||||
|
||||
return { watching };
|
||||
}
|
||||
|
||||
}
|
||||
***/
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WatcherService } from './watcher.service';
|
||||
import { CaslModule } from '../casl/casl.module';
|
||||
import { WatcherController } from './watcher.controller';
|
||||
import { PageAccessModule } from '../page/page-access/page-access.module';
|
||||
|
||||
@Module({
|
||||
imports: [CaslModule],
|
||||
controllers: [],
|
||||
imports: [PageAccessModule],
|
||||
controllers: [WatcherController],
|
||||
providers: [WatcherService],
|
||||
exports: [WatcherService],
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRepo {
|
||||
@@ -27,8 +28,12 @@ export class NotificationRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
type: NotificationTab = 'all',
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('notifications')
|
||||
.selectAll('notifications')
|
||||
.select((eb) => this.withActor(eb))
|
||||
@@ -42,6 +47,12 @@ export class NotificationRepo {
|
||||
]),
|
||||
);
|
||||
|
||||
if (type === 'direct') {
|
||||
query = query.where('type', '!=', NotificationType.PAGE_UPDATED);
|
||||
} else if (type === 'updates') {
|
||||
query = query.where('type', '=', NotificationType.PAGE_UPDATED);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
@@ -138,6 +149,29 @@ export class NotificationRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getRecentlyNotifiedUserIds(
|
||||
userIds: string[],
|
||||
pageId: string,
|
||||
type: string,
|
||||
withinHours: number,
|
||||
): Promise<Set<string>> {
|
||||
if (userIds.length === 0) return new Set();
|
||||
|
||||
const cutoff = new Date(Date.now() - withinHours * 60 * 60 * 1000);
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('notifications')
|
||||
.select('userId')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('pageId', '=', pageId)
|
||||
.where('type', '=', type)
|
||||
.where('createdAt', '>', cutoff)
|
||||
.groupBy('userId')
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { NotificationSettingKey } from '../../../core/notification/notification.constants';
|
||||
|
||||
@Injectable()
|
||||
export class UserRepo {
|
||||
@@ -191,6 +192,24 @@ export class UserRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateNotificationSetting(
|
||||
userId: string,
|
||||
settingKey: NotificationSettingKey,
|
||||
settingValue: boolean,
|
||||
) {
|
||||
return await this.db
|
||||
.updateTable('users')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('notifications', COALESCE(settings->'notifications', '{}'::jsonb)
|
||||
|| jsonb_build_object(${sql.lit(settingKey)}, ${sql.lit(settingValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', userId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -69,6 +69,7 @@ export enum QueueJob {
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||
PAGE_UPDATE_DIGEST = 'page-update-digest',
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface IPageMentionNotificationJob {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IPageUpdateNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorIds: string[];
|
||||
}
|
||||
|
||||
export interface IPermissionGrantedNotificationJob {
|
||||
userIds: string[];
|
||||
pageId: string;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
interface PageUpdate {
|
||||
title: string;
|
||||
url: string;
|
||||
updatedBy: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
userName: string;
|
||||
pageUpdates: PageUpdate[];
|
||||
totalUpdates: number;
|
||||
}
|
||||
|
||||
export const PageUpdateDigestEmail = ({
|
||||
userName,
|
||||
pageUpdates,
|
||||
totalUpdates,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>
|
||||
Hi {getGreetingName(userName)},
|
||||
</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) => (
|
||||
<Section key={i} style={pageCard}>
|
||||
<Text style={pageTitle}>
|
||||
<Link href={page.url} style={link}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</Text>
|
||||
{page.updatedBy.length > 0 && (
|
||||
<Text style={updatedByText}>
|
||||
Edited by {page.updatedBy.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
const pageCard = {
|
||||
borderLeft: '3px solid #e8e5ef',
|
||||
paddingLeft: '12px',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const pageTitle = {
|
||||
...paragraph,
|
||||
margin: '0 0 2px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold' as const,
|
||||
};
|
||||
|
||||
const updatedByText = {
|
||||
...paragraph,
|
||||
margin: '0',
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
};
|
||||
|
||||
export default PageUpdateDigestEmail;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { EmailButton, getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
userName: string;
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const PageUpdateEmail = ({
|
||||
userName,
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {getGreetingName(userName)},</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> updated{' '}
|
||||
<Link href={pageUrl} style={link}>
|
||||
<strong>{pageTitle}</strong>
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageUpdateEmail;
|
||||
@@ -87,3 +87,7 @@ export function MailFooter() {
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function getGreetingName(name?: string): string {
|
||||
return name?.split(' ')[0] || 'there';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user