diff --git a/apps/server/src/core/notification/services/verification.notification.ts b/apps/server/src/core/notification/services/verification.notification.ts index 393b0658..e06258fa 100644 --- a/apps/server/src/core/notification/services/verification.notification.ts +++ b/apps/server/src/core/notification/services/verification.notification.ts @@ -27,6 +27,19 @@ export class VerificationNotificationService { private readonly pagePermissionRepo: PagePermissionRepo, ) {} + private async getAlreadyNotifiedUserIds( + pageVerificationId: string, + type: string, + ): Promise> { + const rows = await this.db + .selectFrom('notifications') + .select('userId') + .where('pageVerificationId', '=', pageVerificationId) + .where('type', '=', type) + .execute(); + return new Set(rows.map((r) => r.userId)); + } + private async filterAccessibleRecipients( userIds: string[], pageId: string, @@ -74,6 +87,15 @@ export class VerificationNotificationService { ); if (accessibleVerifierIds.length === 0) return; + const alreadyNotified = await this.getAlreadyNotifiedUserIds( + verification.id, + NotificationType.PAGE_VERIFICATION_EXPIRING, + ); + const recipients = accessibleVerifierIds.filter( + (id) => !alreadyNotified.has(id), + ); + if (recipients.length === 0) return; + const context = await this.getPageContext( verification.pageId, verification.spaceId, @@ -81,16 +103,17 @@ export class VerificationNotificationService { ); if (!context) return; - const { pageTitle, basePageUrl } = context; + const { pageTitle, spaceName, basePageUrl } = context; const expiresAtIso = new Date(verification.expiresAt).toISOString(); - for (const userId of accessibleVerifierIds) { + for (const userId of recipients) { const notification = await this.notificationService.create({ userId, workspaceId: verification.workspaceId, type: NotificationType.PAGE_VERIFICATION_EXPIRING, pageId: verification.pageId, spaceId: verification.spaceId, + pageVerificationId: verification.id, data: { expiresAt: expiresAtIso }, }); @@ -102,6 +125,7 @@ export class VerificationNotificationService { subject, VerificationExpiringEmail({ pageTitle, + spaceName, pageUrl: basePageUrl, expiresAt: new Date(verification.expiresAt).toLocaleDateString(), }), @@ -139,6 +163,15 @@ export class VerificationNotificationService { ); if (accessibleVerifierIds.length === 0) return; + const alreadyNotified = await this.getAlreadyNotifiedUserIds( + verification.id, + NotificationType.PAGE_VERIFICATION_EXPIRED, + ); + const recipients = accessibleVerifierIds.filter( + (id) => !alreadyNotified.has(id), + ); + if (recipients.length === 0) return; + const context = await this.getPageContext( verification.pageId, verification.spaceId, @@ -146,15 +179,16 @@ export class VerificationNotificationService { ); if (!context) return; - const { pageTitle, basePageUrl } = context; + const { pageTitle, spaceName, basePageUrl } = context; - for (const userId of accessibleVerifierIds) { + for (const userId of recipients) { const notification = await this.notificationService.create({ userId, workspaceId: verification.workspaceId, type: NotificationType.PAGE_VERIFICATION_EXPIRED, pageId: verification.pageId, spaceId: verification.spaceId, + pageVerificationId: verification.id, }); const subject = `"${pageTitle}" verification has expired`; @@ -165,6 +199,7 @@ export class VerificationNotificationService { subject, VerificationExpiredEmail({ pageTitle, + spaceName, pageUrl: basePageUrl, }), ); @@ -305,7 +340,7 @@ export class VerificationNotificationService { .executeTakeFirst(), this.db .selectFrom('spaces') - .select(['id', 'slug']) + .select(['id', 'slug', 'name']) .where('id', '=', spaceId) .executeTakeFirst(), ]); @@ -313,6 +348,6 @@ export class VerificationNotificationService { if (!page || !space) return null; const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`; - return { pageTitle: getPageTitle(page.title), basePageUrl }; + return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl }; } } diff --git a/apps/server/src/database/migrations/20260413T121647-page-verifications.ts b/apps/server/src/database/migrations/20260413T121647-page-verifications.ts index 2b7cfcbf..9d501506 100644 --- a/apps/server/src/database/migrations/20260413T121647-page-verifications.ts +++ b/apps/server/src/database/migrations/20260413T121647-page-verifications.ts @@ -98,9 +98,20 @@ export async function up(db: Kysely): Promise { .on('page_verifiers') .column('user_id') .execute(); + + await db.schema + .alterTable('notifications') + .addColumn('page_verification_id', 'uuid', (col) => + col.references('page_verifications.id').onDelete('cascade'), + ) + .execute(); } export async function down(db: Kysely): Promise { + await db.schema + .alterTable('notifications') + .dropColumn('page_verification_id') + .execute(); await db.schema.dropTable('page_verifiers').ifExists().execute(); await db.schema.dropTable('page_verifications').ifExists().execute(); } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 9e779b9a..9df706ca 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -400,6 +400,7 @@ export interface Notifications { pageId: string | null; spaceId: string | null; commentId: string | null; + pageVerificationId: string | null; data: Json | null; readAt: Timestamp | null; emailedAt: Timestamp | null; diff --git a/apps/server/src/ee b/apps/server/src/ee index d7274d6e..385f36bb 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit d7274d6e592e4f580e59024bb86f38e1392b6a48 +Subproject commit 385f36bbae68dd4cd5631237d805ba39a50cbebc diff --git a/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx b/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx index f516f59a..e9598d7b 100644 --- a/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx +++ b/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -30,19 +30,7 @@ export const ApprovalRejectedEmail = ({ )} -
- -
+ View page ); }; diff --git a/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx b/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx index 63d885d2..f640590b 100644 --- a/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx +++ b/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -23,19 +23,7 @@ export const ApprovalRequestedEmail = ({ {pageTitle} for your approval. -
- -
+ Review page ); }; diff --git a/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx b/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx index df08dfbe..c01f72c8 100644 --- a/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx +++ b/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx @@ -1,36 +1,26 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { pageTitle: string; + spaceName: string; pageUrl: string; } -export const VerificationExpiredEmail = ({ pageTitle, pageUrl }: Props) => { +export const VerificationExpiredEmail = ({ pageTitle, spaceName, pageUrl }: Props) => { return (
Hi there, - The verification for {pageTitle} has expired. Please - re-verify the page to confirm it is still accurate. + The verification for {pageTitle} in{' '} + {spaceName} has expired. Please re-verify the page to + confirm it is still accurate.
-
- -
+ Re-verify page
); }; diff --git a/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx b/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx index 1177dadc..6e35bd62 100644 --- a/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx +++ b/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx @@ -1,16 +1,18 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { pageTitle: string; + spaceName: string; pageUrl: string; expiresAt: string; } export const VerificationExpiringEmail = ({ pageTitle, + spaceName, pageUrl, expiresAt, }: Props) => { @@ -19,23 +21,12 @@ export const VerificationExpiringEmail = ({
Hi there, - The page {pageTitle} needs to be re-verified. The - verification expires on {expiresAt}. + The page {pageTitle} in{' '} + {spaceName} needs to be re-verified. The verification + expires on {expiresAt}.
-
- -
+ Review page ); };