mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 19:02:23 +08:00
@@ -1346,6 +1346,93 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
<!--/$-->␊
|
<!--/$-->␊
|
||||||
`
|
`
|
||||||
|
|
||||||
|
> test@test.com mentioned you in Test Doc
|
||||||
|
|
||||||
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
|
<!--$-->␊
|
||||||
|
<table␊
|
||||||
|
align="center"␊
|
||||||
|
width="100%"␊
|
||||||
|
border="0"␊
|
||||||
|
cellpadding="0"␊
|
||||||
|
cellspacing="0"␊
|
||||||
|
role="presentation">␊
|
||||||
|
<tbody>␊
|
||||||
|
<tr>␊
|
||||||
|
<td>␊
|
||||||
|
<p␊
|
||||||
|
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
|
||||||
|
You are mentioned!␊
|
||||||
|
</p>␊
|
||||||
|
</td>␊
|
||||||
|
</tr>␊
|
||||||
|
</tbody>␊
|
||||||
|
</table>␊
|
||||||
|
<table␊
|
||||||
|
align="center"␊
|
||||||
|
width="100%"␊
|
||||||
|
border="0"␊
|
||||||
|
cellpadding="0"␊
|
||||||
|
cellspacing="0"␊
|
||||||
|
role="presentation">␊
|
||||||
|
<tbody>␊
|
||||||
|
<tr>␊
|
||||||
|
<td>␊
|
||||||
|
<table␊
|
||||||
|
align="center"␊
|
||||||
|
width="100%"␊
|
||||||
|
border="0"␊
|
||||||
|
cellpadding="0"␊
|
||||||
|
cellspacing="0"␊
|
||||||
|
role="presentation">␊
|
||||||
|
<tbody style="width:100%">␊
|
||||||
|
<tr style="width:100%">␊
|
||||||
|
<p␊
|
||||||
|
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
|
||||||
|
<span style="font-weight:600">test@test.com</span> mentioned you␊
|
||||||
|
in␊
|
||||||
|
<a␊
|
||||||
|
href="https://app.affine.pro"␊
|
||||||
|
style="color:#067df7;text-decoration-line:none"␊
|
||||||
|
target="_blank"␊
|
||||||
|
><span style="font-weight:600">Test Doc</span></a␊
|
||||||
|
>.␊
|
||||||
|
</p>␊
|
||||||
|
</tr>␊
|
||||||
|
</tbody>␊
|
||||||
|
</table>␊
|
||||||
|
<table␊
|
||||||
|
align="center"␊
|
||||||
|
width="100%"␊
|
||||||
|
border="0"␊
|
||||||
|
cellpadding="0"␊
|
||||||
|
cellspacing="0"␊
|
||||||
|
role="presentation">␊
|
||||||
|
<tbody style="width:100%">␊
|
||||||
|
<tr style="width:100%">␊
|
||||||
|
<a␊
|
||||||
|
href="https://app.affine.pro"␊
|
||||||
|
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
|
||||||
|
target="_blank"␊
|
||||||
|
><span␊
|
||||||
|
><!--[if mso]><i style="mso-font-width:450%;mso-text-raise:12" hidden>  </i><![endif]--></span␊
|
||||||
|
><span␊
|
||||||
|
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
|
||||||
|
>Open Doc</span␊
|
||||||
|
><span␊
|
||||||
|
><!--[if mso]><i style="mso-font-width:450%" hidden>  ​</i><![endif]--></span␊
|
||||||
|
></a␊
|
||||||
|
>␊
|
||||||
|
</tr>␊
|
||||||
|
</tbody>␊
|
||||||
|
</table>␊
|
||||||
|
</td>␊
|
||||||
|
</tr>␊
|
||||||
|
</tbody>␊
|
||||||
|
</table>␊
|
||||||
|
<!--/$-->␊
|
||||||
|
`
|
||||||
|
|
||||||
> Your workspace has been upgraded to team workspace! 🎉
|
> Your workspace has been upgraded to team workspace! 🎉
|
||||||
|
|
||||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
|
|||||||
Binary file not shown.
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../../models';
|
} from '../../../models';
|
||||||
import { DocReader } from '../../doc';
|
import { DocReader } from '../../doc';
|
||||||
import { NotificationService } from '../service';
|
import { NotificationService } from '../service';
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
module: TestingModule;
|
module: TestingModule;
|
||||||
notificationService: NotificationService;
|
notificationService: NotificationService;
|
||||||
@@ -337,3 +338,46 @@ test('should raw doc title in mention notification if no doc found', async t =>
|
|||||||
t.is(body2.doc.title, 'doc-title-1');
|
t.is(body2.doc.title, 'doc-title-1');
|
||||||
t.is(body2.doc.mode, DocMode.page);
|
t.is(body2.doc.mode, DocMode.page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should send mention email by user setting', async t => {
|
||||||
|
const { notificationService } = t.context;
|
||||||
|
const docId = randomUUID();
|
||||||
|
const notification = await notificationService.createMention({
|
||||||
|
userId: member.id,
|
||||||
|
body: {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
createdByUserId: owner.id,
|
||||||
|
doc: {
|
||||||
|
id: docId,
|
||||||
|
title: 'doc-title-1',
|
||||||
|
blockId: 'block-id-1',
|
||||||
|
mode: DocMode.page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.truthy(notification);
|
||||||
|
// should send mention email
|
||||||
|
const mentionMail = t.context.module.mails.last('Mention');
|
||||||
|
t.is(mentionMail.to, member.email);
|
||||||
|
|
||||||
|
// update user setting to not receive mention email
|
||||||
|
const mentionMailCount = t.context.module.mails.count('Mention');
|
||||||
|
await t.context.models.settings.set(member.id, {
|
||||||
|
receiveMentionEmail: false,
|
||||||
|
});
|
||||||
|
await notificationService.createMention({
|
||||||
|
userId: member.id,
|
||||||
|
body: {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
createdByUserId: owner.id,
|
||||||
|
doc: {
|
||||||
|
id: docId,
|
||||||
|
title: 'doc-title-2',
|
||||||
|
blockId: 'block-id-2',
|
||||||
|
mode: DocMode.page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// should not send mention email
|
||||||
|
t.is(t.context.module.mails.count('Mention'), mentionMailCount);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { DocStorageModule } from '../doc';
|
import { DocStorageModule } from '../doc';
|
||||||
|
import { MailModule } from '../mail';
|
||||||
import { PermissionModule } from '../permission';
|
import { PermissionModule } from '../permission';
|
||||||
import { StorageModule } from '../storage';
|
import { StorageModule } from '../storage';
|
||||||
import { NotificationJob } from './job';
|
import { NotificationJob } from './job';
|
||||||
@@ -8,7 +9,7 @@ import { NotificationResolver, UserNotificationResolver } from './resolver';
|
|||||||
import { NotificationService } from './service';
|
import { NotificationService } from './service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PermissionModule, DocStorageModule, StorageModule],
|
imports: [PermissionModule, DocStorageModule, StorageModule, MailModule],
|
||||||
providers: [
|
providers: [
|
||||||
UserNotificationResolver,
|
UserNotificationResolver,
|
||||||
NotificationResolver,
|
NotificationResolver,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
import { NotificationNotFound, PaginationInput } from '../../base';
|
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
|
||||||
import {
|
import {
|
||||||
InvitationNotificationCreate,
|
InvitationNotificationCreate,
|
||||||
MentionNotification,
|
MentionNotification,
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
UnionNotificationBody,
|
UnionNotificationBody,
|
||||||
} from '../../models';
|
} from '../../models';
|
||||||
import { DocReader } from '../doc';
|
import { DocReader } from '../doc';
|
||||||
|
import { Mailer } from '../mail';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
|
import { generateDocPath } from '../utils/doc';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
@@ -20,7 +22,9 @@ export class NotificationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly docReader: DocReader,
|
private readonly docReader: DocReader,
|
||||||
private readonly workspaceBlobStorage: WorkspaceBlobStorage
|
private readonly workspaceBlobStorage: WorkspaceBlobStorage,
|
||||||
|
private readonly mailer: Mailer,
|
||||||
|
private readonly url: URLHelper
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async cleanExpiredNotifications() {
|
async cleanExpiredNotifications() {
|
||||||
@@ -28,7 +32,48 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createMention(input: MentionNotificationCreate) {
|
async createMention(input: MentionNotificationCreate) {
|
||||||
return await this.models.notification.createMention(input);
|
const notification = await this.models.notification.createMention(input);
|
||||||
|
await this.sendMentionEmail(input);
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendMentionEmail(input: MentionNotificationCreate) {
|
||||||
|
const userSetting = await this.models.settings.get(input.userId);
|
||||||
|
if (!userSetting.receiveMentionEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const receiver = await this.models.user.getWorkspaceUser(input.userId);
|
||||||
|
if (!receiver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = await this.models.doc.getMeta(
|
||||||
|
input.body.workspaceId,
|
||||||
|
input.body.doc.id
|
||||||
|
);
|
||||||
|
const title = doc?.title ?? input.body.doc.title;
|
||||||
|
const url = this.url.link(
|
||||||
|
generateDocPath({
|
||||||
|
workspaceId: input.body.workspaceId,
|
||||||
|
docId: input.body.doc.id,
|
||||||
|
mode: input.body.doc.mode,
|
||||||
|
blockId: input.body.doc.blockId,
|
||||||
|
elementId: input.body.doc.elementId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await this.mailer.send({
|
||||||
|
name: 'Mention',
|
||||||
|
to: receiver.email,
|
||||||
|
props: {
|
||||||
|
user: {
|
||||||
|
$$userId: input.body.createdByUserId,
|
||||||
|
},
|
||||||
|
doc: {
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Mention email sent to user ${receiver.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInvitation(input: InvitationNotificationCreate) {
|
async createInvitation(input: InvitationNotificationCreate) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'ava';
|
import test from 'ava';
|
||||||
|
|
||||||
import { DocID, DocVariant } from '../doc';
|
import { DocMode } from '../../../models';
|
||||||
|
import { DocID, DocVariant, generateDocPath } from '../doc';
|
||||||
|
|
||||||
test('can parse', t => {
|
test('can parse', t => {
|
||||||
// workspace only
|
// workspace only
|
||||||
@@ -80,3 +81,45 @@ test('special case: `wsId:space:page:pageId`', t => {
|
|||||||
t.throws(() => new DocID('ws:space:b:page'));
|
t.throws(() => new DocID('ws:space:b:page'));
|
||||||
t.throws(() => new DocID('ws:s:page:page'));
|
t.throws(() => new DocID('ws:s:page:page'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should generate doc path', t => {
|
||||||
|
t.is(
|
||||||
|
generateDocPath({
|
||||||
|
workspaceId: 'ws',
|
||||||
|
docId: 'doc',
|
||||||
|
mode: DocMode.page,
|
||||||
|
}),
|
||||||
|
'/workspace/ws/doc?mode=page'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
generateDocPath({
|
||||||
|
workspaceId: 'ws',
|
||||||
|
docId: 'doc',
|
||||||
|
mode: DocMode.page,
|
||||||
|
blockId: 'block',
|
||||||
|
}),
|
||||||
|
'/workspace/ws/doc?mode=page&blockIds=block'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
generateDocPath({
|
||||||
|
workspaceId: 'ws',
|
||||||
|
docId: 'doc',
|
||||||
|
mode: DocMode.page,
|
||||||
|
elementId: 'element.+?aaa$!@#',
|
||||||
|
}),
|
||||||
|
'/workspace/ws/doc?mode=page&elementIds=element.%2B%3Faaa%24%21%40%23'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
generateDocPath({
|
||||||
|
workspaceId: 'ws',
|
||||||
|
docId: 'doc',
|
||||||
|
mode: DocMode.page,
|
||||||
|
blockId: 'block',
|
||||||
|
elementId: 'element',
|
||||||
|
}),
|
||||||
|
'/workspace/ws/doc?mode=page&elementIds=element&blockIds=block'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { registerEnumType } from '@nestjs/graphql';
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { DocMode } from '../../models';
|
||||||
|
|
||||||
export enum DocVariant {
|
export enum DocVariant {
|
||||||
Workspace = 'workspace',
|
Workspace = 'workspace',
|
||||||
Page = 'page',
|
Page = 'page',
|
||||||
@@ -33,7 +35,7 @@ export class DocID {
|
|||||||
return this.variant === DocVariant.Workspace
|
return this.variant === DocVariant.Workspace
|
||||||
? this.workspace
|
? this.workspace
|
||||||
: // sub is always truthy when variant is not workspace
|
: // sub is always truthy when variant is not workspace
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
this.sub!;
|
this.sub!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,3 +121,29 @@ export class DocID {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocPathParams = {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
mode: DocMode;
|
||||||
|
blockId?: string;
|
||||||
|
elementId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To generate a doc url path like
|
||||||
|
*
|
||||||
|
* /workspace/{workspaceId}/{docId}?mode={DocMode}&elementIds={elementId}&blockIds={blockId}
|
||||||
|
*/
|
||||||
|
export function generateDocPath(params: DocPathParams) {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
mode: params.mode,
|
||||||
|
});
|
||||||
|
if (params.elementId) {
|
||||||
|
search.set('elementIds', params.elementId);
|
||||||
|
}
|
||||||
|
if (params.blockId) {
|
||||||
|
search.set('blockIds', params.blockId);
|
||||||
|
}
|
||||||
|
return `/workspace/${params.workspaceId}/${params.docId}?${search.toString()}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UserProps } from './components';
|
import { DocProps, UserProps } from './components';
|
||||||
import { WorkspaceProps } from './components/workspace';
|
import { WorkspaceProps } from './components/workspace';
|
||||||
|
|
||||||
export const TEST_USER: UserProps = {
|
export const TEST_USER: UserProps = {
|
||||||
@@ -9,3 +9,8 @@ export const TEST_WORKSPACE: WorkspaceProps = {
|
|||||||
name: 'Test Workspace',
|
name: 'Test Workspace',
|
||||||
avatar: 'https://app.affine.pro/favicon-192.png',
|
avatar: 'https://app.affine.pro/favicon-192.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TEST_DOC: DocProps = {
|
||||||
|
title: 'Test Doc',
|
||||||
|
url: 'https://app.affine.pro',
|
||||||
|
};
|
||||||
|
|||||||
16
packages/backend/server/src/mails/components/doc.tsx
Normal file
16
packages/backend/server/src/mails/components/doc.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Link } from '@react-email/components';
|
||||||
|
|
||||||
|
import { Bold } from './template';
|
||||||
|
|
||||||
|
export interface DocProps {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Doc = (props: DocProps) => {
|
||||||
|
return (
|
||||||
|
<Link href={props.url}>
|
||||||
|
<Bold>{props.title}</Bold>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './date';
|
export * from './date';
|
||||||
|
export * from './doc';
|
||||||
export * from './template';
|
export * from './template';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
|
|||||||
1
packages/backend/server/src/mails/docs/index.ts
Normal file
1
packages/backend/server/src/mails/docs/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './mention';
|
||||||
37
packages/backend/server/src/mails/docs/mention.tsx
Normal file
37
packages/backend/server/src/mails/docs/mention.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { TEST_DOC, TEST_USER } from '../common';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Content,
|
||||||
|
Doc,
|
||||||
|
type DocProps,
|
||||||
|
P,
|
||||||
|
Template,
|
||||||
|
Title,
|
||||||
|
User,
|
||||||
|
type UserProps,
|
||||||
|
} from '../components';
|
||||||
|
|
||||||
|
export type MentionProps = {
|
||||||
|
user: UserProps;
|
||||||
|
doc: DocProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Mention(props: MentionProps) {
|
||||||
|
const { user, doc } = props;
|
||||||
|
return (
|
||||||
|
<Template>
|
||||||
|
<Title>You are mentioned!</Title>
|
||||||
|
<Content>
|
||||||
|
<P>
|
||||||
|
<User {...user} /> mentioned you in <Doc {...doc} />.
|
||||||
|
</P>
|
||||||
|
<Button href={doc.url}>Open Doc</Button>
|
||||||
|
</Content>
|
||||||
|
</Template>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mention.PreviewProps = {
|
||||||
|
user: TEST_USER,
|
||||||
|
doc: TEST_DOC,
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { render as rawRender } from '@react-email/components';
|
import { render as rawRender } from '@react-email/components';
|
||||||
|
|
||||||
|
import { Mention } from './docs';
|
||||||
import {
|
import {
|
||||||
TeamBecomeAdmin,
|
TeamBecomeAdmin,
|
||||||
TeamBecomeCollaborator,
|
TeamBecomeCollaborator,
|
||||||
@@ -114,6 +115,13 @@ export const Renderers = {
|
|||||||
),
|
),
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
//#region Doc
|
||||||
|
Mention: make(
|
||||||
|
Mention,
|
||||||
|
props => `${props.user.email} mentioned you in ${props.doc.title}`
|
||||||
|
),
|
||||||
|
//#endregion
|
||||||
|
|
||||||
//#region Team
|
//#region Team
|
||||||
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
||||||
props.isOwner
|
props.isOwner
|
||||||
|
|||||||
Reference in New Issue
Block a user