feat(server): add invitation status to getInviteInfo response (#11158)

close CLOUD-182
This commit is contained in:
fengmk2
2025-03-25 06:51:26 +00:00
parent 9bad6fa12d
commit 4d15c32242
10 changed files with 144 additions and 15 deletions

View File

@@ -1,9 +1,12 @@
import { import {
acceptInviteByInviteIdMutation, acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery, getMembersByWorkspaceIdQuery,
inviteByEmailMutation, inviteByEmailMutation,
leaveWorkspaceMutation, leaveWorkspaceMutation,
revokeMemberPermissionMutation, revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus, WorkspaceMemberStatus,
} from '@affine/graphql'; } from '@affine/graphql';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
@@ -32,6 +35,34 @@ e2e('should invite a user', async t => {
const invitationNotification = app.queue.last('notification.sendInvitation'); const invitationNotification = app.queue.last('notification.sendInvitation');
t.is(invitationNotification.payload.inviterId, owner.id); t.is(invitationNotification.payload.inviterId, owner.id);
t.is(invitationNotification.payload.inviteId, result.invite); 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 => { e2e('should leave a workspace', async t => {
@@ -290,7 +321,7 @@ e2e('should limit member count correctly', async t => {
const workspace = await app.create(Mockers.Workspace, { const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id }, owner: { id: owner.id },
}); });
await Promise.allSettled( await Promise.all(
Array.from({ length: 10 }).map(async () => { Array.from({ length: 10 }).map(async () => {
const user = await app.signup(); const user = await app.signup();
await app.create(Mockers.WorkspaceUser, { await app.create(Mockers.WorkspaceUser, {
@@ -312,3 +343,62 @@ e2e('should limit member count correctly', async t => {
t.is(result.workspace.memberCount, 11); t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10); 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);
});

View File

@@ -544,27 +544,34 @@ test('should be able to invite by link', async t => {
const [teamInviteId, teamInvite, acceptTeamInvite] = const [teamInviteId, teamInvite, acceptTeamInvite] =
await createInviteLink(tws); await createInviteLink(tws);
const member = await app.signup();
{ {
// check invite link // check invite link
app.switchUser(owner); app.switchUser(member);
const info = await getInviteInfo(app, inviteId); const info = await getInviteInfo(app, inviteId);
t.is(info.workspace.id, ws.id, 'should be able to get invite info'); t.is(info.workspace.id, ws.id, 'should be able to get invite info');
t.falsy(info.status);
// check team invite link // check team invite link
const teamInfo = await getInviteInfo(app, teamInviteId); const teamInfo = await getInviteInfo(app, teamInviteId);
t.is(teamInfo.workspace.id, tws.id, 'should be able to get invite info'); t.is(teamInfo.workspace.id, tws.id, 'should be able to get invite info');
t.falsy(info.status);
} }
{ {
// invite link // invite link
for (const [i] of Array.from({ length: 5 }).entries()) { for (const [i] of Array.from({ length: 5 }).entries()) {
const user = await invite(`test${i}@affine.pro`); const user = await invite(`test${i}@affine.pro`);
const status = (await models.workspaceUser.get(ws.id, user.id))?.status; const role = await models.workspaceUser.get(ws.id, user.id);
t.truthy(role);
const status = role!.status;
t.is( t.is(
status, status,
WorkspaceMemberStatus.UnderReview, WorkspaceMemberStatus.UnderReview,
'should be able to check status' 'should be able to check status'
); );
const info = await getInviteInfo(app, role!.id);
t.is(info.status, WorkspaceMemberStatus.UnderReview);
} }
await t.throwsAsync( await t.throwsAsync(

View File

@@ -163,6 +163,7 @@ export async function getInviteInfo(
name name
avatarUrl avatarUrl
} }
status
} }
} }
`); `);

View File

@@ -20,6 +20,7 @@ import { WorkspaceRole } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
export type InviteInfo = { export type InviteInfo = {
isLink: boolean;
workspaceId: string; workspaceId: string;
inviterUserId?: string; inviterUserId?: string;
inviteeUserId?: string; inviteeUserId?: string;
@@ -45,7 +46,10 @@ export class WorkspaceService {
`workspace:inviteLinkId:${inviteId}` `workspace:inviteLinkId:${inviteId}`
); );
if (typeof invite?.workspaceId === 'string') { if (typeof invite?.workspaceId === 'string') {
return invite; return {
...invite,
isLink: true,
};
} }
const workspaceUser = await this.models.workspaceUser.getById(inviteId); const workspaceUser = await this.models.workspaceUser.getById(inviteId);
@@ -55,6 +59,7 @@ export class WorkspaceService {
} }
return { return {
isLink: false,
workspaceId: workspaceUser.workspaceId, workspaceId: workspaceUser.workspaceId,
inviteeUserId: workspaceUser.userId, inviteeUserId: workspaceUser.userId,
}; };

View File

@@ -514,8 +514,8 @@ export class WorkspaceResolver {
async getInviteInfo( async getInviteInfo(
@CurrentUser() user: UserType | undefined, @CurrentUser() user: UserType | undefined,
@Args('inviteId') inviteId: string @Args('inviteId') inviteId: string
) { ): Promise<InvitationType> {
const { workspaceId, inviteeUserId } = const { workspaceId, inviteeUserId, isLink } =
await this.workspaceService.getInviteInfo(inviteId); await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
@@ -523,8 +523,21 @@ export class WorkspaceResolver {
const inviteeId = inviteeUserId || user?.id; const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound(); if (!inviteeId) throw new UserNotFound();
const invitee = await this.models.user.getWorkspaceUser(inviteeId); const invitee = await this.models.user.getWorkspaceUser(inviteeId);
if (!invitee) throw new UserNotFound();
return { workspace, user: owner, invitee }; 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) @Mutation(() => Boolean)

View File

@@ -12,7 +12,7 @@ import { WorkspaceMemberStatus } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars'; import { SafeIntResolver } from 'graphql-scalars';
import { DocRole, WorkspaceRole } from '../permission'; import { DocRole, WorkspaceRole } from '../permission';
import { UserType } from '../user/types'; import { UserType, WorkspaceUserType } from '../user/types';
registerEnumType(WorkspaceRole, { registerEnumType(WorkspaceRole, {
name: 'WorkspaceRole', name: 'WorkspaceRole',
@@ -120,9 +120,14 @@ export class InvitationType {
@Field({ description: 'Workspace information' }) @Field({ description: 'Workspace information' })
workspace!: InvitationWorkspaceType; workspace!: InvitationWorkspaceType;
@Field({ description: 'User information' }) @Field({ description: 'User information' })
user!: UserType; user!: WorkspaceUserType;
@Field({ description: 'Invitee information' }) @Field({ description: 'Invitee information' })
invitee!: UserType; invitee!: WorkspaceUserType;
@Field(() => WorkspaceMemberStatus, {
description: 'Invitation status in workspace',
nullable: true,
})
status?: WorkspaceMemberStatus;
} }
@InputType() @InputType()

View File

@@ -692,10 +692,13 @@ type InvitationReviewRequestNotificationBodyType {
type InvitationType { type InvitationType {
"""Invitee information""" """Invitee information"""
invitee: UserType! invitee: WorkspaceUserType!
"""Invitation status in workspace"""
status: WorkspaceMemberStatus
"""User information""" """User information"""
user: UserType! user: WorkspaceUserType!
"""Workspace information""" """Workspace information"""
workspace: InvitationWorkspaceType! workspace: InvitationWorkspaceType!

View File

@@ -10,5 +10,6 @@ query getInviteInfo($inviteId: String!) {
name name
avatarUrl avatarUrl
} }
status
} }
} }

View File

@@ -750,6 +750,7 @@ export const getInviteInfoQuery = {
name name
avatarUrl avatarUrl
} }
status
} }
}`, }`,
}; };

View File

@@ -834,9 +834,11 @@ export interface InvitationReviewRequestNotificationBodyType {
export interface InvitationType { export interface InvitationType {
__typename?: 'InvitationType'; __typename?: 'InvitationType';
/** Invitee information */ /** Invitee information */
invitee: UserType; invitee: WorkspaceUserType;
/** Invitation status in workspace */
status: Maybe<WorkspaceMemberStatus>;
/** User information */ /** User information */
user: UserType; user: WorkspaceUserType;
/** Workspace information */ /** Workspace information */
workspace: InvitationWorkspaceType; workspace: InvitationWorkspaceType;
} }
@@ -3192,6 +3194,7 @@ export type GetInviteInfoQuery = {
__typename?: 'Query'; __typename?: 'Query';
getInviteInfo: { getInviteInfo: {
__typename?: 'InvitationType'; __typename?: 'InvitationType';
status: WorkspaceMemberStatus | null;
workspace: { workspace: {
__typename?: 'InvitationWorkspaceType'; __typename?: 'InvitationWorkspaceType';
id: string; id: string;
@@ -3199,7 +3202,7 @@ export type GetInviteInfoQuery = {
avatar: string; avatar: string;
}; };
user: { user: {
__typename?: 'UserType'; __typename?: 'WorkspaceUserType';
id: string; id: string;
name: string; name: string;
avatarUrl: string | null; avatarUrl: string | null;