mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(server): send invitation notification (#10219)
close PD-2307 CLOUD-150
This commit is contained in:
@@ -615,12 +615,16 @@ test('should be able to invite by link', async t => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to send mails', async t => {
|
test('should be able to invite batch and send notifications', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const { inviteBatch } = await init(app, 5);
|
const { inviteBatch } = await init(app, 5);
|
||||||
|
|
||||||
|
const currentCount = app.queue.count('notification.sendInvitation');
|
||||||
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
|
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
|
||||||
t.is(app.mails.count('MemberInvitation'), 2);
|
t.is(app.queue.count('notification.sendInvitation'), currentCount + 2);
|
||||||
|
const job = app.queue.last('notification.sendInvitation');
|
||||||
|
t.truthy(job.payload.inviteId);
|
||||||
|
t.truthy(job.payload.inviterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to emit events', async t => {
|
test('should be able to emit events', async t => {
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import type { TestFn } from 'ava';
|
|
||||||
import ava from 'ava';
|
|
||||||
|
|
||||||
import { AuthService } from '../core/auth/service';
|
|
||||||
import { Models } from '../models';
|
|
||||||
import {
|
|
||||||
acceptInviteById,
|
|
||||||
createTestingApp,
|
|
||||||
createWorkspace,
|
|
||||||
getWorkspace,
|
|
||||||
inviteUser,
|
|
||||||
leaveWorkspace,
|
|
||||||
revokeUser,
|
|
||||||
TestingApp,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
const test = ava as TestFn<{
|
|
||||||
app: TestingApp;
|
|
||||||
client: PrismaClient;
|
|
||||||
auth: AuthService;
|
|
||||||
models: Models;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
test.before(async t => {
|
|
||||||
const app = await createTestingApp();
|
|
||||||
t.context.app = app;
|
|
||||||
t.context.client = app.get(PrismaClient);
|
|
||||||
t.context.auth = app.get(AuthService);
|
|
||||||
t.context.models = app.get(Models);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async t => {
|
|
||||||
await t.context.app.initTestingDB();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.after.always(async t => {
|
|
||||||
await t.context.app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should invite a user', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
const u2 = await app.signupV1('u2@affine.pro');
|
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
|
|
||||||
const invite = await inviteUser(app, workspace.id, u2.email);
|
|
||||||
t.truthy(invite, 'failed to invite user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should leave a workspace', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
const u2 = await app.signupV1('u2@affine.pro');
|
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
const invite = await inviteUser(app, workspace.id, u2.email);
|
|
||||||
|
|
||||||
app.switchUser(u2.id);
|
|
||||||
await acceptInviteById(app, workspace.id, invite);
|
|
||||||
|
|
||||||
const leave = await leaveWorkspace(app, workspace.id);
|
|
||||||
|
|
||||||
t.true(leave, 'failed to leave workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should revoke a user', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
const u2 = await app.signupV1('u2@affine.pro');
|
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
await inviteUser(app, workspace.id, u2.email);
|
|
||||||
|
|
||||||
const currWorkspace = await getWorkspace(app, workspace.id);
|
|
||||||
t.is(currWorkspace.members.length, 2, 'failed to invite user');
|
|
||||||
|
|
||||||
const revoke = await revokeUser(app, workspace.id, u2.id);
|
|
||||||
t.true(revoke, 'failed to revoke user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create user if not exist', async t => {
|
|
||||||
const { app, models } = t.context;
|
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
|
|
||||||
await inviteUser(app, workspace.id, 'u2@affine.pro');
|
|
||||||
|
|
||||||
const u2 = await models.user.getUserByEmail('u2@affine.pro');
|
|
||||||
t.not(u2, undefined, 'failed to create user');
|
|
||||||
t.is(u2?.name, 'u2', 'failed to create user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should invite a user by link', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
const u2 = await app.signupV1('u2@affine.pro');
|
|
||||||
const u1 = await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
|
|
||||||
const invite = await inviteUser(app, workspace.id, u2.email);
|
|
||||||
|
|
||||||
app.switchUser(u2.id);
|
|
||||||
const accept = await acceptInviteById(app, workspace.id, invite);
|
|
||||||
t.true(accept, 'failed to accept invite');
|
|
||||||
|
|
||||||
app.switchUser(u1.id);
|
|
||||||
const invite1 = await inviteUser(app, workspace.id, u2.email);
|
|
||||||
|
|
||||||
t.is(invite, invite1, 'repeat the invitation must return same id');
|
|
||||||
|
|
||||||
const currWorkspace = await getWorkspace(app, workspace.id);
|
|
||||||
const currMember = currWorkspace.members.find(u => u.email === u2.email);
|
|
||||||
t.not(currMember, undefined, 'failed to invite user');
|
|
||||||
t.is(currMember?.inviteId, invite, 'failed to check invite id');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send email', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
const u2 = await app.signupV1('u2@affine.pro');
|
|
||||||
const u1 = await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
const invite = await inviteUser(app, workspace.id, u2.email, true);
|
|
||||||
|
|
||||||
const invitationMail = app.mails.last('MemberInvitation');
|
|
||||||
|
|
||||||
t.is(invitationMail.name, 'MemberInvitation');
|
|
||||||
t.is(invitationMail.to, u2.email);
|
|
||||||
|
|
||||||
app.switchUser(u2.id);
|
|
||||||
await acceptInviteById(app, workspace.id, invite, true);
|
|
||||||
|
|
||||||
const acceptedMail = app.mails.last('MemberAccepted');
|
|
||||||
t.is(acceptedMail.to, u1.email);
|
|
||||||
t.is(acceptedMail.props.user.$$userId, u2.id);
|
|
||||||
|
|
||||||
await leaveWorkspace(app, workspace.id, true);
|
|
||||||
|
|
||||||
const leaveMail = app.mails.last('MemberLeave');
|
|
||||||
|
|
||||||
t.is(leaveMail.to, u1.email);
|
|
||||||
t.is(leaveMail.props.user.$$userId, u2.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should support pagination for member', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
await inviteUser(app, workspace.id, 'u2@affine.pro');
|
|
||||||
await inviteUser(app, workspace.id, 'u3@affine.pro');
|
|
||||||
|
|
||||||
const firstPageWorkspace = await getWorkspace(app, workspace.id, 0, 2);
|
|
||||||
t.is(firstPageWorkspace.members.length, 2, 'failed to check invite id');
|
|
||||||
const secondPageWorkspace = await getWorkspace(app, workspace.id, 2, 2);
|
|
||||||
t.is(secondPageWorkspace.members.length, 1, 'failed to check invite id');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should limit member count correctly', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
await app.signupV1('u1@affine.pro');
|
|
||||||
|
|
||||||
const workspace = await createWorkspace(app);
|
|
||||||
await Promise.allSettled(
|
|
||||||
Array.from({ length: 10 }).map(async (_, i) =>
|
|
||||||
inviteUser(app, workspace.id, `u${i}@affine.pro`)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const ws = await getWorkspace(app, workspace.id);
|
|
||||||
t.assert(ws.members.length <= 3, 'failed to check member list');
|
|
||||||
});
|
|
||||||
@@ -27,7 +27,7 @@ declare module '../../config' {
|
|||||||
|
|
||||||
defineStartupConfig('job', {
|
defineStartupConfig('job', {
|
||||||
queue: {
|
queue: {
|
||||||
prefix: 'affine_job',
|
prefix: AFFiNE.node.test ? 'affine_job_test' : 'affine_job',
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 5,
|
attempts: 5,
|
||||||
// should remove job after it's completed, because we will add a new job with the same job id
|
// should remove job after it's completed, because we will add a new job with the same job id
|
||||||
|
|||||||
@@ -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();
|
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 { notificationService } = t.context;
|
||||||
const inviteId = randomUUID();
|
const inviteId = randomUUID();
|
||||||
const notification = await notificationService.createInvitation({
|
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.workspaceId, workspace.id);
|
||||||
t.is(notification!.body.createdByUserId, owner.id);
|
t.is(notification!.body.createdByUserId, owner.id);
|
||||||
t.is(notification!.body.inviteId, inviteId);
|
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 => {
|
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];
|
const mention = notifications[0];
|
||||||
t.is(mention.body.workspace!.id, workspace.id);
|
t.is(mention.body.workspace!.id, workspace.id);
|
||||||
t.is(mention.body.workspace!.name, 'Test Workspace');
|
t.is(mention.body.workspace!.name, 'Test Workspace');
|
||||||
t.truthy(mention.body.workspace!.avatarUrl);
|
|
||||||
t.is(mention.body.type, NotificationType.Mention);
|
t.is(mention.body.type, NotificationType.Mention);
|
||||||
const body = mention.body as MentionNotificationBody;
|
const body = mention.body as MentionNotificationBody;
|
||||||
t.is(body.doc.title, 'doc-title-2-updated');
|
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];
|
const mention2 = notifications[1];
|
||||||
t.is(mention2.body.workspace!.id, workspace.id);
|
t.is(mention2.body.workspace!.id, workspace.id);
|
||||||
t.is(mention2.body.workspace!.name, 'Test Workspace');
|
t.is(mention2.body.workspace!.name, 'Test Workspace');
|
||||||
t.truthy(mention2.body.workspace!.avatarUrl);
|
|
||||||
t.is(mention2.body.type, NotificationType.Mention);
|
t.is(mention2.body.type, NotificationType.Mention);
|
||||||
const body2 = mention2.body as MentionNotificationBody;
|
const body2 = mention2.body as MentionNotificationBody;
|
||||||
t.is(body2.doc.title, 'doc-title-1-updated');
|
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 { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
|
||||||
import { JobQueue, OnJob } from '../../base';
|
import { JobQueue, OnJob } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
import { NotificationService } from './service';
|
import { NotificationService } from './service';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Jobs {
|
interface Jobs {
|
||||||
'nightly.cleanExpiredNotifications': {};
|
'nightly.cleanExpiredNotifications': {};
|
||||||
|
'notification.sendInvitation': {
|
||||||
|
inviterId: string;
|
||||||
|
inviteId: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationJob {
|
export class NotificationJob {
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly models: Models,
|
||||||
private readonly service: NotificationService,
|
private readonly service: NotificationService,
|
||||||
private readonly queue: JobQueue
|
private readonly queue: JobQueue
|
||||||
) {}
|
) {}
|
||||||
@@ -32,4 +38,23 @@ export class NotificationJob {
|
|||||||
async cleanExpiredNotifications() {
|
async cleanExpiredNotifications() {
|
||||||
await this.service.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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
|
|
||||||
import {
|
import {
|
||||||
|
Config,
|
||||||
|
NotificationNotFound,
|
||||||
|
PaginationInput,
|
||||||
|
URLHelper,
|
||||||
|
} from '../../base';
|
||||||
|
import {
|
||||||
|
DEFAULT_WORKSPACE_NAME,
|
||||||
InvitationNotificationCreate,
|
InvitationNotificationCreate,
|
||||||
MentionNotification,
|
MentionNotification,
|
||||||
MentionNotificationCreate,
|
MentionNotificationCreate,
|
||||||
Models,
|
Models,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
UnionNotificationBody,
|
UnionNotificationBody,
|
||||||
|
Workspace,
|
||||||
} from '../../models';
|
} from '../../models';
|
||||||
import { DocReader } from '../doc';
|
import { DocReader } from '../doc';
|
||||||
import { Mailer } from '../mail';
|
import { Mailer } from '../mail';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
|
||||||
import { generateDocPath } from '../utils/doc';
|
import { generateDocPath } from '../utils/doc';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -22,9 +28,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 mailer: Mailer,
|
private readonly mailer: Mailer,
|
||||||
private readonly url: URLHelper
|
private readonly url: URLHelper,
|
||||||
|
private readonly config: Config
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async cleanExpiredNotifications() {
|
async cleanExpiredNotifications() {
|
||||||
@@ -77,21 +83,50 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createInvitation(input: InvitationNotificationCreate) {
|
async createInvitation(input: InvitationNotificationCreate) {
|
||||||
const isActive = await this.models.workspaceUser.getActive(
|
const workspaceId = input.body.workspaceId;
|
||||||
input.body.workspaceId,
|
const userId = input.userId;
|
||||||
input.userId
|
if (await this.isActiveWorkspaceUser(workspaceId, userId)) {
|
||||||
);
|
|
||||||
if (isActive) {
|
|
||||||
this.logger.debug(
|
|
||||||
`User ${input.userId} is already a active member of workspace ${input.body.workspaceId}, skip creating notification`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ensureWorkspaceContentExists(input.body.workspaceId);
|
await this.ensureWorkspaceContentExists(workspaceId);
|
||||||
return await this.models.notification.createInvitation(
|
const notification = await this.models.notification.createInvitation(
|
||||||
input,
|
input,
|
||||||
NotificationType.Invitation
|
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) {
|
async createInvitationAccepted(input: InvitationNotificationCreate) {
|
||||||
@@ -157,16 +192,7 @@ export class NotificationService {
|
|||||||
Array.from(workspaceIds)
|
Array.from(workspaceIds)
|
||||||
);
|
);
|
||||||
const workspaceInfos = new Map(
|
const workspaceInfos = new Map(
|
||||||
workspaces.map(w => [
|
workspaces.map(w => [w.id, this.formatWorkspaceInfo(w)])
|
||||||
w.id,
|
|
||||||
{
|
|
||||||
id: w.id,
|
|
||||||
name: w.name ?? '',
|
|
||||||
avatarUrl: w.avatarKey
|
|
||||||
? this.workspaceBlobStorage.getAvatarUrl(w.id, w.avatarKey)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// fill latest doc title
|
// fill latest doc title
|
||||||
@@ -202,4 +228,25 @@ export class NotificationService {
|
|||||||
async countByUserId(userId: string) {
|
async countByUserId(userId: string) {
|
||||||
return await this.models.notification.countByUserId(userId);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ export class WorkspaceBlobStorage {
|
|||||||
return sum._sum.size ?? 0;
|
return sum._sum.size ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAvatarUrl(workspaceId: string, avatarKey: string) {
|
getAvatarUrl(workspaceId: string, avatarKey: string | null) {
|
||||||
|
if (!avatarKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return this.url.link(`/api/workspaces/${workspaceId}/blobs/${avatarKey}`);
|
return this.url.link(`/api/workspaces/${workspaceId}/blobs/${avatarKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
import { getStreamAsBuffer } from 'get-stream';
|
||||||
|
|
||||||
import { Cache, NotFound, OnEvent, URLHelper } from '../../../base';
|
import { Cache, JobQueue, NotFound, OnEvent, URLHelper } from '../../../base';
|
||||||
import { Models } from '../../../models';
|
import {
|
||||||
|
DEFAULT_WORKSPACE_AVATAR,
|
||||||
|
DEFAULT_WORKSPACE_NAME,
|
||||||
|
Models,
|
||||||
|
} from '../../../models';
|
||||||
import { DocReader } from '../../doc';
|
import { DocReader } from '../../doc';
|
||||||
import { Mailer } from '../../mail';
|
import { Mailer } from '../../mail';
|
||||||
import { WorkspaceRole } from '../../permission';
|
import { WorkspaceRole } from '../../permission';
|
||||||
import { WorkspaceBlobStorage } from '../../storage';
|
import { WorkspaceBlobStorage } from '../../storage';
|
||||||
|
|
||||||
export const DEFAULT_WORKSPACE_AVATAR =
|
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
|
|
||||||
|
|
||||||
export type InviteInfo = {
|
export type InviteInfo = {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
inviterUserId?: string;
|
inviterUserId?: string;
|
||||||
@@ -27,7 +28,8 @@ export class WorkspaceService {
|
|||||||
private readonly url: URLHelper,
|
private readonly url: URLHelper,
|
||||||
private readonly doc: DocReader,
|
private readonly doc: DocReader,
|
||||||
private readonly blobStorage: WorkspaceBlobStorage,
|
private readonly blobStorage: WorkspaceBlobStorage,
|
||||||
private readonly mailer: Mailer
|
private readonly mailer: Mailer,
|
||||||
|
private readonly queue: JobQueue
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
|
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
|
||||||
@@ -69,7 +71,7 @@ export class WorkspaceService {
|
|||||||
return {
|
return {
|
||||||
avatar,
|
avatar,
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
name: workspaceContent?.name ?? 'Untitled Workspace',
|
name: workspaceContent?.name ?? DEFAULT_WORKSPACE_NAME,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,29 +104,10 @@ export class WorkspaceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInviteEmail({
|
async sendInvitationNotification(inviterId: string, inviteId: string) {
|
||||||
workspaceId,
|
await this.queue.add('notification.sendInvitation', {
|
||||||
inviteeEmail,
|
inviterId,
|
||||||
inviterUserId,
|
inviteId,
|
||||||
inviteId,
|
|
||||||
}: {
|
|
||||||
inviterUserId: string;
|
|
||||||
inviteeEmail: string;
|
|
||||||
inviteId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}) {
|
|
||||||
return await this.mailer.send({
|
|
||||||
name: 'MemberInvitation',
|
|
||||||
to: inviteeEmail,
|
|
||||||
props: {
|
|
||||||
workspace: {
|
|
||||||
$$workspaceId: workspaceId,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
$$userId: inviterUserId,
|
|
||||||
},
|
|
||||||
url: this.url.link(`/invite/${inviteId}`),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export class TeamWorkspaceResolver {
|
|||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args({ name: 'emails', type: () => [String] }) emails: string[],
|
@Args({ name: 'emails', type: () => [String] }) emails: string[],
|
||||||
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
@Args('sendInviteMail', {
|
||||||
|
nullable: true,
|
||||||
|
deprecationReason: 'never used',
|
||||||
|
})
|
||||||
|
_sendInviteMail: boolean
|
||||||
) {
|
) {
|
||||||
await this.ac
|
await this.ac
|
||||||
.user(user.id)
|
.user(user.id)
|
||||||
@@ -116,21 +120,11 @@ export class TeamWorkspaceResolver {
|
|||||||
// NOTE: we always send email even seat not enough
|
// NOTE: we always send email even seat not enough
|
||||||
// because at this moment we cannot know whether the seat increase charge was successful
|
// because at this moment we cannot know whether the seat increase charge was successful
|
||||||
// after user click the invite link, we can check again and reject if charge failed
|
// after user click the invite link, we can check again and reject if charge failed
|
||||||
if (sendInviteMail) {
|
await this.workspaceService.sendInvitationNotification(
|
||||||
try {
|
user.id,
|
||||||
await this.workspaceService.sendInviteEmail({
|
ret.inviteId
|
||||||
workspaceId,
|
);
|
||||||
inviteeEmail: target.email,
|
ret.sentSuccess = true;
|
||||||
inviterUserId: user.id,
|
|
||||||
inviteId: role.id,
|
|
||||||
});
|
|
||||||
ret.sentSuccess = true;
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.warn(
|
|
||||||
`failed to send ${workspaceId} invite email to ${email}: ${e}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('failed to invite user', e);
|
this.logger.error('failed to invite user', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
CanNotRevokeYourself,
|
CanNotRevokeYourself,
|
||||||
DocNotFound,
|
DocNotFound,
|
||||||
EventBus,
|
EventBus,
|
||||||
InternalServerError,
|
|
||||||
MemberNotFoundInSpace,
|
MemberNotFoundInSpace,
|
||||||
MemberQuotaExceeded,
|
MemberQuotaExceeded,
|
||||||
OwnerCanNotLeaveWorkspace,
|
OwnerCanNotLeaveWorkspace,
|
||||||
@@ -446,10 +445,14 @@ export class WorkspaceResolver {
|
|||||||
|
|
||||||
@Mutation(() => String)
|
@Mutation(() => String)
|
||||||
async invite(
|
async invite(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() me: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('email') email: string,
|
@Args('email') email: string,
|
||||||
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean,
|
@Args('sendInviteMail', {
|
||||||
|
nullable: true,
|
||||||
|
deprecationReason: 'never used',
|
||||||
|
})
|
||||||
|
_sendInviteMail: boolean,
|
||||||
@Args('permission', {
|
@Args('permission', {
|
||||||
type: () => WorkspaceRole,
|
type: () => WorkspaceRole,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
@@ -458,7 +461,7 @@ export class WorkspaceResolver {
|
|||||||
_permission?: WorkspaceRole
|
_permission?: WorkspaceRole
|
||||||
) {
|
) {
|
||||||
await this.ac
|
await this.ac
|
||||||
.user(user.id)
|
.user(me.id)
|
||||||
.workspace(workspaceId)
|
.workspace(workspaceId)
|
||||||
.assert('Workspace.Users.Manage');
|
.assert('Workspace.Users.Manage');
|
||||||
|
|
||||||
@@ -491,26 +494,7 @@ export class WorkspaceResolver {
|
|||||||
WorkspaceRole.Collaborator
|
WorkspaceRole.Collaborator
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sendInviteMail) {
|
await this.workspaceService.sendInvitationNotification(me.id, role.id);
|
||||||
try {
|
|
||||||
await this.workspaceService.sendInviteEmail({
|
|
||||||
workspaceId,
|
|
||||||
inviteeEmail: email,
|
|
||||||
inviterUserId: user.id,
|
|
||||||
inviteId: role.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
await this.models.workspaceUser.delete(workspaceId, user.id);
|
|
||||||
|
|
||||||
this.logger.warn(
|
|
||||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new InternalServerError(
|
|
||||||
'Failed to send invite email. Please try again.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return role.id;
|
return role.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// pass through user friendly error
|
// pass through user friendly error
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from './doc';
|
|||||||
export * from './feature';
|
export * from './feature';
|
||||||
export * from './role';
|
export * from './role';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './workspace';
|
||||||
|
|||||||
3
packages/backend/server/src/models/common/workspace.ts
Normal file
3
packages/backend/server/src/models/common/workspace.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const DEFAULT_WORKSPACE_AVATAR =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
|
||||||
|
export const DEFAULT_WORKSPACE_NAME = 'Untitled Workspace';
|
||||||
@@ -921,8 +921,8 @@ type Mutation {
|
|||||||
|
|
||||||
"""import users"""
|
"""import users"""
|
||||||
importUsers(input: ImportUsersInput!): [UserImportResultType!]!
|
importUsers(input: ImportUsersInput!): [UserImportResultType!]!
|
||||||
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
|
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): String!
|
||||||
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]!
|
||||||
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
|
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
|
||||||
|
|
||||||
"""mention user in a doc"""
|
"""mention user in a doc"""
|
||||||
|
|||||||
Reference in New Issue
Block a user