Skip to content

Commit

Permalink
feat(server): improve team invite (#9092)
Browse files Browse the repository at this point in the history
  • Loading branch information
darkskygit authored Dec 11, 2024
1 parent 671c41c commit 9b0f1bb
Show file tree
Hide file tree
Showing 14 changed files with 146 additions and 46 deletions.
9 changes: 7 additions & 2 deletions packages/backend/server/src/core/quota/quota.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { pick } from 'lodash-es';

import { PrismaTransaction } from '../../fundamentals';
import { formatDate, formatSize, Quota, QuotaSchema } from './types';

const QuotaCache = new Map<number, QuotaConfig>();

export class QuotaConfig {
readonly config: Quota;
readonly override?: Quota['configs'];
readonly override?: Partial<Quota['configs']>;

static async get(tx: PrismaTransaction, featureId: number) {
const cachedQuota = QuotaCache.get(featureId);
Expand Down Expand Up @@ -49,7 +51,10 @@ export class QuotaConfig {
configs: Object.assign({}, config.data.configs, override),
});
if (overrideConfig.success) {
this.override = overrideConfig.data.configs;
this.override = pick(
overrideConfig.data.configs,
Object.keys(override)
);
} else {
throw new Error(
`Invalid quota override config: ${override.error.message}, ${JSON.stringify(
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/server/src/core/quota/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export class QuotaService {
.findFirst({
where: {
workspaceId,
feature: { feature: type, type: FeatureKind.Feature },
feature: { feature: type, type: FeatureKind.Quota },
activated: true,
},
select: { configs: true },
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/server/src/core/quota/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export class QuotaManagementService {
FeatureType.UnlimitedWorkspace
);

const quota = {
const quota: QuotaBusinessType = {
name,
blobLimit,
businessBlobLimit,
Expand Down
34 changes: 32 additions & 2 deletions packages/backend/server/src/core/workspaces/resolvers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import {
NotInSpace,
RequestMutex,
TooManyRequest,
URLHelper,
} from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService } from '../../quota';
import { UserService } from '../../user';
import {
InviteLink,
InviteResult,
WorkspaceInviteLinkExpireTime,
WorkspaceType,
Expand All @@ -41,6 +43,7 @@ export class TeamWorkspaceResolver {
private readonly cache: Cache,
private readonly event: EventEmitter,
private readonly mailer: MailService,
private readonly url: URLHelper,
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
private readonly users: UserService,
Expand Down Expand Up @@ -71,6 +74,10 @@ export class TeamWorkspaceResolver {
Permission.Admin
);

if (emails.length > 512) {
return new TooManyRequest();
}

// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.lock(lockFlag);
Expand Down Expand Up @@ -150,8 +157,27 @@ export class TeamWorkspaceResolver {
return results;
}

@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
})
async inviteLink(@Parent() workspace: WorkspaceType) {
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),
};
}
}
return null;
}

@Mutation(() => String)
async inviteLink(
async createInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
Expand All @@ -171,7 +197,11 @@ export class TeamWorkspaceResolver {
const inviteId = nanoid();
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(cacheInviteId, { workspaceId }, { ttl: expireTime });
await this.cache.set(
cacheInviteId,
{ workspaceId, inviteeUserId: user.id },
{ ttl: expireTime }
);
return inviteId;
}

Expand Down
20 changes: 13 additions & 7 deletions packages/backend/server/src/core/workspaces/resolvers/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,12 +485,15 @@ export class WorkspaceResolver {
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
let workspaceId = null;
let invitee = null;
// invite link
const invite = await this.cache.get<{ workspaceId: string }>(
`workspace:inviteLinkId:${inviteId}`
);
const invite = await this.cache.get<{
workspaceId: string;
inviteeUserId: string;
}>(`workspace:inviteLinkId:${inviteId}`);
if (typeof invite?.workspaceId === 'string') {
workspaceId = invite.workspaceId;
invitee = { user: await this.users.findUserById(invite.inviteeUserId) };
}
if (!workspaceId) {
workspaceId = await this.prisma.workspaceUserPermission
Expand All @@ -508,10 +511,13 @@ export class WorkspaceResolver {
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);

const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const invitee = await this.permissions.getWorkspaceInvitation(
inviteId,
workspaceId
);

if (!invitee) {
invitee = await this.permissions.getWorkspaceInvitation(
inviteId,
workspaceId
);
}

let avatar = '';
if (workspaceContent?.avatarKey) {
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/server/src/core/workspaces/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ export class UpdateWorkspaceInput extends PickType(
id!: string;
}

@ObjectType()
export class InviteLink {
@Field(() => String, { description: 'Invite link' })
link!: string;

@Field(() => Date, { description: 'Invite link expire time' })
expireTime!: Date;
}

@ObjectType()
export class InviteResult {
@Field(() => String)
Expand Down
13 changes: 12 additions & 1 deletion packages/backend/server/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,14 @@ type InvitationWorkspaceType {
name: String!
}

type InviteLink {
"""Invite link expire time"""
expireTime: DateTime!

"""Invite link"""
link: String!
}

type InviteResult {
email: String!

Expand Down Expand Up @@ -496,6 +504,7 @@ type Mutation {

"""Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String!
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!

"""Create a new user"""
createUser(input: CreateUserInput!): UserType!
Expand All @@ -514,7 +523,6 @@ type Mutation {
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
inviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean!
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
Expand Down Expand Up @@ -996,6 +1004,9 @@ type WorkspaceType {
"""is current workspace initialized"""
initialized: Boolean!

"""invite link for workspace"""
inviteLink: InviteLink

"""Get user invoice count"""
invoiceCount: Int!
invoices(skip: Int, take: Int = 8): [InvoiceType!]!
Expand Down
25 changes: 18 additions & 7 deletions packages/backend/server/tests/team.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
acceptInviteById,
createTestingApp,
createWorkspace,
getInviteInfo,
grantMember,
inviteLink,
inviteUser,
Expand Down Expand Up @@ -95,11 +96,14 @@ const init = async (app: INestApplication, memberLimit = 10) => {

const createInviteLink = async () => {
const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay');
return async (email: string): Promise<UserAuthedType> => {
const member = await signUp(app, email.split('@')[0], email, '123456');
await acceptInviteById(app, ws.id, inviteId, false, member.token.token);
return member;
};
return [
inviteId,
async (email: string): Promise<UserAuthedType> => {
const member = await signUp(app, email.split('@')[0], email, '123456');
await acceptInviteById(app, ws.id, inviteId, false, member.token.token);
return member;
},
] as const;
};

const admin = await invite('[email protected]', 'Admin');
Expand Down Expand Up @@ -237,8 +241,15 @@ test('should be able to leave workspace', async t => {

test('should be able to invite by link', async t => {
const { app, permissions, quotaManager } = t.context;
const { createInviteLink, ws } = await init(app, 4);
const invite = await createInviteLink();
const { createInviteLink, owner, ws } = await init(app, 4);
const [inviteId, invite] = await createInviteLink();

{
// check invite link
const info = await getInviteInfo(app, owner.token.token, inviteId);
t.is(info.workspace.id, ws.id, 'should be able to get invite info');
}

{
// invite link
const members: UserAuthedType[] = [];
Expand Down
9 changes: 7 additions & 2 deletions packages/backend/server/tests/utils/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ export async function inviteLink(
.send({
query: `
mutation {
inviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime})
createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime})
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.inviteLink;
return res.body.data.createInviteLink;
}

