From 2e1bed061ee2f3d43a22a22574e7afff8785e860 Mon Sep 17 00:00:00 2001 From: liuyi Date: Wed, 30 Apr 2025 14:27:47 +0800 Subject: [PATCH] feat(server): delay subscription after invitation accepted or approved (#11992) --- .../migration.sql | 12 + packages/backend/server/schema.prisma | 39 +- .../server/src/__tests__/e2e/create-app.ts | 4 + .../__tests__/e2e/workspace/invite.spec.ts | 456 --------- .../__tests__/e2e/workspace/member.spec.ts | 452 ++++++++- .../src/__tests__/e2e/workspace/team.spec.ts | 255 ++--- .../server/src/__tests__/mocks/mailer.mock.ts | 30 + .../server/src/__tests__/mocks/queue.mock.ts | 31 + .../__tests__/models/workspace-user.spec.ts | 149 ++- .../backend/server/src/__tests__/team.e2e.ts | 945 ------------------ .../server/src/__tests__/utils/invite.ts | 22 +- .../__tests__/workspace/controller.spec.ts | 2 +- packages/backend/server/src/base/error/def.ts | 13 + .../server/src/base/error/errors.gen.ts | 27 +- .../core/notification/__tests__/job.spec.ts | 20 +- .../src/core/permission/__tests__/doc.spec.ts | 36 +- .../permission/__tests__/workspace.spec.ts | 18 +- .../server/src/core/workspaces/event.ts | 82 +- .../server/src/core/workspaces/index.ts | 7 +- .../src/core/workspaces/resolvers/doc.ts | 67 ++ .../src/core/workspaces/resolvers/history.ts | 2 +- .../src/core/workspaces/resolvers/index.ts | 3 +- .../src/core/workspaces/resolvers/member.ts | 643 ++++++++++++ .../src/core/workspaces/resolvers/team.ts | 292 ------ .../core/workspaces/resolvers/workspace.ts | 437 +------- .../workspaces/{resolvers => }/service.ts | 83 +- .../server/src/core/workspaces/types.ts | 27 +- .../server/src/models/workspace-user.ts | 170 ++-- .../backend/server/src/models/workspace.ts | 4 + .../server/src/plugins/license/index.ts | 3 +- .../server/src/plugins/license/service.ts | 8 +- .../src/plugins/payment/manager/workspace.ts | 13 +- .../server/src/plugins/payment/quota.ts | 12 +- packages/backend/server/src/schema.gql | 50 +- packages/common/graphql/src/graphql/index.ts | 48 +- .../src/graphql/revoke-member-permission.gql | 2 +- .../src/graphql/workspace-intive-by-email.gql | 11 - .../graphql/workspace-intive-by-emails.gql | 4 +- .../workspace-invite-accept-by-invite-id.gql | 2 - .../src/graphql/workspace-invite-batch.gql | 15 - packages/common/graphql/src/schema.ts | 111 +- .../members/cloud-members-panel.tsx | 2 +- .../workspace-setting/members/member-list.tsx | 4 +- .../src/modules/cloud/services/invitation.ts | 3 +- .../src/modules/cloud/stores/accept-invite.ts | 2 - .../modules/permissions/services/members.ts | 13 +- .../src/modules/permissions/stores/members.ts | 31 +- packages/frontend/i18n/src/i18n.gen.ts | 18 + packages/frontend/i18n/src/resources/en.json | 4 + 49 files changed, 1990 insertions(+), 2694 deletions(-) create mode 100644 packages/backend/server/migrations/20250425101411_update_workspace_members/migration.sql delete mode 100644 packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts delete mode 100644 packages/backend/server/src/__tests__/team.e2e.ts create mode 100644 packages/backend/server/src/core/workspaces/resolvers/member.ts delete mode 100644 packages/backend/server/src/core/workspaces/resolvers/team.ts rename packages/backend/server/src/core/workspaces/{resolvers => }/service.ts (80%) delete mode 100644 packages/common/graphql/src/graphql/workspace-intive-by-email.gql delete mode 100644 packages/common/graphql/src/graphql/workspace-invite-batch.gql diff --git a/packages/backend/server/migrations/20250425101411_update_workspace_members/migration.sql b/packages/backend/server/migrations/20250425101411_update_workspace_members/migration.sql new file mode 100644 index 0000000000..e9fe51f31c --- /dev/null +++ b/packages/backend/server/migrations/20250425101411_update_workspace_members/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "WorkspaceMemberSource" AS ENUM ('Email', 'Link'); + +-- AlterEnum +ALTER TYPE "WorkspaceMemberStatus" ADD VALUE 'AllocatingSeat'; + +-- AlterTable +ALTER TABLE "workspace_user_permissions" ADD COLUMN "inviter_id" VARCHAR, +ADD COLUMN "source" "WorkspaceMemberSource" NOT NULL DEFAULT 'Email'; + +-- AddForeignKey +ALTER TABLE "workspace_user_permissions" ADD CONSTRAINT "workspace_user_permissions_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index da5823a9cf..873e29cf74 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -27,7 +27,9 @@ model User { features UserFeature[] userStripeCustomer UserStripeCustomer? - workspacePermissions WorkspaceUserRole[] + workspaces WorkspaceUserRole[] + // Invite others to join the workspace + WorkspaceInvitations WorkspaceUserRole[] @relation("inviter") docPermissions WorkspaceDocUserRole[] connectedAccounts ConnectedAccount[] sessions UserSession[] @@ -150,11 +152,25 @@ model WorkspaceDoc { } enum WorkspaceMemberStatus { - Pending // 1. old state accepted = false - NeedMoreSeat // 2.1 for team: workspace owner need to buy more seat - NeedMoreSeatAndReview // 2.2 for team: workspace owner need to buy more seat and member need review - UnderReview // 3. for team: member is under review - Accepted // 4. old state accepted = true + /// Wait for the invitee to accept the invitation + Pending + /// Wait for administrators to review and accept the link invitation + UnderReview + /// Temporary state for team workspace. There is some time gap between invitation and bill payed + AllocatingSeat + /// Insufficient seat for user becoming active workspace member + NeedMoreSeat + /// Activate workspace member + Accepted + /// @deprecated + NeedMoreSeatAndReview +} + +enum WorkspaceMemberSource { + /// Invited by email + Email + /// Invited by link + Link } model WorkspaceUserRole { @@ -163,16 +179,19 @@ model WorkspaceUserRole { userId String @map("user_id") @db.VarChar // Workspace Role, Owner/Admin/Collaborator/External type Int @db.SmallInt - /// @deprecated Whether the permission invitation is accepted by the user - accepted Boolean @default(false) - /// Whether the invite status of the workspace member + /// the invite status of the workspace member status WorkspaceMemberStatus @default(Pending) + source WorkspaceMemberSource @default(Email) + inviterId String? @map("inviter_id") @db.VarChar createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - /// When the invite status changed updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + inviter User? @relation(name: "inviter", fields: [inviterId], references: [id], onDelete: SetNull) + + /// @deprecated Whether the permission invitation is accepted by the user, use status instead + accepted Boolean @default(false) @@unique([workspaceId, userId]) // optimize for querying user's workspace permissions diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts index 9c0a0b0bd3..412526b0b7 100644 --- a/packages/backend/server/src/__tests__/e2e/create-app.ts +++ b/packages/backend/server/src/__tests__/e2e/create-app.ts @@ -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(); diff --git a/packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts deleted file mode 100644 index 64e55530e3..0000000000 --- a/packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts +++ /dev/null @@ -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); - } -); diff --git a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts index d97c31a369..9ea74b9845 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts @@ -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 => { diff --git a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts index 420a9f0ee7..19541aed9d 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts @@ -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 + ); +}); diff --git a/packages/backend/server/src/__tests__/mocks/mailer.mock.ts b/packages/backend/server/src/__tests__/mocks/mailer.mock.ts index b377ebecf0..5cb6291a3a 100644 --- a/packages/backend/server/src/__tests__/mocks/mailer.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/mailer.mock.ts @@ -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( + name: Mail, + timeout: number = 1000 + ): Promise> { + const { promise, reject, resolve } = Promise.withResolvers(); + + 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; diff --git a/packages/backend/server/src/__tests__/mocks/queue.mock.ts b/packages/backend/server/src/__tests__/mocks/queue.mock.ts index 4d03b83899..25a3ceff4f 100644 --- a/packages/backend/server/src/__tests__/mocks/queue.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/queue.mock.ts @@ -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(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; } diff --git a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts index 5f77f232ef..6b4971134a 100644 --- a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts @@ -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; 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); +}); diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts deleted file mode 100644 index d9865855b2..0000000000 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ /dev/null @@ -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; - 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}.`, - } - ); -}); diff --git a/packages/backend/server/src/__tests__/utils/invite.ts b/packages/backend/server/src/__tests__/utils/invite.ts index 42add4be92..96dc82a8aa 100644 --- a/packages/backend/server/src/__tests__/utils/invite.ts +++ b/packages/backend/server/src/__tests__/utils/invite.ts @@ -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 { 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> { 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( diff --git a/packages/backend/server/src/__tests__/workspace/controller.spec.ts b/packages/backend/server/src/__tests__/workspace/controller.spec.ts index c8ffacd4d3..fa48d6b4a9 100644 --- a/packages/backend/server/src/__tests__/workspace/controller.spec.ts +++ b/packages/backend/server/src/__tests__/workspace/controller.spec.ts @@ -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()); diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 73b3f54960..780721fee4 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -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: { diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 65b6b9f55e..b6796ffe9b 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -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, }); diff --git a/packages/backend/server/src/core/notification/__tests__/job.spec.ts b/packages/backend/server/src/core/notification/__tests__/job.spec.ts index e1d015e3fa..199555e809 100644 --- a/packages/backend/server/src/core/notification/__tests__/job.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/job.spec.ts @@ -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({ diff --git a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts index 1359c6ef69..88440e7eed 100644 --- a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts @@ -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); diff --git a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts index 988ecd0ac4..5c9a110b1f 100644 --- a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts @@ -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( diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts index 0514074736..f0a24b38d2 100644 --- a/packages/backend/server/src/core/workspaces/event.ts +++ b/packages/backend/server/src/core/workspaces/event.ts @@ -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); + } } diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index f7efd06725..98e8ce0f34 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -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'; diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index a77bd99318..8747c8dc85 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -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 { + @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, diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index b031b5ecf7..193afb2075 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/history.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/history.ts @@ -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 { diff --git a/packages/backend/server/src/core/workspaces/resolvers/index.ts b/packages/backend/server/src/core/workspaces/resolvers/index.ts index 3bf14d4a5b..4bbae693f5 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/index.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/index.ts @@ -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'; diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts new file mode 100644 index 0000000000..a566be814d --- /dev/null +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts deleted file mode 100644 index 5778dce204..0000000000 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 1586f86b8d..7251be4c07 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -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 `${infer Prefix}.${infer Suffix}` @@ -68,30 +45,6 @@ export function mapPermissionsToGraphqlPermissions( ) as Record, boolean>; } -@ObjectType() -export class EditorType implements Partial { - @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, 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 { - 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; - } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/service.ts similarity index 80% rename from packages/backend/server/src/core/workspaces/resolvers/service.ts rename to packages/backend/server/src/core/workspaces/service.ts index a596da99ee..0dbd03381f 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/service.ts @@ -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, - }, - }, - }); } } diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index 5553733ff3..c09ac4c5f3 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -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; diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index 1be0fa2fc3..18ea90af9b 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -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; - 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; } } diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 6c5a1dda91..062ab07c61 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -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 } diff --git a/packages/backend/server/src/plugins/license/index.ts b/packages/backend/server/src/plugins/license/index.ts index 615e2d2e73..f2480a2052 100644 --- a/packages/backend/server/src/plugins/license/index.ts +++ b/packages/backend/server/src/plugins/license/index.ts @@ -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 {} diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index 32faf110cf..0adf33e1ee 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -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({ diff --git a/packages/backend/server/src/plugins/payment/manager/workspace.ts b/packages/backend/server/src/plugins/payment/manager/workspace.ts index 244cb3b0b7..b32f072e9e 100644 --- a/packages/backend/server/src/plugins/payment/manager/workspace.ts +++ b/packages/backend/server/src/plugins/payment/manager/workspace.ts @@ -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 ); diff --git a/packages/backend/server/src/plugins/payment/quota.ts b/packages/backend/server/src/plugins/payment/quota.ts index 7eb35f3fa9..125798cbd3 100644 --- a/packages/backend/server/src/plugins/payment/quota.ts +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -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 diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 40b84a0662..e26e0c93de 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 6534b9bda3..ae8aad6356 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -1227,6 +1227,7 @@ export const getWorkspacePageMetaByIdQuery = { } } }`, + deprecations: ["'pageMeta' is deprecated: use [WorkspaceType.doc.meta] instead"], }; export const getWorkspacePublicByIdQuery = { @@ -1517,7 +1518,7 @@ export const revokeMemberPermissionMutation = { id: 'revokeMemberPermissionMutation' as const, op: 'revokeMemberPermission', query: `mutation revokeMemberPermission($workspaceId: String!, $userId: String!) { - revoke(workspaceId: $workspaceId, userId: $userId) + revokeMember(workspaceId: $workspaceId, userId: $userId) }`, }; @@ -1746,59 +1747,24 @@ export const setEnableUrlPreviewMutation = { }`, }; -export const inviteByEmailMutation = { - id: 'inviteByEmailMutation' as const, - op: 'inviteByEmail', - query: `mutation inviteByEmail($workspaceId: String!, $email: String!, $sendInviteMail: Boolean) { - invite( - workspaceId: $workspaceId - email: $email - sendInviteMail: $sendInviteMail - ) -}`, -}; - export const inviteByEmailsMutation = { id: 'inviteByEmailsMutation' as const, op: 'inviteByEmails', - query: `mutation inviteByEmails($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) { - inviteBatch( - workspaceId: $workspaceId - emails: $emails - sendInviteMail: $sendInviteMail - ) { + query: `mutation inviteByEmails($workspaceId: String!, $emails: [String!]!) { + inviteMembers(workspaceId: $workspaceId, emails: $emails) { email inviteId sentSuccess } }`, + deprecations: ["'sentSuccess' is deprecated: Notification will be sent asynchronously"], }; export const acceptInviteByInviteIdMutation = { id: 'acceptInviteByInviteIdMutation' as const, op: 'acceptInviteByInviteId', - query: `mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!, $sendAcceptMail: Boolean) { - acceptInviteById( - workspaceId: $workspaceId - inviteId: $inviteId - sendAcceptMail: $sendAcceptMail - ) -}`, -}; - -export const inviteBatchMutation = { - id: 'inviteBatchMutation' as const, - op: 'inviteBatch', - query: `mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) { - inviteBatch( - workspaceId: $workspaceId - emails: $emails - sendInviteMail: $sendInviteMail - ) { - email - inviteId - sentSuccess - } + query: `mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!) { + acceptInviteById(workspaceId: $workspaceId, inviteId: $inviteId) }`, }; diff --git a/packages/common/graphql/src/graphql/revoke-member-permission.gql b/packages/common/graphql/src/graphql/revoke-member-permission.gql index 6eace191cc..8020ef19ee 100644 --- a/packages/common/graphql/src/graphql/revoke-member-permission.gql +++ b/packages/common/graphql/src/graphql/revoke-member-permission.gql @@ -1,3 +1,3 @@ mutation revokeMemberPermission($workspaceId: String!, $userId: String!) { - revoke(workspaceId: $workspaceId, userId: $userId) + revokeMember(workspaceId: $workspaceId, userId: $userId) } diff --git a/packages/common/graphql/src/graphql/workspace-intive-by-email.gql b/packages/common/graphql/src/graphql/workspace-intive-by-email.gql deleted file mode 100644 index 27032351ff..0000000000 --- a/packages/common/graphql/src/graphql/workspace-intive-by-email.gql +++ /dev/null @@ -1,11 +0,0 @@ -mutation inviteByEmail( - $workspaceId: String! - $email: String! - $sendInviteMail: Boolean -) { - invite( - workspaceId: $workspaceId - email: $email - sendInviteMail: $sendInviteMail - ) -} diff --git a/packages/common/graphql/src/graphql/workspace-intive-by-emails.gql b/packages/common/graphql/src/graphql/workspace-intive-by-emails.gql index a21e22e22e..43a4cb3b9b 100644 --- a/packages/common/graphql/src/graphql/workspace-intive-by-emails.gql +++ b/packages/common/graphql/src/graphql/workspace-intive-by-emails.gql @@ -1,12 +1,10 @@ mutation inviteByEmails( $workspaceId: String! $emails: [String!]! - $sendInviteMail: Boolean ) { - inviteBatch( + inviteMembers( workspaceId: $workspaceId emails: $emails - sendInviteMail: $sendInviteMail ) { email inviteId diff --git a/packages/common/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql b/packages/common/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql index c00a3d8769..9d499dddfd 100644 --- a/packages/common/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql +++ b/packages/common/graphql/src/graphql/workspace-invite-accept-by-invite-id.gql @@ -1,11 +1,9 @@ mutation acceptInviteByInviteId( $workspaceId: String! $inviteId: String! - $sendAcceptMail: Boolean ) { acceptInviteById( workspaceId: $workspaceId inviteId: $inviteId - sendAcceptMail: $sendAcceptMail ) } diff --git a/packages/common/graphql/src/graphql/workspace-invite-batch.gql b/packages/common/graphql/src/graphql/workspace-invite-batch.gql deleted file mode 100644 index 331aaa17a6..0000000000 --- a/packages/common/graphql/src/graphql/workspace-invite-batch.gql +++ /dev/null @@ -1,15 +0,0 @@ -mutation inviteBatch( - $workspaceId: String! - $emails: [String!]! - $sendInviteMail: Boolean -) { - inviteBatch( - workspaceId: $workspaceId - emails: $emails - sendInviteMail: $sendInviteMail - ) { - email - inviteId - sentSuccess - } -} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index fd3ed8d0bb..8761c1c4ea 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -536,6 +536,8 @@ export interface DocType { /** paginated doc granted users list */ grantedUsersList: PaginatedGrantedDocUserType; id: Scalars['String']['output']; + /** Doc metadata */ + meta: WorkspaceDocMeta; mode: PublicDocMode; permissions: DocPermissions; public: Scalars['Boolean']['output']; @@ -588,6 +590,7 @@ export type ErrorDataUnion = | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType + | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType @@ -674,6 +677,7 @@ export enum ErrorNames { INVALID_EMAIL = 'INVALID_EMAIL', INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN', INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP', + INVALID_INVITATION = 'INVALID_INVITATION', INVALID_LICENSE_SESSION_ID = 'INVALID_LICENSE_SESSION_ID', INVALID_LICENSE_TO_ACTIVATE = 'INVALID_LICENSE_TO_ACTIVATE', INVALID_LICENSE_UPDATE_PARAMS = 'INVALID_LICENSE_UPDATE_PARAMS', @@ -692,10 +696,12 @@ export enum ErrorNames { MENTION_USER_ONESELF_DENIED = 'MENTION_USER_ONESELF_DENIED', MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER', NETWORK_ERROR = 'NETWORK_ERROR', + NEW_OWNER_IS_NOT_ACTIVE_MEMBER = 'NEW_OWNER_IS_NOT_ACTIVE_MEMBER', NOTIFICATION_NOT_FOUND = 'NOTIFICATION_NOT_FOUND', NOT_FOUND = 'NOT_FOUND', NOT_IN_SPACE = 'NOT_IN_SPACE', NO_COPILOT_PROVIDER_AVAILABLE = 'NO_COPILOT_PROVIDER_AVAILABLE', + NO_MORE_SEAT = 'NO_MORE_SEAT', OAUTH_ACCOUNT_ALREADY_CONNECTED = 'OAUTH_ACCOUNT_ALREADY_CONNECTED', OAUTH_STATE_EXPIRED = 'OAUTH_STATE_EXPIRED', OWNER_CAN_NOT_LEAVE_WORKSPACE = 'OWNER_CAN_NOT_LEAVE_WORKSPACE', @@ -931,19 +937,19 @@ export interface InviteLink { export interface InviteResult { __typename?: 'InviteResult'; email: Scalars['String']['output']; + /** Invite error */ + error: Maybe; /** Invite id, null if invite record create failed */ inviteId: Maybe; - /** Invite email sent success */ + /** + * Invite email sent success + * @deprecated Notification will be sent asynchronously + */ sentSuccess: Scalars['Boolean']['output']; } export interface InviteUserType { __typename?: 'InviteUserType'; - /** - * User accepted - * @deprecated Use `status` instead - */ - accepted: Scalars['Boolean']['output']; /** User avatar url */ avatarUrl: Maybe; /** @@ -1145,8 +1151,9 @@ export interface Mutation { grantMember: Scalars['Boolean']['output']; /** import users */ importUsers: Array; - invite: Scalars['String']['output']; + /** @deprecated use [inviteMembers] instead */ inviteBatch: Array; + inviteMembers: Array; leaveWorkspace: Scalars['Boolean']['output']; /** mention user in a doc */ mentionUser: Scalars['ID']['output']; @@ -1172,9 +1179,11 @@ export interface Mutation { removeWorkspaceFeature: Scalars['Boolean']['output']; resumeSubscription: SubscriptionType; retryAudioTranscription: Maybe; + /** @deprecated use [revokeMember] instead */ revoke: Scalars['Boolean']['output']; revokeDocUserRoles: Scalars['Boolean']['output']; revokeInviteLink: Scalars['Boolean']['output']; + revokeMember: Scalars['Boolean']['output']; revokePublicDoc: DocType; /** @deprecated use revokePublicDoc instead */ revokePublicPage: DocType; @@ -1214,7 +1223,7 @@ export interface Mutation { export interface MutationAcceptInviteByIdArgs { inviteId: Scalars['String']['input']; sendAcceptMail?: InputMaybe; - workspaceId: Scalars['String']['input']; + workspaceId?: InputMaybe; } export interface MutationActivateLicenseArgs { @@ -1367,16 +1376,14 @@ export interface MutationImportUsersArgs { input: ImportUsersInput; } -export interface MutationInviteArgs { - email: Scalars['String']['input']; - permission?: InputMaybe; +export interface MutationInviteBatchArgs { + emails: Array; sendInviteMail?: InputMaybe; workspaceId: Scalars['String']['input']; } -export interface MutationInviteBatchArgs { +export interface MutationInviteMembersArgs { emails: Array; - sendInviteMail?: InputMaybe; workspaceId: Scalars['String']['input']; } @@ -1467,6 +1474,11 @@ export interface MutationRevokeInviteLinkArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRevokeMemberArgs { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationRevokePublicDocArgs { docId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -1582,6 +1594,11 @@ export interface MutationVerifyEmailArgs { token: Scalars['String']['input']; } +export interface NoMoreSeatDataType { + __typename?: 'NoMoreSeatDataType'; + spaceId: Scalars['String']['output']; +} + export interface NotInSpaceDataType { __typename?: 'NotInSpaceDataType'; spaceId: Scalars['String']['output']; @@ -1729,7 +1746,7 @@ export interface Query { /** Get current user */ currentUser: Maybe; error: ErrorDataUnion; - /** send workspace invitation */ + /** get workspace invitation info */ getInviteInfo: InvitationType; /** * Get is admin of workspace @@ -2226,6 +2243,14 @@ export interface WorkspaceBlobSizes { size: Scalars['SafeInt']['output']; } +export interface WorkspaceDocMeta { + __typename?: 'WorkspaceDocMeta'; + createdAt: Scalars['DateTime']['output']; + createdBy: Maybe; + updatedAt: Scalars['DateTime']['output']; + updatedBy: Maybe; +} + /** Workspace invite link expire time */ export enum WorkspaceInviteLinkExpireTime { OneDay = 'OneDay', @@ -2237,6 +2262,7 @@ export enum WorkspaceInviteLinkExpireTime { /** Member invite status in workspace */ export enum WorkspaceMemberStatus { Accepted = 'Accepted', + AllocatingSeat = 'AllocatingSeat', NeedMoreSeat = 'NeedMoreSeat', NeedMoreSeatAndReview = 'NeedMoreSeatAndReview', Pending = 'Pending', @@ -2248,14 +2274,6 @@ export interface WorkspaceMembersExceedLimitToDowngradeDataType { limit: Scalars['Int']['output']; } -export interface WorkspacePageMeta { - __typename?: 'WorkspacePageMeta'; - createdAt: Scalars['DateTime']['output']; - createdBy: Maybe; - updatedAt: Scalars['DateTime']['output']; - updatedBy: Maybe; -} - export interface WorkspacePermissionNotFoundDataType { __typename?: 'WorkspacePermissionNotFoundDataType'; spaceId: Scalars['String']['output']; @@ -2350,8 +2368,11 @@ export interface WorkspaceType { members: Array; /** Owner of workspace */ owner: UserType; - /** Cloud page metadata of workspace */ - pageMeta: WorkspacePageMeta; + /** + * Cloud page metadata of workspace + * @deprecated use [WorkspaceType.doc.meta] instead + */ + pageMeta: WorkspaceDocMeta; /** map of action permissions */ permissions: WorkspacePermissions; /** is Public workspace */ @@ -3815,7 +3836,7 @@ export type GetWorkspacePageMetaByIdQuery = { workspace: { __typename?: 'WorkspaceType'; pageMeta: { - __typename?: 'WorkspacePageMeta'; + __typename?: 'WorkspaceDocMeta'; createdAt: string; updatedAt: string; createdBy: { @@ -4141,7 +4162,7 @@ export type RevokeMemberPermissionMutationVariables = Exact<{ export type RevokeMemberPermissionMutation = { __typename?: 'Mutation'; - revoke: boolean; + revokeMember: boolean; }; export type RevokePublicPageMutationVariables = Exact<{ @@ -4387,23 +4408,14 @@ export type SetEnableUrlPreviewMutation = { updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; -export type InviteByEmailMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - email: Scalars['String']['input']; - sendInviteMail?: InputMaybe; -}>; - -export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string }; - export type InviteByEmailsMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; emails: Array | Scalars['String']['input']; - sendInviteMail?: InputMaybe; }>; export type InviteByEmailsMutation = { __typename?: 'Mutation'; - inviteBatch: Array<{ + inviteMembers: Array<{ __typename?: 'InviteResult'; email: string; inviteId: string | null; @@ -4414,7 +4426,6 @@ export type InviteByEmailsMutation = { export type AcceptInviteByInviteIdMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; inviteId: Scalars['String']['input']; - sendAcceptMail?: InputMaybe; }>; export type AcceptInviteByInviteIdMutation = { @@ -4422,22 +4433,6 @@ export type AcceptInviteByInviteIdMutation = { acceptInviteById: boolean; }; -export type InviteBatchMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - emails: Array | Scalars['String']['input']; - sendInviteMail?: InputMaybe; -}>; - -export type InviteBatchMutation = { - __typename?: 'Mutation'; - inviteBatch: Array<{ - __typename?: 'InviteResult'; - email: string; - inviteId: string | null; - sentSuccess: boolean; - }>; -}; - export type CreateInviteLinkMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; expireTime: WorkspaceInviteLinkExpireTime; @@ -5228,11 +5223,6 @@ export type Mutations = variables: SetEnableUrlPreviewMutationVariables; response: SetEnableUrlPreviewMutation; } - | { - name: 'inviteByEmailMutation'; - variables: InviteByEmailMutationVariables; - response: InviteByEmailMutation; - } | { name: 'inviteByEmailsMutation'; variables: InviteByEmailsMutationVariables; @@ -5243,11 +5233,6 @@ export type Mutations = variables: AcceptInviteByInviteIdMutationVariables; response: AcceptInviteByInviteIdMutation; } - | { - name: 'inviteBatchMutation'; - variables: InviteBatchMutationVariables; - response: InviteBatchMutation; - } | { name: 'createInviteLinkMutation'; variables: CreateInviteLinkMutationVariables; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx index d90437d99f..f0e891f110 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/cloud-members-panel.tsx @@ -206,7 +206,7 @@ export const CloudWorkspaceMembersPanel = ({ setIsMutating(false); return; } - const results = await membersService.inviteMembers(uniqueEmails, true); + const results = await membersService.inviteMembers(uniqueEmails); const unSuccessInvites = results.reduce((acc, result) => { if (!result.sentSuccess) { acc.push(result.email); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx index 620a824058..26d3586fa7 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx @@ -256,12 +256,14 @@ const MemberItem = ({ const getMemberStatus = (member: Member): I18nString => { switch (member.status) { case WorkspaceMemberStatus.NeedMoreSeat: + case WorkspaceMemberStatus.NeedMoreSeatAndReview: return 'insufficient-team-seat'; case WorkspaceMemberStatus.Pending: return 'Pending'; - case WorkspaceMemberStatus.NeedMoreSeatAndReview: case WorkspaceMemberStatus.UnderReview: return 'Under-Review'; + case WorkspaceMemberStatus.AllocatingSeat: + return 'Allocating Seat'; case WorkspaceMemberStatus.Accepted: switch (member.permission) { case Permission.Owner: diff --git a/packages/frontend/core/src/modules/cloud/services/invitation.ts b/packages/frontend/core/src/modules/cloud/services/invitation.ts index 1326b14ef6..c767d3cd7c 100644 --- a/packages/frontend/core/src/modules/cloud/services/invitation.ts +++ b/packages/frontend/core/src/modules/cloud/services/invitation.ts @@ -63,8 +63,7 @@ export class InvitationService extends Service { } return await this.acceptInviteStore.acceptInvite( this.inviteInfo$.value.workspace.id, - inviteId, - true + inviteId ); } diff --git a/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts b/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts index 4bb6ffb690..c91f8d7734 100644 --- a/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts +++ b/packages/frontend/core/src/modules/cloud/stores/accept-invite.ts @@ -11,7 +11,6 @@ export class AcceptInviteStore extends Store { async acceptInvite( workspaceId: string, inviteId: string, - sendAcceptMail?: boolean, signal?: AbortSignal ) { const data = await this.gqlService.gql({ @@ -20,7 +19,6 @@ export class AcceptInviteStore extends Store { variables: { workspaceId, inviteId, - sendAcceptMail, }, context: { signal }, }); diff --git a/packages/frontend/core/src/modules/permissions/services/members.ts b/packages/frontend/core/src/modules/permissions/services/members.ts index a5433aa98a..6dbfbd6299 100644 --- a/packages/frontend/core/src/modules/permissions/services/members.ts +++ b/packages/frontend/core/src/modules/permissions/services/members.ts @@ -18,19 +18,10 @@ export class WorkspaceMembersService extends Service { members = this.framework.createEntity(WorkspaceMembers); - async inviteMember(email: string, sendInviteMail?: boolean) { - return await this.store.inviteMember( - this.workspaceService.workspace.id, - email, - sendInviteMail - ); - } - - async inviteMembers(emails: string[], sendInviteMail?: boolean) { + async inviteMembers(emails: string[]) { return await this.store.inviteBatch( this.workspaceService.workspace.id, - emails, - sendInviteMail + emails ); } diff --git a/packages/frontend/core/src/modules/permissions/stores/members.ts b/packages/frontend/core/src/modules/permissions/stores/members.ts index 660d21b9cf..0fe6fff410 100644 --- a/packages/frontend/core/src/modules/permissions/stores/members.ts +++ b/packages/frontend/core/src/modules/permissions/stores/members.ts @@ -3,7 +3,6 @@ import { createInviteLinkMutation, getMembersByWorkspaceIdQuery, grantWorkspaceTeamMemberMutation, - inviteByEmailMutation, inviteByEmailsMutation, type Permission, revokeInviteLinkMutation, @@ -43,30 +42,7 @@ export class WorkspaceMembersStore extends Store { return data.workspace; } - async inviteMember( - workspaceId: string, - email: string, - sendInviteMail = false - ) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const invite = await this.workspaceServerService.server.gql({ - query: inviteByEmailMutation, - variables: { - workspaceId, - email, - sendInviteMail, - }, - }); - return invite.invite; - } - - async inviteBatch( - workspaceId: string, - emails: string[], - sendInviteMail = false - ) { + async inviteBatch(workspaceId: string, emails: string[]) { if (!this.workspaceServerService.server) { throw new Error('No Server'); } @@ -75,10 +51,9 @@ export class WorkspaceMembersStore extends Store { variables: { workspaceId, emails, - sendInviteMail, }, }); - return inviteBatch.inviteBatch; + return inviteBatch.inviteMembers; } async generateInviteLink( @@ -128,7 +103,7 @@ export class WorkspaceMembersStore extends Store { }, context: { signal }, }); - return revoke.revoke; + return revoke.revokeMember; } async approveMember(workspaceId: string, userId: string) { diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 890fdedeaa..4af5b36118 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -373,6 +373,10 @@ export function useAFFiNEI18N(): { * `Need More Seats` */ ["Need-More-Seats"](): string; + /** + * `Allocating Seat` + */ + ["Allocating Seat"](): string; /** * `Admin` */ @@ -8078,6 +8082,20 @@ export function useAFFiNEI18N(): { * `Can not batch grant doc owner permissions.` */ ["error.CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS"](): string; + /** + * `Can not set a non-active member as owner.` + */ + ["error.NEW_OWNER_IS_NOT_ACTIVE_MEMBER"](): string; + /** + * `Invalid invitation provided.` + */ + ["error.INVALID_INVITATION"](): string; + /** + * `No more seat available in the Space {{spaceId}}.` + */ + ["error.NO_MORE_SEAT"](options: { + readonly spaceId: string; + }): string; /** * `Unsupported subscription plan: {{plan}}.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index ae7dfa5f62..80ed4a0e42 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -83,6 +83,7 @@ "Collaborator": "Collaborator", "Under-Review": "Under Review", "Need-More-Seats": "Need More Seats", + "Allocating Seat": "Allocating Seat", "Admin": "Admin", "Publish": "Publish", "Published to Web": "Published to web", @@ -2005,6 +2006,9 @@ "error.ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE": "A Team workspace is required to perform this action.", "error.DOC_DEFAULT_ROLE_CAN_NOT_BE_OWNER": "Doc default role can not be owner.", "error.CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS": "Can not batch grant doc owner permissions.", + "error.NEW_OWNER_IS_NOT_ACTIVE_MEMBER": "Can not set a non-active member as owner.", + "error.INVALID_INVITATION": "Invalid invitation provided.", + "error.NO_MORE_SEAT": "No more seat available in the Space {{spaceId}}.", "error.UNSUPPORTED_SUBSCRIPTION_PLAN": "Unsupported subscription plan: {{plan}}.", "error.FAILED_TO_CHECKOUT": "Failed to create checkout session.", "error.INVALID_CHECKOUT_PARAMETERS": "Invalid checkout parameters provided.",