mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(server): delay subscription after invitation accepted or approved (#11992)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}.`,
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
643
packages/backend/server/src/core/workspaces/resolvers/member.ts
Normal file
643
packages/backend/server/src/core/workspaces/resolvers/member.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
Reference in New Issue
Block a user