feat(server): delay subscription after invitation accepted or approved (#11992)

This commit is contained in:
liuyi
2025-04-30 14:27:47 +08:00
committed by GitHub
parent 9a721c65b5
commit 2e1bed061e
49 changed files with 1990 additions and 2694 deletions

View File

@@ -13,6 +13,7 @@ import {
AFFiNELogger,
CacheInterceptor,
CloudThrottlerGuard,
EventBus,
GlobalExceptionFilter,
JobQueue,
OneMB,
@@ -20,6 +21,7 @@ import {
import { SocketIoAdapter } from '../../base/websocket';
import { AuthGuard, AuthService } from '../../core/auth';
import { Mailer } from '../../core/mail';
import { Models } from '../../models';
import {
createFactory,
MockedUser,
@@ -43,6 +45,8 @@ export class TestingApp extends NestApplication {
create = createFactory(this.get(PrismaClient, { strict: false }));
mails = this.get(Mailer, { strict: false }) as MockMailer;
queue = this.get(JobQueue, { strict: false }) as MockJobQueue;
eventBus = this.get(EventBus, { strict: false });
models = this.get(Models, { strict: false });
get url() {
const server = this.getHttpServer();

View File

@@ -1,456 +0,0 @@
import {
acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailMutation,
leaveWorkspaceMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import { Models } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
e2e('should invite a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const result = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
t.truthy(result, 'failed to invite user');
// add invitation notification job
const invitationNotification = app.queue.last('notification.sendInvitation');
t.is(invitationNotification.payload.inviterId, owner.id);
t.is(invitationNotification.payload.inviteId, result.invite);
// invitation status is pending
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: result.invite,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
// u2 accept invite
await app.switchUser(u2);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: result.invite,
},
});
// invitation status is accepted
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: result.invite,
},
});
t.is(getInviteInfo2.status, WorkspaceMemberStatus.Accepted);
});
e2e('should leave a workspace', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
await app.switchUser(u2.id);
const { leaveWorkspace } = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
},
});
t.true(leaveWorkspace, 'failed to leave workspace');
});
e2e('should revoke a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
const { revoke } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: u2.id,
},
});
t.true(revoke, 'failed to revoke user');
});
e2e('should revoke a user on under review', async t => {
const user = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
});
const { revoke } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(revoke, 'failed to revoke user');
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual(
requestDeclinedNotification.payload,
{
userId: user.id,
workspaceId: workspace.id,
reviewerId: owner.id,
},
'should send review declined notification'
);
});
e2e('should create user if not exist', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const email = faker.internet.email();
await app.gql({
query: inviteByEmailMutation,
variables: {
email,
workspaceId: workspace.id,
},
});
const u2 = await app.get(Models).user.getUserByEmail(email);
t.truthy(u2, 'failed to create user');
});
e2e('should invite a user by link', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const invite1 = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
await app.switchUser(u2);
const accept = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: invite1.invite,
workspaceId: workspace.id,
},
});
t.true(accept.acceptInviteById, 'failed to accept invite');
await app.switchUser(owner);
const invite2 = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
t.is(
invite2.invite,
invite1.invite,
'repeat the invitation must return same id'
);
const member = await app
.get(Models)
.workspaceUser.getActive(workspace.id, u2.id);
t.truthy(member, 'failed to invite user');
t.is(member!.id, invite1.invite, 'failed to check invite id');
});
e2e('should send invitation notification and leave email', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const invite = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
const invitationNotification = app.queue.last('notification.sendInvitation');
t.is(invitationNotification.payload.inviterId, owner.id);
t.is(invitationNotification.payload.inviteId, invite.invite);
await app.switchUser(u2);
const accept = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: invite.invite,
workspaceId: workspace.id,
},
});
t.true(accept.acceptInviteById, 'failed to accept invite');
const acceptedNotification = app.queue.last(
'notification.sendInvitationAccepted'
);
t.is(acceptedNotification.payload.inviterId, owner.id);
t.is(acceptedNotification.payload.inviteId, invite.invite);
const leave = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
sendLeaveMail: true,
},
});
t.true(leave.leaveWorkspace, 'failed to leave workspace');
const leaveMail = app.mails.last('MemberLeave');
t.is(leaveMail.to, owner.email);
t.is(leaveMail.props.user.$$userId, u2.id);
});
e2e('should support pagination for member', async t => {
const u1 = await app.signup();
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u1.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
});
e2e('should limit member count correctly', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
});
})
);
await app.switchUser(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
});
e2e('should get invite link info with status', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// owner/member see accept status
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo, 'failed to get invite info');
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
// non-member see null status
await app.signup();
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo2, 'failed to get invite info');
t.is(getInviteInfo2.status, null);
// pending-member see under review status
await app.signup();
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
const { getInviteInfo: getInviteInfo3 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo3, 'failed to get invite info');
t.is(getInviteInfo3.status, WorkspaceMemberStatus.UnderReview);
});
e2e(
'should accept invitation by link directly if status is pending',
async t => {
const owner = await app.create(Mockers.User);
const member = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailMutation,
variables: {
email: member.email,
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.invite,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);

View File

@@ -1,9 +1,459 @@
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
import {
acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailsMutation,
leaveWorkspaceMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import { Models } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createTeamWorkspace(quantity = 10) {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity,
});
return {
owner,
workspace,
};
}
e2e('should invite a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const result = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [u2.email],
workspaceId: workspace.id,
},
});
t.truthy(result, 'failed to invite user');
// add invitation notification job
const invitationNotification = await app.queue.waitFor(
'notification.sendInvitation'
);
t.is(invitationNotification.payload.inviterId, owner.id);
t.is(
invitationNotification.payload.inviteId,
result.inviteMembers[0].inviteId!
);
// invitation status is pending
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitationNotification.payload.inviteId,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
// u2 accept invite
await app.switchUser(u2);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invitationNotification.payload.inviteId,
},
});
// invitation status is accepted
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitationNotification.payload.inviteId,
},
});
t.is(getInviteInfo2.status, WorkspaceMemberStatus.Accepted);
});
e2e('should leave a workspace', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
await app.switchUser(u2.id);
const { leaveWorkspace } = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
},
});
t.true(leaveWorkspace, 'failed to leave workspace');
const leaveMail = await app.mails.waitFor('MemberLeave');
t.is(leaveMail.to, owner.email);
t.is(leaveMail.props.user.$$userId, u2.id);
});
e2e('should revoke a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: u2.id,
},
});
t.true(revokeMember, 'failed to revoke user');
});
e2e('should revoke a user on under review', async t => {
const user = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
});
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(revokeMember, 'failed to revoke user');
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual(
requestDeclinedNotification.payload,
{
userId: user.id,
workspaceId: workspace.id,
reviewerId: owner.id,
},
'should send review declined notification'
);
});
e2e('should create user if not exist', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const email = faker.internet.email();
await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [email],
workspaceId: workspace.id,
},
});
const u2 = await app.get(Models).user.getUserByEmail(email);
t.truthy(u2, 'failed to create user');
});
e2e('should support pagination for member', async t => {
const u1 = await app.signup();
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u1.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
});
e2e('should limit member count correctly', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
});
})
);
await app.switchUser(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
});
e2e('should get invite link info with status', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// owner/member see accept status
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo, 'failed to get invite info');
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
// non-member see null status
await app.signup();
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo2, 'failed to get invite info');
t.is(getInviteInfo2.status, null);
// pending-member see under review status
await app.signup();
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
const { getInviteInfo: getInviteInfo3 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo3, 'failed to get invite info');
t.is(getInviteInfo3.status, WorkspaceMemberStatus.UnderReview);
});
e2e(
'should accept invitation by link directly if status is pending',
async t => {
const owner = await app.create(Mockers.User);
const member = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [member.email],
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.inviteMembers[0].inviteId!,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);
e2e(
'should invite by link and send review request notification below quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace(1);
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
e2e(
'should search members by name and email support case insensitive',
async t => {

View File

@@ -1,146 +1,167 @@
import {
acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
inviteByEmailMutation,
WorkspaceInviteLinkExpireTime,
inviteByEmailsMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { WorkspaceRole } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createTeamWorkspace(quantity = 10) {
const createTeamWorkspace = async (memberLimit = 3) => {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
owner: {
id: owner.id,
},
});
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity,
quantity: memberLimit,
});
const writer = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: writer.id,
workspaceId: workspace.id,
});
const admin = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: admin.id,
workspaceId: workspace.id,
type: WorkspaceRole.Admin,
});
const external = await app.create(Mockers.User);
return {
owner,
workspace,
owner,
admin,
writer,
external,
};
}
};
e2e(
'should invite by link and send review request notification below quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace();
const getInvitationInfo = async (inviteId: string) => {
const result = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
return result.getInviteInfo;
};
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
e2e('should set new invited users to AllocatingSeat', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
const u1 = await app.createUser();
e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace(1);
const result = await app.gql({
query: inviteByEmailsMutation,
variables: {
workspaceId: workspace.id,
emails: [u1.email],
},
});
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
t.not(result.inviteMembers[0].inviteId, null);
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
const invitationInfo = await getInvitationInfo(
result.inviteMembers[0].inviteId!
);
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
});
e2e(
'should accept invitation by link directly if status is pending on team workspace',
async t => {
const { owner, workspace } = await createTeamWorkspace(2);
const member = await app.create(Mockers.User);
e2e('should allocate seats', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailMutation,
variables: {
email: member.email,
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const u1 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
const u2 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
});
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 5,
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.invite,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 10,
offset: 0,
});
t.is(
members.find(m => m.user.id === u1.id)?.status,
WorkspaceMemberStatus.Pending
);
t.is(
members.find(m => m.user.id === u2.id)?.status,
WorkspaceMemberStatus.Accepted
);
t.is(app.queue.count('notification.sendInvitation'), 1);
});
e2e('should set all rests to NeedMoreSeat', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
const u1 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u2 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u3 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u3.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 4,
});
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 10,
offset: 0,
});
t.is(
members.find(m => m.user.id === u2.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
t.is(
members.find(m => m.user.id === u3.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
});

View File

@@ -1,3 +1,4 @@
import { interval, map, take, takeUntil } from 'rxjs';
import Sinon from 'sinon';
import { Mailer } from '../../core/mail';
@@ -22,6 +23,35 @@ export class MockMailer {
return last as any;
}
waitFor<Mail extends MailName>(
name: Mail,
timeout: number = 1000
): Promise<Extract<Jobs['notification.sendMail'], { name: Mail }>> {
const { promise, reject, resolve } = Promise.withResolvers<any>();
interval(10)
.pipe(
take(Math.floor(timeout / 10)),
takeUntil(promise),
map(() => {
const last = this.send.lastCall.args[0];
return last.name === name ? last : undefined;
})
)
.subscribe({
next: val => {
if (val) {
resolve(val);
}
},
complete: () => {
reject(new Error('Timeout wait for job coming'));
},
});
return promise;
}
count(name: MailName) {
return this.send.getCalls().filter(call => call.args[0].name === name)
.length;

View File

@@ -1,3 +1,4 @@
import { interval, map, take, takeUntil } from 'rxjs';
import Sinon from 'sinon';
import { JobQueue } from '../../base';
@@ -20,6 +21,36 @@ export class MockJobQueue {
return { name, payload };
}
waitFor<Job extends JobName>(name: Job, timeout: number = 1000) {
const { promise, reject, resolve } = Promise.withResolvers<{
name: Job;
payload: Jobs[Job];
}>();
interval(10)
.pipe(
take(Math.floor(timeout / 10)),
takeUntil(promise),
map(() => {
const addJobName = this.add.lastCall?.args[0];
const payload = this.add.lastCall?.args[1];
return addJobName === name ? payload : undefined;
})
)
.subscribe({
next: val => {
if (val) {
resolve({ name, payload: val });
}
},
complete: () => {
reject(new Error('Timeout wait for job coming'));
},
});
return promise;
}
count(name: JobName) {
return this.add.getCalls().filter(call => call.args[0] === name).length;
}

View File

@@ -1,10 +1,13 @@
import { randomBytes } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { EventBus, NewOwnerIsNotActiveMember } from '../../base';
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import { createTestingModule, TestingModule } from '../utils';
import { createModule, TestingModule } from '../create-module';
import { Mockers } from '../mocks';
let db: PrismaClient;
let models: Models;
@@ -12,7 +15,7 @@ let module: TestingModule;
let event: Sinon.SinonStubbedInstance<EventBus>;
test.before(async () => {
module = await createTestingModule({
module = await createModule({
tapModule: m => {
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
},
@@ -23,7 +26,6 @@ test.before(async () => {
});
test.beforeEach(async () => {
await module.initTestingDB();
Sinon.reset();
});
@@ -31,15 +33,9 @@ test.after(async () => {
await module.close();
});
async function create() {
return db.workspace.create({
data: { public: false },
});
}
test('should set workspace owner', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.setOwner(workspace.id, user.id);
const owner = await models.workspaceUser.getOwner(workspace.id);
@@ -47,9 +43,15 @@ test('should set workspace owner', async t => {
});
test('should transfer workespace owner', async t => {
const user = await models.user.create({ email: 'u1@affine.pro' });
const user2 = await models.user.create({ email: 'u2@affine.pro' });
const workspace = await models.workspace.create(user.id);
const [user, user2] = await module.create(Mockers.User, 2);
const workspace = await module.create(Mockers.Workspace, {
owner: { id: user.id },
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
await models.workspaceUser.setOwner(workspace.id, user2.id);
@@ -65,9 +67,30 @@ test('should transfer workespace owner', async t => {
t.is(owner2.id, user2.id);
});
test('should throw if transfer owner to non-active member', async t => {
const [user, user2] = await module.create(Mockers.User, 2);
const workspace = await module.create(Mockers.Workspace, {
owner: { id: user.id },
});
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
instanceOf: NewOwnerIsNotActiveMember,
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
instanceOf: NewOwnerIsNotActiveMember,
});
});
test('should get user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin);
const role = await models.workspaceUser.get(workspace.id, user.id);
@@ -76,14 +99,11 @@ test('should get user role', async t => {
});
test('should get active workspace role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.getActive(workspace.id, user.id);
@@ -91,9 +111,8 @@ test('should get active workspace role', async t => {
});
test('should not get inactive workspace role', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
@@ -111,14 +130,11 @@ test('should not get inactive workspace role', async t => {
});
test('should update user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, user.id);
t.is(role!.type, WorkspaceRole.Admin);
@@ -143,8 +159,8 @@ test('should update user role', async t => {
});
test('should return workspace role if status is Accepted', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
await models.workspaceUser.setStatus(
@@ -158,8 +174,8 @@ test('should return workspace role if status is Accepted', async t => {
});
test('should delete workspace user role', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
await models.workspaceUser.setStatus(
@@ -178,9 +194,9 @@ test('should delete workspace user role', async t => {
});
test('should get user workspace roles with filter', async t => {
const ws1 = await create();
const ws2 = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const ws1 = await module.create(Mockers.Workspace);
const ws2 = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceUserRole.createMany({
data: [
@@ -210,19 +226,19 @@ test('should get user workspace roles with filter', async t => {
});
test('should paginate workspace user roles', async t => {
const workspace = await create();
await db.user.createMany({
const workspace = await module.create(Mockers.Workspace);
const users = await db.user.createManyAndReturn({
data: Array.from({ length: 200 }, (_, i) => ({
id: String(i),
name: `u${i}`,
email: `${i}@affine.pro`,
email: `${randomBytes(10).toString('hex')}@affine.pro`,
})),
});
await db.workspaceUserRole.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
data: users.map((user, i) => ({
workspaceId: workspace.id,
userId: String(i),
userId: user.id,
type: WorkspaceRole.Collaborator,
status: Object.values(WorkspaceMemberStatus)[
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
@@ -253,3 +269,38 @@ test('should paginate workspace user roles', async t => {
.map(r => r.id)
);
});
test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async t => {
const users = await module.create(Mockers.User, 4);
const workspace = await module.create(Mockers.Workspace);
for (const user of users) {
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
}
await models.workspaceUser.allocateSeats(workspace.id, 1);
let count = await db.workspaceUserRole.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
},
});
t.is(count, 1);
await models.workspaceUser.allocateSeats(workspace.id, 3);
count = await db.workspaceUserRole.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
},
});
t.is(count, 3);
});

View File

@@ -1,945 +0,0 @@
import { randomUUID } from 'node:crypto';
import { User, WorkspaceMemberStatus } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import { nanoid } from 'nanoid';
import Sinon from 'sinon';
import { AppModule } from '../app.module';
import { EventBus } from '../base';
import { AuthService } from '../core/auth';
import { DocReader } from '../core/doc';
import { DocRole, WorkspaceRole } from '../core/permission';
import { WorkspaceType } from '../core/workspaces';
import { Models } from '../models';
import {
acceptInviteById,
approveMember,
createInviteLink,
createTestingApp,
createWorkspace,
docGrantedUsersList,
getInviteInfo,
getInviteLink,
getWorkspace,
grantDocUserRoles,
grantMember,
inviteUser,
inviteUsers,
leaveWorkspace,
revokeDocUserRoles,
revokeInviteLink,
revokeMember,
revokeUser,
sleep,
TestingApp,
updateDocDefaultRole,
} from './utils';
const test = ava as TestFn<{
app: TestingApp;
auth: AuthService;
event: Sinon.SinonStubbedInstance<EventBus>;
models: Models;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [AppModule],
tapModule: module => {
module
.overrideProvider(EventBus)
.useValue(Sinon.createStubInstance(EventBus));
module.overrideProvider(DocReader).useValue({
getWorkspaceContent() {
return {
name: 'test',
avatarKey: null,
};
},
});
},
});
t.context.app = app;
t.context.auth = app.get(AuthService);
t.context.event = app.get(EventBus);
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();
});
const init = async (
app: TestingApp,
memberLimit = 10,
prefix = randomUUID()
) => {
const owner = await app.signupV1(`${prefix}owner@affine.pro`);
const models = app.get(Models);
{
await models.userFeature.add(owner.id, 'pro_plan_v1', 'test');
}
const workspace = await createWorkspace(app);
const teamWorkspace = await createWorkspace(app);
{
await models.workspaceFeature.add(
teamWorkspace.id,
'team_plan_v1',
'test',
{
memberLimit,
}
);
}
const invite = async (
email: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator,
shouldSendEmail: boolean = false
) => {
const member = await app.signupV1(email);
{
// normal workspace
await app.switchUser(owner);
const inviteId = await inviteUser(
app,
workspace.id,
member.email,
shouldSendEmail
);
await app.switchUser(member);
await acceptInviteById(app, workspace.id, inviteId, shouldSendEmail);
}
{
// team workspace
await app.switchUser(owner);
const inviteId = await inviteUser(
app,
teamWorkspace.id,
member.email,
shouldSendEmail
);
await app.switchUser(member);
await acceptInviteById(app, teamWorkspace.id, inviteId, shouldSendEmail);
await app.switchUser(owner);
await grantMember(app, teamWorkspace.id, member.id, permission);
}
return member;
};
const inviteBatch = async (
emails: string[],
shouldSendEmail: boolean = false
) => {
const members = [];
for (const email of emails) {
const member = await app.signupV1(email);
members.push(member);
}
await app.switchUser(owner);
const invites = await inviteUsers(
app,
teamWorkspace.id,
emails,
shouldSendEmail
);
return [members, invites] as const;
};
const getCreateInviteLinkFetcher = async (ws: WorkspaceType) => {
await app.switchUser(owner);
const { link } = await createInviteLink(app, ws.id, 'OneDay');
const inviteId = link.split('/').pop()!;
return [
inviteId,
async (email: string, shouldSendEmail: boolean = false) => {
const member = await app.signupV1(email);
await acceptInviteById(app, ws.id, inviteId, shouldSendEmail);
return member;
},
async (userId: string) => {
await app.switchUser(userId);
await acceptInviteById(app, ws.id, inviteId, false);
},
] as const;
};
const admin = await invite(`${prefix}admin@affine.pro`, WorkspaceRole.Admin);
const write = await invite(`${prefix}write@affine.pro`);
const read = await invite(
`${prefix}read@affine.pro`,
WorkspaceRole.Collaborator
);
const external = await invite(
`${prefix}external@affine.pro`,
WorkspaceRole.External
);
await app.switchUser(owner.id);
return {
invite,
inviteBatch,
createInviteLink: getCreateInviteLinkFetcher,
owner,
workspace,
teamWorkspace,
admin,
write,
read,
external,
};
};
test('should be able to invite multiple users', async t => {
const { app } = t.context;
const { teamWorkspace: ws, owner, admin, write, read } = await init(app, 5);
{
// no permission
await app.switchUser(read);
await t.throwsAsync(
inviteUsers(app, ws.id, ['test@affine.pro']),
{ instanceOf: Error },
'should throw error if not manager'
);
await app.switchUser(write);
await t.throwsAsync(
inviteUsers(app, ws.id, ['test@affine.pro']),
{ instanceOf: Error },
'should throw error if not manager'
);
}
{
// manager
const m1 = await app.signupV1('m1@affine.pro');
const m2 = await app.signupV1('m2@affine.pro');
await app.switchUser(owner);
t.is(
(await inviteUsers(app, ws.id, [m1.email])).length,
1,
'should be able to invite user'
);
await app.switchUser(admin);
t.is(
(await inviteUsers(app, ws.id, [m2.email])).length,
1,
'should be able to invite user'
);
t.is(
(await inviteUsers(app, ws.id, [m2.email])).length,
0,
'should not be able to invite user if already in workspace'
);
await t.throwsAsync(
inviteUsers(
app,
ws.id,
Array.from({ length: 513 }, (_, i) => `m${i}@affine.pro`)
),
{ message: 'Too many requests.' },
'should throw error if exceed maximum number of invitations per request'
);
}
});
test('should be able to check seat limit', async t => {
const { app, models } = t.context;
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5);
{
// invite
await t.throwsAsync(
invite('member3@affine.pro', WorkspaceRole.Collaborator),
{ message: 'You have exceeded your workspace member quota.' },
'should throw error if exceed member limit'
);
await models.workspaceFeature.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 6,
});
await t.notThrowsAsync(
invite('member4@affine.pro', WorkspaceRole.Collaborator),
'should not throw error if not exceed member limit'
);
}
{
const members1 = inviteBatch(['member5@affine.pro']);
// invite batch
await t.notThrowsAsync(
members1,
'should not throw error in batch invite event reach limit'
);
t.is(
(await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
?.status,
WorkspaceMemberStatus.NeedMoreSeat,
'should be able to check member status'
);
// refresh seat, fifo
await sleep(1000);
const [[members2]] = await inviteBatch(['member6@affine.pro']);
await models.workspaceUser.refresh(ws.id, 7);
t.is(
(await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
?.status,
WorkspaceMemberStatus.Pending,
'should become accepted after refresh'
);
t.is(
(await models.workspaceUser.get(ws.id, members2.id))?.status,
WorkspaceMemberStatus.NeedMoreSeat,
'should not change status'
);
}
});
test('should be able to grant team member permission', async t => {
const { app, models } = t.context;
const { owner, teamWorkspace: ws, write, read } = await init(app);
await app.switchUser(read);
await t.throwsAsync(
grantMember(app, ws.id, write.id, WorkspaceRole.Collaborator),
{ instanceOf: Error },
'should throw error if not owner'
);
await app.switchUser(write);
await t.throwsAsync(
grantMember(app, ws.id, read.id, WorkspaceRole.Collaborator),
{ instanceOf: Error },
'should throw error if not owner'
);
{
// owner should be able to grant permission
await app.switchUser(owner);
t.true(
(await models.workspaceUser.get(ws.id, read.id))?.type ===
WorkspaceRole.Collaborator,
'should be able to check permission'
);
t.truthy(
await grantMember(app, ws.id, read.id, WorkspaceRole.Admin),
'should be able to grant permission'
);
t.true(
(await models.workspaceUser.get(ws.id, read.id))?.type ===
WorkspaceRole.Admin,
'should be able to check permission'
);
}
});
test('should be able to leave workspace', async t => {
const { app } = t.context;
const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
await app.switchUser(owner);
await t.throwsAsync(leaveWorkspace(app, ws.id), {
message: 'Owner can not leave the workspace.',
});
await app.switchUser(admin);
t.true(
await leaveWorkspace(app, ws.id),
'admin should be able to leave workspace'
);
await app.switchUser(write);
t.true(
await leaveWorkspace(app, ws.id),
'write should be able to leave workspace'
);
await app.switchUser(read);
t.true(
await leaveWorkspace(app, ws.id),
'read should be able to leave workspace'
);
});
test('should be able to revoke team member', async t => {
const { app } = t.context;
const { teamWorkspace: ws, owner, admin, write, read } = await init(app);
{
// no permission
await app.switchUser(read);
await t.throwsAsync(
revokeUser(app, ws.id, read.id),
{ instanceOf: Error },
'should throw error if not admin'
);
await t.throwsAsync(
revokeUser(app, ws.id, write.id),
{ instanceOf: Error },
'should throw error if not admin'
);
}
{
// manager
await app.switchUser(admin);
t.true(
await revokeUser(app, ws.id, read.id),
'admin should be able to revoke member'
);
await t.throwsAsync(
revokeUser(app, ws.id, admin.id),
{ instanceOf: Error },
'should not be able to revoke themselves'
);
await app.switchUser(owner);
t.true(
await revokeUser(app, ws.id, write.id),
'owner should be able to revoke member'
);
await t.throwsAsync(revokeUser(app, ws.id, owner.id), {
message: 'You can not revoke your own permission.',
});
await revokeUser(app, ws.id, admin.id);
await app.switchUser(admin);
await t.throwsAsync(
revokeUser(app, ws.id, read.id),
{ instanceOf: Error },
'should not be able to revoke member not in workspace after revoked'
);
}
});
test('should be able to manage invite link', async t => {
const { app } = t.context;
const {
workspace: ws,
teamWorkspace: tws,
owner,
admin,
write,
read,
} = await init(app);
for (const [workspace, managers] of [
[ws, [owner]],
[tws, [owner, admin]],
] as const) {
for (const manager of managers) {
await app.switchUser(manager.id);
const { link } = await createInviteLink(app, workspace.id, 'OneDay');
const { link: currLink } = await getInviteLink(app, workspace.id);
t.is(link, currLink, 'should be able to get invite link');
t.true(
await revokeInviteLink(app, workspace.id),
'should be able to revoke invite link'
);
}
for (const collaborator of [write, read]) {
await app.switchUser(collaborator.id);
await t.throwsAsync(
createInviteLink(app, workspace.id, 'OneDay'),
{ instanceOf: Error },
'should throw error if not manager'
);
await t.throwsAsync(
getInviteLink(app, workspace.id),
{ instanceOf: Error },
'should throw error if not manager'
);
await t.throwsAsync(
revokeInviteLink(app, workspace.id),
{ instanceOf: Error },
'should throw error if not manager'
);
}
}
});
test('should be able to approve team member', async t => {
const { app } = t.context;
const { teamWorkspace: tws, owner, admin, write, read } = await init(app, 6);
{
await app.switchUser(owner);
const { link } = await createInviteLink(app, tws.id, 'OneDay');
const inviteId = link.split('/').pop()!;
const member = await app.signupV1('newmember@affine.pro');
t.true(
await acceptInviteById(app, tws.id, inviteId, false),
'should be able to accept invite'
);
await app.switchUser(owner);
const { members } = await getWorkspace(app, tws.id);
const memberInvite = members.find(m => m.id === member.id)!;
t.is(memberInvite.status, 'UnderReview', 'should be under review');
t.true(await approveMember(app, tws.id, member.id));
const requestApprovedNotification = app.queue.last(
'notification.sendInvitationReviewApproved'
);
t.truthy(requestApprovedNotification);
t.deepEqual(
requestApprovedNotification.payload,
{
inviteId: memberInvite.inviteId,
reviewerId: owner.id,
},
'should send review approved notification'
);
}
{
await app.switchUser(admin);
await t.throwsAsync(
approveMember(app, tws.id, 'not_exists_id'),
{ instanceOf: Error },
'should throw error if member not exists'
);
await app.switchUser(write);
await t.throwsAsync(
approveMember(app, tws.id, 'not_exists_id'),
{ instanceOf: Error },
'should throw error if not manager'
);
await app.switchUser(read);
await t.throwsAsync(
approveMember(app, tws.id, 'not_exists_id'),
{ instanceOf: Error },
'should throw error if not manager'
);
}
});
test('should be able to invite by link', async t => {
const { app, models } = t.context;
const {
createInviteLink,
owner,
workspace: ws,
teamWorkspace: tws,
} = await init(app, 5);
const [inviteId, invite] = await createInviteLink(ws);
const [teamInviteId, teamInvite, acceptTeamInvite] =
await createInviteLink(tws);
const member = await app.signup();
{
// check invite link
await app.switchUser(member);
const info = await getInviteInfo(app, inviteId);
t.is(info.workspace.id, ws.id, 'should be able to get invite info');
t.falsy(info.status);
// check team invite link
const teamInfo = await getInviteInfo(app, teamInviteId);
t.is(teamInfo.workspace.id, tws.id, 'should be able to get invite info');
t.falsy(info.status);
}
{
// invite link
for (const [i] of Array.from({ length: 5 }).entries()) {
const user = await invite(`test${i}@affine.pro`);
const role = await models.workspaceUser.get(ws.id, user.id);
t.truthy(role);
const status = role!.status;
t.is(
status,
WorkspaceMemberStatus.UnderReview,
'should be able to check status'
);
const info = await getInviteInfo(app, role!.id);
t.is(info.status, WorkspaceMemberStatus.UnderReview);
}
await t.throwsAsync(
invite('exceed@affine.pro'),
{ message: 'You have exceeded your workspace member quota.' },
'should throw error if exceed member limit'
);
}
{
// team invite link
const members: User[] = [];
await t.notThrowsAsync(async () => {
members.push(await teamInvite('member3@affine.pro'));
members.push(await teamInvite('member4@affine.pro'));
}, 'should not throw error even exceed member limit');
const [m3, m4] = members;
t.is(
(await models.workspaceUser.get(tws.id, m3.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status'
);
t.is(
(await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status'
);
await models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 6,
});
await models.workspaceUser.refresh(tws.id, 6);
t.is(
(await models.workspaceUser.get(tws.id, m3.id))?.status,
WorkspaceMemberStatus.UnderReview,
'should not change status'
);
t.is(
(await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status'
);
await models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 7,
});
await models.workspaceUser.refresh(tws.id, 7);
t.is(
(await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.UnderReview,
'should not change status'
);
{
await t.throwsAsync(acceptTeamInvite(owner.id), {
message: `You have already joined in Space ${tws.id}.`,
});
}
}
});
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.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 and send notifications', async t => {
const { app, event } = t.context;
{
const { teamWorkspace: tws, inviteBatch } = await init(app, 5);
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
const [membersUpdated] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(membersUpdated, [
'workspace.members.updated',
{
workspaceId: tws.id,
count: 7,
},
]);
}
{
const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
const [, invite] = await createInviteLink(tws);
const user = await invite('m3@affine.pro');
await app.switchUser(owner);
const { members } = await getWorkspace(app, tws.id);
const memberInvite = members.find(m => m.id === user.id)!;
const requestRequestNotification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.truthy(requestRequestNotification);
// find admin
const admins = await t.context.models.workspaceUser.getAdmins(tws.id);
t.deepEqual(
requestRequestNotification.payload,
{
inviteId: memberInvite.inviteId,
reviewerId: admins[0].id,
},
'should send review request notification'
);
await app.switchUser(owner);
await revokeUser(app, tws.id, user.id);
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual(
requestDeclinedNotification.payload,
{
userId: user.id,
workspaceId: tws.id,
reviewerId: owner.id,
},
'should send review declined notification'
);
}
{
const { teamWorkspace: tws, owner, read } = await init(app);
await grantMember(app, tws.id, read.id, WorkspaceRole.Admin);
t.deepEqual(
event.emit.lastCall.args,
[
'workspace.members.roleChanged',
{
userId: read.id,
workspaceId: tws.id,
role: WorkspaceRole.Admin,
},
],
'should emit role changed event'
);
await grantMember(app, tws.id, read.id, WorkspaceRole.Owner);
const [ownershipTransferred] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
ownershipTransferred,
[
'workspace.owner.changed',
{ from: owner.id, to: read.id, workspaceId: tws.id },
],
'should emit owner transferred event'
);
await app.switchUser(read);
await revokeMember(app, tws.id, owner.id);
const [memberRemoved, memberUpdated] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
memberRemoved,
[
'workspace.members.removed',
{
userId: owner.id,
workspaceId: tws.id,
},
],
'should emit owner transferred event'
);
t.deepEqual(
memberUpdated,
[
'workspace.members.updated',
{
count: 4,
workspaceId: tws.id,
},
],
'should emit role changed event'
);
}
});
test('should be able to grant and revoke users role in page', async t => {
const { app } = t.context;
const {
teamWorkspace: ws,
admin,
write,
read,
external,
} = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await grantDocUserRoles(
app,
ws.id,
docId,
[read.id, write.id],
DocRole.Manager
);
t.deepEqual(res, {
grantDocUserRoles: true,
});
// should not downgrade the role if role exists
{
await grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Reader);
// read still be the Manager of this doc
await app.switchUser(read);
const res = await grantDocUserRoles(
app,
ws.id,
docId,
[external.id],
DocRole.Editor
);
t.deepEqual(res, {
grantDocUserRoles: true,
});
await app.switchUser(admin);
const docUsersList = await docGrantedUsersList(app, ws.id, docId);
t.is(docUsersList.workspace.doc.grantedUsersList.totalCount, 3);
const externalRole = docUsersList.workspace.doc.grantedUsersList.edges.find(
(edge: any) => edge.node.user.id === external.id
)?.node.role;
t.is(externalRole, DocRole[DocRole.Editor]);
}
});
test('should be able to change the default role in page', async t => {
const { app } = t.context;
const { teamWorkspace: ws, admin } = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await updateDocDefaultRole(app, ws.id, docId, DocRole.Reader);
t.deepEqual(res, {
updateDocDefaultRole: true,
});
});
test('default page role should be able to override the workspace role', async t => {
const { app } = t.context;
const {
teamWorkspace: workspace,
admin,
read,
external,
} = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await updateDocDefaultRole(
app,
workspace.id,
docId,
DocRole.Manager
);
t.deepEqual(res, {
updateDocDefaultRole: true,
});
// reader can manage the page if the page default role is Manager
{
await app.switchUser(read);
const readerRes = await updateDocDefaultRole(
app,
workspace.id,
docId,
DocRole.Manager
);
t.deepEqual(readerRes, {
updateDocDefaultRole: true,
});
}
// external can't manage the page even if the page default role is Manager
{
await app.switchUser(external);
await t.throwsAsync(
updateDocDefaultRole(app, workspace.id, docId, DocRole.Manager),
{
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
}
);
}
});
test('should be able to grant and revoke doc user role', async t => {
const { app } = t.context;
const { teamWorkspace: ws, admin, read, external } = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await grantDocUserRoles(
app,
ws.id,
docId,
[external.id],
DocRole.Manager
);
t.deepEqual(res, {
grantDocUserRoles: true,
});
// external user can never be able to manage the page
{
await app.switchUser(external);
await t.throwsAsync(
grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Manager),
{
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
}
);
}
// revoke the role of the external user
{
await app.switchUser(admin);
const revokeRes = await revokeDocUserRoles(app, ws.id, docId, external.id);
t.deepEqual(revokeRes, {
revokeDocUserRoles: true,
});
// external user can't manage the page
await app.switchUser(external);
await t.throwsAsync(revokeDocUserRoles(app, ws.id, docId, read.id), {
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
});
}
});
test('update page default role should throw error if the space does not exist', async t => {
const { app } = t.context;
const { admin } = await init(app, 5);
const docId = nanoid();
const nonExistWorkspaceId = 'non-exist-workspace';
await app.switchUser(admin);
await t.throwsAsync(
updateDocDefaultRole(app, nonExistWorkspaceId, docId, DocRole.Manager),
{
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
}
);
});

View File

@@ -1,33 +1,33 @@
import type { InvitationType } from '../../core/workspaces';
import type { TestingApp } from './testing-app';
export async function inviteUser(
app: TestingApp,
workspaceId: string,
email: string,
sendInviteMail = false
email: string
): Promise<string> {
const res = await app.gql(`
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", sendInviteMail: ${sendInviteMail})
inviteMembers(workspaceId: "${workspaceId}", emails: ["${email}"]) {
inviteId
}
}
`);
return res.invite;
return res.inviteMembers[0].inviteId;
}
export async function inviteUsers(
app: TestingApp,
workspaceId: string,
emails: string[],
sendInviteMail = false
emails: string[]
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
const res = await app.gql(
`
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
inviteBatch(
mutation inviteMembers($workspaceId: String!, $emails: [String!]!) {
inviteMembers(
workspaceId: $workspaceId
emails: $emails
sendInviteMail: $sendInviteMail
) {
email
inviteId
@@ -35,10 +35,10 @@ export async function inviteUsers(
}
}
`,
{ workspaceId, emails, sendInviteMail }
{ workspaceId, emails }
);
return res.inviteBatch;
return res.inviteMembers;
}
export async function getInviteLink(

View File

@@ -164,7 +164,7 @@ test('should be able to get permission granted workspace', async t => {
'totally-private',
t.context.u1.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
{ status: WorkspaceMemberStatus.Accepted }
);
storage.get.resolves(blob());

View File

@@ -551,6 +551,19 @@ export const USER_FRIENDLY_ERRORS = {
type: 'invalid_input',
message: 'Can not batch grant doc owner permissions.',
},
new_owner_is_not_active_member: {
type: 'bad_request',
message: 'Can not set a non-active member as owner.',
},
invalid_invitation: {
type: 'invalid_input',
message: 'Invalid invitation provided.',
},
no_more_seat: {
type: 'bad_request',
args: { spaceId: 'string' },
message: ({ spaceId }) => `No more seat available in the Space ${spaceId}.`,
},
// Subscription Errors
unsupported_subscription_plan: {

View File

@@ -507,6 +507,28 @@ export class CanNotBatchGrantDocOwnerPermissions extends UserFriendlyError {
super('invalid_input', 'can_not_batch_grant_doc_owner_permissions', message);
}
}
export class NewOwnerIsNotActiveMember extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'new_owner_is_not_active_member', message);
}
}
export class InvalidInvitation extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_invitation', message);
}
}
@ObjectType()
class NoMoreSeatDataType {
@Field() spaceId!: string
}
export class NoMoreSeat extends UserFriendlyError {
constructor(args: NoMoreSeatDataType, message?: string | ((args: NoMoreSeatDataType) => string)) {
super('bad_request', 'no_more_seat', message, args);
}
}
@ObjectType()
class UnsupportedSubscriptionPlanDataType {
@Field() plan!: string
@@ -1021,6 +1043,9 @@ export enum ErrorNames {
ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE,
DOC_DEFAULT_ROLE_CAN_NOT_BE_OWNER,
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS,
NEW_OWNER_IS_NOT_ACTIVE_MEMBER,
INVALID_INVITATION,
NO_MORE_SEAT,
UNSUPPORTED_SUBSCRIPTION_PLAN,
FAILED_TO_CHECKOUT,
INVALID_CHECKOUT_PARAMETERS,
@@ -1089,5 +1114,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const,
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const,
});

View File

@@ -73,7 +73,9 @@ test('should create invitation notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitation');
await notificationJob.sendInvitation({
@@ -92,7 +94,9 @@ test('should create invitation accepted notification when user accepts the invit
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
{
status: WorkspaceMemberStatus.Accepted,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationAccepted');
await notificationJob.sendInvitationAccepted({
@@ -121,7 +125,9 @@ test('should create invitation review request notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationReviewRequest');
await notificationJob.sendInvitationReviewRequest({
@@ -151,7 +157,9 @@ test('should create invitation review approved notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationReviewApproved');
await notificationJob.sendInvitationReviewApproved({
@@ -181,7 +189,9 @@ test('should create invitation review declined notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationReviewDeclined');
await notificationJob.sendInvitationReviewDeclined({

View File

@@ -46,12 +46,9 @@ test('should get null role', async t => {
test('should return null if workspace role is not accepted', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.UnderReview,
});
const role = await ac.getRole({
workspaceId: ws.id,
@@ -114,12 +111,9 @@ test('should return [External] if doc is public', async t => {
test('should return null if doc role is [None]', async t => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
await models.workspaceUser.set(
ws.id,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await ac.getRole({
workspaceId: ws.id,
@@ -132,12 +126,9 @@ test('should return null if doc role is [None]', async t => {
test('should return [External] if doc role is [None] but doc is public', async t => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
await models.workspaceUser.set(
ws.id,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.Accepted,
});
await models.doc.publish(ws.id, 'doc1');
const role = await ac.getRole({
@@ -180,12 +171,9 @@ test('should assert action', async t => {
)
);
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.Accepted,
});
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);

View File

@@ -45,12 +45,9 @@ test('should get null role', async t => {
test('should return null if role is not accepted', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.UnderReview,
});
const role = await ac.getRole({
workspaceId: ws.id,
@@ -131,12 +128,9 @@ test('should assert action', async t => {
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
);
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Admin, {
status: WorkspaceMemberStatus.Accepted,
});
await t.notThrowsAsync(
ac.assert(

View File

@@ -1,14 +1,42 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '../../base';
import { Models } from '../../models';
import { WorkspaceService } from './resolvers/service';
import { Mailer } from '../mail';
import { WorkspaceService } from './service';
declare global {
interface Events {
'workspace.members.invite': {
inviterId: string;
inviteId: string;
};
'workspace.members.removed': {
workspaceId: string;
userId: string;
};
'workspace.members.leave': {
workspaceId: string;
userId: string;
};
'workspace.members.updated': {
workspaceId: string;
};
'workspace.members.allocateSeats': {
workspaceId: string;
quantity: number;
};
}
}
@Injectable()
export class WorkspaceEvents {
private readonly logger = new Logger(WorkspaceEvents.name);
constructor(
private readonly workspaceService: WorkspaceService,
private readonly models: Models
private readonly models: Models,
private readonly mailer: Mailer
) {}
@OnEvent('workspace.members.roleChanged')
@@ -24,6 +52,30 @@ export class WorkspaceEvents {
});
}
@OnEvent('workspace.members.removed')
async onMemberRemoved({
userId,
workspaceId,
}: Events['workspace.members.removed']) {
const user = await this.models.user.get(userId);
if (!user) {
this.logger.warn(
`User not found for seeding member removed email: ${userId}`
);
return;
}
await this.mailer.send({
name: 'MemberRemoved',
to: user.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
},
});
}
@OnEvent('workspace.owner.changed')
async onOwnerTransferred({
workspaceId,
@@ -49,4 +101,28 @@ export class WorkspaceEvents {
});
}
}
@OnEvent('workspace.members.leave')
async onMemberLeave({
userId,
workspaceId,
}: Events['workspace.members.leave']) {
await this.workspaceService.sendLeaveEmail(workspaceId, userId);
}
@OnEvent('workspace.members.invite')
async onMemberInvite({
inviterId,
inviteId,
}: Events['workspace.members.invite']) {
await this.workspaceService.sendInvitationNotification(inviterId, inviteId);
}
@OnEvent('workspace.members.allocateSeats')
async onAllocateSeats({
workspaceId,
quantity,
}: Events['workspace.members.allocateSeats']) {
await this.workspaceService.allocateSeats(workspaceId, quantity);
}
}

View File

@@ -14,12 +14,12 @@ import { WorkspaceEvents } from './event';
import {
DocHistoryResolver,
DocResolver,
TeamWorkspaceResolver,
WorkspaceBlobResolver,
WorkspaceDocResolver,
WorkspaceMemberResolver,
WorkspaceResolver,
WorkspaceService,
} from './resolvers';
import { WorkspaceService } from './service';
@Module({
imports: [
@@ -36,7 +36,7 @@ import {
controllers: [WorkspacesController],
providers: [
WorkspaceResolver,
TeamWorkspaceResolver,
WorkspaceMemberResolver,
WorkspaceDocResolver,
DocResolver,
DocHistoryResolver,
@@ -48,4 +48,5 @@ import {
})
export class WorkspaceModule {}
export { WorkspaceService } from './service';
export { InvitationType, WorkspaceType } from './types';

View File

@@ -17,6 +17,7 @@ import {
Cache,
DocActionDenied,
DocDefaultRoleCanNotBeOwner,
DocNotFound,
ExpectToGrantDocUserRoles,
ExpectToPublishDoc,
ExpectToRevokeDocUserRoles,
@@ -29,6 +30,7 @@ import {
} from '../../../base';
import { Models, PublicDocMode } from '../../../models';
import { CurrentUser } from '../../auth';
import { Editor } from '../../doc';
import {
AccessController,
DOC_ACTIONS,
@@ -148,6 +150,29 @@ const DocPermissions = registerObjectType<
{ name: 'DocPermissions' }
);
@ObjectType()
export class EditorType implements Partial<Editor> {
@Field()
name!: string;
@Field(() => String, { nullable: true })
avatarUrl!: string | null;
}
@ObjectType()
class WorkspaceDocMeta {
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
@Field(() => EditorType, { nullable: true })
createdBy!: EditorType | null;
@Field(() => EditorType, { nullable: true })
updatedBy!: EditorType | null;
}
@Resolver(() => WorkspaceType)
export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name);
@@ -162,6 +187,28 @@ export class WorkspaceDocResolver {
private readonly cache: Cache
) {}
@ResolveField(() => WorkspaceDocMeta, {
description: 'Cloud page metadata of workspace',
complexity: 2,
deprecationReason: 'use [WorkspaceType.doc.meta] instead',
})
async pageMeta(
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
const metadata = await this.models.doc.getAuthors(workspace.id, pageId);
if (!metadata) {
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => [DocType], {
complexity: 2,
deprecationReason: 'use [WorkspaceType.publicDocs] instead',
@@ -364,6 +411,26 @@ export class DocResolver {
private readonly models: Models
) {}
@ResolveField(() => WorkspaceDocMeta, {
description: 'Doc metadata',
complexity: 2,
})
async meta(@Parent() doc: DocType) {
const metadata = await this.models.doc.getAuthors(
doc.workspaceId,
doc.docId
);
if (!metadata) {
throw new DocNotFound({ spaceId: doc.workspaceId, docId: doc.docId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => DocPermissions)
async permissions(
@CurrentUser() user: CurrentUser,

View File

@@ -16,7 +16,7 @@ import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { AccessController } from '../../permission';
import { DocID } from '../../utils/doc';
import { WorkspaceType } from '../types';
import { EditorType } from './workspace';
import { EditorType } from './doc';
@ObjectType()
class DocHistoryType implements Partial<SnapshotHistory> {

View File

@@ -1,6 +1,5 @@
export * from './blob';
export * from './doc';
export * from './history';
export * from './service';
export * from './team';
export * from './member';
export * from './workspace';

View File

@@ -0,0 +1,643 @@
import {
Args,
Int,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { nanoid } from 'nanoid';
import {
ActionForbiddenOnNonTeamWorkspace,
AlreadyInSpace,
AuthenticationRequired,
Cache,
CanNotRevokeYourself,
EventBus,
InvalidInvitation,
mapAnyError,
MemberNotFoundInSpace,
NoMoreSeat,
OwnerCanNotLeaveWorkspace,
QueryTooLong,
RequestMutex,
SpaceAccessDenied,
Throttle,
TooManyRequest,
URLHelper,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { AccessController, WorkspaceRole } from '../../permission';
import { QuotaService } from '../../quota';
import { UserType } from '../../user';
import { validators } from '../../utils/validators';
import { WorkspaceService } from '../service';
import {
InvitationType,
InviteLink,
InviteResult,
InviteUserType,
WorkspaceInviteLinkExpireTime,
WorkspaceType,
} from '../types';
/**
* Workspace team resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@Resolver(() => WorkspaceType)
export class WorkspaceMemberResolver {
constructor(
private readonly cache: Cache,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly ac: AccessController,
private readonly models: Models,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService,
private readonly quota: QuotaService
) {}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.getOwner(workspace.id);
}
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
memberCount(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.count(workspace.id);
}
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number,
@Args('query', { type: () => String, nullable: true }) query?: string
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Read');
if (query) {
if (query.length > 255) {
throw new QueryTooLong({ max: 255 });
}
const list = await this.models.workspaceUser.search(workspace.id, query, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
status,
}));
} else {
const [list] = await this.models.workspaceUser.paginate(workspace.id, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
status,
}));
}
}
@Mutation(() => [InviteResult])
async inviteMembers(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[]
): Promise<InviteResult[]> {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
if (emails.length > 512) {
throw new TooManyRequest();
}
// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
const isTeam = await this.models.workspace.isTeamWorkspace(workspaceId);
const results: InviteResult[] = [];
for (const [idx, email] of emails.entries()) {
try {
validators.assertValidEmail(email);
let target = await this.models.user.getUserByEmail(email);
if (target) {
const originRecord = await this.models.workspaceUser.get(
workspaceId,
target.id
);
// only invite if the user is not already in the workspace
if (originRecord) {
throw new AlreadyInSpace({ spaceId: workspaceId });
}
} else {
target = await this.models.user.create({
email,
registered: false,
});
}
// no need to check quota, directly go allocating seat path
if (isTeam) {
const role = await this.models.workspaceUser.set(
workspaceId,
target.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.AllocatingSeat,
source: WorkspaceMemberSource.Email,
inviterId: me.id,
}
);
results.push({
email,
inviteId: role.id,
});
} else {
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
if (needMoreSeat) {
throw new NoMoreSeat({ spaceId: workspaceId });
} else {
const role = await this.models.workspaceUser.set(
workspaceId,
target.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
source: WorkspaceMemberSource.Email,
inviterId: me.id,
}
);
this.event.emit('workspace.members.invite', {
inviteId: role.id,
inviterId: me.id,
});
results.push({
email,
inviteId: role.id,
});
}
}
} catch (error) {
results.push({
email,
error: mapAnyError(error),
});
}
}
this.event.emit('workspace.members.updated', {
workspaceId,
});
return results;
}
/**
* @deprecated
*/
@Mutation(() => [InviteResult], {
deprecationReason: 'use [inviteMembers] instead',
})
async inviteBatch(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[],
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean = false
) {
return this.inviteMembers(user, workspaceId, emails);
}
@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
})
async inviteLink(
@Parent() workspace: WorkspaceType,
@CurrentUser() user: CurrentUser
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspace.id}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId);
if (id) {
const expireTime = await this.cache.ttl(cacheId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${id.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
return null;
}
@Mutation(() => InviteLink)
async createInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
expireTime: WorkspaceInviteLinkExpireTime
): Promise<InviteLink> {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
if (typeof invite?.inviteId === 'string') {
const expireTime = await this.cache.ttl(cacheWorkspaceId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${invite.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
const inviteId = nanoid();
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(
cacheInviteId,
{ workspaceId, inviterUserId: user.id },
{ ttl: expireTime }
);
return {
link: this.url.link(`/invite/${inviteId}`),
expireTime: new Date(Date.now() + expireTime),
};
}
@Mutation(() => Boolean)
async revokeInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspaceId}`;
return await this.cache.delete(cacheId);
}
@Mutation(() => Boolean)
async approveMember(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (role) {
if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.models.workspaceUser.setStatus(
workspaceId,
userId,
WorkspaceMemberStatus.AllocatingSeat,
{
inviterId: me.id,
}
);
this.event.emit('workspace.members.updated', {
workspaceId,
});
await this.workspaceService.sendReviewApprovedNotification(
role.id,
me.id
);
}
return true;
} else {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
}
@Mutation(() => Boolean)
async grantMember(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string,
@Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert(
newRole === WorkspaceRole.Owner
? 'Workspace.TransferOwner'
: 'Workspace.Users.Manage'
);
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
if (newRole === WorkspaceRole.Owner) {
await this.models.workspaceUser.setOwner(workspaceId, userId);
} else {
// non-team workspace can only transfer ownership, but no detailed permission control
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (!isTeam) {
throw new ActionForbiddenOnNonTeamWorkspace();
}
await this.models.workspaceUser.set(workspaceId, userId, newRole);
}
return true;
}
@Throttle('strict')
@Public()
@Query(() => InvitationType, {
description: 'get workspace invitation info',
})
async getInviteInfo(
@CurrentUser() user: UserType | undefined,
@Args('inviteId') inviteId: string
): Promise<InvitationType> {
const { workspaceId, inviteeUserId, isLink } =
await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound();
const invitee = await this.models.user.getWorkspaceUser(inviteeId);
if (!invitee) throw new UserNotFound();
let status: WorkspaceMemberStatus | undefined;
if (isLink) {
const invitation = await this.models.workspaceUser.get(
workspaceId,
inviteeId
);
status = invitation?.status;
} else {
const invitation = await this.models.workspaceUser.getById(inviteId);
status = invitation?.status;
}
return { workspace, user: owner, invitee, status };
}
/**
* @deprecated
*/
@Mutation(() => Boolean, {
deprecationReason: 'use [revokeMember] instead',
})
async revoke(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
return this.revokeMember(me, workspaceId, userId);
}
@Mutation(() => Boolean)
async revokeMember(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
if (userId === me.id) {
throw new CanNotRevokeYourself();
}
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert(
role.type === WorkspaceRole.Admin
? 'Workspace.Administrators.Manage'
: 'Workspace.Users.Manage'
);
await this.models.workspaceUser.delete(workspaceId, userId);
if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.workspaceService.sendReviewDeclinedNotification(
userId,
workspaceId,
me.id
);
} else if (role.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.removed', {
userId,
workspaceId,
});
}
this.event.emit('workspace.members.updated', {
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@CurrentUser() user: CurrentUser | undefined,
@Args('inviteId') inviteId: string,
@Args('workspaceId', { deprecationReason: 'never used', nullable: true })
_workspaceId: string,
@Args('sendAcceptMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendAcceptMail: boolean
) {
const role = await this.models.workspaceUser.getById(inviteId);
// invitation by email
if (role) {
if (user && user.id !== role.userId) {
throw new InvalidInvitation();
}
await this.acceptInvitationByEmail(role);
} else {
// invitation by link
if (!user) {
throw new AuthenticationRequired();
}
const invitation = await this.cache.get<{
workspaceId: string;
inviterUserId: string;
}>(`workspace:inviteLinkId:${inviteId}`);
if (!invitation) {
throw new InvalidInvitation();
}
const role = await this.models.workspaceUser.get(
invitation.workspaceId,
user.id
);
if (role) {
// if status is pending, should accept the invitation directly
if (role.status === WorkspaceMemberStatus.Pending) {
await this.acceptInvitationByEmail(role);
} else {
throw new AlreadyInSpace({ spaceId: invitation.workspaceId });
}
}
await this.acceptInvitationByLink(
user,
invitation.workspaceId,
invitation.inviterUserId
);
return true;
}
return true;
}
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('sendLeaveMail', {
nullable: true,
deprecationReason: 'no used anymore',
})
_sendLeaveMail?: boolean,
@Args('workspaceName', {
nullable: true,
deprecationReason: 'no longer used',
})
_workspaceName?: string
) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
if (role.type === WorkspaceRole.Owner) {
throw new OwnerCanNotLeaveWorkspace();
}
await this.models.workspaceUser.delete(workspaceId, user.id);
this.event.emit('workspace.members.leave', {
workspaceId,
userId: user.id,
});
this.event.emit('workspace.members.updated', {
workspaceId,
});
return true;
}
private async acceptInvitationByEmail(role: WorkspaceUserRole) {
await this.models.workspaceUser.setStatus(
role.workspaceId,
role.userId,
WorkspaceMemberStatus.Accepted
);
await this.workspaceService.sendInvitationAcceptedNotification(
role.inviterId ??
(await this.models.workspaceUser.getOwner(role.workspaceId)).id,
role.id
);
}
private async acceptInvitationByLink(
user: CurrentUser,
workspaceId: string,
inviterId: string
) {
let inviter = await this.models.user.getPublicUser(inviterId);
if (!inviter) {
inviter = await this.models.workspaceUser.getOwner(workspaceId);
}
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.UnderReview,
source: WorkspaceMemberSource.Link,
inviterId: inviter.id,
}
);
await this.workspaceService.sendReviewRequestNotification(role.id);
return;
}
}

View File

@@ -1,292 +0,0 @@
import { Logger } from '@nestjs/common';
import {
Args,
Mutation,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { WorkspaceMemberStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import {
ActionForbiddenOnNonTeamWorkspace,
Cache,
EventBus,
MemberNotFoundInSpace,
RequestMutex,
TooManyRequest,
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser } from '../../auth';
import { AccessController, WorkspaceRole } from '../../permission';
import { QuotaService } from '../../quota';
import {
InviteLink,
InviteResult,
WorkspaceInviteLinkExpireTime,
WorkspaceType,
} from '../types';
import { WorkspaceService } from './service';
/**
* Workspace team resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@Resolver(() => WorkspaceType)
export class TeamWorkspaceResolver {
private readonly logger = new Logger(TeamWorkspaceResolver.name);
constructor(
private readonly cache: Cache,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly ac: AccessController,
private readonly models: Models,
private readonly quota: QuotaService,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService
) {}
@ResolveField(() => Boolean, {
name: 'team',
description: 'if workspace is team workspace',
complexity: 2,
})
team(@Parent() workspace: WorkspaceType) {
return this.workspaceService.isTeamWorkspace(workspace.id);
}
@Mutation(() => [InviteResult])
async inviteBatch(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[],
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
if (emails.length > 512) {
throw new TooManyRequest();
}
// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
const results = [];
for (const [idx, email] of emails.entries()) {
const ret: InviteResult = { email, sentSuccess: false, inviteId: null };
try {
let target = await this.models.user.getUserByEmail(email);
if (target) {
const originRecord = await this.models.workspaceUser.get(
workspaceId,
target.id
);
// only invite if the user is not already in the workspace
if (originRecord) continue;
} else {
target = await this.models.user.create({
email,
registered: false,
});
}
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
const role = await this.models.workspaceUser.set(
workspaceId,
target.id,
WorkspaceRole.Collaborator,
needMoreSeat
? WorkspaceMemberStatus.NeedMoreSeat
: WorkspaceMemberStatus.Pending
);
ret.inviteId = role.id;
// 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
await this.workspaceService.sendInvitationNotification(
user.id,
ret.inviteId
);
ret.sentSuccess = true;
} catch (e) {
this.logger.error('failed to invite user', e);
}
results.push(ret);
}
const memberCount = quota.memberCount + results.length;
if (memberCount > quota.memberLimit) {
this.event.emit('workspace.members.updated', {
workspaceId,
count: memberCount,
});
}
return results;
}
@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
})
async inviteLink(
@Parent() workspace: WorkspaceType,
@CurrentUser() user: CurrentUser
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspace.id}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId);
if (id) {
const expireTime = await this.cache.ttl(cacheId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${id.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
return null;
}
@Mutation(() => InviteLink)
async createInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
expireTime: WorkspaceInviteLinkExpireTime
): Promise<InviteLink> {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
if (typeof invite?.inviteId === 'string') {
const expireTime = await this.cache.ttl(cacheWorkspaceId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${invite.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
const inviteId = nanoid();
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(
cacheInviteId,
{ workspaceId, inviterUserId: user.id },
{ ttl: expireTime }
);
return {
link: this.url.link(`/invite/${inviteId}`),
expireTime: new Date(Date.now() + expireTime),
};
}
@Mutation(() => Boolean)
async revokeInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspaceId}`;
return await this.cache.delete(cacheId);
}
@Mutation(() => Boolean)
async approveMember(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (role) {
if (role.status === WorkspaceMemberStatus.UnderReview) {
const result = await this.models.workspaceUser.setStatus(
workspaceId,
userId,
WorkspaceMemberStatus.Accepted
);
await this.workspaceService.sendReviewApprovedNotification(
result.id,
me.id
);
}
return true;
} else {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
}
@Mutation(() => Boolean)
async grantMember(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string,
@Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert(
newRole === WorkspaceRole.Owner
? 'Workspace.TransferOwner'
: 'Workspace.Users.Manage'
);
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
if (newRole === WorkspaceRole.Owner) {
await this.models.workspaceUser.setOwner(workspaceId, userId);
} else {
// non-team workspace can only transfer ownership, but no detailed permission control
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (!isTeam) {
throw new ActionForbiddenOnNonTeamWorkspace();
}
await this.models.workspaceUser.set(workspaceId, userId, newRole);
}
return true;
}
}

View File

@@ -1,7 +1,6 @@
import {
Args,
Field,
Int,
Mutation,
ObjectType,
Parent,
@@ -9,33 +8,17 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { WorkspaceMemberStatus } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../base';
import {
AFFiNELogger,
AlreadyInSpace,
Cache,
CanNotRevokeYourself,
DocNotFound,
EventBus,
MemberNotFoundInSpace,
MemberQuotaExceeded,
OwnerCanNotLeaveWorkspace,
QueryTooLong,
registerObjectType,
RequestMutex,
SpaceAccessDenied,
SpaceNotFound,
Throttle,
TooManyRequest,
UserFriendlyError,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { type Editor } from '../../doc';
import { CurrentUser } from '../../auth';
import {
AccessController,
WORKSPACE_ACTIONS,
@@ -43,14 +26,8 @@ import {
WorkspaceRole,
} from '../../permission';
import { QuotaService, WorkspaceQuotaType } from '../../quota';
import { UserType } from '../../user';
import {
InvitationType,
InviteUserType,
UpdateWorkspaceInput,
WorkspaceType,
} from '../types';
import { WorkspaceService } from './service';
import { WorkspaceService } from '../service';
import { UpdateWorkspaceInput, WorkspaceType } from '../types';
export type DotToUnderline<T extends string> =
T extends `${infer Prefix}.${infer Suffix}`
@@ -68,30 +45,6 @@ export function mapPermissionsToGraphqlPermissions<A extends string>(
) as Record<DotToUnderline<A>, boolean>;
}
@ObjectType()
export class EditorType implements Partial<Editor> {
@Field()
name!: string;
@Field(() => String, { nullable: true })
avatarUrl!: string | null;
}
@ObjectType()
class WorkspacePageMeta {
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
@Field(() => EditorType, { nullable: true })
createdBy!: EditorType | null;
@Field(() => EditorType, { nullable: true })
updatedBy!: EditorType | null;
}
const WorkspacePermissions = registerObjectType<
Record<DotToUnderline<WorkspaceAction>, boolean>
>(
@@ -126,18 +79,32 @@ export class WorkspaceRolePermissions {
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(
private readonly cache: Cache,
private readonly ac: AccessController,
private readonly quota: QuotaService,
private readonly models: Models,
private readonly event: EventBus,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService,
private readonly logger: AFFiNELogger
) {
logger.setContext(WorkspaceResolver.name);
}
@ResolveField(() => Boolean, {
description: 'is current workspace initialized',
complexity: 2,
})
async initialized(@Parent() workspace: WorkspaceType) {
return this.models.doc.exists(workspace.id, workspace.id);
}
@ResolveField(() => Boolean, {
name: 'team',
description: 'if workspace is team workspace',
complexity: 2,
})
team(@Parent() workspace: WorkspaceType) {
return this.workspaceService.isTeamWorkspace(workspace.id);
}
@ResolveField(() => WorkspaceRole, {
description: 'Role of current signed in user in workspace',
complexity: 2,
@@ -174,100 +141,6 @@ export class WorkspaceResolver {
return mapPermissionsToGraphqlPermissions(permissions);
}
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
memberCount(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.count(workspace.id);
}
@ResolveField(() => Boolean, {
description: 'is current workspace initialized',
complexity: 2,
})
async initialized(@Parent() workspace: WorkspaceType) {
return this.models.doc.exists(workspace.id, workspace.id);
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.getOwner(workspace.id);
}
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number,
@Args('query', { type: () => String, nullable: true }) query?: string
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Read');
if (query) {
if (query.length > 255) {
throw new QueryTooLong({ max: 255 });
}
const list = await this.models.workspaceUser.search(workspace.id, query, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
} else {
const [list] = await this.models.workspaceUser.paginate(workspace.id, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
}
}
@ResolveField(() => WorkspacePageMeta, {
description: 'Cloud page metadata of workspace',
complexity: 2,
})
async pageMeta(
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
const metadata = await this.models.doc.getAuthors(workspace.id, pageId);
if (!metadata) {
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => WorkspaceQuotaType, {
name: 'quota',
description: 'quota of workspace',
@@ -442,274 +315,4 @@ export class WorkspaceResolver {
return true;
}
@Mutation(() => String)
async invite(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean,
@Args('permission', {
type: () => WorkspaceRole,
nullable: true,
deprecationReason: 'never used',
})
_permission?: WorkspaceRole
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
try {
// lock to prevent concurrent invite and grant
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
// member limit check
await this.quota.checkSeat(workspaceId);
let user = await this.models.user.getUserByEmail(email);
if (user) {
const role = await this.models.workspaceUser.get(workspaceId, user.id);
// only invite if the user is not already in the workspace
if (role) return role.id;
} else {
user = await this.models.user.create({
email,
registered: false,
});
}
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator
);
await this.workspaceService.sendInvitationNotification(me.id, role.id);
return role.id;
} catch (e) {
// pass through user friendly error
if (e instanceof UserFriendlyError) {
throw e;
}
this.logger.error('failed to invite user', e);
throw new TooManyRequest();
}
}
@Throttle('strict')
@Public()
@Query(() => InvitationType, {
description: 'send workspace invitation',
})
async getInviteInfo(
@CurrentUser() user: UserType | undefined,
@Args('inviteId') inviteId: string
): Promise<InvitationType> {
const { workspaceId, inviteeUserId, isLink } =
await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound();
const invitee = await this.models.user.getWorkspaceUser(inviteeId);
if (!invitee) throw new UserNotFound();
let status: WorkspaceMemberStatus | undefined;
if (isLink) {
const invitation = await this.models.workspaceUser.get(
workspaceId,
inviteeId
);
status = invitation?.status;
} else {
const invitation = await this.models.workspaceUser.getById(inviteId);
status = invitation?.status;
}
return { workspace, user: owner, invitee, status };
}
@Mutation(() => Boolean)
async revoke(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
if (userId === me.id) {
throw new CanNotRevokeYourself();
}
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert(
role.type === WorkspaceRole.Admin
? 'Workspace.Administrators.Manage'
: 'Workspace.Users.Manage'
);
await this.models.workspaceUser.delete(workspaceId, userId);
const count = await this.models.workspaceUser.count(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.workspaceService.sendReviewDeclinedNotification(
userId,
workspaceId,
me.id
);
} else if (role.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.removed', {
userId,
workspaceId,
});
}
return true;
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@CurrentUser() user: CurrentUser | undefined,
@Args('workspaceId') workspaceId: string,
@Args('inviteId') inviteId: string,
@Args('sendAcceptMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendAcceptMail: boolean
) {
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
if (user) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
if (role) {
throw new AlreadyInSpace({ spaceId: workspaceId });
}
// invite link
const invite = await this.cache.get<{ inviteId: string }>(
`workspace:inviteLink:${workspaceId}`
);
if (invite?.inviteId === inviteId) {
await this.acceptInviteByLink(user, workspaceId);
return true;
}
}
await this.acceptInviteByInviteId(inviteId);
return true;
}
private async acceptInviteByInviteId(inviteId: string) {
await this.models.workspaceUser.accept(inviteId);
await this.workspaceService.sendInvitationAcceptedNotification(inviteId);
}
private async acceptInviteByLink(user: CurrentUser, workspaceId: string) {
const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
if (seatAvailable) {
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
// if status is pending, should accept the invite directly
if (role.status === WorkspaceMemberStatus.Pending) {
await this.acceptInviteByInviteId(role.id);
return;
}
await this.workspaceService.sendReviewRequestNotification(role.id);
return;
}
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
// only team workspace allow over limit
if (isTeam) {
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.NeedMoreSeatAndReview
);
// if status is pending, should accept the invite directly
if (role.status === WorkspaceMemberStatus.Pending) {
await this.acceptInviteByInviteId(role.id);
return;
}
await this.workspaceService.sendReviewRequestNotification(role.id);
const memberCount = await this.models.workspaceUser.count(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count: memberCount,
});
return;
}
throw new MemberQuotaExceeded();
}
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail?: boolean,
@Args('workspaceName', {
nullable: true,
deprecationReason: 'no longer used',
})
_workspaceName?: string
) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
if (role.type === WorkspaceRole.Owner) {
throw new OwnerCanNotLeaveWorkspace();
}
await this.models.workspaceUser.delete(workspaceId, user.id);
if (sendLeaveMail) {
await this.workspaceService.sendLeaveEmail(workspaceId, user.id);
}
return true;
}
}

View File

@@ -1,29 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { getStreamAsBuffer } from 'get-stream';
import {
Cache,
JobQueue,
NotFound,
OnEvent,
URLHelper,
UserNotFound,
} from '../../../base';
import { Cache, JobQueue, NotFound, 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';
} from '../../models';
import { DocReader } from '../doc';
import { Mailer } from '../mail';
import { WorkspaceRole } from '../permission';
import { WorkspaceBlobStorage } from '../storage';
export type InviteInfo = {
isLink: boolean;
workspaceId: string;
inviterUserId?: string;
inviteeUserId?: string;
inviterUserId: string | null;
inviteeUserId: string | null;
};
@Injectable()
@@ -62,6 +55,7 @@ export class WorkspaceService {
isLink: false,
workspaceId: workspaceUser.workspaceId,
inviteeUserId: workspaceUser.userId,
inviterUserId: workspaceUser.inviterId,
};
}
@@ -87,26 +81,15 @@ export class WorkspaceService {
};
}
async sendInvitationAcceptedNotification(inviteId: string) {
const { workspaceId, inviterUserId, inviteeUserId } =
await this.getInviteInfo(inviteId);
const inviter = inviterUserId
? await this.models.user.getWorkspaceUser(inviterUserId)
: await this.models.workspaceUser.getOwner(workspaceId);
if (!inviter || !inviteeUserId) {
this.logger.warn(
`Inviter or invitee user not found for inviteId: ${inviteId}`
);
throw new UserNotFound();
}
async sendInvitationAcceptedNotification(
inviterId: string,
inviteId: string
) {
await this.queue.add('notification.sendInvitationAccepted', {
inviterId: inviter.id,
inviterId,
inviteId,
});
}
async sendInvitationNotification(inviterId: string, inviteId: string) {
await this.queue.add('notification.sendInvitation', {
inviterId,
@@ -116,7 +99,7 @@ export class WorkspaceService {
// ================ Team ================
async isTeamWorkspace(workspaceId: string) {
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
return this.models.workspace.isTeamWorkspace(workspaceId);
}
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
@@ -269,27 +252,21 @@ export class WorkspaceService {
});
}
@OnEvent('workspace.members.removed')
async onMemberRemoved({
userId,
workspaceId,
}: Events['workspace.members.removed']) {
const user = await this.models.user.get(userId);
if (!user) {
this.logger.warn(
`User not found for seeding member removed email: ${userId}`
);
return;
async allocateSeats(workspaceId: string, quantity: number) {
const pendings = await this.models.workspaceUser.allocateSeats(
workspaceId,
quantity
);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
for (const member of pendings) {
try {
await this.queue.add('notification.sendInvitation', {
inviterId: member.inviterId ?? owner.id,
inviteId: member.id,
});
} catch (e) {
this.logger.error('Failed to send invitation notification', e);
}
}
await this.mailer.send({
name: 'MemberRemoved',
to: user.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
},
});
}
}

View File

@@ -9,7 +9,7 @@ import {
registerEnumType,
} from '@nestjs/graphql';
import { WorkspaceMemberStatus } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import { GraphQLJSONObject, SafeIntResolver } from 'graphql-scalars';
import { DocRole, WorkspaceRole } from '../permission';
import { UserType, WorkspaceUserType } from '../user/types';
@@ -56,12 +56,6 @@ export class InviteUserType extends OmitType(
@Field({ description: 'Invite id' })
inviteId!: string;
@Field({
description: 'User accepted',
deprecationReason: 'Use `status` instead',
})
accepted!: boolean;
@Field(() => WorkspaceMemberStatus, {
description: 'Member invite status in workspace',
})
@@ -161,10 +155,23 @@ export class InviteResult {
nullable: true,
description: 'Invite id, null if invite record create failed',
})
inviteId!: string | null;
inviteId?: string;
@Field(() => Boolean, { description: 'Invite email sent success' })
sentSuccess!: boolean;
/**
* @deprecated
*/
@Field(() => Boolean, {
description: 'Invite email sent success',
deprecationReason: 'Notification will be sent asynchronously',
defaultValue: true,
})
sentSuccess?: boolean;
@Field(() => GraphQLJSONObject, {
nullable: true,
description: 'Invite error',
})
error?: object;
}
const Day = 24 * 60 * 60 * 1000;

View File

@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { WorkspaceMemberStatus } from '@prisma/client';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { groupBy } from 'lodash-es';
import { EventBus, PaginationInput } from '../base';
import { EventBus, NewOwnerIsNotActiveMember, PaginationInput } from '../base';
import { BaseModel } from './base';
import { WorkspaceRole, workspaceUserSelect } from './common';
@@ -21,22 +25,6 @@ declare global {
workspaceId: string;
role: WorkspaceRole;
};
// below are business events, should be declare somewhere else
'workspace.members.updated': {
workspaceId: string;
count: number;
};
'workspace.members.removed': {
userId: string;
workspaceId: string;
};
'workspace.members.leave': {
workspaceId: string;
user: {
id: string;
email: string;
};
};
}
}
@@ -61,6 +49,20 @@ export class WorkspaceUserModel extends BaseModel {
// If there is already an owner, we need to change the old owner to admin
if (oldOwner) {
const newOwnerOldRole = await this.db.workspaceUserRole.findFirst({
where: {
workspaceId,
userId,
},
});
if (
!newOwnerOldRole ||
newOwnerOldRole.status !== WorkspaceMemberStatus.Accepted
) {
throw new NewOwnerIsNotActiveMember();
}
await this.db.workspaceUserRole.update({
where: {
id: oldOwner.id,
@@ -69,27 +71,14 @@ export class WorkspaceUserModel extends BaseModel {
type: WorkspaceRole.Admin,
},
});
}
await this.db.workspaceUserRole.upsert({
where: {
workspaceId_userId: {
workspaceId,
userId,
await this.db.workspaceUserRole.update({
where: {
id: newOwnerOldRole.id,
},
},
update: {
type: WorkspaceRole.Owner,
},
create: {
workspaceId,
userId,
type: WorkspaceRole.Owner,
status: WorkspaceMemberStatus.Accepted,
},
});
if (oldOwner) {
data: {
type: WorkspaceRole.Owner,
},
});
this.event.emit('workspace.owner.changed', {
workspaceId,
from: oldOwner.userId,
@@ -99,6 +88,14 @@ export class WorkspaceUserModel extends BaseModel {
`Transfer workspace owner of [${workspaceId}] from [${oldOwner.userId}] to [${userId}]`
);
} else {
await this.db.workspaceUserRole.create({
data: {
workspaceId,
userId,
type: WorkspaceRole.Owner,
status: WorkspaceMemberStatus.Accepted,
},
});
this.logger.log(`Set workspace owner of [${workspaceId}] to [${userId}]`);
}
}
@@ -113,7 +110,11 @@ export class WorkspaceUserModel extends BaseModel {
workspaceId: string,
userId: string,
role: WorkspaceRole,
defaultStatus: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
defaultData: {
status?: WorkspaceMemberStatus;
source?: WorkspaceMemberSource;
inviterId?: string;
} = {}
) {
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
@@ -141,12 +142,20 @@ export class WorkspaceUserModel extends BaseModel {
return newRole;
} else {
const {
status = WorkspaceMemberStatus.Pending,
source = WorkspaceMemberSource.Email,
inviterId,
} = defaultData;
return await this.db.workspaceUserRole.create({
data: {
workspaceId,
userId,
type: role,
status: defaultStatus,
status,
source,
inviterId,
},
});
}
@@ -155,8 +164,12 @@ export class WorkspaceUserModel extends BaseModel {
async setStatus(
workspaceId: string,
userId: string,
status: WorkspaceMemberStatus
status: WorkspaceMemberStatus,
data: {
inviterId?: string;
} = {}
) {
const { inviterId } = data;
return await this.db.workspaceUserRole.update({
where: {
workspaceId_userId: {
@@ -166,17 +179,11 @@ export class WorkspaceUserModel extends BaseModel {
},
data: {
status,
inviterId,
},
});
}
async accept(id: string) {
await this.db.workspaceUserRole.update({
where: { id },
data: { status: WorkspaceMemberStatus.Accepted },
});
}
async delete(workspaceId: string, userId: string) {
await this.db.workspaceUserRole.deleteMany({
where: {
@@ -268,6 +275,20 @@ export class WorkspaceUserModel extends BaseModel {
});
}
/**
* Get the number of users those in the status should be charged in billing system in a workspace.
*/
async chargedCount(workspaceId: string) {
return this.db.workspaceUserRole.count({
where: {
workspaceId,
status: {
not: WorkspaceMemberStatus.UnderReview,
},
},
});
}
async getUserActiveRoles(
userId: string,
filter: { role?: WorkspaceRole } = {}
@@ -341,51 +362,62 @@ export class WorkspaceUserModel extends BaseModel {
}
@Transactional()
async refresh(workspaceId: string, memberLimit: number) {
async allocateSeats(workspaceId: string, limit: number) {
const usedCount = await this.db.workspaceUserRole.count({
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
where: {
workspaceId,
status: {
in: [WorkspaceMemberStatus.Accepted, WorkspaceMemberStatus.Pending],
},
},
});
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
if (limit <= usedCount) {
return [];
}
const members = await this.db.workspaceUserRole.findMany({
select: { id: true, status: true },
const membersToBeAllocated = await this.db.workspaceUserRole.findMany({
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.AllocatingSeat,
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
orderBy: { createdAt: 'asc' },
take: limit - usedCount,
});
const needChange = members.slice(0, availableCount);
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
needChange,
m => m.status
);
const groups = groupBy(
membersToBeAllocated,
member => member.source
) as Record<WorkspaceMemberSource, WorkspaceUserRole[]>;
const toPendings = NeedMoreSeat ?? [];
if (toPendings.length > 0) {
if (groups.Email?.length > 0) {
await this.db.workspaceUserRole.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
where: { id: { in: groups.Email.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
if (toUnderReviewUserIds.length > 0) {
if (groups.Link?.length > 0) {
await this.db.workspaceUserRole.updateMany({
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
where: { id: { in: groups.Link.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Accepted },
});
}
// after allocating, all rests should be `NeedMoreSeat`
await this.db.workspaceUserRole.updateMany({
where: {
workspaceId,
status: WorkspaceMemberStatus.AllocatingSeat,
},
data: { status: WorkspaceMemberStatus.NeedMoreSeat },
});
return groups.Email;
}
}

View File

@@ -98,5 +98,9 @@ export class WorkspaceModel extends BaseModel {
const workspace = await this.get(workspaceId);
return workspace?.enableDocEmbedding ?? false;
}
async isTeamWorkspace(workspaceId: string) {
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
}
// #endregion
}

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { WorkspaceModule } from '../../core/workspaces';
import { LicenseResolver } from './resolver';
import { LicenseService } from './service';
@Module({
imports: [QuotaModule, PermissionModule],
imports: [QuotaModule, PermissionModule, WorkspaceModule],
providers: [LicenseService, LicenseResolver],
})
export class LicenseModule {}

View File

@@ -47,7 +47,10 @@ export class LicenseService {
memberLimit: quantity,
}
);
await this.models.workspaceUser.refresh(workspaceId, quantity);
this.event.emit('workspace.members.allocateSeats', {
workspaceId,
quantity,
});
break;
default:
break;
@@ -188,7 +191,7 @@ export class LicenseService {
@OnEvent('workspace.members.updated')
async updateTeamSeats(payload: Events['workspace.members.updated']) {
const { workspaceId, count } = payload;
const { workspaceId } = payload;
const license = await this.db.installedLicense.findUnique({
where: {
@@ -200,6 +203,7 @@ export class LicenseService {
return;
}
const count = await this.models.workspaceUser.chargedCount(workspaceId);
await this.fetchAffinePro(`/api/team/licenses/${license.key}/seats`, {
method: 'POST',
body: JSON.stringify({

View File

@@ -274,18 +274,21 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}
@OnEvent('workspace.members.updated')
async onMembersUpdated({
workspaceId,
count,
}: Events['workspace.members.updated']) {
async onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
const count = await this.models.workspaceUser.chargedCount(workspaceId);
const subscription = await this.getSubscription({
plan: SubscriptionPlan.Team,
workspaceId,
});
if (!subscription || !subscription.stripeSubscriptionId) {
if (
!subscription ||
!subscription.stripeSubscriptionId ||
count === subscription.quantity
) {
return;
}
const stripeSubscription = await this.stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId
);

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '../../base';
import { WorkspaceService } from '../../core/workspaces/resolvers';
import { EventBus, OnEvent } from '../../base';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { SubscriptionPlan } from './types';
@@ -9,7 +9,8 @@ import { SubscriptionPlan } from './types';
export class QuotaOverride {
constructor(
private readonly workspace: WorkspaceService,
private readonly models: Models
private readonly models: Models,
private readonly event: EventBus
) {}
@OnEvent('workspace.subscription.activated')
@@ -30,7 +31,10 @@ export class QuotaOverride {
memberLimit: quantity,
}
);
await this.models.workspaceUser.refresh(workspaceId, quantity);
this.event.emit('workspace.members.allocateSeats', {
workspaceId,
quantity,
});
if (!isTeam) {
// this event will triggered when subscription is activated or changed
// we only send emails when the team workspace is activated

View File

@@ -430,6 +430,9 @@ type DocType {
"""paginated doc granted users list"""
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
id: String!
"""Doc metadata"""
meta: WorkspaceDocMeta!
mode: PublicDocMode!
permissions: DocPermissions!
public: Boolean!
@@ -446,7 +449,7 @@ type EditorType {
name: String!
}
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames {
ACCESS_DENIED
@@ -514,6 +517,7 @@ enum ErrorNames {
INVALID_EMAIL
INVALID_EMAIL_TOKEN
INVALID_HISTORY_TIMESTAMP
INVALID_INVITATION
INVALID_LICENSE_SESSION_ID
INVALID_LICENSE_TO_ACTIVATE
INVALID_LICENSE_UPDATE_PARAMS
@@ -532,10 +536,12 @@ enum ErrorNames {
MENTION_USER_ONESELF_DENIED
MISSING_OAUTH_QUERY_PARAMETER
NETWORK_ERROR
NEW_OWNER_IS_NOT_ACTIVE_MEMBER
NOTIFICATION_NOT_FOUND
NOT_FOUND
NOT_IN_SPACE
NO_COPILOT_PROVIDER_AVAILABLE
NO_MORE_SEAT
OAUTH_ACCOUNT_ALREADY_CONNECTED
OAUTH_STATE_EXPIRED
OWNER_CAN_NOT_LEAVE_WORKSPACE
@@ -775,17 +781,17 @@ type InviteLink {
type InviteResult {
email: String!
"""Invite error"""
error: JSONObject
"""Invite id, null if invite record create failed"""
inviteId: String
"""Invite email sent success"""
sentSuccess: Boolean!
sentSuccess: Boolean! @deprecated(reason: "Notification will be sent asynchronously")
}
type InviteUserType {
"""User accepted"""
accepted: Boolean! @deprecated(reason: "Use `status` instead")
"""User avatar url"""
avatarUrl: String
@@ -939,7 +945,7 @@ type MissingOauthQueryParameterDataType {
}
type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): Boolean!
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean @deprecated(reason: "never used"), workspaceId: String @deprecated(reason: "never used")): Boolean!
activateLicense(license: String!, workspaceId: String!): License!
"""add a category to context"""
@@ -1013,9 +1019,9 @@ type Mutation {
"""import users"""
importUsers(input: ImportUsersInput!): [UserImportResultType!]!
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!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]! @deprecated(reason: "use [inviteMembers] instead")
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
"""mention user in a doc"""
mentionUser(input: MentionInput!): ID!
@@ -1047,9 +1053,10 @@ type Mutation {
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
retryAudioTranscription(jobId: String!, workspaceId: String!): TranscriptionResultType
revoke(userId: String!, workspaceId: String!): Boolean!
revoke(userId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use [revokeMember] instead")
revokeDocUserRoles(input: RevokeDocUserRoleInput!): Boolean!
revokeInviteLink(workspaceId: String!): Boolean!
revokeMember(userId: String!, workspaceId: String!): Boolean!
revokePublicDoc(docId: String!, workspaceId: String!): DocType!
revokePublicPage(docId: String!, workspaceId: String!): DocType! @deprecated(reason: "use revokePublicDoc instead")
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
@@ -1094,6 +1101,10 @@ type Mutation {
verifyEmail(token: String!): Boolean!
}
type NoMoreSeatDataType {
spaceId: String!
}
type NotInSpaceDataType {
spaceId: String!
}
@@ -1241,7 +1252,7 @@ type Query {
currentUser: UserType
error(name: ErrorNames!): ErrorDataUnion!
"""send workspace invitation"""
"""get workspace invitation info"""
getInviteInfo(inviteId: String!): InvitationType!
"""Get is admin of workspace"""
@@ -1673,6 +1684,13 @@ type WorkspaceBlobSizes {
size: SafeInt!
}
type WorkspaceDocMeta {
createdAt: DateTime!
createdBy: EditorType
updatedAt: DateTime!
updatedBy: EditorType
}
"""Workspace invite link expire time"""
enum WorkspaceInviteLinkExpireTime {
OneDay
@@ -1684,6 +1702,7 @@ enum WorkspaceInviteLinkExpireTime {
"""Member invite status in workspace"""
enum WorkspaceMemberStatus {
Accepted
AllocatingSeat
NeedMoreSeat
NeedMoreSeatAndReview
Pending
@@ -1694,13 +1713,6 @@ type WorkspaceMembersExceedLimitToDowngradeDataType {
limit: Int!
}
type WorkspacePageMeta {
createdAt: DateTime!
createdBy: EditorType
updatedAt: DateTime!
updatedBy: EditorType
}
type WorkspacePermissionNotFoundDataType {
spaceId: String!
}
@@ -1803,7 +1815,7 @@ type WorkspaceType {
owner: UserType!
"""Cloud page metadata of workspace"""
pageMeta(pageId: String!): WorkspacePageMeta!
pageMeta(pageId: String!): WorkspaceDocMeta! @deprecated(reason: "use [WorkspaceType.doc.meta] instead")
"""map of action permissions"""
permissions: WorkspacePermissions!