diff --git a/packages/backend/server/src/core/quota/quota.ts b/packages/backend/server/src/core/quota/quota.ts index ac106f7cc1..be884c80c8 100644 --- a/packages/backend/server/src/core/quota/quota.ts +++ b/packages/backend/server/src/core/quota/quota.ts @@ -1,3 +1,5 @@ +import { pick } from 'lodash-es'; + import { PrismaTransaction } from '../../fundamentals'; import { formatDate, formatSize, Quota, QuotaSchema } from './types'; @@ -5,7 +7,7 @@ const QuotaCache = new Map(); export class QuotaConfig { readonly config: Quota; - readonly override?: Quota['configs']; + readonly override?: Partial; static async get(tx: PrismaTransaction, featureId: number) { const cachedQuota = QuotaCache.get(featureId); @@ -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( diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index 619574747e..97d59630f9 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -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 }, diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts index a74942eeec..54c79e0e5c 100644 --- a/packages/backend/server/src/core/quota/storage.ts +++ b/packages/backend/server/src/core/quota/storage.ts @@ -191,7 +191,7 @@ export class QuotaManagementService { FeatureType.UnlimitedWorkspace ); - const quota = { + const quota: QuotaBusinessType = { name, blobLimit, businessBlobLimit, diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 1d4a59f6a1..ce950306bc 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -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, @@ -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, @@ -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); @@ -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 }) @@ -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; } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 9b42d6a419..d20e2dec97 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -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 @@ -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) { diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index 4452f5d85a..be5a1961e4 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -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) diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index c463efc2b3..e0987c41a5 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -361,6 +361,14 @@ type InvitationWorkspaceType { name: String! } +type InviteLink { + """Invite link expire time""" + expireTime: DateTime! + + """Invite link""" + link: String! +} + type InviteResult { email: String! @@ -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! @@ -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! @@ -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!]! diff --git a/packages/backend/server/tests/team.e2e.ts b/packages/backend/server/tests/team.e2e.ts index 10196301f1..938c97ae46 100644 --- a/packages/backend/server/tests/team.e2e.ts +++ b/packages/backend/server/tests/team.e2e.ts @@ -17,6 +17,7 @@ import { acceptInviteById, createTestingApp, createWorkspace, + getInviteInfo, grantMember, inviteLink, inviteUser, @@ -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 => { - 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 => { + 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('admin@affine.pro', 'Admin'); @@ -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[] = []; diff --git a/packages/backend/server/tests/utils/invite.ts b/packages/backend/server/tests/utils/invite.ts index 8daa903ae3..9d9dc68676 100644 --- a/packages/backend/server/tests/utils/invite.ts +++ b/packages/backend/server/tests/utils/invite.ts @@ -78,7 +78,7 @@ export async function inviteLink( .send({ query: ` mutation { - inviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) + createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) } `, }) @@ -86,7 +86,7 @@ export async function inviteLink( 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( @@ -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; } diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts index 1528244f2d..6c8dee4b6d 100644 --- a/packages/frontend/core/src/modules/permissions/stores/permission.ts +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -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, @@ -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) { diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 0275fcc8f5..9d92badc70 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -1249,6 +1249,10 @@ query getWorkspaceConfig($id: String!) { workspace(id: $id) { enableAi enableUrlPreview + inviteLink { + link + expireTime + } } }`, }; @@ -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) }`, }; diff --git a/packages/frontend/graphql/src/graphql/workspace-config.gql b/packages/frontend/graphql/src/graphql/workspace-config.gql index ce7ff27d00..ff5d366dd5 100644 --- a/packages/frontend/graphql/src/graphql/workspace-config.gql +++ b/packages/frontend/graphql/src/graphql/workspace-config.gql @@ -2,5 +2,9 @@ query getWorkspaceConfig($id: String!) { workspace(id: $id) { enableAi enableUrlPreview + inviteLink { + link + expireTime + } } } diff --git a/packages/frontend/graphql/src/graphql/workspace-invite-link.gql b/packages/frontend/graphql/src/graphql/workspace-invite-link.gql index 17f02a903b..5ddc566097 100644 --- a/packages/frontend/graphql/src/graphql/workspace-invite-link.gql +++ b/packages/frontend/graphql/src/graphql/workspace-invite-link.gql @@ -1,6 +1,6 @@ -mutation inviteLink( +mutation createInviteLink( $workspaceId: String! $expireTime: WorkspaceInviteLinkExpireTime! ) { - inviteLink(workspaceId: $workspaceId, expireTime: $expireTime) + createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 20d3d1373a..77a33afdf8 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -438,6 +438,14 @@ export interface InvitationWorkspaceType { name: Scalars['String']['output']; } +export interface InviteLink { + __typename?: 'InviteLink'; + /** Invite link expire time */ + expireTime: Scalars['DateTime']['output']; + /** Invite link */ + link: Scalars['String']['output']; +} + export interface InviteResult { __typename?: 'InviteResult'; email: Scalars['String']['output']; @@ -559,6 +567,7 @@ export interface Mutation { createCopilotSession: Scalars['String']['output']; /** Create a stripe customer portal to manage payment methods */ createCustomerPortal: Scalars['String']['output']; + createInviteLink: Scalars['String']['output']; /** Create a new user */ createUser: UserType; /** Create a new workspace */ @@ -573,7 +582,6 @@ export interface Mutation { grantMember: Scalars['String']['output']; invite: Scalars['String']['output']; inviteBatch: Array; - inviteLink: Scalars['String']['output']; leaveWorkspace: Scalars['Boolean']['output']; publishPage: WorkspacePage; recoverDoc: Scalars['DateTime']['output']; @@ -673,6 +681,11 @@ export interface MutationCreateCopilotSessionArgs { options: CreateChatSessionInput; } +export interface MutationCreateInviteLinkArgs { + expireTime: WorkspaceInviteLinkExpireTime; + workspaceId: Scalars['String']['input']; +} + export interface MutationCreateUserArgs { input: CreateUserInput; } @@ -719,11 +732,6 @@ export interface MutationInviteBatchArgs { workspaceId: Scalars['String']['input']; } -export interface MutationInviteLinkArgs { - expireTime: WorkspaceInviteLinkExpireTime; - workspaceId: Scalars['String']['input']; -} - export interface MutationLeaveWorkspaceArgs { sendLeaveMail?: InputMaybe; workspaceId: Scalars['String']['input']; @@ -1342,6 +1350,8 @@ export interface WorkspaceType { id: Scalars['ID']['output']; /** is current workspace initialized */ initialized: Scalars['Boolean']['output']; + /** invite link for workspace */ + inviteLink: Maybe; /** Get user invoice count */ invoiceCount: Scalars['Int']['output']; invoices: Array; @@ -2526,6 +2536,11 @@ export type GetWorkspaceConfigQuery = { __typename?: 'WorkspaceType'; enableAi: boolean; enableUrlPreview: boolean; + inviteLink: { + __typename?: 'InviteLink'; + link: string; + expireTime: string; + } | null; }; }; @@ -2670,14 +2685,14 @@ export type InviteBatchMutation = { }>; }; -export type InviteLinkMutationVariables = Exact<{ +export type CreateInviteLinkMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; expireTime: WorkspaceInviteLinkExpireTime; }>; -export type InviteLinkMutation = { +export type CreateInviteLinkMutation = { __typename?: 'Mutation'; - inviteLink: string; + createInviteLink: string; }; export type RevokeInviteLinkMutationVariables = Exact<{ @@ -3228,9 +3243,9 @@ export type Mutations = response: InviteBatchMutation; } | { - name: 'inviteLinkMutation'; - variables: InviteLinkMutationVariables; - response: InviteLinkMutation; + name: 'createInviteLinkMutation'; + variables: CreateInviteLinkMutationVariables; + response: CreateInviteLinkMutation; } | { name: 'revokeInviteLinkMutation';