export async function acceptInviteById(
Expand Down Expand Up @@ -187,5 +187,10 @@ export async function getInviteInfo(
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message, {
cause: res.body.errors[0].cause,
});
}
return res.body.data.getInviteInfo;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import {
acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
getWorkspaceInfoQuery,
grantWorkspaceTeamMemberMutation,
inviteByEmailMutation,
inviteByEmailsMutation,
inviteLinkMutation,
leaveWorkspaceMutation,
type Permission,
revokeInviteLinkMutation,
Expand Down Expand Up @@ -83,13 +83,13 @@ export class WorkspacePermissionStore extends Store {
throw new Error('No Server');
}
const inviteLink = await this.workspaceServerService.server.gql({
query: inviteLinkMutation,
query: createInviteLinkMutation,
variables: {
workspaceId,
expireTime,
},
});
return inviteLink.inviteLink;
return inviteLink.createInviteLink;
}

async revokeInviteLink(workspaceId: string, signal?: AbortSignal) {
Expand Down
16 changes: 10 additions & 6 deletions packages/frontend/graphql/src/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,10 @@ query getWorkspaceConfig($id: String!) {
workspace(id: $id) {
enableAi
enableUrlPreview
inviteLink {
link
expireTime
}
}
}`,
};
Expand Down Expand Up @@ -1431,14 +1435,14 @@ mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail
}`,
};

export const inviteLinkMutation = {
id: 'inviteLinkMutation' as const,
operationName: 'inviteLink',
definitionName: 'inviteLink',
export const createInviteLinkMutation = {
id: 'createInviteLinkMutation' as const,
operationName: 'createInviteLink',
definitionName: 'createInviteLink',
containsFile: false,
query: `
mutation inviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) {
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
mutation createInviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) {
createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
}`,
};

Expand Down
4 changes: 4 additions & 0 deletions packages/frontend/graphql/src/graphql/workspace-config.gql
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ query getWorkspaceConfig($id: String!) {
workspace(id: $id) {
enableAi
enableUrlPreview
inviteLink {
link
expireTime
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
mutation inviteLink(
mutation createInviteLink(
$workspaceId: String!
$expireTime: WorkspaceInviteLinkExpireTime!
) {
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
}
Loading

0 comments on commit 9b0f1bb

Please sign in to comment.