fix(server): should direct allocate seat if workspace is not team (#12469)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Added the ability for workspace owners to approve members under review, with different approval processes for team and non-team workspaces.
- **Bug Fixes**
  - Improved accuracy of workspace seat quota calculations for member management.
- **Tests**
  - Enhanced test coverage and consistency for workspace member actions, including approval and revocation scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-05-23 04:25:53 +00:00
parent f38b8fef4d
commit 7978a2545f
3 changed files with 82 additions and 68 deletions
@@ -1,5 +1,6 @@
import { import {
acceptInviteByInviteIdMutation, acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation, createInviteLinkMutation,
getInviteInfoQuery, getInviteInfoQuery,
getMembersByWorkspaceIdQuery, getMembersByWorkspaceIdQuery,
@@ -15,16 +16,11 @@ import { Models } from '../../../models';
import { Mockers } from '../../mocks'; import { Mockers } from '../../mocks';
import { app, e2e } from '../test'; import { app, e2e } from '../test';
async function createTeamWorkspace(quantity = 10) { async function createWorkspace() {
const owner = await app.create(Mockers.User); const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, { const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id }, owner: { id: owner.id },
}); });
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity,
});
return { return {
owner, owner,
@@ -33,13 +29,10 @@ async function createTeamWorkspace(quantity = 10) {
} }
e2e('should invite a user', async t => { e2e('should invite a user', async t => {
const u2 = await app.signup(); const { owner, workspace } = await createWorkspace();
const owner = await app.signup(); const u2 = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
const result = await app.gql({ const result = await app.gql({
query: inviteByEmailsMutation, query: inviteByEmailsMutation,
variables: { variables: {
@@ -47,6 +40,7 @@ e2e('should invite a user', async t => {
workspaceId: workspace.id, workspaceId: workspace.id,
}, },
}); });
t.truthy(result, 'failed to invite user'); t.truthy(result, 'failed to invite user');
// add invitation notification job // add invitation notification job
const invitationNotification = await app.queue.waitFor( const invitationNotification = await app.queue.waitFor(
@@ -68,7 +62,7 @@ e2e('should invite a user', async t => {
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending); t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
// u2 accept invite // u2 accept invite
await app.switchUser(u2); await app.login(u2);
await app.gql({ await app.gql({
query: acceptInviteByInviteIdMutation, query: acceptInviteByInviteIdMutation,
variables: { variables: {
@@ -88,18 +82,14 @@ e2e('should invite a user', async t => {
}); });
e2e('should leave a workspace', async t => { e2e('should leave a workspace', async t => {
const u2 = await app.signup(); const { owner, workspace } = await createWorkspace();
const owner = await app.signup(); const u2 = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, { await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id, workspaceId: workspace.id,
userId: u2.id, userId: u2.id,
}); });
await app.switchUser(u2.id); await app.login(u2);
const { leaveWorkspace } = await app.gql({ const { leaveWorkspace } = await app.gql({
query: leaveWorkspaceMutation, query: leaveWorkspaceMutation,
variables: { variables: {
@@ -137,19 +127,42 @@ e2e('should revoke a user', async t => {
t.true(revokeMember, 'failed to revoke user'); t.true(revokeMember, 'failed to revoke user');
}); });
e2e('should revoke a user on under review', async t => { e2e('should approve a user on under review', async t => {
const user = await app.signup(); const { owner, workspace } = await createWorkspace();
const owner = await app.signup(); const user = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, { await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id, workspaceId: workspace.id,
userId: user.id, userId: user.id,
status: WorkspaceMemberStatus.UnderReview, status: WorkspaceMemberStatus.UnderReview,
}); });
await app.login(owner);
const { approveMember } = await app.gql({
query: approveWorkspaceTeamMemberMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(approveMember, 'failed to approve member');
t.is(
(await app.get(Models).workspaceUser.get(workspace.id, user.id))?.status,
WorkspaceMemberStatus.Accepted
);
});
e2e('should revoke a user on under review', async t => {
const { owner, workspace } = await createWorkspace();
const user = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
});
await app.login(owner);
const { revokeMember } = await app.gql({ const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation, query: revokeMemberPermissionMutation,
variables: { variables: {
@@ -174,13 +187,10 @@ e2e('should revoke a user on under review', async t => {
}); });
e2e('should create user if not exist', async t => { e2e('should create user if not exist', async t => {
const owner = await app.signup(); const { owner, workspace } = await createWorkspace();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const email = faker.internet.email(); const email = faker.internet.email();
await app.login(owner);
await app.gql({ await app.gql({
query: inviteByEmailsMutation, query: inviteByEmailsMutation,
variables: { variables: {
@@ -194,13 +204,10 @@ e2e('should create user if not exist', async t => {
}); });
e2e('should support pagination for member', async t => { e2e('should support pagination for member', async t => {
const u1 = await app.signup(); const { owner, workspace } = await createWorkspace();
const u2 = await app.signup(); const u1 = await app.create(Mockers.User);
const owner = await app.signup(); const u2 = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, { await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id, workspaceId: workspace.id,
userId: u1.id, userId: u1.id,
@@ -210,6 +217,7 @@ e2e('should support pagination for member', async t => {
userId: u2.id, userId: u2.id,
}); });
await app.login(owner);
let result = await app.gql({ let result = await app.gql({
query: getMembersByWorkspaceIdQuery, query: getMembersByWorkspaceIdQuery,
variables: { variables: {
@@ -245,14 +253,11 @@ e2e('should support pagination for member', async t => {
}); });
e2e('should limit member count correctly', async t => { e2e('should limit member count correctly', async t => {
const owner = await app.signup(); const { owner, workspace } = await createWorkspace();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await Promise.all( await Promise.all(
Array.from({ length: 10 }).map(async () => { Array.from({ length: 10 }).map(async () => {
const user = await app.signup(); const user = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, { await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id, workspaceId: workspace.id,
userId: user.id, userId: user.id,
@@ -260,7 +265,7 @@ e2e('should limit member count correctly', async t => {
}) })
); );
await app.switchUser(owner); await app.login(owner);
const result = await app.gql({ const result = await app.gql({
query: getMembersByWorkspaceIdQuery, query: getMembersByWorkspaceIdQuery,
variables: { variables: {
@@ -274,11 +279,7 @@ e2e('should limit member count correctly', async t => {
}); });
e2e('should get invite link info with status', async t => { e2e('should get invite link info with status', async t => {
const owner = await app.signup(); const { owner, workspace } = await createWorkspace();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner); await app.login(owner);
const { createInviteLink } = await app.gql({ const { createInviteLink } = await app.gql({
@@ -335,13 +336,9 @@ e2e('should get invite link info with status', async t => {
e2e( e2e(
'should accept invitation by link directly if status is pending', 'should accept invitation by link directly if status is pending',
async t => { async t => {
const owner = await app.create(Mockers.User); const { owner, workspace } = await createWorkspace();
const member = 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); await app.login(owner);
// create a pending invitation // create a pending invitation
const invite = await app.gql({ const invite = await app.gql({
@@ -387,7 +384,7 @@ e2e(
e2e( e2e(
'should invite by link and send review request notification below quota limit', 'should invite by link and send review request notification below quota limit',
async t => { async t => {
const { owner, workspace } = await createTeamWorkspace(); const { owner, workspace } = await createWorkspace();
await app.login(owner); await app.login(owner);
const { createInviteLink } = await app.gql({ const { createInviteLink } = await app.gql({
@@ -422,7 +419,11 @@ e2e(
e2e( e2e(
'should invite by link and send review request notification over quota limit', 'should invite by link and send review request notification over quota limit',
async t => { async t => {
const { owner, workspace } = await createTeamWorkspace(1); const { owner, workspace } = await createWorkspace();
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 3,
});
await app.login(owner); await app.login(owner);
const { createInviteLink } = await app.gql({ const { createInviteLink } = await app.gql({
@@ -457,10 +458,7 @@ e2e(
e2e( e2e(
'should search members by name and email support case insensitive', 'should search members by name and email support case insensitive',
async t => { async t => {
const owner = await app.create(Mockers.User); const { owner, workspace } = await createWorkspace();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const user1 = await app.create(Mockers.User, { const user1 = await app.create(Mockers.User, {
name: faker.internet.displayName({ firstName: 'Lucy' }), name: faker.internet.displayName({ firstName: 'Lucy' }),
}); });
@@ -173,7 +173,8 @@ export class QuotaService {
async getWorkspaceSeatQuota(workspaceId: string) { async getWorkspaceSeatQuota(workspaceId: string) {
const quota = await this.getWorkspaceQuota(workspaceId); const quota = await this.getWorkspaceQuota(workspaceId);
const memberCount = await this.models.workspaceUser.count(workspaceId); const memberCount =
await this.models.workspaceUser.chargedCount(workspaceId);
return { return {
memberCount, memberCount,
@@ -343,22 +343,37 @@ export class WorkspaceMemberResolver {
.workspace(workspaceId) .workspace(workspaceId)
.assert('Workspace.Users.Manage'); .assert('Workspace.Users.Manage');
const isTeam = await this.models.workspace.isTeamWorkspace(workspaceId);
const role = await this.models.workspaceUser.get(workspaceId, userId); const role = await this.models.workspaceUser.get(workspaceId, userId);
if (role) { if (role) {
if (role.status === WorkspaceMemberStatus.UnderReview) { if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.models.workspaceUser.setStatus( if (isTeam) {
workspaceId, await this.models.workspaceUser.setStatus(
userId, workspaceId,
WorkspaceMemberStatus.AllocatingSeat, userId,
{ WorkspaceMemberStatus.AllocatingSeat,
inviterId: me.id, {
inviterId: me.id,
}
);
} else {
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
if (quota.memberCount >= quota.memberLimit) {
throw new NoMoreSeat({ spaceId: workspaceId });
} else {
await this.models.workspaceUser.setStatus(
workspaceId,
userId,
WorkspaceMemberStatus.Accepted
);
} }
); }
this.event.emit('workspace.members.updated', { this.event.emit('workspace.members.updated', {
workspaceId, workspaceId,
}); });
await this.workspaceService.sendReviewApprovedNotification( await this.workspaceService.sendReviewApprovedNotification(
role.id, role.id,
me.id me.id