From d7b3dc683bbd0b15250a9bc46b5cafa83bb3a00b Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 24 Mar 2025 02:27:23 +0000 Subject: [PATCH] feat(server): send invitation notification (#10219) close PD-2307 CLOUD-150 --- .../backend/server/src/__tests__/team.e2e.ts | 8 +- .../src/__tests__/workspace-invite.e2e.ts | 174 ----- .../server/src/base/job/queue/config.ts | 2 +- .../core/notification/__tests__/job.spec.ts | 87 +++ .../notification/__tests__/resolver.e2e.ts | 605 ------------------ .../notification/__tests__/service.spec.ts | 27 +- .../server/src/core/notification/job.ts | 25 + .../server/src/core/notification/service.ts | 95 ++- .../server/src/core/storage/wrappers/blob.ts | 5 +- .../src/core/workspaces/resolvers/service.ts | 43 +- .../src/core/workspaces/resolvers/team.ts | 26 +- .../core/workspaces/resolvers/workspace.ts | 32 +- .../backend/server/src/models/common/index.ts | 1 + .../server/src/models/common/workspace.ts | 3 + packages/backend/server/src/schema.gql | 4 +- 15 files changed, 255 insertions(+), 882 deletions(-) delete mode 100644 packages/backend/server/src/__tests__/workspace-invite.e2e.ts create mode 100644 packages/backend/server/src/core/notification/__tests__/job.spec.ts delete mode 100644 packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts create mode 100644 packages/backend/server/src/models/common/workspace.ts diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index a64ddd9b73..80dcd909c8 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -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 { inviteBatch } = await init(app, 5); + const currentCount = app.queue.count('notification.sendInvitation'); 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 => { diff --git a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts deleted file mode 100644 index 6dcdf4d062..0000000000 --- a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts +++ /dev/null @@ -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'); -}); diff --git a/packages/backend/server/src/base/job/queue/config.ts b/packages/backend/server/src/base/job/queue/config.ts index 5c3cd3b301..c912728dd5 100644 --- a/packages/backend/server/src/base/job/queue/config.ts +++ b/packages/backend/server/src/base/job/queue/config.ts @@ -27,7 +27,7 @@ declare module '../../config' { defineStartupConfig('job', { queue: { - prefix: 'affine_job', + prefix: AFFiNE.node.test ? 'affine_job_test' : 'affine_job', defaultJobOptions: { attempts: 5, // should remove job after it's completed, because we will add a new job with the same job id diff --git a/packages/backend/server/src/core/notification/__tests__/job.spec.ts b/packages/backend/server/src/core/notification/__tests__/job.spec.ts new file mode 100644 index 0000000000..12f1c41860 --- /dev/null +++ b/packages/backend/server/src/core/notification/__tests__/job.spec.ts @@ -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; + +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); +}); diff --git a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts deleted file mode 100644 index ca3f232783..0000000000 --- a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts +++ /dev/null @@ -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); - } -}); diff --git a/packages/backend/server/src/core/notification/__tests__/service.spec.ts b/packages/backend/server/src/core/notification/__tests__/service.spec.ts index f7b16fb999..6070825647 100644 --- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -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'); diff --git a/packages/backend/server/src/core/notification/job.ts b/packages/backend/server/src/core/notification/job.ts index 7ef44ecd54..89c9f0810a 100644 --- a/packages/backend/server/src/core/notification/job.ts +++ b/packages/backend/server/src/core/notification/job.ts @@ -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, + }, + }); + } } diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts index 50ca9c71bf..6bda186f17 100644 --- a/packages/backend/server/src/core/notification/service.ts +++ b/packages/backend/server/src/core/notification/service.ts @@ -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; + } } diff --git a/packages/backend/server/src/core/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts index f41c9feca9..980137cdd3 100644 --- a/packages/backend/server/src/core/storage/wrappers/blob.ts +++ b/packages/backend/server/src/core/storage/wrappers/blob.ts @@ -142,7 +142,10 @@ export class WorkspaceBlobStorage { 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}`); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index 51575bac6a..83307cbb6b 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { getStreamAsBuffer } from 'get-stream'; -import { Cache, NotFound, OnEvent, URLHelper } from '../../../base'; -import { Models } from '../../../models'; +import { Cache, JobQueue, NotFound, OnEvent, URLHelper } from '../../../base'; +import { + DEFAULT_WORKSPACE_AVATAR, + DEFAULT_WORKSPACE_NAME, + Models, +} from '../../../models'; import { DocReader } from '../../doc'; import { Mailer } from '../../mail'; import { WorkspaceRole } from '../../permission'; 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 = { workspaceId: string; inviterUserId?: string; @@ -27,7 +28,8 @@ export class WorkspaceService { private readonly url: URLHelper, private readonly doc: DocReader, private readonly blobStorage: WorkspaceBlobStorage, - private readonly mailer: Mailer + private readonly mailer: Mailer, + private readonly queue: JobQueue ) {} async getInviteInfo(inviteId: string): Promise { @@ -69,7 +71,7 @@ export class WorkspaceService { return { avatar, id: workspaceId, - name: workspaceContent?.name ?? 'Untitled Workspace', + name: workspaceContent?.name ?? DEFAULT_WORKSPACE_NAME, }; } @@ -102,29 +104,10 @@ export class WorkspaceService { }); } - async sendInviteEmail({ - workspaceId, - inviteeEmail, - inviterUserId, - 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}`), - }, + async sendInvitationNotification(inviterId: string, inviteId: string) { + await this.queue.add('notification.sendInvitation', { + inviterId, + inviteId, }); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index e682eb2e3e..8d8bc67032 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -64,7 +64,11 @@ export class TeamWorkspaceResolver { @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: 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 .user(user.id) @@ -116,21 +120,11 @@ export class TeamWorkspaceResolver { // NOTE: we always send email even seat not enough // 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 - if (sendInviteMail) { - try { - await this.workspaceService.sendInviteEmail({ - workspaceId, - inviteeEmail: target.email, - inviterUserId: user.id, - inviteId: role.id, - }); - ret.sentSuccess = true; - } catch (e) { - this.logger.warn( - `failed to send ${workspaceId} invite email to ${email}: ${e}` - ); - } - } + await this.workspaceService.sendInvitationNotification( + user.id, + ret.inviteId + ); + ret.sentSuccess = true; } catch (e) { this.logger.error('failed to invite user', e); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 5541036ec2..fb8a8919cd 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -20,7 +20,6 @@ import { CanNotRevokeYourself, DocNotFound, EventBus, - InternalServerError, MemberNotFoundInSpace, MemberQuotaExceeded, OwnerCanNotLeaveWorkspace, @@ -446,10 +445,14 @@ export class WorkspaceResolver { @Mutation(() => String) async invite( - @CurrentUser() user: CurrentUser, + @CurrentUser() me: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('email') email: string, - @Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean, + @Args('sendInviteMail', { + nullable: true, + deprecationReason: 'never used', + }) + _sendInviteMail: boolean, @Args('permission', { type: () => WorkspaceRole, nullable: true, @@ -458,7 +461,7 @@ export class WorkspaceResolver { _permission?: WorkspaceRole ) { await this.ac - .user(user.id) + .user(me.id) .workspace(workspaceId) .assert('Workspace.Users.Manage'); @@ -491,26 +494,7 @@ export class WorkspaceResolver { WorkspaceRole.Collaborator ); - if (sendInviteMail) { - 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.' - ); - } - } + await this.workspaceService.sendInvitationNotification(me.id, role.id); return role.id; } catch (e) { // pass through user friendly error diff --git a/packages/backend/server/src/models/common/index.ts b/packages/backend/server/src/models/common/index.ts index 5af32ddc19..2b681fb54a 100644 --- a/packages/backend/server/src/models/common/index.ts +++ b/packages/backend/server/src/models/common/index.ts @@ -3,3 +3,4 @@ export * from './doc'; export * from './feature'; export * from './role'; export * from './user'; +export * from './workspace'; diff --git a/packages/backend/server/src/models/common/workspace.ts b/packages/backend/server/src/models/common/workspace.ts new file mode 100644 index 0000000000..260108807f --- /dev/null +++ b/packages/backend/server/src/models/common/workspace.ts @@ -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'; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 9fa3470024..47ddd56371 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -921,8 +921,8 @@ type Mutation { """import users""" importUsers(input: ImportUsersInput!): [UserImportResultType!]! - invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String! - inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! + invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): String! + 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! """mention user in a doc"""