feat(server): send invitation notification (#10219)
close PD-2307 CLOUD-150
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import {
|
||||
Models,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberStatus,
|
||||
} from '../../../models';
|
||||
import { WorkspaceRole } from '../../permission';
|
||||
import { NotificationJob } from '../job';
|
||||
import { NotificationService } from '../service';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
notificationJob: NotificationJob;
|
||||
notificationService: NotificationService;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule();
|
||||
t.context.module = module;
|
||||
t.context.notificationJob = module.get(NotificationJob);
|
||||
t.context.notificationService = module.get(NotificationService);
|
||||
t.context.models = module.get(Models);
|
||||
});
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let workspace: Workspace;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
member = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
workspace = await t.context.models.workspace.create(owner.id);
|
||||
});
|
||||
|
||||
test.afterEach.always(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should ignore create invitation notification when inviteId not exists', async t => {
|
||||
const { notificationJob, notificationService } = t.context;
|
||||
const spy = Sinon.spy(notificationService, 'createInvitation');
|
||||
await notificationJob.sendInvitation({
|
||||
inviterId: owner.id,
|
||||
inviteId: `not-exists-${randomUUID()}`,
|
||||
});
|
||||
t.is(spy.callCount, 0);
|
||||
});
|
||||
|
||||
test('should create invitation notification', async t => {
|
||||
const { notificationJob, notificationService } = t.context;
|
||||
const invite = await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
member.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
WorkspaceMemberStatus.Pending
|
||||
);
|
||||
const spy = Sinon.spy(notificationService, 'createInvitation');
|
||||
await notificationJob.sendInvitation({
|
||||
inviterId: owner.id,
|
||||
inviteId: invite.id,
|
||||
});
|
||||
t.is(spy.callCount, 1);
|
||||
t.is(spy.firstCall.args[0].userId, member.id);
|
||||
t.is(spy.firstCall.args[0].body.workspaceId, workspace.id);
|
||||
t.is(spy.firstCall.args[0].body.createdByUserId, owner.id);
|
||||
});
|
||||
@@ -1,605 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import {
|
||||
acceptInviteById,
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
getNotificationCount,
|
||||
inviteUser,
|
||||
listNotifications,
|
||||
mentionUser,
|
||||
readNotification,
|
||||
TestingApp,
|
||||
} from '../../../__tests__/utils';
|
||||
import { DocMode, Models, NotificationType } from '../../../models';
|
||||
import { MentionNotificationBodyType, NotificationObjectType } from '../types';
|
||||
|
||||
let app: TestingApp;
|
||||
let models: Models;
|
||||
|
||||
test.before(async () => {
|
||||
app = await createTestingApp();
|
||||
models = app.get(Models);
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should mention user in a doc', async t => {
|
||||
const member = await app.signupV1();
|
||||
const owner = await app.signupV1();
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await createWorkspace(app);
|
||||
await models.workspace.update(workspace.id, {
|
||||
name: 'test-workspace-name',
|
||||
avatarKey: 'test-avatar-key',
|
||||
});
|
||||
const inviteId = await inviteUser(app, workspace.id, member.email);
|
||||
await app.switchUser(member);
|
||||
await acceptInviteById(app, workspace.id, inviteId);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const mentionId = await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-1',
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
t.truthy(mentionId);
|
||||
// mention user at another doc
|
||||
await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-2',
|
||||
title: 'doc-title-2',
|
||||
elementId: 'element-id-2',
|
||||
mode: DocMode.edgeless,
|
||||
},
|
||||
});
|
||||
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 0,
|
||||
});
|
||||
t.is(result.totalCount, 2);
|
||||
const notifications = result.edges.map(edge => edge.node);
|
||||
t.is(notifications.length, 2);
|
||||
|
||||
const notification = notifications[1] as NotificationObjectType;
|
||||
t.is(notification.read, false);
|
||||
t.truthy(notification.createdAt);
|
||||
t.truthy(notification.updatedAt);
|
||||
const body = notification.body as MentionNotificationBodyType;
|
||||
t.is(body.workspace!.id, workspace.id);
|
||||
t.is(body.doc.id, 'doc-id-1');
|
||||
t.is(body.doc.title, 'doc-title-1');
|
||||
t.is(body.doc.blockId, 'block-id-1');
|
||||
t.is(body.doc.mode, DocMode.page);
|
||||
t.is(body.createdByUser!.id, owner.id);
|
||||
t.is(body.createdByUser!.name, owner.name);
|
||||
t.is(body.workspace!.id, workspace.id);
|
||||
t.is(body.workspace!.name, 'test-workspace-name');
|
||||
t.truthy(body.workspace!.avatarUrl);
|
||||
|
||||
const notification2 = notifications[0] as NotificationObjectType;
|
||||
t.is(notification2.read, false);
|
||||
t.truthy(notification2.createdAt);
|
||||
t.truthy(notification2.updatedAt);
|
||||
const body2 = notification2.body as MentionNotificationBodyType;
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.doc.id, 'doc-id-2');
|
||||
t.is(body2.doc.title, 'doc-title-2');
|
||||
t.is(body2.doc.elementId, 'element-id-2');
|
||||
t.is(body2.doc.mode, DocMode.edgeless);
|
||||
t.is(body2.createdByUser!.id, owner.id);
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.workspace!.name, 'test-workspace-name');
|
||||
t.truthy(body2.workspace!.avatarUrl);
|
||||
});
|
||||
|
||||
test('should mention doc mode support string value', async t => {
|
||||
const member = await app.signup();
|
||||
const owner = await app.signup();
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await createWorkspace(app);
|
||||
await models.workspace.update(workspace.id, {
|
||||
name: 'test-workspace-name',
|
||||
avatarKey: 'test-avatar-key',
|
||||
});
|
||||
const inviteId = await inviteUser(app, workspace.id, member.email);
|
||||
await app.switchUser(member);
|
||||
await acceptInviteById(app, workspace.id, inviteId);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const mentionId = await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-1',
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: 'page' as DocMode,
|
||||
},
|
||||
});
|
||||
t.truthy(mentionId);
|
||||
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 0,
|
||||
});
|
||||
t.is(result.totalCount, 1);
|
||||
const notifications = result.edges.map(edge => edge.node);
|
||||
t.is(notifications.length, 1);
|
||||
|
||||
const notification = notifications[0] as NotificationObjectType;
|
||||
t.is(notification.read, false);
|
||||
t.truthy(notification.createdAt);
|
||||
t.truthy(notification.updatedAt);
|
||||
const body = notification.body as MentionNotificationBodyType;
|
||||
t.is(body.workspace!.id, workspace.id);
|
||||
t.is(body.doc.id, 'doc-id-1');
|
||||
t.is(body.doc.title, 'doc-title-1');
|
||||
t.is(body.doc.blockId, 'block-id-1');
|
||||
t.is(body.doc.mode, DocMode.page);
|
||||
t.is(body.createdByUser!.id, owner.id);
|
||||
t.is(body.createdByUser!.name, owner.name);
|
||||
t.is(body.workspace!.id, workspace.id);
|
||||
t.is(body.workspace!.name, 'test-workspace-name');
|
||||
t.truthy(body.workspace!.avatarUrl);
|
||||
});
|
||||
|
||||
test('should throw error when mention user has no Doc.Read role', async t => {
|
||||
const member = await app.signupV1();
|
||||
const owner = await app.signupV1();
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await createWorkspace(app);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const docId = randomUUID();
|
||||
await t.throwsAsync(
|
||||
mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: `Mentioned user can not access doc ${docId}.`,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when mention a not exists user', async t => {
|
||||
const owner = await app.signupV1();
|
||||
const workspace = await createWorkspace(app);
|
||||
await app.switchUser(owner);
|
||||
const docId = randomUUID();
|
||||
await t.throwsAsync(
|
||||
mentionUser(app, {
|
||||
userId: 'user-id-not-exists',
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: `Mentioned user can not access doc ${docId}.`,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should not mention user oneself', async t => {
|
||||
const owner = await app.signupV1();
|
||||
const workspace = await createWorkspace(app);
|
||||
await app.switchUser(owner);
|
||||
await t.throwsAsync(
|
||||
mentionUser(app, {
|
||||
userId: owner.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-1',
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: 'You can not mention yourself.',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should mark notification as read', async t => {
|
||||
const member = await app.signupV1();
|
||||
const owner = await app.signupV1();
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await createWorkspace(app);
|
||||
const inviteId = await inviteUser(app, workspace.id, member.email);
|
||||
await app.switchUser(member);
|
||||
await acceptInviteById(app, workspace.id, inviteId);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const mentionId = await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-1',
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
t.truthy(mentionId);
|
||||
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 0,
|
||||
});
|
||||
t.is(result.totalCount, 1);
|
||||
|
||||
const notifications = result.edges.map(edge => edge.node);
|
||||
const notification = notifications[0] as NotificationObjectType;
|
||||
t.is(notification.read, false);
|
||||
|
||||
await readNotification(app, notification.id);
|
||||
|
||||
const count = await getNotificationCount(app);
|
||||
t.is(count, 0);
|
||||
|
||||
// read again should work
|
||||
await readNotification(app, notification.id);
|
||||
});
|
||||
|
||||
test('should throw error when read the other user notification', async t => {
|
||||
const member = await app.signupV1();
|
||||
const owner = await app.signupV1();
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await createWorkspace(app);
|
||||
const inviteId = await inviteUser(app, workspace.id, member.email);
|
||||
await app.switchUser(member);
|
||||
await acceptInviteById(app, workspace.id, inviteId);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const mentionId = await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-1',
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
t.truthy(mentionId);
|
||||
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 0,
|
||||
});
|
||||
const notifications = result.edges.map(edge => edge.node);
|
||||
const notification = notifications[0] as NotificationObjectType;
|
||||
t.is(notification.read, false);
|
||||
|
||||
await app.switchUser(owner);
|
||||
await t.throwsAsync(readNotification(app, notification.id), {
|
||||
message: 'Notification not found.',
|
||||
});
|
||||
// notification not exists
|
||||
await t.throwsAsync(readNotification(app, 'notification-id-not-exists'), {
|
||||
message: 'Notification not found.',
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw error when mention call with invalid params', async t => {
|
||||
const owner = await app.signup();
|
||||
await app.switchUser(owner);
|
||||
await t.throwsAsync(
|
||||
mentionUser(app, {
|
||||
userId: '',
|
||||
workspaceId: '',
|
||||
doc: {
|
||||
id: '',
|
||||
title: 'doc-title-1'.repeat(100),
|
||||
blockId: '',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: /Validation error/,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error when mention mode value is invalid', async t => {
|
||||
const owner = await app.signup();
|
||||
await app.switchUser(owner);
|
||||
await t.throwsAsync(
|
||||
mentionUser(app, {
|
||||
userId: randomUUID(),
|
||||
workspaceId: randomUUID(),
|
||||
doc: {
|
||||
id: randomUUID(),
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: 'invalid-mode' as DocMode,
|
||||
},
|
||||
}),
|
||||
{
|
||||
message:
|
||||
'Variable "$input" got invalid value "invalid-mode" at "input.doc.mode"; Value "invalid-mode" does not exist in "DocMode" enum.',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should list and count notifications', async t => {
|
||||
const member = await app.signupV1();
|
||||
const owner = await app.signupV1();
|
||||
|
||||
{
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 0,
|
||||
});
|
||||
const notifications = result.edges.map(edge => edge.node);
|
||||
t.is(notifications.length, 0);
|
||||
t.is(result.totalCount, 0);
|
||||
}
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await createWorkspace(app);
|
||||
await models.workspace.update(workspace.id, {
|
||||
name: 'test-workspace-name1',
|
||||
avatarKey: 'test-avatar-key1',
|
||||
});
|
||||
const inviteId = await inviteUser(app, workspace.id, member.email);
|
||||
const workspace2 = await createWorkspace(app);
|
||||
await models.workspace.update(workspace2.id, {
|
||||
name: 'test-workspace-name2',
|
||||
avatarKey: 'test-avatar-key2',
|
||||
});
|
||||
const inviteId2 = await inviteUser(app, workspace2.id, member.email);
|
||||
await app.switchUser(member);
|
||||
await acceptInviteById(app, workspace.id, inviteId);
|
||||
await acceptInviteById(app, workspace2.id, inviteId2);
|
||||
|
||||
await app.switchUser(owner);
|
||||
await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-1',
|
||||
title: 'doc-title-1',
|
||||
blockId: 'block-id-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-2',
|
||||
title: 'doc-title-2',
|
||||
blockId: 'block-id-2',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-3',
|
||||
title: 'doc-title-3',
|
||||
blockId: 'block-id-3',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
// mention user in another workspace
|
||||
await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace2.id,
|
||||
doc: {
|
||||
id: 'doc-id-4',
|
||||
title: 'doc-title-4',
|
||||
blockId: 'block-id-4',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
|
||||
{
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 0,
|
||||
});
|
||||
const notifications = result.edges.map(
|
||||
edge => edge.node
|
||||
) as NotificationObjectType[];
|
||||
t.is(notifications.length, 4);
|
||||
t.is(result.totalCount, 4);
|
||||
|
||||
const notification = notifications[0];
|
||||
t.is(notification.read, false);
|
||||
const body = notification.body as MentionNotificationBodyType;
|
||||
t.is(body.type, NotificationType.Mention);
|
||||
t.is(body.workspace!.id, workspace2.id);
|
||||
t.is(body.doc.id, 'doc-id-4');
|
||||
t.is(body.doc.title, 'doc-title-4');
|
||||
t.is(body.doc.blockId, 'block-id-4');
|
||||
t.is(body.createdByUser!.id, owner.id);
|
||||
t.is(body.workspace!.id, workspace2.id);
|
||||
t.is(body.workspace!.name, 'test-workspace-name2');
|
||||
t.truthy(body.workspace!.avatarUrl);
|
||||
|
||||
const notification2 = notifications[1];
|
||||
t.is(notification2.read, false);
|
||||
const body2 = notification2.body as MentionNotificationBodyType;
|
||||
t.is(body2.type, NotificationType.Mention);
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.doc.id, 'doc-id-3');
|
||||
t.is(body2.doc.title, 'doc-title-3');
|
||||
t.is(body2.doc.blockId, 'block-id-3');
|
||||
t.is(body2.createdByUser!.id, owner.id);
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.workspace!.name, 'test-workspace-name1');
|
||||
t.truthy(body2.workspace!.avatarUrl);
|
||||
}
|
||||
|
||||
{
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 10,
|
||||
offset: 2,
|
||||
});
|
||||
t.is(result.totalCount, 4);
|
||||
t.is(result.pageInfo.hasNextPage, false);
|
||||
t.is(result.pageInfo.hasPreviousPage, true);
|
||||
const notifications = result.edges.map(
|
||||
edge => edge.node
|
||||
) as NotificationObjectType[];
|
||||
t.is(notifications.length, 2);
|
||||
|
||||
const notification = notifications[0];
|
||||
t.is(notification.read, false);
|
||||
const body = notification.body as MentionNotificationBodyType;
|
||||
t.is(body.workspace!.id, workspace.id);
|
||||
t.is(body.doc.id, 'doc-id-2');
|
||||
t.is(body.doc.title, 'doc-title-2');
|
||||
t.is(body.doc.blockId, 'block-id-2');
|
||||
t.is(body.createdByUser!.id, owner.id);
|
||||
t.is(body.workspace!.id, workspace.id);
|
||||
t.is(body.workspace!.name, 'test-workspace-name1');
|
||||
t.truthy(body.workspace!.avatarUrl);
|
||||
|
||||
const notification2 = notifications[1];
|
||||
t.is(notification2.read, false);
|
||||
const body2 = notification2.body as MentionNotificationBodyType;
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.doc.id, 'doc-id-1');
|
||||
t.is(body2.doc.title, 'doc-title-1');
|
||||
t.is(body2.doc.blockId, 'block-id-1');
|
||||
t.is(body2.createdByUser!.id, owner.id);
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.workspace!.name, 'test-workspace-name1');
|
||||
t.truthy(body2.workspace!.avatarUrl);
|
||||
}
|
||||
|
||||
{
|
||||
await app.switchUser(member);
|
||||
const result = await listNotifications(app, {
|
||||
first: 2,
|
||||
offset: 0,
|
||||
});
|
||||
t.is(result.totalCount, 4);
|
||||
t.is(result.pageInfo.hasNextPage, true);
|
||||
t.is(result.pageInfo.hasPreviousPage, false);
|
||||
const notifications = result.edges.map(
|
||||
edge => edge.node
|
||||
) as NotificationObjectType[];
|
||||
t.is(notifications.length, 2);
|
||||
|
||||
const notification = notifications[0];
|
||||
t.is(notification.read, false);
|
||||
const body = notification.body as MentionNotificationBodyType;
|
||||
t.is(body.workspace!.id, workspace2.id);
|
||||
t.is(body.doc.id, 'doc-id-4');
|
||||
t.is(body.doc.title, 'doc-title-4');
|
||||
t.is(body.doc.blockId, 'block-id-4');
|
||||
t.is(body.createdByUser!.id, owner.id);
|
||||
t.is(body.workspace!.id, workspace2.id);
|
||||
t.is(body.workspace!.name, 'test-workspace-name2');
|
||||
t.truthy(body.workspace!.avatarUrl);
|
||||
t.is(
|
||||
notification.createdAt.toString(),
|
||||
Buffer.from(result.pageInfo.startCursor!, 'base64').toString('utf-8')
|
||||
);
|
||||
const notification2 = notifications[1];
|
||||
t.is(notification2.read, false);
|
||||
const body2 = notification2.body as MentionNotificationBodyType;
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.doc.id, 'doc-id-3');
|
||||
t.is(body2.doc.title, 'doc-title-3');
|
||||
t.is(body2.doc.blockId, 'block-id-3');
|
||||
t.is(body2.createdByUser!.id, owner.id);
|
||||
t.is(body2.workspace!.id, workspace.id);
|
||||
t.is(body2.workspace!.name, 'test-workspace-name1');
|
||||
t.truthy(body2.workspace!.avatarUrl);
|
||||
|
||||
await app.switchUser(owner);
|
||||
await mentionUser(app, {
|
||||
userId: member.id,
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: 'doc-id-5',
|
||||
title: 'doc-title-5',
|
||||
blockId: 'block-id-5',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
});
|
||||
|
||||
// get new notifications
|
||||
await app.switchUser(member);
|
||||
const result2 = await listNotifications(app, {
|
||||
first: 2,
|
||||
offset: 0,
|
||||
after: result.pageInfo.startCursor,
|
||||
});
|
||||
t.is(result2.totalCount, 5);
|
||||
t.is(result2.pageInfo.hasNextPage, false);
|
||||
t.is(result2.pageInfo.hasPreviousPage, true);
|
||||
const notifications2 = result2.edges.map(
|
||||
edge => edge.node
|
||||
) as NotificationObjectType[];
|
||||
t.is(notifications2.length, 1);
|
||||
|
||||
const notification3 = notifications2[0];
|
||||
t.is(notification3.read, false);
|
||||
const body3 = notification3.body as MentionNotificationBodyType;
|
||||
t.is(body3.workspace!.id, workspace.id);
|
||||
t.is(body3.doc.id, 'doc-id-5');
|
||||
t.is(body3.doc.title, 'doc-title-5');
|
||||
t.is(body3.doc.blockId, 'block-id-5');
|
||||
t.is(body3.createdByUser!.id, owner.id);
|
||||
t.is(body3.createdByUser!.name, owner.name);
|
||||
t.is(body3.workspace!.id, workspace.id);
|
||||
t.is(body3.workspace!.name, 'test-workspace-name1');
|
||||
t.truthy(body3.workspace!.avatarUrl);
|
||||
|
||||
// no new notifications
|
||||
const result3 = await listNotifications(app, {
|
||||
first: 2,
|
||||
offset: 0,
|
||||
after: result2.pageInfo.startCursor,
|
||||
});
|
||||
t.is(result3.totalCount, 5);
|
||||
t.is(result3.pageInfo.hasNextPage, false);
|
||||
t.is(result3.pageInfo.hasPreviousPage, true);
|
||||
t.is(result3.pageInfo.startCursor, null);
|
||||
t.is(result3.pageInfo.endCursor, null);
|
||||
t.is(result3.edges.length, 0);
|
||||
}
|
||||
});
|
||||
@@ -64,7 +64,7 @@ test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should create invitation notification', async t => {
|
||||
test('should create invitation notification and email', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
const notification = await notificationService.createInvitation({
|
||||
@@ -81,6 +81,29 @@ test('should create invitation notification', async t => {
|
||||
t.is(notification!.body.workspaceId, workspace.id);
|
||||
t.is(notification!.body.createdByUserId, owner.id);
|
||||
t.is(notification!.body.inviteId, inviteId);
|
||||
// should send invitation email
|
||||
const invitationMail = t.context.module.mails.last('MemberInvitation');
|
||||
t.is(invitationMail.to, member.email);
|
||||
});
|
||||
|
||||
test('should not send invitation email if user setting is not to receive invitation email', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
await t.context.models.settings.set(member.id, {
|
||||
receiveInvitationEmail: false,
|
||||
});
|
||||
const invitationMailCount = t.context.module.mails.count('MemberInvitation');
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: owner.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
t.truthy(notification);
|
||||
// no new invitation email should be sent
|
||||
t.is(t.context.module.mails.count('MemberInvitation'), invitationMailCount);
|
||||
});
|
||||
|
||||
test('should not create invitation notification if user is already a member', async t => {
|
||||
@@ -276,7 +299,6 @@ test('should use latest doc title in mention notification', async t => {
|
||||
const mention = notifications[0];
|
||||
t.is(mention.body.workspace!.id, workspace.id);
|
||||
t.is(mention.body.workspace!.name, 'Test Workspace');
|
||||
t.truthy(mention.body.workspace!.avatarUrl);
|
||||
t.is(mention.body.type, NotificationType.Mention);
|
||||
const body = mention.body as MentionNotificationBody;
|
||||
t.is(body.doc.title, 'doc-title-2-updated');
|
||||
@@ -285,7 +307,6 @@ test('should use latest doc title in mention notification', async t => {
|
||||
const mention2 = notifications[1];
|
||||
t.is(mention2.body.workspace!.id, workspace.id);
|
||||
t.is(mention2.body.workspace!.name, 'Test Workspace');
|
||||
t.truthy(mention2.body.workspace!.avatarUrl);
|
||||
t.is(mention2.body.type, NotificationType.Mention);
|
||||
const body2 = mention2.body as MentionNotificationBody;
|
||||
t.is(body2.doc.title, 'doc-title-1-updated');
|
||||
|
||||
@@ -2,17 +2,23 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue, OnJob } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { NotificationService } from './service';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'nightly.cleanExpiredNotifications': {};
|
||||
'notification.sendInvitation': {
|
||||
inviterId: string;
|
||||
inviteId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationJob {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly service: NotificationService,
|
||||
private readonly queue: JobQueue
|
||||
) {}
|
||||
@@ -32,4 +38,23 @@ export class NotificationJob {
|
||||
async cleanExpiredNotifications() {
|
||||
await this.service.cleanExpiredNotifications();
|
||||
}
|
||||
|
||||
@OnJob('notification.sendInvitation')
|
||||
async sendInvitation({
|
||||
inviterId,
|
||||
inviteId,
|
||||
}: Jobs['notification.sendInvitation']) {
|
||||
const invite = await this.models.workspaceUser.getById(inviteId);
|
||||
if (!invite) {
|
||||
return;
|
||||
}
|
||||
await this.service.createInvitation({
|
||||
userId: invite.userId,
|
||||
body: {
|
||||
workspaceId: invite.workspaceId,
|
||||
createdByUserId: inviterId,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
|
||||
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
|
||||
import {
|
||||
Config,
|
||||
NotificationNotFound,
|
||||
PaginationInput,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import {
|
||||
DEFAULT_WORKSPACE_NAME,
|
||||
InvitationNotificationCreate,
|
||||
MentionNotification,
|
||||
MentionNotificationCreate,
|
||||
Models,
|
||||
NotificationType,
|
||||
UnionNotificationBody,
|
||||
Workspace,
|
||||
} from '../../models';
|
||||
import { DocReader } from '../doc';
|
||||
import { Mailer } from '../mail';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { generateDocPath } from '../utils/doc';
|
||||
|
||||
@Injectable()
|
||||
@@ -22,9 +28,9 @@ export class NotificationService {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly workspaceBlobStorage: WorkspaceBlobStorage,
|
||||
private readonly mailer: Mailer,
|
||||
private readonly url: URLHelper
|
||||
private readonly url: URLHelper,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
async cleanExpiredNotifications() {
|
||||
@@ -77,21 +83,50 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
async createInvitation(input: InvitationNotificationCreate) {
|
||||
const isActive = await this.models.workspaceUser.getActive(
|
||||
input.body.workspaceId,
|
||||
input.userId
|
||||
);
|
||||
if (isActive) {
|
||||
this.logger.debug(
|
||||
`User ${input.userId} is already a active member of workspace ${input.body.workspaceId}, skip creating notification`
|
||||
);
|
||||
const workspaceId = input.body.workspaceId;
|
||||
const userId = input.userId;
|
||||
if (await this.isActiveWorkspaceUser(workspaceId, userId)) {
|
||||
return;
|
||||
}
|
||||
await this.ensureWorkspaceContentExists(input.body.workspaceId);
|
||||
return await this.models.notification.createInvitation(
|
||||
await this.ensureWorkspaceContentExists(workspaceId);
|
||||
const notification = await this.models.notification.createInvitation(
|
||||
input,
|
||||
NotificationType.Invitation
|
||||
);
|
||||
await this.sendInvitationEmail(input);
|
||||
return notification;
|
||||
}
|
||||
|
||||
private async sendInvitationEmail(input: InvitationNotificationCreate) {
|
||||
const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`);
|
||||
if (this.config.node.dev) {
|
||||
// make it easier to test in dev mode
|
||||
this.logger.debug(`Invite link: ${inviteUrl}`);
|
||||
}
|
||||
const userSetting = await this.models.settings.get(input.userId);
|
||||
if (!userSetting.receiveInvitationEmail) {
|
||||
return;
|
||||
}
|
||||
const receiver = await this.models.user.getWorkspaceUser(input.userId);
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
await this.mailer.send({
|
||||
name: 'MemberInvitation',
|
||||
to: receiver.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: input.body.createdByUserId,
|
||||
},
|
||||
workspace: {
|
||||
$$workspaceId: input.body.workspaceId,
|
||||
},
|
||||
url: inviteUrl,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`Invitation email sent to user ${receiver.id} for workspace ${input.body.workspaceId}`
|
||||
);
|
||||
}
|
||||
|
||||
async createInvitationAccepted(input: InvitationNotificationCreate) {
|
||||
@@ -157,16 +192,7 @@ export class NotificationService {
|
||||
Array.from(workspaceIds)
|
||||
);
|
||||
const workspaceInfos = new Map(
|
||||
workspaces.map(w => [
|
||||
w.id,
|
||||
{
|
||||
id: w.id,
|
||||
name: w.name ?? '',
|
||||
avatarUrl: w.avatarKey
|
||||
? this.workspaceBlobStorage.getAvatarUrl(w.id, w.avatarKey)
|
||||
: undefined,
|
||||
},
|
||||
])
|
||||
workspaces.map(w => [w.id, this.formatWorkspaceInfo(w)])
|
||||
);
|
||||
|
||||
// fill latest doc title
|
||||
@@ -202,4 +228,25 @@ export class NotificationService {
|
||||
async countByUserId(userId: string) {
|
||||
return await this.models.notification.countByUserId(userId);
|
||||
}
|
||||
|
||||
private formatWorkspaceInfo(workspace: Workspace) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name ?? DEFAULT_WORKSPACE_NAME,
|
||||
// TODO(@fengmk2): workspace avatar url is not public access by default, impl it in future
|
||||
// avatarUrl: this.workspaceBlobStorage.getAvatarUrl(
|
||||
// workspace.id,
|
||||
// workspace.avatarKey
|
||||
// ),
|
||||
url: this.url.link(`/workspace/${workspace.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
private async isActiveWorkspaceUser(workspaceId: string, userId: string) {
|
||||
const isActive = await this.models.workspaceUser.getActive(
|
||||
workspaceId,
|
||||
userId
|
||||
);
|
||||
return !!isActive;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user