From 5c934c64aae7956c56171c2c5f74eb4f0ed4cf36 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Fri, 17 Jan 2025 06:16:49 +0000 Subject: [PATCH] feat(server): workspace model (#9714) --- .../src/__tests__/models/workspace.spec.ts | 1090 +++++++++++++++++ .../backend/server/src/models/common/index.ts | 1 + .../server/src/models/common/permission.ts | 6 + packages/backend/server/src/models/index.ts | 3 + .../backend/server/src/models/workspace.ts | 495 ++++++++ 5 files changed, 1595 insertions(+) create mode 100644 packages/backend/server/src/__tests__/models/workspace.spec.ts create mode 100644 packages/backend/server/src/models/common/permission.ts create mode 100644 packages/backend/server/src/models/workspace.ts diff --git a/packages/backend/server/src/__tests__/models/workspace.spec.ts b/packages/backend/server/src/__tests__/models/workspace.spec.ts new file mode 100644 index 0000000000..cf095b3a7d --- /dev/null +++ b/packages/backend/server/src/__tests__/models/workspace.spec.ts @@ -0,0 +1,1090 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +import { Config } from '../../base/config'; +import { Permission } from '../../models/common'; +import { UserModel } from '../../models/user'; +import { WorkspaceModel } from '../../models/workspace'; +import { createTestingModule, initTestingDB } from '../utils'; + +interface Context { + config: Config; + module: TestingModule; + db: PrismaClient; + user: UserModel; + workspace: WorkspaceModel; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + t.context.user = module.get(UserModel); + t.context.workspace = module.get(WorkspaceModel); + t.context.db = module.get(PrismaClient); + t.context.config = module.get(Config); + t.context.module = module; +}); + +test.beforeEach(async t => { + await initTestingDB(t.context.db); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +test('should create a new workspace, default to private', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + t.truthy(workspace.id); + t.truthy(workspace.createdAt); + t.is(workspace.public, false); + + const workspace1 = await t.context.workspace.get(workspace.id); + t.deepEqual(workspace, workspace1); +}); + +test('should get null for non-exist workspace', async t => { + const workspace = await t.context.workspace.get('non-exist'); + t.is(workspace, null); +}); + +test('should update workspace', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const data = { + public: true, + enableAi: true, + enableUrlPreview: true, + }; + await t.context.workspace.update(workspace.id, data); + const workspace1 = await t.context.workspace.get(workspace.id); + t.deepEqual(workspace1, { + ...workspace, + ...data, + }); +}); + +test('should delete workspace', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + await t.context.workspace.delete(workspace.id); + const workspace1 = await t.context.workspace.get(workspace.id); + t.is(workspace1, null); + // delete again should not throw + await t.context.workspace.delete(workspace.id); +}); + +test('should workspace owner has all permissions', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + let allowed = await t.context.workspace.isMember( + workspace.id, + user.id, + Permission.Owner + ); + t.is(allowed, true); + allowed = await t.context.workspace.isMember( + workspace.id, + user.id, + Permission.Admin + ); + t.is(allowed, true); + allowed = await t.context.workspace.isMember( + workspace.id, + user.id, + Permission.Write + ); + t.is(allowed, true); + allowed = await t.context.workspace.isMember( + workspace.id, + user.id, + Permission.Read + ); + t.is(allowed, true); +}); + +test('should workspace admin has all permissions except owner', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + await t.context.db.workspaceUserPermission.create({ + data: { + workspaceId: workspace.id, + userId: otherUser.id, + type: Permission.Admin, + status: WorkspaceMemberStatus.Accepted, + }, + }); + let allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Owner + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Admin + ); + t.is(allowed, true); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Write + ); + t.is(allowed, true); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Read + ); + t.is(allowed, true); +}); + +test('should workspace write has write and read permissions', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + await t.context.db.workspaceUserPermission.create({ + data: { + workspaceId: workspace.id, + userId: otherUser.id, + type: Permission.Write, + status: WorkspaceMemberStatus.Accepted, + }, + }); + let allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Owner + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Admin + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Write + ); + t.is(allowed, true); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Read + ); + t.is(allowed, true); +}); + +test('should workspace read has read permission only', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + await t.context.db.workspaceUserPermission.create({ + data: { + workspaceId: workspace.id, + userId: otherUser.id, + type: Permission.Read, + status: WorkspaceMemberStatus.Accepted, + }, + }); + let allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Owner + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Admin + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Write + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Read + ); + t.is(allowed, true); +}); + +test('should user not in workspace has no permissions', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + let allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Owner + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Admin + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Write + ); + t.is(allowed, false); + allowed = await t.context.workspace.isMember( + workspace.id, + otherUser.id, + Permission.Read + ); + t.is(allowed, false); +}); + +test('should find user accessible workspaces', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const workspace1 = await t.context.workspace.create(user.id); + const workspace2 = await t.context.workspace.create(user.id); + await t.context.workspace.create(otherUser.id); + const workspaces = await t.context.workspace.findAccessibleWorkspaces( + user.id + ); + t.is(workspaces.length, 2); + t.deepEqual( + workspaces.map(w => w.workspace.id), + [workspace1.id, workspace2.id] + ); +}); + +test('should grant member with read permission and Pending status by default', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + + const event = t.context.module.get(EventEmitter2); + const updatedSpy = Sinon.spy(); + event.on('workspace.members.updated', updatedSpy); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.Pending); + + // grant again should do nothing + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id + ); + t.deepEqual(member1, member2); + t.true( + updatedSpy.calledOnceWith({ + workspaceId: workspace.id, + count: 2, + }) + ); +}); + +test('should grant Pending status member to Accepted status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.Pending); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Read); + t.is(member2.status, WorkspaceMemberStatus.Accepted); +}); + +test('should grant new owner and change exists owner to admin', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.Accepted); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Owner, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Owner); + t.is(member2.status, WorkspaceMemberStatus.Accepted); + // check old owner + const owner = await t.context.workspace.getMember(workspace.id, user.id); + t.is(owner!.type, Permission.Admin); + t.is(owner!.status, WorkspaceMemberStatus.Accepted); +}); + +test('should grant write permission on exists member', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.Accepted); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Write, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Write); + t.is(member2.status, WorkspaceMemberStatus.Accepted); +}); + +test('should grant UnderReview status member to Accepted status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.UnderReview + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.UnderReview); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Read); + t.is(member2.status, WorkspaceMemberStatus.Accepted); +}); + +test('should grant NeedMoreSeat status member to Pending status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.NeedMoreSeat + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeat); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Pending + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Read); + t.is(member2.status, WorkspaceMemberStatus.Pending); +}); + +test('should grant NeedMoreSeatAndReview status member to UnderReview status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.NeedMoreSeatAndReview + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.UnderReview + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Read); + t.is(member2.status, WorkspaceMemberStatus.UnderReview); +}); + +test('should grant Pending status member to write permission and Accepted status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.Pending); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Write, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + // TODO(fengmk2): fix this + // t.is(member2.type, Permission.Write); + t.is(member2.status, WorkspaceMemberStatus.Accepted); +}); + +test('should grant no thing on invalid status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member1 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.NeedMoreSeat + ); + t.is(member1.workspaceId, workspace.id); + t.is(member1.userId, otherUser.id); + t.is(member1.type, Permission.Read); + t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeat); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.workspaceId, workspace.id); + t.is(member2.userId, otherUser.id); + t.is(member2.type, Permission.Read); + t.is(member2.status, WorkspaceMemberStatus.NeedMoreSeat); +}); + +test('should get the accepted status workspace member', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + const member = await t.context.workspace.getMember( + workspace.id, + otherUser.id + ); + t.is(member!.workspaceId, workspace.id); + t.is(member!.userId, otherUser.id); + t.is(member!.type, Permission.Read); + t.is(member!.status, WorkspaceMemberStatus.Accepted); +}); + +test('should get any status workspace member, including pending and accepted', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test2@affine.pro', + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Pending + ); + const member = await t.context.workspace.getMemberInAnyStatus( + workspace.id, + otherUser.id + ); + t.is(member!.workspaceId, workspace.id); + t.is(member!.userId, otherUser.id); + t.is(member!.type, Permission.Read); + t.is(member!.status, WorkspaceMemberStatus.Pending); +}); + +test('should get workspace owner by workspace id', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const owner = await t.context.workspace.getOwner(workspace.id); + t.is(owner!.workspaceId, workspace.id); + t.is(owner!.userId, user.id); + t.is(owner!.type, Permission.Owner); + t.is(owner!.status, WorkspaceMemberStatus.Accepted); + t.truthy(owner!.user); + t.deepEqual(owner!.user, user); +}); + +test('should find workspace admin by workspace id', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser1 = await t.context.user.create({ + email: 'tes1@affine.pro', + }); + const otherUser2 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const otherUser3 = await t.context.user.create({ + email: 'test3@affine.pro', + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser1.id, + Permission.Admin, + WorkspaceMemberStatus.Accepted + ); + await t.context.workspace.grantMember( + workspace.id, + otherUser2.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + // pending member should not be admin + await t.context.workspace.grantMember( + workspace.id, + otherUser3.id, + Permission.Admin, + WorkspaceMemberStatus.Pending + ); + const members = await t.context.workspace.findAdmins(workspace.id); + t.is(members.length, 1); + t.is(members[0].workspaceId, workspace.id); + t.is(members[0].userId, otherUser1.id); + t.is(members[0].type, Permission.Admin); + t.is(members[0].status, WorkspaceMemberStatus.Accepted); +}); + +test('should find workspace ids by owner id', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace1 = await t.context.workspace.create(user.id); + const workspace2 = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'tes1@affine.pro', + }); + await t.context.workspace.create(otherUser.id); + const workspaceIds = await t.context.workspace.findOwnedIds(user.id); + t.deepEqual(workspaceIds, [workspace1.id, workspace2.id]); +}); + +test('should the workspace member total count, including pending and accepted', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser1 = await t.context.user.create({ + email: 'tes1@affine.pro', + }); + const otherUser2 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser1.id, + Permission.Read, + WorkspaceMemberStatus.Pending + ); + await t.context.workspace.grantMember( + workspace.id, + otherUser2.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + const count = await t.context.workspace.getMemberTotalCount(workspace.id); + t.is(count, 3); +}); + +test('should the workspace member used count, only count the accepted member', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser1 = await t.context.user.create({ + email: 'tes1@affine.pro', + }); + const otherUser2 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser1.id, + Permission.Read, + WorkspaceMemberStatus.Pending + ); + await t.context.workspace.grantMember( + workspace.id, + otherUser2.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + const count = await t.context.workspace.getMemberUsedCount(workspace.id); + t.is(count, 2); +}); + +test('should accept workspace member invitation', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser1 = await t.context.user.create({ + email: 'test1@affine.pro', + }); + const member = await t.context.workspace.grantMember( + workspace.id, + otherUser1.id + ); + t.is(member.status, WorkspaceMemberStatus.Pending); + let success = await t.context.workspace.acceptMemberInvitation( + member.id, + workspace.id + ); + t.is(success, true); + const member1 = await t.context.workspace.getMemberInAnyStatus( + workspace.id, + otherUser1.id + ); + t.is(member1!.status, WorkspaceMemberStatus.Accepted); + // accept again should do nothing + success = await t.context.workspace.acceptMemberInvitation( + member.id, + workspace.id + ); + t.is(success, false); + const member2 = await t.context.workspace.getMember( + workspace.id, + otherUser1.id + ); + t.deepEqual(member1, member2); + + // accept with UnderReview status + const otherUser2 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const member3 = await t.context.workspace.grantMember( + workspace.id, + otherUser2.id + ); + t.is(member3.status, WorkspaceMemberStatus.Pending); + success = await t.context.workspace.acceptMemberInvitation( + member3.id, + workspace.id, + WorkspaceMemberStatus.UnderReview + ); + t.is(success, true); + const member4 = await t.context.workspace.getMember( + workspace.id, + otherUser2.id + ); + t.is(member4!.status, WorkspaceMemberStatus.UnderReview); + // accept again should do nothing + success = await t.context.workspace.acceptMemberInvitation( + member3.id, + workspace.id, + WorkspaceMemberStatus.UnderReview + ); + t.is(success, false); +}); + +test('should delete workspace member in Pending, Accepted status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test1@affine.pro', + }); + const member = await t.context.workspace.grantMember( + workspace.id, + otherUser.id + ); + t.is(member.status, WorkspaceMemberStatus.Pending); + + const event = t.context.module.get(EventEmitter2); + const updatedSpy = Sinon.spy(); + event.on('workspace.members.updated', updatedSpy); + let success = await t.context.workspace.deleteMember( + workspace.id, + otherUser.id + ); + t.is(success, true); + const member1 = await t.context.workspace.getMember( + workspace.id, + otherUser.id + ); + t.is(member1, null); + t.true( + updatedSpy.calledOnceWith({ + workspaceId: workspace.id, + count: 1, + }) + ); + + // delete again should do nothing + success = await t.context.workspace.deleteMember(workspace.id, otherUser.id); + t.is(success, false); + + const member2 = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + t.is(member2.status, WorkspaceMemberStatus.Accepted); + success = await t.context.workspace.deleteMember(workspace.id, otherUser.id); + t.is(success, true); +}); + +test('should trigger workspace.members.requestDeclined event when delete workspace member in UnderReview status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test1@affine.pro', + }); + const member = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.UnderReview + ); + t.is(member.status, WorkspaceMemberStatus.UnderReview); + + const event = t.context.module.get(EventEmitter2); + const updatedSpy = Sinon.spy(); + const requestDeclinedSpy = Sinon.spy(); + event.on('workspace.members.updated', updatedSpy); + event.on('workspace.members.requestDeclined', requestDeclinedSpy); + let success = await t.context.workspace.deleteMember( + workspace.id, + otherUser.id + ); + t.is(success, true); + const member1 = await t.context.workspace.getMember( + workspace.id, + otherUser.id + ); + t.is(member1, null); + t.true( + updatedSpy.calledOnceWith({ + workspaceId: workspace.id, + count: 1, + }) + ); + t.true( + requestDeclinedSpy.calledOnceWith({ + workspaceId: workspace.id, + userId: otherUser.id, + }) + ); +}); + +test('should trigger workspace.members.requestDeclined event when delete workspace member in NeedMoreSeatAndReview status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser = await t.context.user.create({ + email: 'test1@affine.pro', + }); + const member = await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.NeedMoreSeatAndReview + ); + t.is(member.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); + + const event = t.context.module.get(EventEmitter2); + const updatedSpy = Sinon.spy(); + const requestDeclinedSpy = Sinon.spy(); + event.on('workspace.members.updated', updatedSpy); + event.on('workspace.members.requestDeclined', requestDeclinedSpy); + let success = await t.context.workspace.deleteMember( + workspace.id, + otherUser.id + ); + t.is(success, true); + const member1 = await t.context.workspace.getMember( + workspace.id, + otherUser.id + ); + t.is(member1, null); + t.true( + updatedSpy.calledOnceWith({ + workspaceId: workspace.id, + count: 1, + }) + ); + t.true( + requestDeclinedSpy.calledOnceWith({ + workspaceId: workspace.id, + userId: otherUser.id, + }) + ); +}); + +test('should refresh member seat status', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser1 = await t.context.user.create({ + email: 'test1@affine.pro', + }); + const otherUser2 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const otherUser3 = await t.context.user.create({ + email: 'test3@affine.pro', + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser1.id, + Permission.Read, + WorkspaceMemberStatus.NeedMoreSeatAndReview + ); + await t.context.workspace.grantMember( + workspace.id, + otherUser2.id, + Permission.Read, + WorkspaceMemberStatus.Pending + ); + await t.context.workspace.grantMember( + workspace.id, + otherUser3.id, + Permission.Read, + WorkspaceMemberStatus.NeedMoreSeat + ); + let count = await t.context.db.workspaceUserPermission.count({ + where: { + workspaceId: workspace.id, + status: WorkspaceMemberStatus.Pending, + }, + }); + t.is(count, 1); + // available not enough + await t.context.workspace.refreshMemberSeatStatus(workspace.id, 1); + count = await t.context.db.workspaceUserPermission.count({ + where: { + workspaceId: workspace.id, + status: WorkspaceMemberStatus.Pending, + }, + }); + t.is(count, 1); + + // available enough + await t.context.workspace.refreshMemberSeatStatus(workspace.id, 3); + // pending member should be 2 and under review member should be 1 + count = await t.context.db.workspaceUserPermission.count({ + where: { + workspaceId: workspace.id, + status: WorkspaceMemberStatus.Pending, + }, + }); + t.is(count, 2); + count = await t.context.db.workspaceUserPermission.count({ + where: { + workspaceId: workspace.id, + status: WorkspaceMemberStatus.UnderReview, + }, + }); + t.is(count, 1); + + // again should do nothing + await t.context.workspace.refreshMemberSeatStatus(workspace.id, 3); + count = await t.context.db.workspaceUserPermission.count({ + where: { + workspaceId: workspace.id, + status: WorkspaceMemberStatus.Pending, + }, + }); + t.is(count, 2); +}); + +test('should find the workspace members order by type:desc and createdAt:asc', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + for (let i = 0; i < 10; i++) { + const otherUser = await t.context.user.create({ + email: `test${i}@affine.pro`, + }); + await t.context.workspace.grantMember( + workspace.id, + otherUser.id, + Permission.Read, + WorkspaceMemberStatus.Accepted + ); + } + let members = await t.context.workspace.findMembers(workspace.id); + t.is(members.length, 8); + t.is(members[0].type, Permission.Owner); + t.is(members[0].status, WorkspaceMemberStatus.Accepted); + for (let i = 1; i < 8; i++) { + t.is(members[i].type, Permission.Read); + t.is(members[i].status, WorkspaceMemberStatus.Accepted); + } + members = await t.context.workspace.findMembers(workspace.id, { take: 100 }); + t.is(members.length, 11); + t.is(members[0].type, Permission.Owner); + t.is(members[0].status, WorkspaceMemberStatus.Accepted); + for (let i = 1; i < 11; i++) { + t.is(members[i].type, Permission.Read); + t.is(members[i].status, WorkspaceMemberStatus.Accepted); + } + // skip should work + members = await t.context.workspace.findMembers(workspace.id, { skip: 5 }); + t.is(members.length, 6); + t.is(members[0].type, Permission.Read); +}); + +test('should get the workspace member invitation', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const workspace = await t.context.workspace.create(user.id); + const otherUser1 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + const invitation = await t.context.workspace.grantMember( + workspace.id, + otherUser1.id + ); + t.is(invitation.status, WorkspaceMemberStatus.Pending); + const invitation1 = await t.context.workspace.getMemberInvitation( + invitation.id + ); + t.deepEqual(invitation, invitation1); +}); diff --git a/packages/backend/server/src/models/common/index.ts b/packages/backend/server/src/models/common/index.ts index 42a9f556aa..2314abfdf5 100644 --- a/packages/backend/server/src/models/common/index.ts +++ b/packages/backend/server/src/models/common/index.ts @@ -1 +1,2 @@ export * from './feature'; +export * from './permission'; diff --git a/packages/backend/server/src/models/common/permission.ts b/packages/backend/server/src/models/common/permission.ts new file mode 100644 index 0000000000..a86fcb6c2b --- /dev/null +++ b/packages/backend/server/src/models/common/permission.ts @@ -0,0 +1,6 @@ +export enum Permission { + Read = 0, + Write = 1, + Admin = 10, + Owner = 99, +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 8da29e9681..c16fcb0460 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -12,12 +12,14 @@ import { MODELS_SYMBOL } from './provider'; import { SessionModel } from './session'; import { UserModel } from './user'; import { VerificationTokenModel } from './verification-token'; +import { WorkspaceModel } from './workspace'; const MODELS = { user: UserModel, session: SessionModel, verificationToken: VerificationTokenModel, feature: FeatureModel, + workspace: WorkspaceModel, }; type ModelsType = { @@ -73,3 +75,4 @@ export * from './feature'; export * from './session'; export * from './user'; export * from './verification-token'; +export * from './workspace'; diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts new file mode 100644 index 0000000000..e157dd35d7 --- /dev/null +++ b/packages/backend/server/src/models/workspace.ts @@ -0,0 +1,495 @@ +import { Injectable } from '@nestjs/common'; +import { + type Workspace, + WorkspaceMemberStatus, + type WorkspaceUserPermission, +} from '@prisma/client'; +import { groupBy } from 'lodash-es'; + +import { EventEmitter } from '../base'; +import { BaseModel } from './base'; +import { Permission } from './common'; + +export { WorkspaceMemberStatus }; +export type { Workspace }; +export type UpdateWorkspaceInput = Pick< + Partial, + 'public' | 'enableAi' | 'enableUrlPreview' +>; + +export interface FindWorkspaceMembersOptions { + skip?: number; + /** + * Default to `8` + */ + take?: number; +} + +@Injectable() +export class WorkspaceModel extends BaseModel { + constructor(private readonly event: EventEmitter) { + super(); + } + + // #region workspace + + /** + * Create a new workspace for the user, default to private. + */ + async create(userId: string) { + const workspace = await this.db.workspace.create({ + data: { + public: false, + permissions: { + create: { + type: Permission.Owner, + userId: userId, + accepted: true, + status: WorkspaceMemberStatus.Accepted, + }, + }, + }, + }); + this.logger.log(`Created workspace ${workspace.id} for user ${userId}`); + return workspace; + } + + /** + * Update the workspace with the given data. + */ + async update(workspaceId: string, data: UpdateWorkspaceInput) { + await this.db.workspace.update({ + where: { + id: workspaceId, + }, + data, + }); + this.logger.log( + `Updated workspace ${workspaceId} with data ${JSON.stringify(data)}` + ); + } + + async get(workspaceId: string) { + return await this.db.workspace.findUnique({ + where: { + id: workspaceId, + }, + }); + } + + async delete(workspaceId: string) { + await this.db.workspace.deleteMany({ + where: { + id: workspaceId, + }, + }); + this.logger.log(`Deleted workspace ${workspaceId}`); + } + + /** + * Find the workspace ids that the user is a member of owner. + */ + async findOwnedIds(userId: string) { + const rows = await this.db.workspaceUserPermission.findMany({ + where: { + userId, + type: Permission.Owner, + OR: this.acceptedCondition, + }, + select: { + workspaceId: true, + }, + }); + return rows.map(row => row.workspaceId); + } + + /** + * Find the accessible workspaces for the user. + */ + async findAccessibleWorkspaces(userId: string) { + return await this.db.workspaceUserPermission.findMany({ + where: { + userId, + OR: this.acceptedCondition, + }, + include: { + workspace: true, + }, + }); + } + + // #endregion + + // #region workspace member and permission + + /** + * Grant the workspace member with the given permission and status. + */ + async grantMember( + workspaceId: string, + userId: string, + permission: Permission = Permission.Read, + status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending + ): Promise { + const data = await this.db.workspaceUserPermission.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + }); + + if (!data) { + // Create a new permission + // TODO(fengmk2): should we check the permission here? Like owner can't be pending? + const created = await this.db.workspaceUserPermission.create({ + data: { + workspaceId, + userId, + type: permission, + status, + }, + }); + await this.notifyMembersUpdated(workspaceId); + return created; + } + + // If the user is already accepted and the new permission is owner, we need to revoke old owner + if (data.status === WorkspaceMemberStatus.Accepted || data.accepted) { + return await this.db.$transaction(async tx => { + const updated = await tx.workspaceUserPermission.update({ + where: { + workspaceId_userId: { workspaceId, userId }, + }, + data: { type: permission }, + }); + // If the new permission is owner, we need to revoke old owner + if (permission === Permission.Owner) { + await tx.workspaceUserPermission.updateMany({ + where: { + workspaceId, + type: Permission.Owner, + userId: { not: userId }, + }, + data: { type: Permission.Admin }, + }); + this.logger.log( + `Change owner of workspace ${workspaceId} to ${userId}` + ); + } + return updated; + }); + } + + // If the user is not accepted, we can update the status directly + const allowedStatus = this.getAllowedStatusSource(data.status); + if (allowedStatus.includes(status)) { + const updated = await this.db.workspaceUserPermission.update({ + where: { workspaceId_userId: { workspaceId, userId } }, + data: { + status, + // TODO(fengmk2): should we update the permission here? + // type: permission, + }, + }); + return updated; + } + + // nothing to do + return data; + } + + /** + * Get the workspace member invitation. + */ + async getMemberInvitation(invitationId: string) { + return await this.db.workspaceUserPermission.findUnique({ + where: { + id: invitationId, + }, + }); + } + + /** + * Accept the workspace member invitation. + * @param status: the status to update to, default to `Accepted`. Can be `Accepted` or `UnderReview`. + */ + async acceptMemberInvitation( + invitationId: string, + workspaceId: string, + status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted + ) { + const { count } = await this.db.workspaceUserPermission.updateMany({ + where: { + id: invitationId, + workspaceId: workspaceId, + // TODO(fengmk2): should we check the status here? + AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }], + }, + data: { accepted: true, status }, + }); + return count > 0; + } + + /** + * Get a workspace member in accepted status by workspace id and user id. + */ + async getMember(workspaceId: string, userId: string) { + return await this.db.workspaceUserPermission.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + OR: this.acceptedCondition, + }, + }); + } + + /** + * Get a workspace member in any status by workspace id and user id. + */ + async getMemberInAnyStatus(workspaceId: string, userId: string) { + return await this.db.workspaceUserPermission.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + }); + } + + /** + * Returns whether a given user is a member of a workspace and has the given or higher permission. + * Default to read permission. + */ + async isMember( + workspaceId: string, + userId: string, + permission: Permission = Permission.Read + ) { + const count = await this.db.workspaceUserPermission.count({ + where: { + workspaceId, + userId, + OR: this.acceptedCondition, + type: { + gte: permission, + }, + }, + }); + return count > 0; + } + + /** + * Get the workspace owner. + */ + async getOwner(workspaceId: string) { + return await this.db.workspaceUserPermission.findFirst({ + where: { + workspaceId, + type: Permission.Owner, + OR: this.acceptedCondition, + }, + include: { + user: true, + }, + }); + } + + /** + * Find the workspace admins. + */ + async findAdmins(workspaceId: string) { + return await this.db.workspaceUserPermission.findMany({ + where: { + workspaceId, + type: Permission.Admin, + OR: this.acceptedCondition, + }, + include: { + user: true, + }, + }); + } + + /** + * Find the workspace members. + */ + async findMembers( + workspaceId: string, + options: FindWorkspaceMembersOptions = {} + ) { + return await this.db.workspaceUserPermission.findMany({ + where: { + workspaceId, + }, + skip: options.skip, + take: options.take || 8, + orderBy: [{ createdAt: 'asc' }, { type: 'desc' }], + include: { + user: true, + }, + }); + } + + /** + * Delete a workspace member by workspace id and user id. + * Except the owner, the owner can't be deleted. + */ + async deleteMember(workspaceId: string, userId: string) { + const member = await this.getMemberInAnyStatus(workspaceId, userId); + + // We shouldn't revoke owner permission + // should auto deleted by workspace/user delete cascading + if (!member || member.type === Permission.Owner) { + return false; + } + + await this.db.workspaceUserPermission.deleteMany({ + where: { + workspaceId, + userId, + }, + }); + this.logger.log( + `Deleted workspace member ${userId} from workspace ${workspaceId}` + ); + + await this.notifyMembersUpdated(workspaceId); + + if ( + member.status === WorkspaceMemberStatus.UnderReview || + member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview + ) { + this.event.emit('workspace.members.requestDeclined', { + workspaceId, + userId, + }); + } + + return true; + } + + private async notifyMembersUpdated(workspaceId: string) { + const count = await this.getMemberTotalCount(workspaceId); + this.event.emit('workspace.members.updated', { + workspaceId, + count, + }); + } + + /** + * Get the workspace member total count, including pending and accepted. + */ + async getMemberTotalCount(workspaceId: string) { + return await this.db.workspaceUserPermission.count({ + where: { + workspaceId, + }, + }); + } + + /** + * Get the workspace member used count, only count the accepted member + */ + async getMemberUsedCount(workspaceId: string) { + return await this.db.workspaceUserPermission.count({ + where: { + workspaceId, + OR: this.acceptedCondition, + }, + }); + } + + /** + * Refresh the workspace member seat status. + */ + async refreshMemberSeatStatus(workspaceId: string, memberLimit: number) { + const usedCount = await this.getMemberUsedCount(workspaceId); + const availableCount = memberLimit - usedCount; + if (availableCount <= 0) { + return; + } + + return await this.db.$transaction(async tx => { + const members = await tx.workspaceUserPermission.findMany({ + select: { id: true, status: true }, + where: { + workspaceId, + status: { + in: [ + WorkspaceMemberStatus.NeedMoreSeat, + WorkspaceMemberStatus.NeedMoreSeatAndReview, + ], + }, + }, + // find the oldest members first + orderBy: { createdAt: 'asc' }, + }); + + const needChange = members.slice(0, availableCount); + const groups = groupBy(needChange, m => m.status); + + const toPendings = groups.NeedMoreSeat; + if (toPendings) { + // NeedMoreSeat => Pending + await tx.workspaceUserPermission.updateMany({ + where: { id: { in: toPendings.map(m => m.id) } }, + data: { status: WorkspaceMemberStatus.Pending }, + }); + } + + const toUnderReviews = groups.NeedMoreSeatAndReview; + if (toUnderReviews) { + // NeedMoreSeatAndReview => UnderReview + await tx.workspaceUserPermission.updateMany({ + where: { id: { in: toUnderReviews.map(m => m.id) } }, + data: { status: WorkspaceMemberStatus.UnderReview }, + }); + } + }); + } + + /** + * Accepted condition for workspace member. + */ + private get acceptedCondition() { + return [ + { + // keep compatibility with old data + accepted: true, + }, + { + status: WorkspaceMemberStatus.Accepted, + }, + ]; + } + + /** + * NeedMoreSeat => Pending + * + * NeedMoreSeatAndReview => UnderReview + * + * Pending | UnderReview => Accepted + */ + private getAllowedStatusSource( + to: WorkspaceMemberStatus + ): WorkspaceMemberStatus[] { + switch (to) { + case WorkspaceMemberStatus.NeedMoreSeat: + return [WorkspaceMemberStatus.Pending]; + case WorkspaceMemberStatus.NeedMoreSeatAndReview: + return [WorkspaceMemberStatus.UnderReview]; + case WorkspaceMemberStatus.Pending: + case WorkspaceMemberStatus.UnderReview: // need admin to review in team workspace + return [WorkspaceMemberStatus.Accepted]; + default: + return []; + } + } + + // #endregion +}