diff --git a/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts index 1dd0748f3a..3046640593 100644 --- a/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts @@ -5,7 +5,6 @@ import { listNotificationsQuery, MentionNotificationBodyType, mentionUserMutation, - notificationCountQuery, NotificationObjectType, NotificationType, readAllNotificationsMutation, @@ -13,6 +12,7 @@ import { } from '@affine/graphql'; import { Mockers } from '../../mocks'; +import { createRealtimeClient, realtimeRequest } from '../realtime'; import { app, e2e } from '../test'; async function init() { @@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => { }, }); } - const count = await app.gql({ - query: notificationCountQuery, - }); - t.is(count.currentUser!.notificationCount, 0); + const socket = await createRealtimeClient(app, member); + t.teardown(() => socket.disconnect()); + const count = await realtimeRequest(socket, 'notification.count.get', {}); + t.is(count.count, 0); // read again should work for (const notification of notifications) { diff --git a/packages/backend/server/src/__tests__/e2e/realtime.ts b/packages/backend/server/src/__tests__/e2e/realtime.ts new file mode 100644 index 0000000000..9f56851ce2 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/realtime.ts @@ -0,0 +1,92 @@ +import type { + RealtimeAck, + RealtimeRequestInputOf, + RealtimeRequestName, + RealtimeRequestOutputOf, +} from '@affine/realtime'; +import { io, type Socket as SocketIOClient } from 'socket.io-client'; +import type { Response } from 'supertest'; + +import type { MockedUser } from '../mocks'; +import type { TestingApp } from './create-app'; + +const REALTIME_CLIENT_VERSION = '0.26.0'; +const WS_TIMEOUT_MS = 5_000; + +function cookieHeader(res: Response) { + return (res.get('Set-Cookie') ?? []) + .map(cookie => cookie.split(';')[0]) + .join('; '); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + label: string +) { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`Timeout (${timeoutMs}ms): ${label}`)); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function waitForConnect(socket: SocketIOClient) { + if (socket.connected) { + return; + } + + await withTimeout( + new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }), + WS_TIMEOUT_MS, + 'realtime socket connect' + ); +} + +export async function createRealtimeClient(app: TestingApp, user: MockedUser) { + const login = await app.login(user); + const socket = io(app.url, { + transports: ['websocket'], + reconnection: false, + forceNew: true, + extraHeaders: { + cookie: cookieHeader(login), + }, + }); + await waitForConnect(socket); + return socket; +} + +export async function realtimeRequest( + socket: SocketIOClient, + op: Op, + input: RealtimeRequestInputOf +): Promise> { + const ack = await withTimeout( + new Promise>>(resolve => { + socket.emit( + 'realtime:request', + { op, input, clientVersion: REALTIME_CLIENT_VERSION }, + (res: RealtimeAck>) => resolve(res) + ); + }), + WS_TIMEOUT_MS, + `realtime request ${op}` + ); + + if ('error' in ack) { + throw new Error(`${ack.error.name}: ${ack.error.message}`); + } + + return ack.data; +} 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 fea38d2611..bddc6d1e36 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts @@ -5,7 +5,6 @@ import { approveWorkspaceTeamMemberMutation, createInviteLinkMutation, getInviteInfoQuery, - getMembersByWorkspaceIdQuery, inviteByEmailsMutation, leaveWorkspaceMutation, revokeMemberPermissionMutation, @@ -13,16 +12,21 @@ import { WorkspaceMemberStatus, } from '@affine/graphql'; import { faker } from '@faker-js/faker'; +import { + WorkspaceMemberSource, + WorkspaceMemberStatus as PrismaWorkspaceMemberStatus, +} from '@prisma/client'; import { EntitlementService } from '../../../core/entitlement'; import { WorkspacePolicyService } from '../../../core/permission'; -import { Models } from '../../../models'; +import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models'; import { SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, } from '../../../plugins/payment/types'; import { Mockers } from '../../mocks'; +import { createRealtimeClient, realtimeRequest } from '../realtime'; import { app, e2e } from '../test'; const TWO_BILLION_BYTES = 2_000_000_000; @@ -380,39 +384,31 @@ e2e('should support pagination for member', async t => { userId: u2.id, }); - await app.login(owner); - let result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - skip: 0, - take: 2, - }, + const socket = await createRealtimeClient(app, owner); + t.teardown(() => socket.disconnect()); + let result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + skip: 0, + take: 2, }); - t.is(result.workspace.memberCount, 3); - t.is(result.workspace.members.length, 2); + t.is(result.memberCount, 3); + t.is(result.members.length, 2); - result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - skip: 2, - take: 2, - }, + result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + skip: 2, + take: 2, }); - t.is(result.workspace.memberCount, 3); - t.is(result.workspace.members.length, 1); + t.is(result.memberCount, 3); + t.is(result.members.length, 1); - result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - skip: 3, - take: 2, - }, + result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + skip: 3, + take: 2, }); - t.is(result.workspace.memberCount, 3); - t.is(result.workspace.members.length, 0); + t.is(result.memberCount, 3); + t.is(result.members.length, 0); }); e2e('should limit member count correctly', async t => { @@ -428,17 +424,15 @@ e2e('should limit member count correctly', async t => { }) ); - await app.login(owner); - const result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - skip: 0, - take: 10, - }, + const socket = await createRealtimeClient(app, owner); + t.teardown(() => socket.disconnect()); + const result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + skip: 0, + take: 10, }); - t.is(result.workspace.memberCount, 11); - t.is(result.workspace.members.length, 10); + t.is(result.memberCount, 11); + t.is(result.members.length, 10); }); e2e('should get invite link info with status', async t => { @@ -634,38 +628,54 @@ e2e( userId: user2.id, }); - await app.login(owner); - let result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - query: 'lucy', - }, + const socket = await createRealtimeClient(app, owner); + t.teardown(() => socket.disconnect()); + let result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + query: 'lucy', }); - t.is(result.workspace.memberCount, 3); - t.is(result.workspace.members.length, 1); - t.is(result.workspace.members[0].name, user1.name); + t.is(result.memberCount, 3); + t.is(result.members.length, 1); + t.is(result.members[0].name, user1.name); - result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - query: 'LUCY', - }, + result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + query: 'LUCY', }); - t.is(result.workspace.memberCount, 3); - t.is(result.workspace.members.length, 1); - t.is(result.workspace.members[0].name, user1.name); + t.is(result.memberCount, 3); + t.is(result.members.length, 1); + t.is(result.members[0].name, user1.name); - result = await app.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId: workspace.id, - query: 'jeanne_doe', - }, + result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + query: 'jeanne_doe', }); - t.is(result.workspace.memberCount, 3); - t.is(result.workspace.members.length, 1); - t.is(result.workspace.members[0].email, user2.email); + t.is(result.memberCount, 3); + t.is(result.members.length, 1); + t.is(result.members[0].email, user2.email); + + const pendingEmail = `pending_search.${randomUUID()}@affine.pro`; + const pendingUser = await app.create(Mockers.User, { + email: pendingEmail, + }); + await app + .get(Models) + .workspaceUser.set( + workspace.id, + pendingUser.id, + ModelWorkspaceRole.Collaborator, + { + status: PrismaWorkspaceMemberStatus.Pending, + source: WorkspaceMemberSource.Email, + } + ); + result = await realtimeRequest(socket, 'workspace.members.get', { + workspaceId: workspace.id, + query: 'pending_search', + }); + t.is(result.memberCount, 4); + t.is(result.members.length, 1); + t.is(result.members[0].email, pendingEmail); + t.is(result.members[0].status, WorkspaceMemberStatus.Pending); } ); diff --git a/packages/backend/server/src/base/cache/index.ts b/packages/backend/server/src/base/cache/index.ts index 86f92c4fc8..7cafacb3e3 100644 --- a/packages/backend/server/src/base/cache/index.ts +++ b/packages/backend/server/src/base/cache/index.ts @@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor'; }) export class CacheModule {} export { Cache, SessionCache }; - export { CacheInterceptor, MakeCache, PreventCache } from './interceptor'; +export { isValidCacheTtl } from './provider'; diff --git a/packages/backend/server/src/base/cache/provider.ts b/packages/backend/server/src/base/cache/provider.ts index 397ab66ac3..9ef866adf5 100644 --- a/packages/backend/server/src/base/cache/provider.ts +++ b/packages/backend/server/src/base/cache/provider.ts @@ -7,6 +7,10 @@ export interface CacheSetOptions { ttl?: number; } +export function isValidCacheTtl(ttl: unknown): ttl is number { + return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0; +} + export class CacheProvider { constructor(private readonly redis: Redis) {} diff --git a/packages/backend/server/src/base/index.ts b/packages/backend/server/src/base/index.ts index 2af9f383f8..1d62717476 100644 --- a/packages/backend/server/src/base/index.ts +++ b/packages/backend/server/src/base/index.ts @@ -1,6 +1,7 @@ export { Cache, CacheInterceptor, + isValidCacheTtl, MakeCache, PreventCache, SessionCache, diff --git a/packages/backend/server/src/core/access-token/resolver.ts b/packages/backend/server/src/core/access-token/resolver.ts index 9b60dea51f..2c9d3dfcba 100644 --- a/packages/backend/server/src/core/access-token/resolver.ts +++ b/packages/backend/server/src/core/access-token/resolver.ts @@ -9,7 +9,7 @@ import { Resolver, } from '@nestjs/graphql'; -import { ActionForbidden } from '../../base'; +import { ActionForbidden, EventBus } from '../../base'; import { Models } from '../../models'; import { CurrentUser } from '../auth/session'; import { UserType } from '../user'; @@ -26,7 +26,10 @@ class GenerateAccessTokenInput { @Resolver(() => AccessToken) export class AccessTokenResolver { - constructor(private readonly models: Models) {} + constructor( + private readonly models: Models, + private readonly event: EventBus + ) {} @Query(() => [RevealedAccessToken], { deprecationReason: 'use currentUser.revealedAccessTokens', @@ -42,11 +45,13 @@ export class AccessTokenResolver { @CurrentUser() user: CurrentUser, @Args('input') input: GenerateAccessTokenInput ): Promise { - return await this.models.accessToken.create({ + const token = await this.models.accessToken.create({ userId: user.id, name: input.name, expiresAt: input.expiresAt, }); + this.event.emit('user.access_token.created', { userId: user.id }); + return token; } @Mutation(() => Boolean) @@ -55,6 +60,7 @@ export class AccessTokenResolver { @Args('id') id: string ): Promise { await this.models.accessToken.revoke(id, user.id); + this.event.emit('user.access_token.revoked', { userId: user.id }); return true; } } diff --git a/packages/backend/server/src/core/features/resolver.ts b/packages/backend/server/src/core/features/resolver.ts index 87ce795a45..0c77cebb7c 100644 --- a/packages/backend/server/src/core/features/resolver.ts +++ b/packages/backend/server/src/core/features/resolver.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/graphql'; import { difference } from 'lodash-es'; -import { BadRequest } from '../../base'; +import { BadRequest, EventBus } from '../../base'; import { Feature, Models, type UserFeatureName } from '../../models'; import { Admin } from '../common'; import { EntitlementService } from '../entitlement'; @@ -42,7 +42,8 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig { export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig { constructor( private readonly models: Models, - private readonly entitlement: EntitlementService + private readonly entitlement: EntitlementService, + private readonly event: EventBus ) { super(); } @@ -76,6 +77,11 @@ export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig { removed.map(feature => this.models.userFeature.remove(id, feature)) ); + const user = await this.models.user.get(id); + if (user) { + this.event.emit('user.updated', user); + } + return features; } diff --git a/packages/backend/server/src/core/permission/index.ts b/packages/backend/server/src/core/permission/index.ts index 6a013d473d..727ed10090 100644 --- a/packages/backend/server/src/core/permission/index.ts +++ b/packages/backend/server/src/core/permission/index.ts @@ -41,6 +41,10 @@ export { PERMISSION_SHADOW_MISMATCH_CATEGORIES, PermissionDiagnosticService, } from './diagnostic'; +export { + type DotToUnderline, + mapPermissionsToGraphqlPermissions, +} from './permission-map'; export { WorkspacePolicyService } from './policy'; export { PermissionProjectionChecker } from './projection-checker'; export { PermissionService } from './service'; diff --git a/packages/backend/server/src/core/permission/permission-map.ts b/packages/backend/server/src/core/permission/permission-map.ts new file mode 100644 index 0000000000..24e47135ae --- /dev/null +++ b/packages/backend/server/src/core/permission/permission-map.ts @@ -0,0 +1,15 @@ +export type DotToUnderline = + T extends `${infer Prefix}.${infer Suffix}` + ? `${Prefix}_${DotToUnderline}` + : T; + +export function mapPermissionsToGraphqlPermissions( + permission: Record +): Record, boolean> { + return Object.fromEntries( + Object.entries(permission).map(([key, value]) => [ + key.replaceAll('.', '_'), + value, + ]) + ) as Record, boolean>; +} diff --git a/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts b/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts index 529f3ac09c..55a4769763 100644 --- a/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts +++ b/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts @@ -5,19 +5,43 @@ import { import test from 'ava'; import { z } from 'zod'; +import { PublicDocMode } from '../../../models'; import type { CopilotTranscriptionReader } from '../../../plugins/copilot/transcript'; import { CopilotTranscriptRealtimeProvider } from '../../../plugins/copilot/transcript'; import type { CurrentUser } from '../../auth'; import { CommentRealtimeProvider } from '../../comment/realtime'; import { NotificationRealtimeProvider } from '../../notification/realtime'; -import type { PermissionAccess } from '../../permission'; +import { + DocRole, + type PermissionAccess, + WorkspaceRole, +} from '../../permission'; import { QuotaStateRealtimeProvider } from '../../quota/realtime'; +import { UserRealtimeProvider } from '../../user/realtime'; +import { + DocGrantsRealtimeProvider, + DocShareRealtimeProvider, +} from '../../workspaces/doc-realtime'; +import { + WorkspaceAccessRealtimeProvider, + WorkspaceConfigRealtimeProvider, + WorkspaceMembersRealtimeProvider, +} from '../../workspaces/realtime'; import { RealtimeGateway } from '../gateway'; import { realtimeCommentRoom, + realtimeDocGrantsRoom, + realtimeDocShareStateRoom, realtimeNotificationRoom, realtimeTranscriptTaskRoom, + realtimeUserAccessTokensRoom, + realtimeUserProfileRoom, + realtimeUserSettingsRoom, + realtimeWorkspaceAccessRoom, + realtimeWorkspaceConfigRoom, realtimeWorkspaceEmbeddingProgressRoom, + realtimeWorkspaceInviteLinkRoom, + realtimeWorkspaceMembersRoom, realtimeWorkspaceQuotaStateRoom, registerRealtimeLiveQuery, } from '../index'; @@ -166,6 +190,18 @@ test('room helpers produce stable realtime room names', t => { realtimeWorkspaceEmbeddingProgressRoom('space'), 'workspace:space:embedding-progress' ); + t.is(realtimeWorkspaceAccessRoom('space'), 'workspace:space:access'); + t.is(realtimeWorkspaceConfigRoom('space'), 'workspace:space:config'); + t.is(realtimeWorkspaceMembersRoom('space'), 'workspace:space:members'); + t.is(realtimeWorkspaceInviteLinkRoom('space'), 'workspace:space:invite-link'); + t.is( + realtimeDocShareStateRoom('space', 'doc'), + 'workspace:space:doc:doc:share-state' + ); + t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants'); + t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile'); + t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings'); + t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens'); t.is( realtimeTranscriptTaskRoom('space', 'task'), 'copilot:transcript:space:task' @@ -225,6 +261,484 @@ test('realtime providers expose runtime injection metadata for registry dependen QuotaStateRealtimeProvider ).includes(RealtimeRegistry) ); + t.true( + Reflect.getMetadata( + 'design:paramtypes', + WorkspaceAccessRealtimeProvider + ).includes(RealtimeRegistry) + ); + t.true( + Reflect.getMetadata( + 'design:paramtypes', + WorkspaceConfigRealtimeProvider + ).includes(RealtimeRegistry) + ); + t.true( + Reflect.getMetadata( + 'design:paramtypes', + WorkspaceMembersRealtimeProvider + ).includes(RealtimeRegistry) + ); + t.true( + Reflect.getMetadata('design:paramtypes', DocShareRealtimeProvider).includes( + RealtimeRegistry + ) + ); + t.true( + Reflect.getMetadata( + 'design:paramtypes', + DocGrantsRealtimeProvider + ).includes(RealtimeRegistry) + ); + t.true( + Reflect.getMetadata('design:paramtypes', UserRealtimeProvider).includes( + RealtimeRegistry + ) + ); +}); + +test('workspace realtime providers register access, config, members and invite link handlers', async t => { + const registry = new RealtimeRegistry(); + const assertions: unknown[] = []; + const ac = { + user(userId: string) { + return { + workspace(workspaceId: string) { + return { + async assert(action: string) { + assertions.push({ userId, workspaceId, action }); + }, + async permissions() { + return { + role: WorkspaceRole.Admin, + permissions: { 'Workspace.Read': true }, + }; + }, + }; + }, + }; + }, + } as unknown as PermissionAccess; + const models = { + workspace: { + get: async () => ({ + enableAi: true, + enableSharing: false, + enableUrlPreview: true, + enableDocEmbedding: false, + }), + }, + workspaceUser: { + search: async () => [], + paginate: async () => [ + [ + { + id: 'invite', + type: WorkspaceRole.Collaborator, + status: 'Accepted', + user: { + id: 'u1', + name: 'User', + email: 'u1@affine.pro', + avatarUrl: null, + }, + }, + ], + 1, + ], + count: async () => 1, + }, + }; + const workspaceService = { + isTeamWorkspace: async () => true, + }; + const cache = { + get: async () => ({ inviteId: 'invite-link' }), + ttl: async () => 10, + }; + const url = { + link: (path: string) => `https://app.affine.pro${path}`, + }; + + new WorkspaceAccessRealtimeProvider( + ac, + workspaceService as never, + registry + ).onModuleInit(); + new WorkspaceConfigRealtimeProvider( + ac, + models as never, + registry + ).onModuleInit(); + new WorkspaceMembersRealtimeProvider( + cache as never, + url as never, + ac, + models as never, + registry + ).onModuleInit(); + + t.deepEqual( + await registry.getRequest('workspace.access.get').handle(user, { + workspaceId: 'space', + }), + { + access: { + role: 'Admin', + permissions: { Workspace_Read: true }, + team: true, + }, + } + ); + t.deepEqual( + await registry.getRequest('workspace.config.get').handle(user, { + workspaceId: 'space', + }), + { + config: { + enableAi: true, + enableSharing: false, + enableUrlPreview: true, + enableDocEmbedding: false, + }, + } + ); + t.like( + await registry.getRequest('workspace.members.get').handle(user, { + workspaceId: 'space', + take: 1000, + }), + { memberCount: 1 } + ); + t.like( + await registry.getRequest('workspace.invite-link.get').handle(user, { + workspaceId: 'space', + }), + { + inviteLink: { + link: 'https://app.affine.pro/invite/invite-link', + }, + } + ); + t.is( + registry + .getTopic('workspace.access.changed') + .room(user, { workspaceId: 'space' }), + realtimeWorkspaceAccessRoom('space') + ); + t.is( + registry + .getTopic('workspace.config.changed') + .room(user, { workspaceId: 'space' }), + realtimeWorkspaceConfigRoom('space') + ); + t.is( + registry + .getTopic('workspace.members.changed') + .room(user, { workspaceId: 'space' }), + realtimeWorkspaceMembersRoom('space') + ); + t.is( + registry + .getTopic('workspace.invite-link.changed') + .room(user, { workspaceId: 'space' }), + realtimeWorkspaceInviteLinkRoom('space') + ); + t.true( + assertions.some( + item => + JSON.stringify(item) === + JSON.stringify({ + userId: 'u1', + workspaceId: 'space', + action: 'Workspace.Users.Read', + }) + ) + ); +}); + +test('doc realtime providers register share state and grants handlers', async t => { + const registry = new RealtimeRegistry(); + const assertedActions: string[] = []; + const ac = { + user(userId: string) { + return { + doc(workspaceId: string, docId: string) { + return { + async assert(action: string) { + t.deepEqual( + { userId, workspaceId, docId }, + { + userId: 'u1', + workspaceId: 'space', + docId: 'doc', + } + ); + assertedActions.push(action); + }, + }; + }, + }; + }, + } as unknown as PermissionAccess; + const models = { + doc: { + getDocInfo: async () => ({ + public: true, + mode: PublicDocMode.Page, + defaultRole: DocRole.Reader, + }), + }, + docUser: { + findDirectGrantDocIdsByUser: async () => [], + paginate: async () => [ + [ + { + userId: 'u2', + type: DocRole.Manager, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + 1, + ], + }, + user: { + getWorkspaceUsers: async () => [ + { + id: 'u2', + name: 'User 2', + email: 'u2@affine.pro', + avatarUrl: null, + }, + ], + }, + }; + + const grants = { + paginateGrantedUsers: async () => ({ + totalCount: 1, + pageInfo: { endCursor: null, hasNextPage: false }, + edges: [ + { + node: { + type: DocRole.Manager, + user: { + id: 'u2', + name: 'User 2', + email: 'u2@affine.pro', + avatarUrl: null, + }, + }, + }, + ], + }), + }; + + new DocShareRealtimeProvider(ac, models as never, registry).onModuleInit(); + new DocGrantsRealtimeProvider( + ac, + models as never, + grants as never, + registry + ).onModuleInit(); + + t.deepEqual( + await registry.getRequest('doc.share-state.get').handle(user, { + workspaceId: 'space', + docId: 'doc', + }), + { + state: { + public: true, + mode: 'Page', + defaultRole: 'Reader', + }, + } + ); + t.like( + await registry.getRequest('doc.grants.get').handle(user, { + workspaceId: 'space', + docId: 'doc', + pagination: { first: 10 }, + }), + { totalCount: 1 } + ); + t.deepEqual(assertedActions, ['Doc.Read', 'Doc.Users.Read']); + t.is( + registry + .getTopic('doc.share-state.changed') + .room(user, { workspaceId: 'space', docId: 'doc' }), + realtimeDocShareStateRoom('space', 'doc') + ); + t.is( + registry + .getTopic('doc.grants.changed') + .room(user, { workspaceId: 'space', docId: 'doc' }), + realtimeDocGrantsRoom('space', 'doc') + ); +}); + +test('user realtime provider snapshots private profile settings and access tokens without plaintext token', async t => { + const registry = new RealtimeRegistry(); + const models = { + user: { + get: async () => ({ + id: 'u1', + name: 'User', + email: 'u1@affine.pro', + avatarUrl: null, + emailVerifiedAt: new Date(0), + password: 'hash', + disabled: false, + }), + }, + userSettings: { + get: async () => ({ + receiveInvitationEmail: true, + receiveMentionEmail: false, + receiveCommentEmail: true, + }), + }, + userFeature: { + list: async () => ['administrator'], + }, + accessToken: { + list: async () => [ + { + id: 'token', + name: 'Token', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: null, + }, + ], + }, + }; + + new UserRealtimeProvider(models as never, registry).onModuleInit(); + + t.deepEqual(await registry.getRequest('user.profile.get').handle(user, {}), { + user: { + id: 'u1', + name: 'User', + email: 'u1@affine.pro', + emailVerified: true, + hasPassword: true, + avatarUrl: null, + features: ['Admin'], + }, + }); + t.deepEqual( + await registry + .getRequest('user.profile.get') + .handle(undefined as never, {}), + { user: null } + ); + t.is( + registry.getTopic('user.profile.changed').room(user, {}), + realtimeUserProfileRoom('u1') + ); + t.is( + registry.getTopic('user.settings.changed').room(user, {}), + realtimeUserSettingsRoom('u1') + ); + t.is( + registry.getTopic('user.access-tokens.changed').room(user, {}), + realtimeUserAccessTokensRoom('u1') + ); + t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), { + settings: { + receiveInvitationEmail: true, + receiveMentionEmail: false, + receiveCommentEmail: true, + }, + }); + t.deepEqual( + await registry.getRequest('user.access-tokens.get').handle(user, {}), + { + tokens: [ + { + id: 'token', + name: 'Token', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: null, + }, + ], + } + ); +}); + +test('new realtime providers publish changed events from domain events', t => { + const published: unknown[][] = []; + const publisher = { + publish: (...args: unknown[]) => published.push(args), + publishChanged: (...args: unknown[]) => published.push(args), + } as unknown as RealtimePublisher; + + const workspaceAccess = new WorkspaceAccessRealtimeProvider( + {} as never, + {} as never, + undefined, + publisher + ); + workspaceAccess.onMembersUpdated({ workspaceId: 'space' }); + + const workspaceConfig = new WorkspaceConfigRealtimeProvider( + {} as never, + {} as never, + undefined, + publisher + ); + workspaceConfig.onWorkspaceUpdated({ id: 'space' } as never); + + const workspaceMembers = new WorkspaceMembersRealtimeProvider( + {} as never, + {} as never, + {} as never, + {} as never, + undefined, + publisher + ); + workspaceMembers.onInviteLinkCreated({ workspaceId: 'space' }); + + const docShare = new DocShareRealtimeProvider( + {} as never, + {} as never, + undefined, + publisher + ); + docShare.onPublicStateChanged({ workspaceId: 'space', docId: 'doc' }); + + const docGrants = new DocGrantsRealtimeProvider( + {} as never, + {} as never, + {} as never, + undefined, + publisher + ); + docGrants.onOwnerChanged({ + workspaceId: 'space', + docId: 'doc', + userId: 'u2', + }); + + const userProvider = new UserRealtimeProvider( + {} as never, + undefined, + publisher + ); + userProvider.onUserAccessTokenCreated({ userId: 'u1' }); + + t.deepEqual( + published.map(args => args[0]), + [ + 'workspace.access.changed', + 'workspace.config.changed', + 'workspace.invite-link.changed', + 'doc.share-state.changed', + 'doc.grants.changed', + 'user.access-tokens.changed', + ] + ); }); test('quota realtime provider exposes effective quota state snapshots', async t => { diff --git a/packages/backend/server/src/core/realtime/index.ts b/packages/backend/server/src/core/realtime/index.ts index e3f4e0ba4f..cc0051c2ca 100644 --- a/packages/backend/server/src/core/realtime/index.ts +++ b/packages/backend/server/src/core/realtime/index.ts @@ -16,12 +16,21 @@ export { RealtimePublisher } from './publisher'; export { RealtimeRegistry } from './registry'; export { realtimeCommentRoom, + realtimeDocGrantsRoom, + realtimeDocShareStateRoom, realtimeNotificationRoom, realtimeTranscriptTaskRoom, + realtimeUserAccessTokensRoom, + realtimeUserProfileRoom, realtimeUserQuotaStateRoom, realtimeUserRoom, + realtimeUserSettingsRoom, + realtimeWorkspaceAccessRoom, + realtimeWorkspaceConfigRoom, realtimeWorkspaceDocRoom, realtimeWorkspaceEmbeddingProgressRoom, + realtimeWorkspaceInviteLinkRoom, + realtimeWorkspaceMembersRoom, realtimeWorkspaceQuotaStateRoom, realtimeWorkspaceRoom, } from './rooms'; diff --git a/packages/backend/server/src/core/realtime/publisher.ts b/packages/backend/server/src/core/realtime/publisher.ts index 571f0134a5..ec05e42bd8 100644 --- a/packages/backend/server/src/core/realtime/publisher.ts +++ b/packages/backend/server/src/core/realtime/publisher.ts @@ -1,6 +1,8 @@ import { getRealtimeInputKey, type RealtimeEvent, + type RealtimeTopicEventOf, + type RealtimeTopicInputOf, type RealtimeTopicName, } from '@affine/realtime'; import { Injectable, Logger } from '@nestjs/common'; @@ -44,6 +46,20 @@ export class RealtimePublisher { } } + publishChanged( + topic: Topic, + input: RealtimeTopicInputOf, + reason: string, + options?: { room?: string } + ) { + this.publish( + topic, + input, + { changed: true, reason } as RealtimeTopicEventOf, + options + ); + } + publishLocal(payload: RealtimePublishPayload) { const handler = this.registry.getTopic(payload.topic); const room = payload.room ?? handler.room(null, payload.input as never); diff --git a/packages/backend/server/src/core/realtime/rooms.ts b/packages/backend/server/src/core/realtime/rooms.ts index 1363746eb2..0c10617f9a 100644 --- a/packages/backend/server/src/core/realtime/rooms.ts +++ b/packages/backend/server/src/core/realtime/rooms.ts @@ -40,3 +40,39 @@ export function realtimeUserQuotaStateRoom(userId: string) { export function realtimeWorkspaceQuotaStateRoom(workspaceId: string) { return realtimeWorkspaceRoom(workspaceId, 'quota-state'); } + +export function realtimeWorkspaceAccessRoom(workspaceId: string) { + return realtimeWorkspaceRoom(workspaceId, 'access'); +} + +export function realtimeWorkspaceConfigRoom(workspaceId: string) { + return realtimeWorkspaceRoom(workspaceId, 'config'); +} + +export function realtimeWorkspaceMembersRoom(workspaceId: string) { + return realtimeWorkspaceRoom(workspaceId, 'members'); +} + +export function realtimeWorkspaceInviteLinkRoom(workspaceId: string) { + return realtimeWorkspaceRoom(workspaceId, 'invite-link'); +} + +export function realtimeDocShareStateRoom(workspaceId: string, docId: string) { + return realtimeWorkspaceDocRoom(workspaceId, docId, 'share-state'); +} + +export function realtimeDocGrantsRoom(workspaceId: string, docId: string) { + return realtimeWorkspaceDocRoom(workspaceId, docId, 'grants'); +} + +export function realtimeUserProfileRoom(userId: string) { + return realtimeUserRoom(userId, 'profile'); +} + +export function realtimeUserSettingsRoom(userId: string) { + return realtimeUserRoom(userId, 'settings'); +} + +export function realtimeUserAccessTokensRoom(userId: string) { + return realtimeUserRoom(userId, 'access-tokens'); +} diff --git a/packages/backend/server/src/core/user/index.ts b/packages/backend/server/src/core/user/index.ts index 8c5d5f83a4..087ffb7766 100644 --- a/packages/backend/server/src/core/user/index.ts +++ b/packages/backend/server/src/core/user/index.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { PermissionModule } from '../permission'; import { StorageModule } from '../storage'; import { UserAvatarController } from './controller'; +import { UserRealtimeProvider } from './realtime'; import { UserManagementResolver, UserResolver, @@ -11,7 +12,12 @@ import { @Module({ imports: [StorageModule, PermissionModule], - providers: [UserResolver, UserManagementResolver, UserSettingsResolver], + providers: [ + UserResolver, + UserManagementResolver, + UserSettingsResolver, + UserRealtimeProvider, + ], controllers: [UserAvatarController], }) export class UserModule {} diff --git a/packages/backend/server/src/core/user/realtime.ts b/packages/backend/server/src/core/user/realtime.ts new file mode 100644 index 0000000000..9d2fa949cb --- /dev/null +++ b/packages/backend/server/src/core/user/realtime.ts @@ -0,0 +1,187 @@ +import type { + AccessTokenSnapshot, + CurrentUserProfileSnapshot, + UserSettingsSnapshot, +} from '@affine/realtime'; +import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; +import { z } from 'zod'; + +import { AuthenticationRequired, OnEvent, UserNotFound } from '../../base'; +import { Feature, Models } from '../../models'; +import { sessionUser } from '../auth/service'; +import { AvailableUserFeatureConfig } from '../features/types'; +import { registerRealtimeLiveQuery } from '../realtime/provider'; +import { RealtimePublisher } from '../realtime/publisher'; +import { RealtimeRegistry } from '../realtime/registry'; +import { + realtimeUserAccessTokensRoom, + realtimeUserProfileRoom, + realtimeUserSettingsRoom, +} from '../realtime/rooms'; + +const emptyInput = z.object({}).strict(); + +function assertAuthenticated(user?: { id: string }) { + if (!user) { + throw new AuthenticationRequired(); + } + return user; +} + +@Injectable() +export class UserRealtimeProvider + extends AvailableUserFeatureConfig + implements OnModuleInit +{ + constructor( + private readonly models: Models, + @Optional() private readonly registry?: RealtimeRegistry, + @Optional() private readonly publisher?: RealtimePublisher + ) { + super(); + } + + onModuleInit() { + if (!this.registry) return; + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'user.profile.get', + input: emptyInput, + handle: async user => ({ + user: user ? await this.getProfile(user.id) : null, + }), + }, + topic: { + name: 'user.profile.changed', + input: emptyInput, + authorize: async () => {}, + room: user => { + if (!user) { + throw new Error('Authenticated user is required'); + } + return realtimeUserProfileRoom(user.id); + }, + }, + }); + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'user.settings.get', + input: emptyInput, + handle: async user => ({ + settings: await this.getSettings(assertAuthenticated(user).id), + }), + }, + topic: { + name: 'user.settings.changed', + input: emptyInput, + authorize: async () => {}, + room: user => { + if (!user) { + throw new Error('Authenticated user is required'); + } + return realtimeUserSettingsRoom(user.id); + }, + }, + }); + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'user.access-tokens.get', + input: emptyInput, + handle: async user => ({ + tokens: await this.getAccessTokens(assertAuthenticated(user).id), + }), + }, + topic: { + name: 'user.access-tokens.changed', + input: emptyInput, + authorize: async () => {}, + room: user => { + if (!user) { + throw new Error('Authenticated user is required'); + } + return realtimeUserAccessTokensRoom(user.id); + }, + }, + }); + } + + @OnEvent('user.updated', { suppressError: true }) + onUserUpdated(user: Events['user.updated']) { + this.publisher?.publishChanged('user.profile.changed', {}, 'user-updated', { + room: realtimeUserProfileRoom(user.id), + }); + } + + @OnEvent('user.settings.updated', { suppressError: true }) + onUserSettingsUpdated({ userId }: Events['user.settings.updated']) { + this.publisher?.publishChanged( + 'user.settings.changed', + {}, + 'settings-updated', + { room: realtimeUserSettingsRoom(userId) } + ); + } + + @OnEvent('user.access_token.created', { suppressError: true }) + onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) { + this.publishAccessTokens(userId, 'access-token-created'); + } + + @OnEvent('user.access_token.revoked', { suppressError: true }) + onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) { + this.publishAccessTokens(userId, 'access-token-revoked'); + } + + private async getProfile( + userId: string + ): Promise { + const user = await this.models.user.get(userId); + if (!user) { + throw new UserNotFound(); + } + const current = sessionUser(user); + return { + id: current.id, + name: current.name, + email: current.email, + emailVerified: current.emailVerified, + hasPassword: current.hasPassword, + avatarUrl: current.avatarUrl ?? null, + features: (await this.models.userFeature.list(userId)) + .filter(feature => this.availableUserFeatures().has(feature)) + .map(feature => this.serializeFeature(feature)), + }; + } + + private serializeFeature(feature: string) { + return ( + Object.entries(Feature).find(([, value]) => value === feature)?.[0] ?? + feature + ); + } + + private async getSettings(userId: string): Promise { + return await this.models.userSettings.get(userId); + } + + private async getAccessTokens( + userId: string + ): Promise { + const tokens = await this.models.accessToken.list(userId); + return tokens.map(token => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt.toISOString(), + expiresAt: token.expiresAt?.toISOString() ?? null, + })); + } + + private publishAccessTokens(userId: string, reason: string) { + this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, { + room: realtimeUserAccessTokensRoom(userId), + }); + } +} diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 275db70f32..7e8c36a003 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -16,6 +16,7 @@ import { isNil, omitBy } from 'lodash-es'; import { CannotDeleteOwnAccount, + EventBus, type FileUpload, ImageFormatNotSupported, OneMB, @@ -186,7 +187,10 @@ export class UserResolver { @Resolver(() => UserType) export class UserSettingsResolver { - constructor(private readonly models: Models) {} + constructor( + private readonly models: Models, + private readonly event: EventBus + ) {} @Mutation(() => Boolean, { name: 'updateSettings', @@ -199,6 +203,7 @@ export class UserSettingsResolver { ) { UserSettingsSchema.parse(input); await this.models.userSettings.set(user.id, input); + this.event.emit('user.settings.updated', { userId: user.id }); return true; } diff --git a/packages/backend/server/src/core/workspaces/doc-grants.ts b/packages/backend/server/src/core/workspaces/doc-grants.ts new file mode 100644 index 0000000000..61a6e1bb6d --- /dev/null +++ b/packages/backend/server/src/core/workspaces/doc-grants.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; + +import { paginate, PaginationInput } from '../../base'; +import { Models } from '../../models'; +import type { WorkspaceUserType } from '../user'; + +@Injectable() +export class DocGrantsService { + constructor(private readonly models: Models) {} + + async paginateGrantedUsers( + workspaceId: string, + docId: string, + pagination: PaginationInput + ) { + const [permissions, totalCount] = await this.models.docUser.paginate( + workspaceId, + docId, + pagination + ); + const workspaceUsers = await this.models.user.getWorkspaceUsers( + permissions.map(p => p.userId) + ); + const workspaceUsersMap = new Map( + workspaceUsers.map(user => [user.id, user]) + ); + + return paginate( + permissions.map(permission => { + const user = workspaceUsersMap.get(permission.userId); + if (!user) { + throw new Error(`Doc grant user ${permission.userId} not found`); + } + return { + ...permission, + user: user as WorkspaceUserType, + }; + }), + 'createdAt', + pagination, + totalCount + ); + } +} diff --git a/packages/backend/server/src/core/workspaces/doc-realtime.ts b/packages/backend/server/src/core/workspaces/doc-realtime.ts new file mode 100644 index 0000000000..dd21b7beaa --- /dev/null +++ b/packages/backend/server/src/core/workspaces/doc-realtime.ts @@ -0,0 +1,221 @@ +import type { + DocShareStateSnapshot, + PaginatedDocGrantedUsersSnapshot, +} from '@affine/realtime'; +import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; +import { z } from 'zod'; + +import { OnEvent, PaginationInput } from '../../base'; +import { DocRole, Models, PublicDocMode } from '../../models'; +import { PermissionAccess } from '../permission'; +import { registerRealtimeLiveQuery } from '../realtime/provider'; +import { RealtimePublisher } from '../realtime/publisher'; +import { RealtimeRegistry } from '../realtime/registry'; +import { + realtimeDocGrantsRoom, + realtimeDocShareStateRoom, +} from '../realtime/rooms'; +import { DocGrantsService } from './doc-grants'; + +const docInput = z + .object({ workspaceId: z.string(), docId: z.string() }) + .strict(); + +@Injectable() +export class DocShareRealtimeProvider implements OnModuleInit { + constructor( + private readonly ac: PermissionAccess, + private readonly models: Models, + @Optional() private readonly registry?: RealtimeRegistry, + @Optional() private readonly publisher?: RealtimePublisher + ) {} + + onModuleInit() { + if (!this.registry) return; + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'doc.share-state.get', + input: docInput, + handle: async (user, input) => ({ + state: await this.getShareState( + user.id, + input.workspaceId, + input.docId + ), + }), + }, + topic: { + name: 'doc.share-state.changed', + input: docInput, + authorize: async (user, input) => { + await this.assertRead(user.id, input.workspaceId, input.docId); + }, + room: (_user, input) => + realtimeDocShareStateRoom(input.workspaceId, input.docId), + }, + }); + } + + @OnEvent('doc.public_state.changed', { suppressError: true }) + onPublicStateChanged({ + workspaceId, + docId, + }: Events['doc.public_state.changed']) { + this.publish(workspaceId, docId, 'public-state-changed'); + } + + @OnEvent('doc.default_role.changed', { suppressError: true }) + onDefaultRoleChanged({ + workspaceId, + docId, + }: Events['doc.default_role.changed']) { + this.publish(workspaceId, docId, 'default-role-changed'); + } + + private async getShareState( + userId: string, + workspaceId: string, + docId: string + ): Promise { + await this.assertRead(userId, workspaceId, docId); + const doc = await this.models.doc.getDocInfo(workspaceId, docId); + if (!doc) { + return null; + } + return { + public: doc.public, + mode: PublicDocMode[doc.mode], + defaultRole: DocRole[doc.defaultRole], + }; + } + + private async assertRead(userId: string, workspaceId: string, docId: string) { + await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Read'); + } + + private publish(workspaceId: string, docId: string, reason: string) { + this.publisher?.publishChanged( + 'doc.share-state.changed', + { workspaceId, docId }, + reason, + { room: realtimeDocShareStateRoom(workspaceId, docId) } + ); + } +} + +@Injectable() +export class DocGrantsRealtimeProvider implements OnModuleInit { + constructor( + private readonly ac: PermissionAccess, + private readonly models: Models, + private readonly grants: DocGrantsService, + @Optional() private readonly registry?: RealtimeRegistry, + @Optional() private readonly publisher?: RealtimePublisher + ) {} + + onModuleInit() { + if (!this.registry) return; + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'doc.grants.get', + input: z + .object({ + workspaceId: z.string(), + docId: z.string(), + pagination: z + .object({ + first: z.number().int().positive(), + offset: z.number().int().nonnegative().optional(), + after: z.string().optional(), + }) + .strict(), + }) + .strict(), + handle: async (user, input) => + this.getGrants(user.id, input.workspaceId, input.docId, { + first: input.pagination.first, + offset: input.pagination.offset ?? 0, + after: input.pagination.after, + }), + }, + topic: { + name: 'doc.grants.changed', + input: docInput, + authorize: async (user, input) => { + await this.assertRead(user.id, input.workspaceId, input.docId); + }, + room: (_user, input) => + realtimeDocGrantsRoom(input.workspaceId, input.docId), + }, + }); + } + + @OnEvent('doc.grants.changed', { suppressError: true }) + onGrantsChanged({ workspaceId, docId }: Events['doc.grants.changed']) { + this.publish(workspaceId, docId, 'grants-changed'); + } + + @OnEvent('doc.owner.changed', { suppressError: true }) + onOwnerChanged({ workspaceId, docId }: Events['doc.owner.changed']) { + this.publish(workspaceId, docId, 'owner-changed'); + } + + @OnEvent('user.updated', { suppressError: true }) + async onUserUpdated(user: Events['user.updated']) { + const grants = await this.models.docUser.findDirectGrantDocIdsByUser( + user.id + ); + for (const grant of grants) { + this.publish(grant.workspaceId, grant.docId, 'user-updated'); + } + } + + private async getGrants( + userId: string, + workspaceId: string, + docId: string, + input: PaginationInput + ): Promise { + await this.assertRead(userId, workspaceId, docId); + const pagination = PaginationInput.decode.transform(input, {} as never); + const page = await this.grants.paginateGrantedUsers( + workspaceId, + docId, + pagination + ); + + return { + totalCount: page.totalCount, + pageInfo: { + endCursor: page.pageInfo.endCursor ?? null, + hasNextPage: page.pageInfo.hasNextPage, + }, + edges: page.edges.map(edge => ({ + node: { + role: DocRole[edge.node.type], + user: { + id: edge.node.user.id, + name: edge.node.user.name, + email: edge.node.user.email, + avatarUrl: edge.node.user.avatarUrl ?? null, + }, + }, + })), + }; + } + + private async assertRead(userId: string, workspaceId: string, docId: string) { + await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Users.Read'); + } + + private publish(workspaceId: string, docId: string, reason: string) { + this.publisher?.publishChanged( + 'doc.grants.changed', + { workspaceId, docId }, + reason, + { room: realtimeDocGrantsRoom(workspaceId, docId) } + ); + } +} diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts index c17c7ba93b..2a9fbf9ef5 100644 --- a/packages/backend/server/src/core/workspaces/event.ts +++ b/packages/backend/server/src/core/workspaces/event.ts @@ -26,6 +26,12 @@ declare global { workspaceId: string; quantity: number; }; + 'workspace.invite_link.created': { + workspaceId: string; + }; + 'workspace.invite_link.revoked': { + workspaceId: string; + }; } } diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index b4f3ce97ff..84268eb1e8 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -10,7 +10,17 @@ import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; import { UserModule } from '../user'; import { WorkspacesController } from './controller'; +import { DocGrantsService } from './doc-grants'; +import { + DocGrantsRealtimeProvider, + DocShareRealtimeProvider, +} from './doc-realtime'; import { WorkspaceEvents } from './event'; +import { + WorkspaceAccessRealtimeProvider, + WorkspaceConfigRealtimeProvider, + WorkspaceMembersRealtimeProvider, +} from './realtime'; import { DocHistoryResolver, DocResolver, @@ -44,7 +54,13 @@ import { WorkspaceStatsJob } from './stats.job'; DocHistoryResolver, WorkspaceBlobResolver, WorkspaceService, + DocGrantsService, WorkspaceEvents, + WorkspaceAccessRealtimeProvider, + WorkspaceConfigRealtimeProvider, + WorkspaceMembersRealtimeProvider, + DocShareRealtimeProvider, + DocGrantsRealtimeProvider, AdminWorkspaceResolver, WorkspaceStatsJob, ], diff --git a/packages/backend/server/src/core/workspaces/realtime.ts b/packages/backend/server/src/core/workspaces/realtime.ts new file mode 100644 index 0000000000..efca6361d0 --- /dev/null +++ b/packages/backend/server/src/core/workspaces/realtime.ts @@ -0,0 +1,401 @@ +import { + WORKSPACE_MEMBERS_REQUEST_TAKE_MAX, + type WorkspaceAccessSnapshot, + type WorkspaceConfigSnapshot, + type WorkspaceInviteLinkSnapshot, + type WorkspaceMemberSnapshot, +} from '@affine/realtime'; +import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; +import { z } from 'zod'; + +import { + Cache, + isValidCacheTtl, + OnEvent, + QueryTooLong, + URLHelper, +} from '../../base'; +import { Models } from '../../models'; +import type { WorkspaceUserCompat } from '../../models/workspace-user-compat'; +import type { CurrentUser } from '../auth'; +import { + mapPermissionsToGraphqlPermissions, + PermissionAccess, + WorkspaceRole, +} from '../permission'; +import { registerRealtimeLiveQuery } from '../realtime/provider'; +import { RealtimePublisher } from '../realtime/publisher'; +import { RealtimeRegistry } from '../realtime/registry'; +import { + realtimeWorkspaceAccessRoom, + realtimeWorkspaceConfigRoom, + realtimeWorkspaceInviteLinkRoom, + realtimeWorkspaceMembersRoom, +} from '../realtime/rooms'; +import { WorkspaceService } from './service'; + +const workspaceInput = z.object({ workspaceId: z.string() }).strict(); + +function serializeWorkspaceMember( + row: WorkspaceUserCompat +): WorkspaceMemberSnapshot { + if (!row.user) { + throw new Error('Workspace member user is required'); + } + const role = WorkspaceRole[row.type as WorkspaceRole]; + return { + ...row.user, + avatarUrl: row.user.avatarUrl ?? null, + permission: role, + role, + inviteId: row.id, + emailVerified: null, + status: row.status, + }; +} + +@Injectable() +export class WorkspaceAccessRealtimeProvider implements OnModuleInit { + constructor( + private readonly ac: PermissionAccess, + private readonly workspaceService: WorkspaceService, + @Optional() private readonly registry?: RealtimeRegistry, + @Optional() private readonly publisher?: RealtimePublisher + ) {} + + onModuleInit() { + if (!this.registry) return; + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'workspace.access.get', + input: workspaceInput, + handle: async (user, input) => ({ + access: await this.getAccess(user, input.workspaceId), + }), + }, + topic: { + name: 'workspace.access.changed', + input: workspaceInput, + authorize: async (user, input) => { + await this.ac + .user(user.id) + .workspace(input.workspaceId) + .assert('Workspace.Read'); + }, + room: (_user, input) => realtimeWorkspaceAccessRoom(input.workspaceId), + }, + }); + } + + @OnEvent('workspace.members.updated', { suppressError: true }) + onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) { + this.publish(workspaceId, 'members-updated'); + } + + @OnEvent('workspace.members.roleChanged', { suppressError: true }) + onMemberRoleChanged({ + workspaceId, + }: Events['workspace.members.roleChanged']) { + this.publish(workspaceId, 'member-role-changed'); + } + + @OnEvent('workspace.owner.changed', { suppressError: true }) + onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) { + this.publish(workspaceId, 'owner-changed'); + } + + @OnEvent('workspace.quota_state.changed', { suppressError: true }) + onWorkspaceQuotaStateChanged({ + workspaceId, + }: Events['workspace.quota_state.changed']) { + this.publish(workspaceId, 'quota-state-changed'); + } + + private async getAccess( + user: CurrentUser, + workspaceId: string + ): Promise { + await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read'); + const { role, permissions } = await this.ac + .user(user.id) + .workspace(workspaceId) + .permissions(); + + return { + role: role ? WorkspaceRole[role] : WorkspaceRole[WorkspaceRole.External], + permissions: mapPermissionsToGraphqlPermissions(permissions), + team: await this.workspaceService.isTeamWorkspace(workspaceId), + }; + } + + private publish(workspaceId: string, reason: string) { + this.publisher?.publishChanged( + 'workspace.access.changed', + { workspaceId }, + reason, + { room: realtimeWorkspaceAccessRoom(workspaceId) } + ); + } +} + +@Injectable() +export class WorkspaceConfigRealtimeProvider implements OnModuleInit { + constructor( + private readonly ac: PermissionAccess, + private readonly models: Models, + @Optional() private readonly registry?: RealtimeRegistry, + @Optional() private readonly publisher?: RealtimePublisher + ) {} + + onModuleInit() { + if (!this.registry) return; + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'workspace.config.get', + input: workspaceInput, + handle: async (user, input) => ({ + config: await this.getConfig(user, input.workspaceId), + }), + }, + topic: { + name: 'workspace.config.changed', + input: workspaceInput, + authorize: async (user, input) => { + await this.assertRead(user.id, input.workspaceId); + }, + room: (_user, input) => realtimeWorkspaceConfigRoom(input.workspaceId), + }, + }); + } + + @OnEvent('workspace.updated', { suppressError: true }) + onWorkspaceUpdated(workspace: Events['workspace.updated']) { + this.publisher?.publishChanged( + 'workspace.config.changed', + { workspaceId: workspace.id }, + 'workspace-updated', + { room: realtimeWorkspaceConfigRoom(workspace.id) } + ); + } + + private async getConfig( + user: CurrentUser, + workspaceId: string + ): Promise { + await this.assertRead(user.id, workspaceId); + const workspace = await this.models.workspace.get(workspaceId); + return { + enableAi: Boolean(workspace?.enableAi), + enableSharing: Boolean(workspace?.enableSharing), + enableUrlPreview: Boolean(workspace?.enableUrlPreview), + enableDocEmbedding: Boolean(workspace?.enableDocEmbedding), + }; + } + + private async assertRead(userId: string, workspaceId: string) { + await this.ac + .user(userId) + .workspace(workspaceId) + .assert('Workspace.Settings.Read'); + } +} + +@Injectable() +export class WorkspaceMembersRealtimeProvider implements OnModuleInit { + constructor( + private readonly cache: Cache, + private readonly url: URLHelper, + private readonly ac: PermissionAccess, + private readonly models: Models, + @Optional() private readonly registry?: RealtimeRegistry, + @Optional() private readonly publisher?: RealtimePublisher + ) {} + + onModuleInit() { + if (!this.registry) return; + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'workspace.members.get', + input: z + .object({ + workspaceId: z.string(), + skip: z.number().int().nonnegative().optional(), + take: z.number().int().nonnegative().optional(), + query: z.string().optional(), + }) + .strict(), + handle: async (user, input) => this.getMembers(user, input), + }, + topic: { + name: 'workspace.members.changed', + input: workspaceInput, + authorize: async (user, input) => { + await this.assertMembersRead(user.id, input.workspaceId); + }, + room: (_user, input) => realtimeWorkspaceMembersRoom(input.workspaceId), + }, + }); + + registerRealtimeLiveQuery(this.registry, { + request: { + name: 'workspace.invite-link.get', + input: workspaceInput, + handle: async (user, input) => ({ + inviteLink: await this.getInviteLink(user, input.workspaceId), + }), + }, + topic: { + name: 'workspace.invite-link.changed', + input: workspaceInput, + authorize: async (user, input) => { + await this.assertInviteManage(user.id, input.workspaceId); + }, + room: (_user, input) => + realtimeWorkspaceInviteLinkRoom(input.workspaceId), + }, + }); + } + + @OnEvent('workspace.members.updated', { suppressError: true }) + onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) { + this.publishMembers(workspaceId, 'members-updated'); + } + + @OnEvent('workspace.members.roleChanged', { suppressError: true }) + onMemberRoleChanged({ + workspaceId, + }: Events['workspace.members.roleChanged']) { + this.publishMembers(workspaceId, 'member-role-changed'); + } + + @OnEvent('workspace.owner.changed', { suppressError: true }) + onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) { + this.publishMembers(workspaceId, 'owner-changed'); + } + + @OnEvent('workspace.invite_link.created', { suppressError: true }) + onInviteLinkCreated({ + workspaceId, + }: Events['workspace.invite_link.created']) { + this.publishInviteLink(workspaceId, 'invite-link-created'); + } + + @OnEvent('workspace.invite_link.revoked', { suppressError: true }) + onInviteLinkRevoked({ + workspaceId, + }: Events['workspace.invite_link.revoked']) { + this.publishInviteLink(workspaceId, 'invite-link-revoked'); + } + + @OnEvent('user.updated', { suppressError: true }) + async onUserUpdated(user: Events['user.updated']) { + const workspaceIds = await this.models.workspaceUser.getUserWorkspaceIds( + user.id + ); + for (const workspaceId of workspaceIds) { + this.publishMembers(workspaceId, 'user-updated'); + } + } + + private async getMembers( + user: CurrentUser, + input: { + workspaceId: string; + skip?: number; + take?: number; + query?: string; + } + ) { + await this.assertMembersRead(user.id, input.workspaceId); + + const pagination = { + offset: Math.max(input.skip ?? 0, 0), + first: Math.min( + Math.max(input.take ?? 8, 1), + WORKSPACE_MEMBERS_REQUEST_TAKE_MAX + ), + }; + + if (input.query) { + if (input.query.length > 255) { + throw new QueryTooLong({ max: 255 }); + } + const members = await this.models.workspaceUser.search( + input.workspaceId, + input.query, + pagination + ); + return { + members: members.map(serializeWorkspaceMember), + memberCount: await this.models.workspaceUser.count(input.workspaceId), + }; + } + + const [members, memberCount] = await this.models.workspaceUser.paginate( + input.workspaceId, + pagination + ); + return { + members: members.map(serializeWorkspaceMember), + memberCount, + }; + } + + private async getInviteLink( + user: CurrentUser, + workspaceId: string + ): Promise { + await this.assertInviteManage(user.id, workspaceId); + + const cacheId = `workspace:inviteLink:${workspaceId}`; + const id = await this.cache.get<{ inviteId: string }>(cacheId); + if (!id) { + return null; + } + + const expireTime = await this.cache.ttl(cacheId); + if (!isValidCacheTtl(expireTime)) { + return null; + } + + return { + link: this.url.link(`/invite/${id.inviteId}`), + expireTime: new Date(Date.now() + expireTime * 1000).toISOString(), + }; + } + + private async assertMembersRead(userId: string, workspaceId: string) { + await this.ac + .user(userId) + .workspace(workspaceId) + .assert('Workspace.Users.Read'); + } + + private async assertInviteManage(userId: string, workspaceId: string) { + await this.ac + .user(userId) + .workspace(workspaceId) + .assert('Workspace.Users.Manage'); + } + + private publishMembers(workspaceId: string, reason: string) { + this.publisher?.publishChanged( + 'workspace.members.changed', + { workspaceId }, + reason, + { room: realtimeWorkspaceMembersRoom(workspaceId) } + ); + } + + private publishInviteLink(workspaceId: string, reason: string) { + this.publisher?.publishChanged( + 'workspace.invite-link.changed', + { workspaceId }, + reason, + { room: realtimeWorkspaceInviteLinkRoom(workspaceId) } + ); + } +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 95c43a1e01..4ba9aa6036 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -19,6 +19,7 @@ import { DocActionDenied, DocDefaultRoleCanNotBeOwner, DocNotFound, + EventBus, ExpectToGrantDocUserRoles, ExpectToPublishDoc, ExpectToRevokeDocUserRoles, @@ -37,16 +38,15 @@ import { DOC_ACTIONS, DocAction, DocRole, + type DotToUnderline, + mapPermissionsToGraphqlPermissions, PermissionAccess, PermissionService, } from '../../permission'; import { PublicUserType, WorkspaceUserType } from '../../user'; +import { DocGrantsService } from '../doc-grants'; import { WorkspaceType } from '../types'; import { TimeBucket, TimeWindow } from './analytics-types'; -import { - DotToUnderline, - mapPermissionsToGraphqlPermissions, -} from './workspace'; registerEnumType(PublicDocMode, { name: 'PublicDocMode', @@ -298,7 +298,8 @@ export class WorkspaceDocResolver { private readonly ac: PermissionAccess, private readonly permission: PermissionService, private readonly models: Models, - private readonly cache: Cache + private readonly cache: Cache, + private readonly event: EventBus ) {} @ResolveField(() => WorkspaceDocMeta, { @@ -442,6 +443,7 @@ export class WorkspaceDocResolver { await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); const doc = await this.models.doc.publish(workspaceId, docId, mode); + this.event.emit('doc.public_state.changed', { workspaceId, docId }); this.logger.log( `Publish page ${docId} with mode ${mode} in workspace ${workspaceId}` @@ -467,6 +469,7 @@ export class WorkspaceDocResolver { await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); const doc = await this.models.doc.unpublish(workspaceId, docId); + this.event.emit('doc.public_state.changed', { workspaceId, docId }); this.logger.log(`Revoke public doc ${docId} in workspace ${workspaceId}`); @@ -535,7 +538,9 @@ export class DocResolver { constructor( private readonly ac: PermissionAccess, - private readonly models: Models + private readonly models: Models, + private readonly grants: DocGrantsService, + private readonly event: EventBus ) {} @ResolveField(() => PublicUserType, { @@ -660,28 +665,11 @@ export class DocResolver { @Args('pagination', PaginationInput.decode) pagination: PaginationInput ): Promise { await this.ac.user(user.id).doc(doc).assert('Doc.Users.Read'); - - const [permissions, totalCount] = await this.models.docUser.paginate( + return await this.grants.paginateGrantedUsers( doc.workspaceId, doc.docId, pagination ); - - const workspaceUsers = await this.models.user.getWorkspaceUsers( - permissions.map(p => p.userId) - ); - - const workspaceUsersMap = new Map(workspaceUsers.map(wu => [wu.id, wu])); - - return paginate( - permissions.map(p => ({ - ...p, - user: workspaceUsersMap.get(p.userId) as WorkspaceUserType, - })), - 'createdAt', - pagination, - totalCount - ); } @Mutation(() => Boolean) @@ -713,6 +701,10 @@ export class DocResolver { input.userIds, input.role ); + this.event.emit('doc.grants.changed', { + workspaceId: input.workspaceId, + docId: input.docId, + }); const info = { ...pairs, @@ -749,6 +741,10 @@ export class DocResolver { input.docId, input.userId ); + this.event.emit('doc.grants.changed', { + workspaceId: input.workspaceId, + docId: input.docId, + }); const info = { ...pairs, @@ -791,6 +787,11 @@ export class DocResolver { input.docId, input.userId ); + this.event.emit('doc.owner.changed', { + workspaceId: input.workspaceId, + docId: input.docId, + userId: input.userId, + }); this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`); } else { await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage'); @@ -800,6 +801,10 @@ export class DocResolver { input.userId, input.role ); + this.event.emit('doc.grants.changed', { + workspaceId: input.workspaceId, + docId: input.docId, + }); this.logger.log(`Update doc user role (${JSON.stringify(info)})`); } @@ -851,6 +856,10 @@ export class DocResolver { input.docId, input.role ); + this.event.emit('doc.default_role.changed', { + workspaceId: input.workspaceId, + docId: input.docId, + }); return true; } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index 3ab6789b2a..aa4eac875a 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -22,6 +22,7 @@ import { CanNotRevokeYourself, EventBus, InvalidInvitation, + isValidCacheTtl, mapAnyError, MemberNotFoundInSpace, NoMoreSeat, @@ -258,7 +259,7 @@ export class WorkspaceMemberResolver { const id = await this.cache.get<{ inviteId: string }>(cacheId); if (id) { const expireTime = await this.cache.ttl(cacheId); - if (Number.isSafeInteger(expireTime)) { + if (isValidCacheTtl(expireTime)) { return { link: this.url.link(`/invite/${id.inviteId}`), expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds @@ -284,7 +285,7 @@ export class WorkspaceMemberResolver { 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)) { + if (isValidCacheTtl(expireTime)) { return { link: this.url.link(`/invite/${invite.inviteId}`), expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds @@ -300,6 +301,7 @@ export class WorkspaceMemberResolver { { workspaceId, inviterUserId: user.id }, { ttl: expireTime } ); + this.event.emit('workspace.invite_link.created', { workspaceId }); return { link: this.url.link(`/invite/${inviteId}`), expireTime: new Date(Date.now() + expireTime), @@ -317,7 +319,13 @@ export class WorkspaceMemberResolver { .assert('Workspace.Users.Manage'); const cacheId = `workspace:inviteLink:${workspaceId}`; - return await this.cache.delete(cacheId); + const invite = await this.cache.get<{ inviteId: string }>(cacheId); + const deleted = await this.cache.delete(cacheId); + if (invite?.inviteId) { + await this.cache.delete(`workspace:inviteLinkId:${invite.inviteId}`); + } + this.event.emit('workspace.invite_link.revoked', { workspaceId }); + return deleted; } @Mutation(() => Boolean) @@ -406,6 +414,11 @@ export class WorkspaceMemberResolver { } await this.models.workspaceUser.set(workspaceId, userId, newRole); + if (role.status !== WorkspaceMemberStatus.Accepted) { + this.event.emit('workspace.members.updated', { + workspaceId, + }); + } } return true; @@ -608,6 +621,10 @@ export class WorkspaceMemberResolver { WorkspaceMemberStatus.Accepted ); + this.event.emit('workspace.members.updated', { + workspaceId: role.workspaceId, + }); + await this.workspaceService.sendInvitationAcceptedNotification( role.inviterId ?? (await this.models.workspaceUser.getOwner(role.workspaceId)).id, @@ -640,6 +657,7 @@ export class WorkspaceMemberResolver { ); await this.workspaceService.sendReviewRequestNotification(role.id); + this.event.emit('workspace.members.updated', { workspaceId }); return; } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 0c7f9a5942..8987e6c432 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -19,7 +19,9 @@ import { } from '../../../base'; import { Models } from '../../../models'; import { CurrentUser } from '../../auth'; +import type { DotToUnderline } from '../../permission'; import { + mapPermissionsToGraphqlPermissions, PermissionAccess, WORKSPACE_ACTIONS, WorkspaceAction, @@ -29,22 +31,6 @@ import { QuotaService, WorkspaceQuotaType } from '../../quota'; import { WorkspaceService } from '../service'; import { UpdateWorkspaceInput, WorkspaceType } from '../types'; -export type DotToUnderline = - T extends `${infer Prefix}.${infer Suffix}` - ? `${Prefix}_${DotToUnderline}` - : T; - -export function mapPermissionsToGraphqlPermissions( - permission: Record -): Record, boolean> { - return Object.fromEntries( - Object.entries(permission).map(([key, value]) => [ - key.replaceAll('.', '_'), - value, - ]) - ) as Record, boolean>; -} - const WorkspacePermissions = registerObjectType< Record, boolean> >( diff --git a/packages/backend/server/src/models/access-token.ts b/packages/backend/server/src/models/access-token.ts index 02da2f47bc..571a7b2c57 100644 --- a/packages/backend/server/src/models/access-token.ts +++ b/packages/backend/server/src/models/access-token.ts @@ -5,6 +5,17 @@ import { BaseModel } from './base'; const REDACTED_TOKEN = '[REDACTED]'; +declare global { + interface Events { + 'user.access_token.created': { + userId: string; + }; + 'user.access_token.revoked': { + userId: string; + }; + } +} + export interface CreateAccessTokenInput { userId: string; name: string; diff --git a/packages/backend/server/src/models/doc-user.ts b/packages/backend/server/src/models/doc-user.ts index 1a25292306..0ad6e6145a 100644 --- a/packages/backend/server/src/models/doc-user.ts +++ b/packages/backend/server/src/models/doc-user.ts @@ -10,6 +10,20 @@ import { BaseModel } from './base'; import { DocRole } from './common'; import { docRoleFromNew } from './permission-write'; +declare global { + interface Events { + 'doc.grants.changed': { + workspaceId: string; + docId: string; + }; + 'doc.owner.changed': { + workspaceId: string; + docId: string; + userId: string; + }; + } +} + @Injectable() export class DocUserModel extends BaseModel { /** @@ -124,6 +138,14 @@ export class DocUserModel extends BaseModel { return grants.map(grant => this.docGrantToCompat(grant)); } + async findDirectGrantDocIdsByUser(userId: string) { + return await this.db.docGrant.findMany({ + where: { principalType: 'user', principalId: userId }, + select: { workspaceId: true, docId: true }, + distinct: ['workspaceId', 'docId'], + }); + } + count(workspaceId: string, docId: string) { return this.db.docGrant.count({ where: { diff --git a/packages/backend/server/src/models/doc.ts b/packages/backend/server/src/models/doc.ts index da44ec259f..3e14cba6a7 100644 --- a/packages/backend/server/src/models/doc.ts +++ b/packages/backend/server/src/models/doc.ts @@ -19,6 +19,14 @@ declare global { workspaceId: string; docId: string; }; + 'doc.public_state.changed': { + workspaceId: string; + docId: string; + }; + 'doc.default_role.changed': { + workspaceId: string; + docId: string; + }; } } diff --git a/packages/backend/server/src/models/user-settings.ts b/packages/backend/server/src/models/user-settings.ts index 8cdf332b07..dd0b7f1a76 100644 --- a/packages/backend/server/src/models/user-settings.ts +++ b/packages/backend/server/src/models/user-settings.ts @@ -4,6 +4,14 @@ import z from 'zod'; import { BaseModel } from './base'; +declare global { + interface Events { + 'user.settings.updated': { + userId: string; + }; + } +} + export const UserSettingsSchema = z.object({ receiveInvitationEmail: z.boolean().default(true), receiveMentionEmail: z.boolean().default(true), diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index 1a768b7660..bf21d107ab 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -221,7 +221,7 @@ export class UserModel extends BaseModel { }); this.logger.debug(`User [${user.id}] updated`); - this.event.emit('user.updated', user); + this.event.emitDetached('user.updated', user); return user; } diff --git a/packages/backend/server/src/models/workspace-user-compat.ts b/packages/backend/server/src/models/workspace-user-compat.ts index 7cb91054ae..7e93119621 100644 --- a/packages/backend/server/src/models/workspace-user-compat.ts +++ b/packages/backend/server/src/models/workspace-user-compat.ts @@ -186,36 +186,68 @@ export async function searchCompatRows( pagination: { first: number; offset: number; after?: string | Date } ) { const after = pagination.after - ? Prisma.sql`AND wm.created_at >= ${pagination.after}::timestamptz` + ? Prisma.sql`AND created_at >= ${pagination.after}::timestamptz` : Prisma.empty; const rows = await db.$queryRaw` - SELECT - COALESCE(wm.legacy_permission_id, wm.id) AS id, - wm.workspace_id AS "workspaceId", - wm.user_id AS "userId", - CASE wm.role - WHEN 'owner' THEN ${WorkspaceRole.Owner} - WHEN 'admin' THEN ${WorkspaceRole.Admin} - ELSE ${WorkspaceRole.Collaborator} - END AS type, - 'Accepted'::"WorkspaceMemberStatus" AS status, - CASE wm.source - WHEN 'link' THEN 'Link'::"WorkspaceMemberSource" - ELSE 'Email'::"WorkspaceMemberSource" - END AS source, - NULL::varchar AS "inviterId", - wm.created_at AS "createdAt", - wm.updated_at AS "updatedAt", - u.name AS "userName", - u.email AS "userEmail", - u.avatar_url AS "userAvatarUrl" - FROM workspace_members wm - INNER JOIN users u ON u.id = wm.user_id - WHERE wm.workspace_id = ${workspaceId} - AND wm.state = 'active' - AND (u.email ILIKE ${`%${query}%`} OR u.name ILIKE ${`%${query}%`}) + SELECT * + FROM ( + SELECT + COALESCE(wm.legacy_permission_id, wm.id) AS id, + wm.workspace_id AS "workspaceId", + wm.user_id AS "userId", + CASE wm.role + WHEN 'owner' THEN ${WorkspaceRole.Owner} + WHEN 'admin' THEN ${WorkspaceRole.Admin} + ELSE ${WorkspaceRole.Collaborator} + END AS type, + 'Accepted'::"WorkspaceMemberStatus" AS status, + CASE wm.source + WHEN 'link' THEN 'Link'::"WorkspaceMemberSource" + ELSE 'Email'::"WorkspaceMemberSource" + END AS source, + NULL::varchar AS "inviterId", + wm.created_at AS "createdAt", + wm.updated_at AS "updatedAt", + u.name AS "userName", + u.email AS "userEmail", + u.avatar_url AS "userAvatarUrl", + wm.created_at AS created_at + FROM workspace_members wm + INNER JOIN users u ON u.id = wm.user_id + WHERE wm.workspace_id = ${workspaceId} + AND wm.state = 'active' + UNION ALL + SELECT + COALESCE(wi.legacy_permission_id, wi.id) AS id, + wi.workspace_id AS "workspaceId", + wi.invitee_user_id AS "userId", + CASE wi.requested_role + WHEN 'admin' THEN ${WorkspaceRole.Admin} + ELSE ${WorkspaceRole.Collaborator} + END AS type, + CASE wi.status + WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus" + WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus" + ELSE 'Pending'::"WorkspaceMemberStatus" + END AS status, + CASE wi.kind + WHEN 'link' THEN 'Link'::"WorkspaceMemberSource" + ELSE 'Email'::"WorkspaceMemberSource" + END AS source, + wi.inviter_user_id AS "inviterId", + wi.created_at AS "createdAt", + wi.updated_at AS "updatedAt", + u.name AS "userName", + u.email AS "userEmail", + u.avatar_url AS "userAvatarUrl", + wi.created_at AS created_at + FROM workspace_invitations wi + INNER JOIN users u ON u.id = wi.invitee_user_id + WHERE wi.workspace_id = ${workspaceId} + ) roles + WHERE ("userEmail" ILIKE ${`%${query}%`} OR "userName" ILIKE ${`%${query}%`}) ${after} - ORDER BY wm.created_at ASC + ORDER BY created_at ASC OFFSET ${pagination.offset} LIMIT ${pagination.first} `; diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index 132e0179de..2cd2ebf3cf 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -422,6 +422,26 @@ export class WorkspaceUserModel extends BaseModel { return await findUserActiveWorkspaceRoles(this.db, userId, filter); } + async getUserWorkspaceIds(userId: string) { + const [roles, invitations] = await Promise.all([ + this.db.workspaceMember.findMany({ + where: { userId, state: 'active' }, + select: { workspaceId: true }, + }), + this.db.workspaceInvitation.findMany({ + where: { inviteeUserId: userId }, + select: { workspaceId: true }, + }), + ]); + + return Array.from( + new Set([ + ...roles.map(role => role.workspaceId), + ...invitations.map(invitation => invitation.workspaceId), + ]) + ); + } + async hasSharedWorkspace(userId: string, otherUserId: string) { return await hasSharedWorkspace(this.db, userId, otherUserId); } diff --git a/packages/common/graphql/src/graphql/access-token/list.gql b/packages/common/graphql/src/graphql/access-token/list.gql deleted file mode 100644 index 45b2af5ab2..0000000000 --- a/packages/common/graphql/src/graphql/access-token/list.gql +++ /dev/null @@ -1,11 +0,0 @@ -query listUserAccessTokens { - currentUser { - revealedAccessTokens { - id - name - createdAt - expiresAt - token - } - } -} diff --git a/packages/common/graphql/src/graphql/copilot-context-workspace-query.gql b/packages/common/graphql/src/graphql/copilot-context-workspace-query.gql deleted file mode 100644 index fcd6dfb43f..0000000000 --- a/packages/common/graphql/src/graphql/copilot-context-workspace-query.gql +++ /dev/null @@ -1,6 +0,0 @@ -query getWorkspaceEmbeddingStatus($workspaceId: String!) { - queryWorkspaceEmbeddingStatus(workspaceId: $workspaceId) { - total - embedded - } -} diff --git a/packages/common/graphql/src/graphql/fragments/current-user-profile.gql b/packages/common/graphql/src/graphql/fragments/current-user-profile.gql deleted file mode 100644 index f2a4b148a4..0000000000 --- a/packages/common/graphql/src/graphql/fragments/current-user-profile.gql +++ /dev/null @@ -1,36 +0,0 @@ -fragment CurrentUserProfile on UserType { - id - name - email - avatarUrl - emailVerified - features - settings { - receiveInvitationEmail - receiveMentionEmail - receiveCommentEmail - } - quota { - name - blobLimit - storageQuota - historyPeriod - memberLimit - humanReadable { - name - blobLimit - storageQuota - historyPeriod - memberLimit - } - } - quotaUsage { - storageQuota - } - copilot { - quota { - limit - used - } - } -} diff --git a/packages/common/graphql/src/graphql/get-current-user-profile.gql b/packages/common/graphql/src/graphql/get-current-user-profile.gql deleted file mode 100644 index fd786998a8..0000000000 --- a/packages/common/graphql/src/graphql/get-current-user-profile.gql +++ /dev/null @@ -1,7 +0,0 @@ -#import './fragments/current-user-profile.gql' - -query getCurrentUserProfile { - currentUser { - ...CurrentUserProfile - } -} diff --git a/packages/common/graphql/src/graphql/get-doc-default-role.gql b/packages/common/graphql/src/graphql/get-doc-default-role.gql deleted file mode 100644 index 8c3e1854f6..0000000000 --- a/packages/common/graphql/src/graphql/get-doc-default-role.gql +++ /dev/null @@ -1,7 +0,0 @@ -query getDocDefaultRole($workspaceId: String!, $docId: String!) { - workspace(id: $workspaceId) { - doc(docId: $docId) { - defaultRole - } - } -} diff --git a/packages/common/graphql/src/graphql/get-members-by-workspace-id.gql b/packages/common/graphql/src/graphql/get-members-by-workspace-id.gql deleted file mode 100644 index cd653c100c..0000000000 --- a/packages/common/graphql/src/graphql/get-members-by-workspace-id.gql +++ /dev/null @@ -1,20 +0,0 @@ -query getMembersByWorkspaceId( - $workspaceId: String! - $skip: Int - $take: Int - $query: String -) { - workspace(id: $workspaceId) { - memberCount - members(skip: $skip, take: $take, query: $query) { - id - name - email - avatarUrl - permission - inviteId - emailVerified - status - } - } -} diff --git a/packages/common/graphql/src/graphql/get-page-granted-users-list.gql b/packages/common/graphql/src/graphql/get-page-granted-users-list.gql deleted file mode 100644 index e507165ef5..0000000000 --- a/packages/common/graphql/src/graphql/get-page-granted-users-list.gql +++ /dev/null @@ -1,28 +0,0 @@ -query getPageGrantedUsersList( - $pagination: PaginationInput! - $docId: String! - $workspaceId: String! -) { - workspace(id: $workspaceId) { - doc(docId: $docId) { - grantedUsersList(pagination: $pagination) { - totalCount - pageInfo { - endCursor - hasNextPage - } - edges { - node { - role - user { - id - name - email - avatarUrl - } - } - } - } - } - } -} diff --git a/packages/common/graphql/src/graphql/get-workspace-info.gql b/packages/common/graphql/src/graphql/get-workspace-info.gql deleted file mode 100644 index abd19fd9db..0000000000 --- a/packages/common/graphql/src/graphql/get-workspace-info.gql +++ /dev/null @@ -1,28 +0,0 @@ -query getWorkspaceInfo($workspaceId: String!) { - workspace(id: $workspaceId) { - permissions { - Workspace_Administrators_Manage - Workspace_Blobs_List - Workspace_Blobs_Read - Workspace_Blobs_Write - Workspace_Copilot - Workspace_CreateDoc - Workspace_Delete - Workspace_Organize_Read - Workspace_Payment_Manage - Workspace_Properties_Create - Workspace_Properties_Delete - Workspace_Properties_Read - Workspace_Properties_Update - Workspace_Read - Workspace_Settings_Read - Workspace_Settings_Update - Workspace_Sync - Workspace_TransferOwner - Workspace_Users_Manage - Workspace_Users_Read - } - role - team - } -} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index c8741d5d47..2235f10d3c 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -41,42 +41,6 @@ export const credentialsRequirementsFragment = `fragment CredentialsRequirements ...PasswordLimits } }`; -export const currentUserProfileFragment = `fragment CurrentUserProfile on UserType { - id - name - email - avatarUrl - emailVerified - features - settings { - receiveInvitationEmail - receiveMentionEmail - receiveCommentEmail - } - quota { - name - blobLimit - storageQuota - historyPeriod - memberLimit - humanReadable { - name - blobLimit - storageQuota - historyPeriod - memberLimit - } - } - quotaUsage { - storageQuota - } - copilot { - quota { - limit - used - } - } -}`; export const paginatedCopilotChatsFragment = `fragment PaginatedCopilotChats on PaginatedCopilotHistoriesType { pageInfo { hasNextPage @@ -117,22 +81,6 @@ export const generateUserAccessTokenMutation = { }`, }; -export const listUserAccessTokensQuery = { - id: 'listUserAccessTokensQuery' as const, - op: 'listUserAccessTokens', - query: `query listUserAccessTokens { - currentUser { - revealedAccessTokens { - id - name - createdAt - expiresAt - token - } - } -}`, -}; - export const revokeUserAccessTokenMutation = { id: 'revokeUserAccessTokenMutation' as const, op: 'revokeUserAccessToken', @@ -1287,18 +1235,6 @@ export const matchFilesQuery = { }`, }; -export const getWorkspaceEmbeddingStatusQuery = { - id: 'getWorkspaceEmbeddingStatusQuery' as const, - op: 'getWorkspaceEmbeddingStatus', - query: `query getWorkspaceEmbeddingStatus($workspaceId: String!) { - queryWorkspaceEmbeddingStatus(workspaceId: $workspaceId) { - total - embedded - } -}`, - deprecations: ["'queryWorkspaceEmbeddingStatus' is deprecated: Use realtime subscription \"workspace.embedding.progress.changed\" instead."], -}; - export const queueWorkspaceEmbeddingMutation = { id: 'queueWorkspaceEmbeddingMutation' as const, op: 'queueWorkspaceEmbedding', @@ -1936,17 +1872,6 @@ export const getCurrentUserFeaturesQuery = { }`, }; -export const getCurrentUserProfileQuery = { - id: 'getCurrentUserProfileQuery' as const, - op: 'getCurrentUserProfile', - query: `query getCurrentUserProfile { - currentUser { - ...CurrentUserProfile - } -} -${currentUserProfileFragment}`, -}; - export const getCurrentUserQuery = { id: 'getCurrentUserQuery' as const, op: 'getCurrentUser', @@ -1988,18 +1913,6 @@ export const getDocCreatedByUpdatedByListQuery = { }`, }; -export const getDocDefaultRoleQuery = { - id: 'getDocDefaultRoleQuery' as const, - op: 'getDocDefaultRole', - query: `query getDocDefaultRole($workspaceId: String!, $docId: String!) { - workspace(id: $workspaceId) { - doc(docId: $docId) { - defaultRole - } - } -}`, -}; - export const getDocLastAccessedMembersQuery = { id: 'getDocLastAccessedMembersQuery' as const, op: 'getDocLastAccessedMembers', @@ -2118,27 +2031,6 @@ export const getMemberCountByWorkspaceIdQuery = { }`, }; -export const getMembersByWorkspaceIdQuery = { - id: 'getMembersByWorkspaceIdQuery' as const, - op: 'getMembersByWorkspaceId', - query: `query getMembersByWorkspaceId($workspaceId: String!, $skip: Int, $take: Int, $query: String) { - workspace(id: $workspaceId) { - memberCount - members(skip: $skip, take: $take, query: $query) { - id - name - email - avatarUrl - permission - inviteId - emailVerified - status - } - } -}`, - deprecations: ["'permission' is deprecated: Use role instead"], -}; - export const oauthProvidersQuery = { id: 'oauthProvidersQuery' as const, op: 'oauthProviders', @@ -2149,35 +2041,6 @@ export const oauthProvidersQuery = { }`, }; -export const getPageGrantedUsersListQuery = { - id: 'getPageGrantedUsersListQuery' as const, - op: 'getPageGrantedUsersList', - query: `query getPageGrantedUsersList($pagination: PaginationInput!, $docId: String!, $workspaceId: String!) { - workspace(id: $workspaceId) { - doc(docId: $docId) { - grantedUsersList(pagination: $pagination) { - totalCount - pageInfo { - endCursor - hasNextPage - } - edges { - node { - role - user { - id - name - email - avatarUrl - } - } - } - } - } - } -}`, -}; - export const getPublicUserByIdQuery = { id: 'getPublicUserByIdQuery' as const, op: 'getPublicUserById', @@ -2262,39 +2125,6 @@ export const getUserQuery = { }`, }; -export const getWorkspaceInfoQuery = { - id: 'getWorkspaceInfoQuery' as const, - op: 'getWorkspaceInfo', - query: `query getWorkspaceInfo($workspaceId: String!) { - workspace(id: $workspaceId) { - permissions { - Workspace_Administrators_Manage - Workspace_Blobs_List - Workspace_Blobs_Read - Workspace_Blobs_Write - Workspace_Copilot - Workspace_CreateDoc - Workspace_Delete - Workspace_Organize_Read - Workspace_Payment_Manage - Workspace_Properties_Create - Workspace_Properties_Delete - Workspace_Properties_Read - Workspace_Properties_Update - Workspace_Read - Workspace_Settings_Read - Workspace_Settings_Update - Workspace_Sync - Workspace_TransferOwner - Workspace_Users_Manage - Workspace_Users_Read - } - role - team - } -}`, -}; - export const getWorkspacePageByIdQuery = { id: 'getWorkspacePageByIdQuery' as const, op: 'getWorkspacePageById', @@ -2643,17 +2473,6 @@ export const mentionUserMutation = { }`, }; -export const notificationCountQuery = { - id: 'notificationCountQuery' as const, - op: 'notificationCount', - query: `query notificationCount { - currentUser { - notificationCount - } -}`, - deprecations: ["'notificationCount' is deprecated: Use realtime subscription \"notification.count.changed\" instead."], -}; - export const pricesQuery = { id: 'pricesQuery' as const, op: 'prices', @@ -3117,19 +2936,6 @@ export const workspaceByokSettingsQuery = { }`, }; -export const getWorkspaceConfigQuery = { - id: 'getWorkspaceConfigQuery' as const, - op: 'getWorkspaceConfig', - query: `query getWorkspaceConfig($id: String!) { - workspace(id: $id) { - enableAi - enableSharing - enableUrlPreview - enableDocEmbedding - } -}`, -}; - export const setEnableAiMutation = { id: 'setEnableAiMutation' as const, op: 'setEnableAi', @@ -3191,19 +2997,6 @@ export const acceptInviteByInviteIdMutation = { }`, }; -export const getWorkspaceInviteLinkQuery = { - id: 'getWorkspaceInviteLinkQuery' as const, - op: 'getWorkspaceInviteLink', - query: `query getWorkspaceInviteLink($id: String!) { - workspace(id: $id) { - inviteLink { - link - expireTime - } - } -}`, -}; - export const createInviteLinkMutation = { id: 'createInviteLinkMutation' as const, op: 'createInviteLink', @@ -3244,34 +3037,6 @@ export const workspaceInvoicesQuery = { deprecations: ["'id' is deprecated: removed"], }; -export const workspaceQuotaQuery = { - id: 'workspaceQuotaQuery' as const, - op: 'workspaceQuota', - query: `query workspaceQuota($id: String!) { - workspace(id: $id) { - quota { - name - blobLimit - storageQuota - usedStorageQuota - historyPeriod - memberLimit - memberCount - overcapacityMemberCount - humanReadable { - name - blobLimit - storageQuota - historyPeriod - memberLimit - memberCount - overcapacityMemberCount - } - } - } -}`, -}; - export const getWorkspaceRolePermissionsQuery = { id: 'getWorkspaceRolePermissionsQuery' as const, op: 'getWorkspaceRolePermissions', diff --git a/packages/common/graphql/src/graphql/notification-count.gql b/packages/common/graphql/src/graphql/notification-count.gql deleted file mode 100644 index 1d36b833c6..0000000000 --- a/packages/common/graphql/src/graphql/notification-count.gql +++ /dev/null @@ -1,5 +0,0 @@ -query notificationCount { - currentUser { - notificationCount - } -} diff --git a/packages/common/graphql/src/graphql/workspace-config.gql b/packages/common/graphql/src/graphql/workspace-config.gql deleted file mode 100644 index dbab91c3bc..0000000000 --- a/packages/common/graphql/src/graphql/workspace-config.gql +++ /dev/null @@ -1,8 +0,0 @@ -query getWorkspaceConfig($id: String!) { - workspace(id: $id) { - enableAi - enableSharing - enableUrlPreview - enableDocEmbedding - } -} diff --git a/packages/common/graphql/src/graphql/workspace-invite-link-get.gql b/packages/common/graphql/src/graphql/workspace-invite-link-get.gql deleted file mode 100644 index ab20c3d0ba..0000000000 --- a/packages/common/graphql/src/graphql/workspace-invite-link-get.gql +++ /dev/null @@ -1,8 +0,0 @@ -query getWorkspaceInviteLink($id: String!) { - workspace(id: $id) { - inviteLink { - link - expireTime - } - } -} diff --git a/packages/common/graphql/src/graphql/workspace-quota.gql b/packages/common/graphql/src/graphql/workspace-quota.gql deleted file mode 100644 index 0f7c76aedc..0000000000 --- a/packages/common/graphql/src/graphql/workspace-quota.gql +++ /dev/null @@ -1,23 +0,0 @@ -query workspaceQuota($id: String!) { - workspace(id: $id) { - quota { - name - blobLimit - storageQuota - usedStorageQuota - historyPeriod - memberLimit - memberCount - overcapacityMemberCount - humanReadable { - name - blobLimit - storageQuota - historyPeriod - memberLimit - memberCount - overcapacityMemberCount - } - } - } -} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 2835f15ca7..b19a492081 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -3785,25 +3785,6 @@ export type GenerateUserAccessTokenMutation = { }; }; -export type ListUserAccessTokensQueryVariables = Exact<{ - [key: string]: never; -}>; - -export type ListUserAccessTokensQuery = { - __typename?: 'Query'; - currentUser: { - __typename?: 'UserType'; - revealedAccessTokens: Array<{ - __typename?: 'RevealedAccessToken'; - id: string; - name: string; - createdAt: string; - expiresAt: string | null; - token: string; - }>; - } | null; -}; - export type RevokeUserAccessTokenMutationVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -5112,19 +5093,6 @@ export type MatchFilesQuery = { } | null; }; -export type GetWorkspaceEmbeddingStatusQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type GetWorkspaceEmbeddingStatusQuery = { - __typename?: 'Query'; - queryWorkspaceEmbeddingStatus: { - __typename?: 'ContextWorkspaceEmbeddingStatus'; - total: number; - embedded: number; - }; -}; - export type QueueWorkspaceEmbeddingMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; docId: Array | Scalars['String']['input']; @@ -6271,43 +6239,6 @@ export type CredentialsRequirementsFragment = { }; }; -export type CurrentUserProfileFragment = { - __typename?: 'UserType'; - id: string; - name: string; - email: string; - avatarUrl: string | null; - emailVerified: boolean; - features: Array; - settings: { - __typename?: 'UserSettingsType'; - receiveInvitationEmail: boolean; - receiveMentionEmail: boolean; - receiveCommentEmail: boolean; - }; - quota: { - __typename?: 'UserQuotaType'; - name: string; - blobLimit: number; - storageQuota: number; - historyPeriod: number; - memberLimit: number; - humanReadable: { - __typename?: 'UserQuotaHumanReadableType'; - name: string; - blobLimit: string; - storageQuota: string; - historyPeriod: string; - memberLimit: string; - }; - }; - quotaUsage: { __typename?: 'UserQuotaUsageType'; storageQuota: number }; - copilot: { - __typename?: 'Copilot'; - quota: { __typename?: 'CopilotQuota'; limit: number | null; used: number }; - }; -}; - export type PaginatedCopilotChatsFragment = { __typename?: 'PaginatedCopilotHistoriesType'; pageInfo: { @@ -6388,54 +6319,6 @@ export type GetCurrentUserFeaturesQuery = { } | null; }; -export type GetCurrentUserProfileQueryVariables = Exact<{ - [key: string]: never; -}>; - -export type GetCurrentUserProfileQuery = { - __typename?: 'Query'; - currentUser: { - __typename?: 'UserType'; - id: string; - name: string; - email: string; - avatarUrl: string | null; - emailVerified: boolean; - features: Array; - settings: { - __typename?: 'UserSettingsType'; - receiveInvitationEmail: boolean; - receiveMentionEmail: boolean; - receiveCommentEmail: boolean; - }; - quota: { - __typename?: 'UserQuotaType'; - name: string; - blobLimit: number; - storageQuota: number; - historyPeriod: number; - memberLimit: number; - humanReadable: { - __typename?: 'UserQuotaHumanReadableType'; - name: string; - blobLimit: string; - storageQuota: string; - historyPeriod: string; - memberLimit: string; - }; - }; - quotaUsage: { __typename?: 'UserQuotaUsageType'; storageQuota: number }; - copilot: { - __typename?: 'Copilot'; - quota: { - __typename?: 'CopilotQuota'; - limit: number | null; - used: number; - }; - }; - } | null; -}; - export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never }>; export type GetCurrentUserQuery = { @@ -6481,19 +6364,6 @@ export type GetDocCreatedByUpdatedByListQuery = { }; }; -export type GetDocDefaultRoleQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; - docId: Scalars['String']['input']; -}>; - -export type GetDocDefaultRoleQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - doc: { __typename?: 'DocType'; defaultRole: DocRole }; - }; -}; - export type GetDocLastAccessedMembersQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; docId: Scalars['String']['input']; @@ -6634,32 +6504,6 @@ export type GetMemberCountByWorkspaceIdQuery = { workspace: { __typename?: 'WorkspaceType'; memberCount: number }; }; -export type GetMembersByWorkspaceIdQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; - skip?: InputMaybe; - take?: InputMaybe; - query?: InputMaybe; -}>; - -export type GetMembersByWorkspaceIdQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - memberCount: number; - members: Array<{ - __typename?: 'InviteUserType'; - id: string; - name: string | null; - email: string | null; - avatarUrl: string | null; - permission: Permission; - inviteId: string; - emailVerified: boolean | null; - status: WorkspaceMemberStatus; - }>; - }; -}; - export type OauthProvidersQueryVariables = Exact<{ [key: string]: never }>; export type OauthProvidersQuery = { @@ -6670,45 +6514,6 @@ export type OauthProvidersQuery = { }; }; -export type GetPageGrantedUsersListQueryVariables = Exact<{ - pagination: PaginationInput; - docId: Scalars['String']['input']; - workspaceId: Scalars['String']['input']; -}>; - -export type GetPageGrantedUsersListQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - doc: { - __typename?: 'DocType'; - grantedUsersList: { - __typename?: 'PaginatedGrantedDocUserType'; - totalCount: number; - pageInfo: { - __typename?: 'PageInfo'; - endCursor: string | null; - hasNextPage: boolean; - }; - edges: Array<{ - __typename?: 'GrantedDocUserTypeEdge'; - node: { - __typename?: 'GrantedDocUserType'; - role: DocRole; - user: { - __typename?: 'WorkspaceUserType'; - id: string; - name: string; - email: string; - avatarUrl: string | null; - }; - }; - }>; - }; - }; - }; -}; - export type GetPublicUserByIdQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -6805,42 +6610,6 @@ export type GetUserQuery = { | null; }; -export type GetWorkspaceInfoQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type GetWorkspaceInfoQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - role: Permission; - team: boolean; - permissions: { - __typename?: 'WorkspacePermissions'; - Workspace_Administrators_Manage: boolean; - Workspace_Blobs_List: boolean; - Workspace_Blobs_Read: boolean; - Workspace_Blobs_Write: boolean; - Workspace_Copilot: boolean; - Workspace_CreateDoc: boolean; - Workspace_Delete: boolean; - Workspace_Organize_Read: boolean; - Workspace_Payment_Manage: boolean; - Workspace_Properties_Create: boolean; - Workspace_Properties_Delete: boolean; - Workspace_Properties_Read: boolean; - Workspace_Properties_Update: boolean; - Workspace_Read: boolean; - Workspace_Settings_Read: boolean; - Workspace_Settings_Update: boolean; - Workspace_Sync: boolean; - Workspace_TransferOwner: boolean; - Workspace_Users_Manage: boolean; - Workspace_Users_Read: boolean; - }; - }; -}; - export type GetWorkspacePageByIdQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; pageId: Scalars['String']['input']; @@ -7268,13 +7037,6 @@ export type MentionUserMutation = { mentionUser: string; }; -export type NotificationCountQueryVariables = Exact<{ [key: string]: never }>; - -export type NotificationCountQuery = { - __typename?: 'Query'; - currentUser: { __typename?: 'UserType'; notificationCount: number } | null; -}; - export type PricesQueryVariables = Exact<{ [key: string]: never }>; export type PricesQuery = { @@ -7784,21 +7546,6 @@ export type WorkspaceByokSettingsQuery = { }; }; -export type GetWorkspaceConfigQueryVariables = Exact<{ - id: Scalars['String']['input']; -}>; - -export type GetWorkspaceConfigQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - enableAi: boolean; - enableSharing: boolean; - enableUrlPreview: boolean; - enableDocEmbedding: boolean; - }; -}; - export type SetEnableAiMutationVariables = Exact<{ id: Scalars['ID']['input']; enableAi: Scalars['Boolean']['input']; @@ -7864,22 +7611,6 @@ export type AcceptInviteByInviteIdMutation = { acceptInviteById: boolean; }; -export type GetWorkspaceInviteLinkQueryVariables = Exact<{ - id: Scalars['String']['input']; -}>; - -export type GetWorkspaceInviteLinkQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - inviteLink: { - __typename?: 'InviteLink'; - link: string; - expireTime: string; - } | null; - }; -}; - export type CreateInviteLinkMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; expireTime: WorkspaceInviteLinkExpireTime; @@ -7928,38 +7659,6 @@ export type WorkspaceInvoicesQuery = { }; }; -export type WorkspaceQuotaQueryVariables = Exact<{ - id: Scalars['String']['input']; -}>; - -export type WorkspaceQuotaQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - quota: { - __typename?: 'WorkspaceQuotaType'; - name: string; - blobLimit: number; - storageQuota: number; - usedStorageQuota: number; - historyPeriod: number; - memberLimit: number; - memberCount: number; - overcapacityMemberCount: number; - humanReadable: { - __typename?: 'WorkspaceQuotaHumanReadableType'; - name: string; - blobLimit: string; - storageQuota: string; - historyPeriod: string; - memberLimit: string; - memberCount: string; - overcapacityMemberCount: string; - }; - }; - }; -}; - export type GetWorkspaceRolePermissionsQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -8016,11 +7715,6 @@ export type GrantWorkspaceTeamMemberMutation = { }; export type Queries = - | { - name: 'listUserAccessTokensQuery'; - variables: ListUserAccessTokensQueryVariables; - response: ListUserAccessTokensQuery; - } | { name: 'adminAllSharedLinksQuery'; variables: AdminAllSharedLinksQueryVariables; @@ -8136,11 +7830,6 @@ export type Queries = variables: MatchFilesQueryVariables; response: MatchFilesQuery; } - | { - name: 'getWorkspaceEmbeddingStatusQuery'; - variables: GetWorkspaceEmbeddingStatusQueryVariables; - response: GetWorkspaceEmbeddingStatusQuery; - } | { name: 'getCopilotHistoryIdsQuery'; variables: GetCopilotHistoryIdsQueryVariables; @@ -8226,11 +7915,6 @@ export type Queries = variables: GetCurrentUserFeaturesQueryVariables; response: GetCurrentUserFeaturesQuery; } - | { - name: 'getCurrentUserProfileQuery'; - variables: GetCurrentUserProfileQueryVariables; - response: GetCurrentUserProfileQuery; - } | { name: 'getCurrentUserQuery'; variables: GetCurrentUserQueryVariables; @@ -8241,11 +7925,6 @@ export type Queries = variables: GetDocCreatedByUpdatedByListQueryVariables; response: GetDocCreatedByUpdatedByListQuery; } - | { - name: 'getDocDefaultRoleQuery'; - variables: GetDocDefaultRoleQueryVariables; - response: GetDocDefaultRoleQuery; - } | { name: 'getDocLastAccessedMembersQuery'; variables: GetDocLastAccessedMembersQueryVariables; @@ -8271,21 +7950,11 @@ export type Queries = variables: GetMemberCountByWorkspaceIdQueryVariables; response: GetMemberCountByWorkspaceIdQuery; } - | { - name: 'getMembersByWorkspaceIdQuery'; - variables: GetMembersByWorkspaceIdQueryVariables; - response: GetMembersByWorkspaceIdQuery; - } | { name: 'oauthProvidersQuery'; variables: OauthProvidersQueryVariables; response: OauthProvidersQuery; } - | { - name: 'getPageGrantedUsersListQuery'; - variables: GetPageGrantedUsersListQueryVariables; - response: GetPageGrantedUsersListQuery; - } | { name: 'getPublicUserByIdQuery'; variables: GetPublicUserByIdQueryVariables; @@ -8311,11 +7980,6 @@ export type Queries = variables: GetUserQueryVariables; response: GetUserQuery; } - | { - name: 'getWorkspaceInfoQuery'; - variables: GetWorkspaceInfoQueryVariables; - response: GetWorkspaceInfoQuery; - } | { name: 'getWorkspacePageByIdQuery'; variables: GetWorkspacePageByIdQueryVariables; @@ -8391,11 +8055,6 @@ export type Queries = variables: ListNotificationsQueryVariables; response: ListNotificationsQuery; } - | { - name: 'notificationCountQuery'; - variables: NotificationCountQueryVariables; - response: NotificationCountQuery; - } | { name: 'pricesQuery'; variables: PricesQueryVariables; @@ -8426,26 +8085,11 @@ export type Queries = variables: WorkspaceByokSettingsQueryVariables; response: WorkspaceByokSettingsQuery; } - | { - name: 'getWorkspaceConfigQuery'; - variables: GetWorkspaceConfigQueryVariables; - response: GetWorkspaceConfigQuery; - } - | { - name: 'getWorkspaceInviteLinkQuery'; - variables: GetWorkspaceInviteLinkQueryVariables; - response: GetWorkspaceInviteLinkQuery; - } | { name: 'workspaceInvoicesQuery'; variables: WorkspaceInvoicesQueryVariables; response: WorkspaceInvoicesQuery; } - | { - name: 'workspaceQuotaQuery'; - variables: WorkspaceQuotaQueryVariables; - response: WorkspaceQuotaQuery; - } | { name: 'getWorkspaceRolePermissionsQuery'; variables: GetWorkspaceRolePermissionsQueryVariables; diff --git a/packages/common/nbstore/src/impls/cloud/socket.ts b/packages/common/nbstore/src/impls/cloud/socket.ts index 9bef8945e7..d0961d5868 100644 --- a/packages/common/nbstore/src/impls/cloud/socket.ts +++ b/packages/common/nbstore/src/impls/cloud/socket.ts @@ -235,11 +235,19 @@ class SocketManager { }, }; } + + reset() { + this.socket.disconnect(); + } } const SOCKET_MANAGER_CACHE = new Map(); +function getSocketManagerKey(endpoint: string, isSelfHosted: boolean) { + return `${endpoint}:${isSelfHosted ? 'selfhosted' : 'cloud'}`; +} + function getSocketManager(endpoint: string, isSelfHosted: boolean) { - const key = `${endpoint}:${isSelfHosted ? 'selfhosted' : 'cloud'}`; + const key = getSocketManagerKey(endpoint, isSelfHosted); let manager = SOCKET_MANAGER_CACHE.get(key); if (!manager) { manager = new SocketManager(endpoint, isSelfHosted); @@ -252,6 +260,12 @@ export class SocketConnection extends AutoReconnectConnection<{ socket: Socket; disconnect: () => void; }> { + static resetSharedConnection(endpoint: string, isSelfHosted: boolean) { + SOCKET_MANAGER_CACHE.get( + getSocketManagerKey(endpoint, isSelfHosted) + )?.reset(); + } + manager = getSocketManager(this.endpoint, this.isSelfHosted); constructor( diff --git a/packages/common/nbstore/src/realtime/__tests__/manager.spec.ts b/packages/common/nbstore/src/realtime/__tests__/manager.spec.ts index a4c4f306c1..32d89587d7 100644 --- a/packages/common/nbstore/src/realtime/__tests__/manager.spec.ts +++ b/packages/common/nbstore/src/realtime/__tests__/manager.spec.ts @@ -41,10 +41,15 @@ class FakeSocket { } } +const { resetSharedConnection } = vi.hoisted(() => ({ + resetSharedConnection: vi.fn(), +})); const socket = new FakeSocket(); vi.mock('../../impls/cloud/socket', () => ({ SocketConnection: class { + static resetSharedConnection = resetSharedConnection; + readonly inner = { socket }; status = 'connected'; readonly maybeConnection = { socket }; @@ -68,6 +73,7 @@ beforeEach(() => { socket.nextSubscriptionId = 0; socket.connected = true; socket.disconnected = false; + resetSharedConnection.mockClear(); }); test('getRealtimeInputKey is deterministic for realtime subscription inputs', () => { @@ -124,6 +130,33 @@ test('request rejects server ack error', async () => { ); }); +test('user profile request can bootstrap without authenticated context', async () => { + const manager = new RealtimeManager(); + manager.setContext({ + endpoint: 'http://server', + isSelfHosted: false, + authenticated: false, + }); + socket.nextRequestAck = { data: { user: null } }; + + await expect(manager.request('user.profile.get', {})).resolves.toEqual({ + user: null, + }); +}); + +test('non-bootstrap request still requires authenticated context', async () => { + const manager = new RealtimeManager(); + manager.setContext({ + endpoint: 'http://server', + isSelfHosted: false, + authenticated: false, + }); + + await expect(manager.request('notification.count.get', {})).rejects.toThrow( + 'Realtime is not authenticated' + ); +}); + test('request rejects when aborted', async () => { const manager = new RealtimeManager(); manager.setContext({ @@ -203,7 +236,7 @@ test('unsubscribe leaves server room and clears status', async () => { }); }); -test('context switch disconnects socket and completes subscriptions', async () => { +test('context switch disconnects socket and keeps subscriptions for reauth', async () => { const manager = new RealtimeManager(); manager.setContext({ endpoint: 'http://server', @@ -223,11 +256,95 @@ test('context switch disconnects socket and completes subscriptions', async () = }); expect(socket.disconnected).toBe(true); - expect(completed).toHaveBeenCalled(); + expect(completed).not.toHaveBeenCalled(); expect(manager.getStatus()).toMatchObject({ endpoint: 'http://other-server', connected: false, - subscriptions: 0, + subscriptions: 1, + }); +}); + +test('auth context switch resets shared socket connection', () => { + const manager = new RealtimeManager(); + manager.setContext({ + endpoint: 'http://server', + isSelfHosted: false, + authenticated: true, + }); + + manager.setContext({ + endpoint: 'http://server', + isSelfHosted: false, + authenticated: false, + }); + + expect(resetSharedConnection).toHaveBeenCalledWith('http://server', false); +}); + +test('context switch resubscribes existing subscriptions on next connect', async () => { + const manager = new RealtimeManager(); + manager.setContext({ + endpoint: 'http://server', + isSelfHosted: false, + authenticated: true, + }); + const received: unknown[] = []; + const subscription = manager + .subscribe('notification.count.changed', {}) + .subscribe(event => received.push(event)); + await vi.waitFor(() => expect(received).toEqual([{ type: 'ready' }])); + + manager.setContext({ + endpoint: 'http://other-server', + isSelfHosted: false, + authenticated: true, + }); + await manager.request('notification.count.get', {}); + + await vi.waitFor(() => + expect( + socket.emitted.filter(item => item.event === 'realtime:subscribe') + ).toHaveLength(2) + ); + expect(received).toEqual([{ type: 'ready' }, { type: 'ready' }]); + subscription.unsubscribe(); +}); + +test('unsubscribe uses resubscribed server subscription id', async () => { + const manager = new RealtimeManager(); + manager.setContext({ + endpoint: 'http://server', + isSelfHosted: false, + authenticated: true, + }); + const subscription = manager + .subscribe('notification.count.changed', {}) + .subscribe(); + await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(1)); + + manager.setContext({ + endpoint: 'http://other-server', + isSelfHosted: false, + authenticated: true, + }); + await manager.request('notification.count.get', {}); + await vi.waitFor(() => + expect( + socket.emitted.filter(item => item.event === 'realtime:subscribe') + ).toHaveLength(2) + ); + + subscription.unsubscribe(); + + expect(manager.getStatus().subscriptions).toBe(0); + expect(socket.emitted.at(-1)).toEqual({ + event: 'realtime:unsubscribe', + payload: { + subscriptionId: 'sub-2', + topic: 'notification.count.changed', + input: {}, + clientVersion: 'test', + }, }); }); diff --git a/packages/common/nbstore/src/realtime/manager.ts b/packages/common/nbstore/src/realtime/manager.ts index 502e17c2af..ece2c41378 100644 --- a/packages/common/nbstore/src/realtime/manager.ts +++ b/packages/common/nbstore/src/realtime/manager.ts @@ -37,6 +37,7 @@ export class RealtimeManager { private socketConnection?: SocketConnection; private socketKey?: string; private lastError?: { name: string; message: string }; + private subscriptionsNeedResubscribe = false; private readonly subscriptions = new Map< string, { @@ -44,21 +45,32 @@ export class RealtimeManager { input: RealtimeTopicInputOf; inputKey: string; subject$: Subject; + onResubscribed: (subscriptionId: string) => void; } >(); setContext(context: RealtimeContext) { const nextContext = { ...context }; + const previousContext = this.context; const changed = - !this.context || - this.context.endpoint !== nextContext.endpoint || - this.context.isSelfHosted !== nextContext.isSelfHosted || - this.context.authenticated !== nextContext.authenticated; + !previousContext || + previousContext.endpoint !== nextContext.endpoint || + previousContext.isSelfHosted !== nextContext.isSelfHosted || + previousContext.authenticated !== nextContext.authenticated; this.context = nextContext; if (changed) { this.resetConnection(); + if ( + previousContext && + previousContext.authenticated !== nextContext.authenticated + ) { + SocketConnection.resetSharedConnection( + previousContext.endpoint, + previousContext.isSelfHosted + ); + } } } @@ -67,7 +79,7 @@ export class RealtimeManager { input: RealtimeRequestInputOf, options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { - const socket = await this.connect(); + const socket = await this.connect(op === 'user.profile.get'); const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT; let timeoutId: ReturnType | undefined; let abortHandler: (() => void) | undefined; @@ -154,6 +166,9 @@ export class RealtimeManager { input, inputKey: getRealtimeInputKey(input), subject$, + onResubscribed: nextSubscriptionId => { + subscriptionId = nextSubscriptionId; + }, }); subscriber.next({ type: 'ready', @@ -167,7 +182,7 @@ export class RealtimeManager { } }, error: error => subscriber.error(error), - complete: () => subscriber.complete(), + complete: () => {}, }); } catch (error) { this.lastError = normalizeError(error); @@ -209,8 +224,11 @@ export class RealtimeManager { }; } - private async connect() { - if (!this.context?.endpoint || !this.context.authenticated) { + private async connect(allowUnauthenticated = false) { + if ( + !this.context?.endpoint || + (!this.context.authenticated && !allowUnauthenticated) + ) { const error = new Error('Realtime is not authenticated'); error.name = 'RealtimeUnauthenticated'; throw error; @@ -232,6 +250,9 @@ export class RealtimeManager { this.socketConnection.inner.socket.on('realtime:event', this.handleEvent); this.socketConnection.inner.socket.off('connect', this.handleReconnect); this.socketConnection.inner.socket.on('connect', this.handleReconnect); + if (this.subscriptionsNeedResubscribe && this.context.authenticated) { + await this.resubscribeAll(); + } return this.socketConnection.inner.socket; } @@ -272,6 +293,7 @@ export class RealtimeManager { this.subscriptions.delete(subscriptionId); this.subscriptions.set(ack.data.subscriptionId, subscription); + subscription.onResubscribed(ack.data.subscriptionId); subscription.subject$.next({ type: 'ready', }); @@ -281,6 +303,7 @@ export class RealtimeManager { subscription.subject$.error(error); } } + this.subscriptionsNeedResubscribe = false; } private resetConnection() { @@ -297,9 +320,6 @@ export class RealtimeManager { } this.socketConnection = undefined; this.socketKey = undefined; - for (const subscription of this.subscriptions.values()) { - subscription.subject$.complete(); - } - this.subscriptions.clear(); + this.subscriptionsNeedResubscribe = this.subscriptions.size > 0; } } diff --git a/packages/common/realtime/src/index.ts b/packages/common/realtime/src/index.ts index ea8c385255..72915b727a 100644 --- a/packages/common/realtime/src/index.ts +++ b/packages/common/realtime/src/index.ts @@ -1,9 +1,50 @@ -import type { CommentChangeObjectType } from '@affine/graphql'; - export type RealtimeRequestName = keyof RealtimeRequestMap; export type RealtimeTopicName = keyof RealtimeTopicMap; +export const WORKSPACE_MEMBERS_REQUEST_TAKE_MAX = 100; + export interface RealtimeRequestMap { + 'workspace.access.get': { + input: { workspaceId: string }; + output: { access: WorkspaceAccessSnapshot }; + }; + 'workspace.config.get': { + input: { workspaceId: string }; + output: { config: WorkspaceConfigSnapshot }; + }; + 'workspace.members.get': { + input: { + workspaceId: string; + skip?: number; + take?: number; + query?: string; + }; + output: { members: WorkspaceMemberSnapshot[]; memberCount: number }; + }; + 'workspace.invite-link.get': { + input: { workspaceId: string }; + output: { inviteLink: WorkspaceInviteLinkSnapshot | null }; + }; + 'doc.share-state.get': { + input: { workspaceId: string; docId: string }; + output: { state: DocShareStateSnapshot | null }; + }; + 'doc.grants.get': { + input: { workspaceId: string; docId: string; pagination: PaginationInput }; + output: PaginatedDocGrantedUsersSnapshot; + }; + 'user.profile.get': { + input: Record; + output: { user: CurrentUserProfileSnapshot | null }; + }; + 'user.settings.get': { + input: Record; + output: { settings: UserSettingsSnapshot }; + }; + 'user.access-tokens.get': { + input: Record; + output: { tokens: AccessTokenSnapshot[] }; + }; 'notification.count.get': { input: Record; output: { count: number }; @@ -16,7 +57,7 @@ export interface RealtimeRequestMap { first?: number; }; output: { - changes: CommentChangeObjectType[]; + changes: CommentChangeSnapshot[]; startCursor: string; endCursor: string; hasNextPage: boolean; @@ -44,6 +85,117 @@ export interface RealtimeRequestMap { }; } +export type WorkspaceRoleSnapshot = + | 'Owner' + | 'Admin' + | 'Collaborator' + | 'External' + | string; + +export type DocRoleSnapshot = + | 'Owner' + | 'Manager' + | 'Editor' + | 'Commenter' + | 'Reader' + | 'External' + | string; + +export type PublicDocModeSnapshot = 'Page' | 'Edgeless' | string; + +export interface WorkspaceAccessSnapshot { + role: WorkspaceRoleSnapshot; + permissions: Record; + team: boolean; +} + +export interface WorkspaceConfigSnapshot { + enableAi: boolean; + enableSharing: boolean; + enableUrlPreview: boolean; + enableDocEmbedding: boolean; +} + +export interface WorkspaceMemberSnapshot { + id: string; + name: string; + email: string; + avatarUrl: string | null; + permission: WorkspaceRoleSnapshot; + role: WorkspaceRoleSnapshot; + inviteId: string; + emailVerified: boolean | null; + status: string; +} + +export interface WorkspaceInviteLinkSnapshot { + link: string; + expireTime: string; +} + +export interface DocShareStateSnapshot { + public: boolean; + mode: PublicDocModeSnapshot; + defaultRole: DocRoleSnapshot; +} + +export interface PaginationInput { + first: number; + offset?: number; + after?: string; +} + +export interface DocGrantedUserSnapshot { + role: DocRoleSnapshot; + user: { + id: string; + name: string; + email: string; + avatarUrl: string | null; + }; +} + +export interface PaginatedDocGrantedUsersSnapshot { + totalCount: number; + pageInfo: { + endCursor: string | null; + hasNextPage: boolean; + }; + edges: { node: DocGrantedUserSnapshot }[]; +} + +export interface CurrentUserProfileSnapshot { + id: string; + name: string; + email: string; + emailVerified: boolean; + hasPassword: boolean | null; + avatarUrl: string | null; + features?: string[]; +} + +export interface UserSettingsSnapshot { + receiveInvitationEmail: boolean; + receiveMentionEmail: boolean; + receiveCommentEmail: boolean; +} + +export interface AccessTokenSnapshot { + id: string; + name: string; + createdAt: string; + expiresAt: string | null; +} + +export type CommentChangeActionSnapshot = 'update' | 'delete'; + +export interface CommentChangeSnapshot { + id: string; + action: CommentChangeActionSnapshot; + item: object; + commentId: string | null; +} + export interface UserQuotaStateSnapshot { userId: string; plan: string; @@ -101,6 +253,42 @@ export type WorkspaceEmbeddingProgressReason = | 'resync'; export interface RealtimeTopicMap { + 'workspace.access.changed': { + input: { workspaceId: string }; + event: { changed: true; reason: string }; + }; + 'workspace.config.changed': { + input: { workspaceId: string }; + event: { changed: true; reason: string }; + }; + 'workspace.members.changed': { + input: { workspaceId: string }; + event: { changed: true; reason: string }; + }; + 'workspace.invite-link.changed': { + input: { workspaceId: string }; + event: { changed: true; reason: string }; + }; + 'doc.share-state.changed': { + input: { workspaceId: string; docId: string }; + event: { changed: true; reason: string }; + }; + 'doc.grants.changed': { + input: { workspaceId: string; docId: string }; + event: { changed: true; reason: string }; + }; + 'user.profile.changed': { + input: Record; + event: { changed: true; reason: string }; + }; + 'user.settings.changed': { + input: Record; + event: { changed: true; reason: string }; + }; + 'user.access-tokens.changed': { + input: Record; + event: { changed: true; reason: string }; + }; 'notification.count.changed': { input: Record; event: { diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Fragments/CurrentUserProfile.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Fragments/CurrentUserProfile.graphql.swift deleted file mode 100644 index 0a5fcbf178..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Fragments/CurrentUserProfile.graphql.swift +++ /dev/null @@ -1,191 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public struct CurrentUserProfile: AffineGraphQL.SelectionSet, Fragment { - public static var fragmentDefinition: StaticString { - #"fragment CurrentUserProfile on UserType { __typename id name email avatarUrl emailVerified features settings { __typename receiveInvitationEmail receiveMentionEmail receiveCommentEmail } quota { __typename name blobLimit storageQuota historyPeriod memberLimit humanReadable { __typename name blobLimit storageQuota historyPeriod memberLimit } } quotaUsage { __typename storageQuota } copilot { __typename quota { __typename limit used } } }"# - } - - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("id", AffineGraphQL.ID.self), - .field("name", String.self), - .field("email", String.self), - .field("avatarUrl", String?.self), - .field("emailVerified", Bool.self), - .field("features", [GraphQLEnum].self), - .field("settings", Settings.self), - .field("quota", Quota.self), - .field("quotaUsage", QuotaUsage.self), - .field("copilot", Copilot.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.self - ] } - - public var id: AffineGraphQL.ID { __data["id"] } - /// User name - public var name: String { __data["name"] } - /// User email - public var email: String { __data["email"] } - /// User avatar url - public var avatarUrl: String? { __data["avatarUrl"] } - /// User email verified - public var emailVerified: Bool { __data["emailVerified"] } - /// Enabled features of a user - public var features: [GraphQLEnum] { __data["features"] } - /// Get user settings - public var settings: Settings { __data["settings"] } - public var quota: Quota { __data["quota"] } - public var quotaUsage: QuotaUsage { __data["quotaUsage"] } - public var copilot: Copilot { __data["copilot"] } - - /// Settings - /// - /// Parent Type: `UserSettingsType` - public struct Settings: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserSettingsType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("receiveInvitationEmail", Bool.self), - .field("receiveMentionEmail", Bool.self), - .field("receiveCommentEmail", Bool.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.Settings.self - ] } - - /// Receive invitation email - public var receiveInvitationEmail: Bool { __data["receiveInvitationEmail"] } - /// Receive mention email - public var receiveMentionEmail: Bool { __data["receiveMentionEmail"] } - /// Receive comment email - public var receiveCommentEmail: Bool { __data["receiveCommentEmail"] } - } - - /// Quota - /// - /// Parent Type: `UserQuotaType` - public struct Quota: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("name", String.self), - .field("blobLimit", AffineGraphQL.SafeInt.self), - .field("storageQuota", AffineGraphQL.SafeInt.self), - .field("historyPeriod", AffineGraphQL.SafeInt.self), - .field("memberLimit", Int.self), - .field("humanReadable", HumanReadable.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.Quota.self - ] } - - public var name: String { __data["name"] } - public var blobLimit: AffineGraphQL.SafeInt { __data["blobLimit"] } - public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] } - public var historyPeriod: AffineGraphQL.SafeInt { __data["historyPeriod"] } - public var memberLimit: Int { __data["memberLimit"] } - public var humanReadable: HumanReadable { __data["humanReadable"] } - - /// Quota.HumanReadable - /// - /// Parent Type: `UserQuotaHumanReadableType` - public struct HumanReadable: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaHumanReadableType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("name", String.self), - .field("blobLimit", String.self), - .field("storageQuota", String.self), - .field("historyPeriod", String.self), - .field("memberLimit", String.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.Quota.HumanReadable.self - ] } - - public var name: String { __data["name"] } - public var blobLimit: String { __data["blobLimit"] } - public var storageQuota: String { __data["storageQuota"] } - public var historyPeriod: String { __data["historyPeriod"] } - public var memberLimit: String { __data["memberLimit"] } - } - } - - /// QuotaUsage - /// - /// Parent Type: `UserQuotaUsageType` - public struct QuotaUsage: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaUsageType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("storageQuota", AffineGraphQL.SafeInt.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.QuotaUsage.self - ] } - - @available(*, deprecated, message: "use `UserQuotaType[\'usedStorageQuota\']` instead") - public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] } - } - - /// Copilot - /// - /// Parent Type: `Copilot` - public struct Copilot: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("quota", Quota.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.Copilot.self - ] } - - /// Get the quota of the user in the workspace - public var quota: Quota { __data["quota"] } - - /// Copilot.Quota - /// - /// Parent Type: `CopilotQuota` - public struct Quota: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotQuota } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("limit", AffineGraphQL.SafeInt?.self), - .field("used", AffineGraphQL.SafeInt.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - CurrentUserProfile.Copilot.Quota.self - ] } - - public var limit: AffineGraphQL.SafeInt? { __data["limit"] } - public var used: AffineGraphQL.SafeInt { __data["used"] } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserProfileQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserProfileQuery.graphql.swift deleted file mode 100644 index 340242c813..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCurrentUserProfileQuery.graphql.swift +++ /dev/null @@ -1,81 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetCurrentUserProfileQuery: GraphQLQuery { - public static let operationName: String = "getCurrentUserProfile" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getCurrentUserProfile { currentUser { __typename ...CurrentUserProfile } }"#, - fragments: [CurrentUserProfile.self] - )) - - public init() {} - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("currentUser", CurrentUser?.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetCurrentUserProfileQuery.Data.self - ] } - - /// Get current user - public var currentUser: CurrentUser? { __data["currentUser"] } - - /// CurrentUser - /// - /// Parent Type: `UserType` - public struct CurrentUser: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .fragment(CurrentUserProfile.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetCurrentUserProfileQuery.Data.CurrentUser.self, - CurrentUserProfile.self - ] } - - public var id: AffineGraphQL.ID { __data["id"] } - /// User name - public var name: String { __data["name"] } - /// User email - public var email: String { __data["email"] } - /// User avatar url - public var avatarUrl: String? { __data["avatarUrl"] } - /// User email verified - public var emailVerified: Bool { __data["emailVerified"] } - /// Enabled features of a user - public var features: [GraphQLEnum] { __data["features"] } - /// Get user settings - public var settings: Settings { __data["settings"] } - public var quota: Quota { __data["quota"] } - public var quotaUsage: QuotaUsage { __data["quotaUsage"] } - public var copilot: Copilot { __data["copilot"] } - - public struct Fragments: FragmentContainer { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public var currentUserProfile: CurrentUserProfile { _toFragment() } - } - - public typealias Settings = CurrentUserProfile.Settings - - public typealias Quota = CurrentUserProfile.Quota - - public typealias QuotaUsage = CurrentUserProfile.QuotaUsage - - public typealias Copilot = CurrentUserProfile.Copilot - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetDocDefaultRoleQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetDocDefaultRoleQuery.graphql.swift deleted file mode 100644 index 046fa00ae9..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetDocDefaultRoleQuery.graphql.swift +++ /dev/null @@ -1,83 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetDocDefaultRoleQuery: GraphQLQuery { - public static let operationName: String = "getDocDefaultRole" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getDocDefaultRole($workspaceId: String!, $docId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $docId) { __typename defaultRole } } }"# - )) - - public var workspaceId: String - public var docId: String - - public init( - workspaceId: String, - docId: String - ) { - self.workspaceId = workspaceId - self.docId = docId - } - - public var __variables: Variables? { [ - "workspaceId": workspaceId, - "docId": docId - ] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetDocDefaultRoleQuery.Data.self - ] } - - /// Get workspace by id - public var workspace: Workspace { __data["workspace"] } - - /// Workspace - /// - /// Parent Type: `WorkspaceType` - public struct Workspace: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("doc", Doc.self, arguments: ["docId": .variable("docId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetDocDefaultRoleQuery.Data.Workspace.self - ] } - - /// Get get with given id - public var doc: Doc { __data["doc"] } - - /// Workspace.Doc - /// - /// Parent Type: `DocType` - public struct Doc: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.DocType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("defaultRole", GraphQLEnum.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetDocDefaultRoleQuery.Data.Workspace.Doc.self - ] } - - public var defaultRole: GraphQLEnum { __data["defaultRole"] } - } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetMembersByWorkspaceIdQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetMembersByWorkspaceIdQuery.graphql.swift deleted file mode 100644 index 918366ea52..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetMembersByWorkspaceIdQuery.graphql.swift +++ /dev/null @@ -1,120 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetMembersByWorkspaceIdQuery: GraphQLQuery { - public static let operationName: String = "getMembersByWorkspaceId" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getMembersByWorkspaceId($workspaceId: String!, $skip: Int, $take: Int, $query: String) { workspace(id: $workspaceId) { __typename memberCount members(skip: $skip, take: $take, query: $query) { __typename id name email avatarUrl permission inviteId emailVerified status } } }"# - )) - - public var workspaceId: String - public var skip: GraphQLNullable - public var take: GraphQLNullable - public var query: GraphQLNullable - - public init( - workspaceId: String, - skip: GraphQLNullable, - take: GraphQLNullable, - query: GraphQLNullable - ) { - self.workspaceId = workspaceId - self.skip = skip - self.take = take - self.query = query - } - - public var __variables: Variables? { [ - "workspaceId": workspaceId, - "skip": skip, - "take": take, - "query": query - ] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetMembersByWorkspaceIdQuery.Data.self - ] } - - /// Get workspace by id - public var workspace: Workspace { __data["workspace"] } - - /// Workspace - /// - /// Parent Type: `WorkspaceType` - public struct Workspace: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("memberCount", Int.self), - .field("members", [Member].self, arguments: [ - "skip": .variable("skip"), - "take": .variable("take"), - "query": .variable("query") - ]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetMembersByWorkspaceIdQuery.Data.Workspace.self - ] } - - /// member count of workspace - public var memberCount: Int { __data["memberCount"] } - /// Members of workspace - public var members: [Member] { __data["members"] } - - /// Workspace.Member - /// - /// Parent Type: `InviteUserType` - public struct Member: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.InviteUserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("id", AffineGraphQL.ID.self), - .field("name", String?.self), - .field("email", String?.self), - .field("avatarUrl", String?.self), - .field("permission", GraphQLEnum.self), - .field("inviteId", String.self), - .field("emailVerified", Bool?.self), - .field("status", GraphQLEnum.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetMembersByWorkspaceIdQuery.Data.Workspace.Member.self - ] } - - public var id: AffineGraphQL.ID { __data["id"] } - /// User name - public var name: String? { __data["name"] } - /// User email - public var email: String? { __data["email"] } - /// User avatar url - public var avatarUrl: String? { __data["avatarUrl"] } - /// User permission in workspace - @available(*, deprecated, message: "Use role instead") - public var permission: GraphQLEnum { __data["permission"] } - /// Invite id - public var inviteId: String { __data["inviteId"] } - /// User email verified - public var emailVerified: Bool? { __data["emailVerified"] } - /// Member invite status in workspace - public var status: GraphQLEnum { __data["status"] } - } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetPageGrantedUsersListQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetPageGrantedUsersListQuery.graphql.swift deleted file mode 100644 index 5f8778c8c5..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetPageGrantedUsersListQuery.graphql.swift +++ /dev/null @@ -1,197 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetPageGrantedUsersListQuery: GraphQLQuery { - public static let operationName: String = "getPageGrantedUsersList" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getPageGrantedUsersList($pagination: PaginationInput!, $docId: String!, $workspaceId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $docId) { __typename grantedUsersList(pagination: $pagination) { __typename totalCount pageInfo { __typename endCursor hasNextPage } edges { __typename node { __typename role user { __typename id name email avatarUrl } } } } } } }"# - )) - - public var pagination: PaginationInput - public var docId: String - public var workspaceId: String - - public init( - pagination: PaginationInput, - docId: String, - workspaceId: String - ) { - self.pagination = pagination - self.docId = docId - self.workspaceId = workspaceId - } - - public var __variables: Variables? { [ - "pagination": pagination, - "docId": docId, - "workspaceId": workspaceId - ] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.self - ] } - - /// Get workspace by id - public var workspace: Workspace { __data["workspace"] } - - /// Workspace - /// - /// Parent Type: `WorkspaceType` - public struct Workspace: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("doc", Doc.self, arguments: ["docId": .variable("docId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.self - ] } - - /// Get get with given id - public var doc: Doc { __data["doc"] } - - /// Workspace.Doc - /// - /// Parent Type: `DocType` - public struct Doc: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.DocType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("grantedUsersList", GrantedUsersList.self, arguments: ["pagination": .variable("pagination")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.Doc.self - ] } - - /// paginated doc granted users list - public var grantedUsersList: GrantedUsersList { __data["grantedUsersList"] } - - /// Workspace.Doc.GrantedUsersList - /// - /// Parent Type: `PaginatedGrantedDocUserType` - public struct GrantedUsersList: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PaginatedGrantedDocUserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("totalCount", Int.self), - .field("pageInfo", PageInfo.self), - .field("edges", [Edge].self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.Doc.GrantedUsersList.self - ] } - - public var totalCount: Int { __data["totalCount"] } - public var pageInfo: PageInfo { __data["pageInfo"] } - public var edges: [Edge] { __data["edges"] } - - /// Workspace.Doc.GrantedUsersList.PageInfo - /// - /// Parent Type: `PageInfo` - public struct PageInfo: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PageInfo } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("endCursor", String?.self), - .field("hasNextPage", Bool.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.Doc.GrantedUsersList.PageInfo.self - ] } - - public var endCursor: String? { __data["endCursor"] } - public var hasNextPage: Bool { __data["hasNextPage"] } - } - - /// Workspace.Doc.GrantedUsersList.Edge - /// - /// Parent Type: `GrantedDocUserTypeEdge` - public struct Edge: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.GrantedDocUserTypeEdge } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("node", Node.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.Doc.GrantedUsersList.Edge.self - ] } - - public var node: Node { __data["node"] } - - /// Workspace.Doc.GrantedUsersList.Edge.Node - /// - /// Parent Type: `GrantedDocUserType` - public struct Node: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.GrantedDocUserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("role", GraphQLEnum.self), - .field("user", User.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.Doc.GrantedUsersList.Edge.Node.self - ] } - - public var role: GraphQLEnum { __data["role"] } - public var user: User { __data["user"] } - - /// Workspace.Doc.GrantedUsersList.Edge.Node.User - /// - /// Parent Type: `WorkspaceUserType` - public struct User: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("id", String.self), - .field("name", String.self), - .field("email", String.self), - .field("avatarUrl", String?.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetPageGrantedUsersListQuery.Data.Workspace.Doc.GrantedUsersList.Edge.Node.User.self - ] } - - public var id: String { __data["id"] } - public var name: String { __data["name"] } - public var email: String { __data["email"] } - public var avatarUrl: String? { __data["avatarUrl"] } - } - } - } - } - } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceConfigQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceConfigQuery.graphql.swift deleted file mode 100644 index 01aa16626e..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceConfigQuery.graphql.swift +++ /dev/null @@ -1,65 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetWorkspaceConfigQuery: GraphQLQuery { - public static let operationName: String = "getWorkspaceConfig" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getWorkspaceConfig($id: String!) { workspace(id: $id) { __typename enableAi enableSharing enableUrlPreview enableDocEmbedding } }"# - )) - - public var id: String - - public init(id: String) { - self.id = id - } - - public var __variables: Variables? { ["id": id] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("workspace", Workspace.self, arguments: ["id": .variable("id")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceConfigQuery.Data.self - ] } - - /// Get workspace by id - public var workspace: Workspace { __data["workspace"] } - - /// Workspace - /// - /// Parent Type: `WorkspaceType` - public struct Workspace: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("enableAi", Bool.self), - .field("enableSharing", Bool.self), - .field("enableUrlPreview", Bool.self), - .field("enableDocEmbedding", Bool.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceConfigQuery.Data.Workspace.self - ] } - - /// Enable AI - public var enableAi: Bool { __data["enableAi"] } - /// Enable workspace sharing - public var enableSharing: Bool { __data["enableSharing"] } - /// Enable url previous when sharing - public var enableUrlPreview: Bool { __data["enableUrlPreview"] } - /// Enable doc embedding - public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceEmbeddingStatusQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceEmbeddingStatusQuery.graphql.swift deleted file mode 100644 index 93af54970e..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceEmbeddingStatusQuery.graphql.swift +++ /dev/null @@ -1,57 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetWorkspaceEmbeddingStatusQuery: GraphQLQuery { - public static let operationName: String = "getWorkspaceEmbeddingStatus" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getWorkspaceEmbeddingStatus($workspaceId: String!) { queryWorkspaceEmbeddingStatus(workspaceId: $workspaceId) { __typename total embedded } }"# - )) - - public var workspaceId: String - - public init(workspaceId: String) { - self.workspaceId = workspaceId - } - - public var __variables: Variables? { ["workspaceId": workspaceId] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("queryWorkspaceEmbeddingStatus", QueryWorkspaceEmbeddingStatus.self, arguments: ["workspaceId": .variable("workspaceId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceEmbeddingStatusQuery.Data.self - ] } - - /// query workspace embedding status - public var queryWorkspaceEmbeddingStatus: QueryWorkspaceEmbeddingStatus { __data["queryWorkspaceEmbeddingStatus"] } - - /// QueryWorkspaceEmbeddingStatus - /// - /// Parent Type: `ContextWorkspaceEmbeddingStatus` - public struct QueryWorkspaceEmbeddingStatus: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ContextWorkspaceEmbeddingStatus } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("total", AffineGraphQL.SafeInt.self), - .field("embedded", AffineGraphQL.SafeInt.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceEmbeddingStatusQuery.Data.QueryWorkspaceEmbeddingStatus.self - ] } - - public var total: AffineGraphQL.SafeInt { __data["total"] } - public var embedded: AffineGraphQL.SafeInt { __data["embedded"] } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceInfoQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceInfoQuery.graphql.swift deleted file mode 100644 index 0949212b80..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetWorkspaceInfoQuery.graphql.swift +++ /dev/null @@ -1,119 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class GetWorkspaceInfoQuery: GraphQLQuery { - public static let operationName: String = "getWorkspaceInfo" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query getWorkspaceInfo($workspaceId: String!) { workspace(id: $workspaceId) { __typename permissions { __typename Workspace_Administrators_Manage Workspace_Blobs_List Workspace_Blobs_Read Workspace_Blobs_Write Workspace_Copilot Workspace_CreateDoc Workspace_Delete Workspace_Organize_Read Workspace_Payment_Manage Workspace_Properties_Create Workspace_Properties_Delete Workspace_Properties_Read Workspace_Properties_Update Workspace_Read Workspace_Settings_Read Workspace_Settings_Update Workspace_Sync Workspace_TransferOwner Workspace_Users_Manage Workspace_Users_Read } role team } }"# - )) - - public var workspaceId: String - - public init(workspaceId: String) { - self.workspaceId = workspaceId - } - - public var __variables: Variables? { ["workspaceId": workspaceId] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceInfoQuery.Data.self - ] } - - /// Get workspace by id - public var workspace: Workspace { __data["workspace"] } - - /// Workspace - /// - /// Parent Type: `WorkspaceType` - public struct Workspace: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("permissions", Permissions.self), - .field("role", GraphQLEnum.self), - .field("team", Bool.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceInfoQuery.Data.Workspace.self - ] } - - /// map of action permissions - public var permissions: Permissions { __data["permissions"] } - /// Role of current signed in user in workspace - public var role: GraphQLEnum { __data["role"] } - /// if workspace is team workspace - public var team: Bool { __data["team"] } - - /// Workspace.Permissions - /// - /// Parent Type: `WorkspacePermissions` - public struct Permissions: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspacePermissions } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("Workspace_Administrators_Manage", Bool.self), - .field("Workspace_Blobs_List", Bool.self), - .field("Workspace_Blobs_Read", Bool.self), - .field("Workspace_Blobs_Write", Bool.self), - .field("Workspace_Copilot", Bool.self), - .field("Workspace_CreateDoc", Bool.self), - .field("Workspace_Delete", Bool.self), - .field("Workspace_Organize_Read", Bool.self), - .field("Workspace_Payment_Manage", Bool.self), - .field("Workspace_Properties_Create", Bool.self), - .field("Workspace_Properties_Delete", Bool.self), - .field("Workspace_Properties_Read", Bool.self), - .field("Workspace_Properties_Update", Bool.self), - .field("Workspace_Read", Bool.self), - .field("Workspace_Settings_Read", Bool.self), - .field("Workspace_Settings_Update", Bool.self), - .field("Workspace_Sync", Bool.self), - .field("Workspace_TransferOwner", Bool.self), - .field("Workspace_Users_Manage", Bool.self), - .field("Workspace_Users_Read", Bool.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - GetWorkspaceInfoQuery.Data.Workspace.Permissions.self - ] } - - public var workspace_Administrators_Manage: Bool { __data["Workspace_Administrators_Manage"] } - public var workspace_Blobs_List: Bool { __data["Workspace_Blobs_List"] } - public var workspace_Blobs_Read: Bool { __data["Workspace_Blobs_Read"] } - public var workspace_Blobs_Write: Bool { __data["Workspace_Blobs_Write"] } - public var workspace_Copilot: Bool { __data["Workspace_Copilot"] } - public var workspace_CreateDoc: Bool { __data["Workspace_CreateDoc"] } - public var workspace_Delete: Bool { __data["Workspace_Delete"] } - public var workspace_Organize_Read: Bool { __data["Workspace_Organize_Read"] } - public var workspace_Payment_Manage: Bool { __data["Workspace_Payment_Manage"] } - public var workspace_Properties_Create: Bool { __data["Workspace_Properties_Create"] } - public var workspace_Properties_Delete: Bool { __data["Workspace_Properties_Delete"] } - public var workspace_Properties_Read: Bool { __data["Workspace_Properties_Read"] } - public var workspace_Properties_Update: Bool { __data["Workspace_Properties_Update"] } - public var workspace_Read: Bool { __data["Workspace_Read"] } - public var workspace_Settings_Read: Bool { __data["Workspace_Settings_Read"] } - public var workspace_Settings_Update: Bool { __data["Workspace_Settings_Update"] } - public var workspace_Sync: Bool { __data["Workspace_Sync"] } - public var workspace_TransferOwner: Bool { __data["Workspace_TransferOwner"] } - public var workspace_Users_Manage: Bool { __data["Workspace_Users_Manage"] } - public var workspace_Users_Read: Bool { __data["Workspace_Users_Read"] } - } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/ListUserAccessTokensQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/ListUserAccessTokensQuery.graphql.swift deleted file mode 100644 index c8f3eca058..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/ListUserAccessTokensQuery.graphql.swift +++ /dev/null @@ -1,76 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class ListUserAccessTokensQuery: GraphQLQuery { - public static let operationName: String = "listUserAccessTokens" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query listUserAccessTokens { currentUser { __typename revealedAccessTokens { __typename id name createdAt expiresAt token } } }"# - )) - - public init() {} - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("currentUser", CurrentUser?.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - ListUserAccessTokensQuery.Data.self - ] } - - /// Get current user - public var currentUser: CurrentUser? { __data["currentUser"] } - - /// CurrentUser - /// - /// Parent Type: `UserType` - public struct CurrentUser: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("revealedAccessTokens", [RevealedAccessToken].self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - ListUserAccessTokensQuery.Data.CurrentUser.self - ] } - - public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] } - - /// CurrentUser.RevealedAccessToken - /// - /// Parent Type: `RevealedAccessToken` - public struct RevealedAccessToken: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("id", String.self), - .field("name", String.self), - .field("createdAt", AffineGraphQL.DateTime.self), - .field("expiresAt", AffineGraphQL.DateTime?.self), - .field("token", String.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - ListUserAccessTokensQuery.Data.CurrentUser.RevealedAccessToken.self - ] } - - public var id: String { __data["id"] } - public var name: String { __data["name"] } - public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] } - public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] } - public var token: String { __data["token"] } - } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/NotificationCountQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/NotificationCountQuery.graphql.swift deleted file mode 100644 index 1677bd9f65..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/NotificationCountQuery.graphql.swift +++ /dev/null @@ -1,69 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class NotificationCountQuery: GraphQLQuery { - public static let operationName: String = "notificationCount" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query notificationCount { currentUser { __typename notifications(pagination: { first: 1 }) { __typename totalCount } } }"# - )) - - public init() {} - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("currentUser", CurrentUser?.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - NotificationCountQuery.Data.self - ] } - - /// Get current user - public var currentUser: CurrentUser? { __data["currentUser"] } - - /// CurrentUser - /// - /// Parent Type: `UserType` - public struct CurrentUser: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("notifications", Notifications.self, arguments: ["pagination": ["first": 1]]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - NotificationCountQuery.Data.CurrentUser.self - ] } - - /// Get current user notifications - public var notifications: Notifications { __data["notifications"] } - - /// CurrentUser.Notifications - /// - /// Parent Type: `PaginatedNotificationObjectType` - public struct Notifications: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PaginatedNotificationObjectType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("totalCount", Int.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - NotificationCountQuery.Data.CurrentUser.Notifications.self - ] } - - public var totalCount: Int { __data["totalCount"] } - } - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/WorkspaceQuotaQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/WorkspaceQuotaQuery.graphql.swift deleted file mode 100644 index 164c1f27a5..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/WorkspaceQuotaQuery.graphql.swift +++ /dev/null @@ -1,122 +0,0 @@ -// @generated -// This file was automatically generated and should not be edited. - -@_exported import ApolloAPI - -public class WorkspaceQuotaQuery: GraphQLQuery { - public static let operationName: String = "workspaceQuota" - public static let operationDocument: ApolloAPI.OperationDocument = .init( - definition: .init( - #"query workspaceQuota($id: String!) { workspace(id: $id) { __typename quota { __typename name blobLimit storageQuota usedStorageQuota historyPeriod memberLimit memberCount overcapacityMemberCount humanReadable { __typename name blobLimit storageQuota historyPeriod memberLimit memberCount overcapacityMemberCount } } } }"# - )) - - public var id: String - - public init(id: String) { - self.id = id - } - - public var __variables: Variables? { ["id": id] } - - public struct Data: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } - public static var __selections: [ApolloAPI.Selection] { [ - .field("workspace", Workspace.self, arguments: ["id": .variable("id")]), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - WorkspaceQuotaQuery.Data.self - ] } - - /// Get workspace by id - public var workspace: Workspace { __data["workspace"] } - - /// Workspace - /// - /// Parent Type: `WorkspaceType` - public struct Workspace: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("quota", Quota.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - WorkspaceQuotaQuery.Data.Workspace.self - ] } - - /// quota of workspace - public var quota: Quota { __data["quota"] } - - /// Workspace.Quota - /// - /// Parent Type: `WorkspaceQuotaType` - public struct Quota: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceQuotaType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("name", String.self), - .field("blobLimit", AffineGraphQL.SafeInt.self), - .field("storageQuota", AffineGraphQL.SafeInt.self), - .field("usedStorageQuota", AffineGraphQL.SafeInt.self), - .field("historyPeriod", AffineGraphQL.SafeInt.self), - .field("memberLimit", Int.self), - .field("memberCount", Int.self), - .field("overcapacityMemberCount", Int.self), - .field("humanReadable", HumanReadable.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - WorkspaceQuotaQuery.Data.Workspace.Quota.self - ] } - - public var name: String { __data["name"] } - public var blobLimit: AffineGraphQL.SafeInt { __data["blobLimit"] } - public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] } - public var usedStorageQuota: AffineGraphQL.SafeInt { __data["usedStorageQuota"] } - public var historyPeriod: AffineGraphQL.SafeInt { __data["historyPeriod"] } - public var memberLimit: Int { __data["memberLimit"] } - public var memberCount: Int { __data["memberCount"] } - public var overcapacityMemberCount: Int { __data["overcapacityMemberCount"] } - public var humanReadable: HumanReadable { __data["humanReadable"] } - - /// Workspace.Quota.HumanReadable - /// - /// Parent Type: `WorkspaceQuotaHumanReadableType` - public struct HumanReadable: AffineGraphQL.SelectionSet { - public let __data: DataDict - public init(_dataDict: DataDict) { __data = _dataDict } - - public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceQuotaHumanReadableType } - public static var __selections: [ApolloAPI.Selection] { [ - .field("__typename", String.self), - .field("name", String.self), - .field("blobLimit", String.self), - .field("storageQuota", String.self), - .field("historyPeriod", String.self), - .field("memberLimit", String.self), - .field("memberCount", String.self), - .field("overcapacityMemberCount", String.self), - ] } - public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ - WorkspaceQuotaQuery.Data.Workspace.Quota.HumanReadable.self - ] } - - public var name: String { __data["name"] } - public var blobLimit: String { __data["blobLimit"] } - public var storageQuota: String { __data["storageQuota"] } - public var historyPeriod: String { __data["historyPeriod"] } - public var memberLimit: String { __data["memberLimit"] } - public var memberCount: String { __data["memberCount"] } - public var overcapacityMemberCount: String { __data["overcapacityMemberCount"] } - } - } - } - } -} diff --git a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts index 6968920e52..9c8a8b89cb 100644 --- a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts +++ b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts @@ -3,6 +3,7 @@ import { AuthService } from '@affine/core/modules/cloud/services/auth'; import { FetchService } from '@affine/core/modules/cloud/services/fetch'; import { AuthStore } from '@affine/core/modules/cloud/stores/auth'; import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog'; +import { NbstoreService } from '@affine/core/modules/storage'; import { UrlService } from '@affine/core/modules/url/services/url'; import { Framework } from '@toeverything/infra'; import { of } from 'rxjs'; @@ -37,12 +38,16 @@ describe('AuthService oauthPreflight', () => { } as any); framework.service(UrlService, { getClientScheme: () => null } as any); framework.service(GlobalDialogService, { open: vi.fn() } as any); + framework.service(NbstoreService, { + realtime: { subscribe: () => of() }, + } as any); framework.service(AuthService, [ FetchService, AuthStore, UrlService, GlobalDialogService, + NbstoreService, ]); const auth = framework.provider().get(AuthService); diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.spec.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.spec.ts index 25dcbc20c7..601cee7ed4 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.spec.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.spec.ts @@ -31,4 +31,22 @@ describe('CopilotClient action streams', () => { '/api/copilot/actions/session-1/stream?messageId=message-1&actionId=mindmap.generate&actionVersion=v1&runId=run-1&retry=true' ); }); + + test('loads embedding status through realtime request', async () => { + const gql = vi.fn(); + const request = vi.fn().mockResolvedValue({ total: 4, embedded: 2 }); + const client = new CopilotClient(gql, vi.fn(), { request }); + + await expect(client.getEmbeddingStatus('workspace-1')).resolves.toEqual({ + total: 4, + embedded: 2, + }); + + expect(gql).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledWith( + 'workspace.embedding.progress.get', + { workspaceId: 'workspace-1' }, + { timeoutMs: 10000 } + ); + }); }); diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts index 5c16c65f6b..e82444884b 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/copilot-client.ts @@ -1,5 +1,6 @@ import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required'; import type { AIToolsConfig } from '@affine/core/modules/ai-button'; +import type { NbstoreService } from '@affine/core/modules/storage'; import { UserFriendlyError } from '@affine/error'; import { addContextBlobMutation, @@ -17,7 +18,6 @@ import { getCopilotRecentSessionsQuery, getCopilotSessionQuery, getCopilotSessionsQuery, - getWorkspaceEmbeddingStatusQuery, type GraphQLQuery, listContextObjectQuery, listContextQuery, @@ -98,7 +98,8 @@ export class CopilotClient { readonly eventSource: ( url: string, eventSourceInitDict?: EventSourceInit - ) => EventSource + ) => EventSource, + readonly realtime?: Pick ) {} async createSession( @@ -546,11 +547,15 @@ export class CopilotClient { return queryString.toString(); } - getEmbeddingStatus(workspaceId: string) { - return this.gql({ - query: getWorkspaceEmbeddingStatusQuery, - variables: { workspaceId }, - }).then(res => res.queryWorkspaceEmbeddingStatus); + async getEmbeddingStatus(workspaceId: string) { + if (!this.realtime) { + throw new Error('Realtime client is required'); + } + return await this.realtime.request( + 'workspace.embedding.progress.get', + { workspaceId }, + { timeoutMs: 10000 } + ); } addContextBlob(options: OptionsField) { diff --git a/packages/frontend/core/src/blocksuite/ai/runtime/request/service.ts b/packages/frontend/core/src/blocksuite/ai/runtime/request/service.ts index 293a220803..0d6f5420ed 100644 --- a/packages/frontend/core/src/blocksuite/ai/runtime/request/service.ts +++ b/packages/frontend/core/src/blocksuite/ai/runtime/request/service.ts @@ -1,3 +1,4 @@ +import type { NbstoreService } from '@affine/core/modules/storage'; import { ContextCategories, type CopilotChatHistoryFragment, @@ -390,7 +391,8 @@ export function createAIRequestService( eventSource: ( url: string, eventSourceInitDict?: EventSourceInit - ) => EventSource + ) => EventSource, + realtime: Pick ) { - return new AIRequestService(new CopilotClient(gql, eventSource)); + return new AIRequestService(new CopilotClient(gql, eventSource, realtime)); } diff --git a/packages/frontend/core/src/components/providers/workspace-side-effects.tsx b/packages/frontend/core/src/components/providers/workspace-side-effects.tsx index 867841b52e..751469de1f 100644 --- a/packages/frontend/core/src/components/providers/workspace-side-effects.tsx +++ b/packages/frontend/core/src/components/providers/workspace-side-effects.tsx @@ -24,6 +24,7 @@ import { DocsService } from '@affine/core/modules/doc'; import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { useRegisterNavigationCommands } from '@affine/core/modules/navigation/view/use-register-navigation-commands'; import { QuickSearchContainer } from '@affine/core/modules/quicksearch'; +import { NbstoreService } from '@affine/core/modules/storage'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { getAFFiNEWorkspaceSchema, @@ -140,12 +141,14 @@ export const WorkspaceSideEffects = () => { const graphqlService = useService(GraphQLService); const eventSourceService = useService(EventSourceService); const authService = useService(AuthService); + const nbstoreService = useService(NbstoreService); useEffect(() => { const dispose = setupAIProvider( createAIRequestService( graphqlService.gql, - eventSourceService.eventSource + eventSourceService.eventSource, + nbstoreService.realtime ), globalDialogService, authService @@ -155,6 +158,7 @@ export const WorkspaceSideEffects = () => { }; }, [ eventSourceService, + nbstoreService, workspaceDialogService, graphqlService, globalDialogService, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx index 4ffd9517e7..1a7e2b7197 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/mcp-server/setting-panel.tsx @@ -46,13 +46,12 @@ const McpServerSetting = () => { return accessTokens?.find(token => token.name === 'mcp'); }, [accessTokens]); - const displayedToken = revealedAccessToken ?? mcpAccessToken; const hasMcpToken = Boolean(revealedAccessToken || mcpAccessToken); const hasCopyableToken = Boolean(revealedAccessToken); const isRedactedDisplay = hasMcpToken && !hasCopyableToken; const code = useMemo(() => { - return displayedToken + return revealedAccessToken ? JSON.stringify( { mcpServers: { @@ -61,7 +60,7 @@ const McpServerSetting = () => { url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`, note: `Read docs from AFFiNE workspace "${workspaceName}"`, headers: { - Authorization: `Bearer ${displayedToken.token}`, + Authorization: `Bearer ${revealedAccessToken.token}`, }, }, }, @@ -70,7 +69,7 @@ const McpServerSetting = () => { 2 ) : null; - }, [displayedToken, workspaceName, workspaceService, serverService]); + }, [revealedAccessToken, workspaceName, workspaceService, serverService]); const copyJsonDisabled = !code || mutating || isRedactedDisplay; const copyJsonTooltip = isRedactedDisplay diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx index aefd79c4b2..b21f322def 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx @@ -31,6 +31,7 @@ import { import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { PeekViewService } from '@affine/core/modules/peek-view'; +import { NbstoreService } from '@affine/core/modules/storage'; import { AppThemeService } from '@affine/core/modules/theme'; import { ViewBody, @@ -55,14 +56,16 @@ import * as styles from './index.css'; function useAIRequestService() { const graphqlService = useService(GraphQLService); const eventSourceService = useService(EventSourceService); + const nbstoreService = useService(NbstoreService); return useMemo( () => createAIRequestService( graphqlService.gql, - eventSourceService.eventSource + eventSourceService.eventSource, + nbstoreService.realtime ), - [graphqlService, eventSourceService] + [graphqlService, eventSourceService, nbstoreService] ); } diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index 1c22cb0b15..e86b237791 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -36,6 +36,7 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { useSignalValue } from '@affine/core/modules/doc-info/utils'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { PeekViewService } from '@affine/core/modules/peek-view'; +import { NbstoreService } from '@affine/core/modules/storage'; import { AppThemeService } from '@affine/core/modules/theme'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; @@ -75,6 +76,7 @@ export const EditorChatPanel = ({ const framework = useFramework(); const graphqlService = useService(GraphQLService); const eventSourceService = useService(EventSourceService); + const nbstoreService = useService(NbstoreService); const workbench = useService(WorkbenchService).workbench; const t = useI18n(); @@ -108,9 +110,10 @@ export const EditorChatPanel = ({ () => createAIRequestService( graphqlService.gql, - eventSourceService.eventSource + eventSourceService.eventSource, + nbstoreService.realtime ), - [eventSourceService.eventSource, graphqlService.gql] + [eventSourceService.eventSource, graphqlService.gql, nbstoreService] ); const [pendingSessionId] = useState(() => { diff --git a/packages/frontend/core/src/modules/cloud/entities/user-feature.ts b/packages/frontend/core/src/modules/cloud/entities/user-feature.ts index eb13fa3dac..fc4afabb09 100644 --- a/packages/frontend/core/src/modules/cloud/entities/user-feature.ts +++ b/packages/frontend/core/src/modules/cloud/entities/user-feature.ts @@ -12,8 +12,8 @@ import { } from '@toeverything/infra'; import { map, tap } from 'rxjs'; +import { mapRealtimeEnum } from '../realtime/enum'; import type { AuthService } from '../services/auth'; -import type { UserFeatureStore } from '../stores/user-feature'; export class UserFeature extends Entity { // undefined means no user, null means loading @@ -32,10 +32,7 @@ export class UserFeature extends Entity { isRevalidating$ = new LiveData(false); error$ = new LiveData(null); - constructor( - private readonly authService: AuthService, - private readonly store: UserFeatureStore - ) { + constructor(private readonly authService: AuthService) { super(); } @@ -44,23 +41,20 @@ export class UserFeature extends Entity { accountId: this.authService.session.account$.value?.id, })), exhaustMapSwitchUntilChanged( - (a, b) => a.accountId === b.accountId, + () => false, ({ accountId }) => { - return fromPromise(async signal => { + return fromPromise(async () => { if (!accountId) { return; // no feature if no user } - const { userId, features } = await this.store.getUserFeatures(signal); - if (userId !== accountId) { - // The user has changed, ignore the result - this.authService.session.revalidate(); - await this.authService.session.waitForRevalidation(); - return; - } + const account = this.authService.session.account$.value; + if (account?.id !== accountId) return; return { - userId: userId, - features: features, + userId: account.id, + features: account.info?.features?.map(feature => + mapRealtimeEnum(FeatureType, feature, 'user feature') + ), }; }).pipe( smartRetry(), diff --git a/packages/frontend/core/src/modules/cloud/entities/user-quota.spec.ts b/packages/frontend/core/src/modules/cloud/entities/user-quota.spec.ts index 653ba3c75d..a65603439d 100644 --- a/packages/frontend/core/src/modules/cloud/entities/user-quota.spec.ts +++ b/packages/frontend/core/src/modules/cloud/entities/user-quota.spec.ts @@ -1,4 +1,3 @@ -import type { GetCurrentUserProfileQuery } from '@affine/graphql'; import type { UserQuotaStateSnapshot } from '@affine/realtime'; import { Framework } from '@toeverything/infra'; import { Subject } from 'rxjs'; @@ -8,8 +7,6 @@ import { AuthService } from '../services/auth'; import { UserQuotaStore } from '../stores/user-quota'; import { UserQuota } from './user-quota'; -type Quota = NonNullable['quota']; - const authService = { session: { ['account$']: { @@ -41,24 +38,6 @@ function createQuotaState( }; } -function createQuota(overrides: Partial = {}): Quota { - return { - name: 'Legacy', - blobLimit: 1024, - storageQuota: 2048, - historyPeriod: 30 * 24 * 60 * 60, - memberLimit: 8, - humanReadable: { - name: 'Legacy', - blobLimit: '1 KB', - storageQuota: '2 KB', - historyPeriod: '30 days', - memberLimit: '8', - }, - ...overrides, - }; -} - function createStore( overrides: Partial, eventSubject = new Subject<{ type: 'ready' } | { changed: true }>() @@ -66,7 +45,6 @@ function createStore( return { fetchUserQuotaState: vi.fn(), subscribeUserQuotaState: vi.fn(() => eventSubject), - fetchUserQuota: vi.fn(), ...overrides, } as unknown as UserQuotaStore; } @@ -103,24 +81,20 @@ describe('UserQuota', () => { await vi.waitFor(() => expect(quota.used$.value).toBe(768)); expect(store.fetchUserQuotaState).toHaveBeenCalledTimes(2); - expect(store.fetchUserQuota).not.toHaveBeenCalled(); quota.dispose(); }); - test('falls back to legacy GraphQL quota when realtime request fails', async () => { + test('surfaces realtime quota errors without GraphQL fallback', async () => { + const error = new Error('offline'); const store = createStore({ - fetchUserQuotaState: vi.fn().mockRejectedValue(new Error('offline')), - fetchUserQuota: vi.fn().mockResolvedValue({ - quota: createQuota(), - used: 256, - }), + fetchUserQuotaState: vi.fn().mockRejectedValue(error), }); const quota = createEntity(store); quota.revalidate(); - await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy')); - expect(quota.used$.value).toBe(256); + await vi.waitFor(() => expect(quota.error$.value).toBe(error)); + expect(quota.quota$.value).toBeNull(); quota.dispose(); }); }); diff --git a/packages/frontend/core/src/modules/cloud/entities/user-quota.ts b/packages/frontend/core/src/modules/cloud/entities/user-quota.ts index c1673adaa2..36b5ac3aa6 100644 --- a/packages/frontend/core/src/modules/cloud/entities/user-quota.ts +++ b/packages/frontend/core/src/modules/cloud/entities/user-quota.ts @@ -1,4 +1,3 @@ -import type { GetCurrentUserProfileQuery } from '@affine/graphql'; import type { RealtimeTopicEventOf, UserQuotaStateSnapshot, @@ -11,9 +10,20 @@ import { RealtimeLiveQuery } from '../realtime/live-query'; import type { AuthService } from '../services/auth'; import type { UserQuotaStore } from '../stores/user-quota'; -type QuotaType = NonNullable< - GetCurrentUserProfileQuery['currentUser'] ->['quota']; +type QuotaType = { + name: string; + blobLimit: number; + storageQuota: number; + historyPeriod: number; + memberLimit: number; + humanReadable: { + name: string; + blobLimit: string; + storageQuota: string; + historyPeriod: string; + memberLimit: string; + }; +}; const DAY_SECONDS = 24 * 60 * 60; @@ -159,9 +169,6 @@ export class UserQuota extends Entity { quota: userQuotaFromState(state), used: state.usedStorageQuota, }; - } catch { - const { quota, used } = await this.store.fetchUserQuota(signal); - return { quota, used }; } finally { this.isRevalidating$.setValue(false); } diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 143f812479..3dab0b148c 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -101,7 +101,6 @@ import { ServerConfigStore } from './stores/server-config'; import { ServerListStore } from './stores/server-list'; import { SubscriptionStore } from './stores/subscription'; import { UserCopilotQuotaStore } from './stores/user-copilot-quota'; -import { UserFeatureStore } from './stores/user-feature'; import { UserQuotaStore } from './stores/user-quota'; import { UserSettingsStore } from './stores/user-settings'; import { DocCreatedByService } from './services/doc-created-by'; @@ -146,6 +145,7 @@ export function configureCloudModule(framework: Framework) { AuthStore, UrlService, GlobalDialogService, + NbstoreService, ]) .store(AuthStore, [ FetchService, @@ -153,6 +153,7 @@ export function configureCloudModule(framework: Framework) { GlobalState, ServerService, AuthProvider, + NbstoreService, ]) .entity(AuthSession, [AuthStore]) .service(SubscriptionService, [SubscriptionStore]) @@ -165,7 +166,7 @@ export function configureCloudModule(framework: Framework) { .entity(Subscription, [AuthService, ServerService, SubscriptionStore]) .entity(SubscriptionPrices, [ServerService, SubscriptionStore]) .service(UserQuotaService) - .store(UserQuotaStore, [GraphQLService, NbstoreService]) + .store(UserQuotaStore, [NbstoreService]) .entity(UserQuota, [AuthService, UserQuotaStore]) .service(UserCopilotQuotaService) .store(UserCopilotQuotaStore, [GraphQLService]) @@ -175,8 +176,7 @@ export function configureCloudModule(framework: Framework) { ServerService, ]) .service(UserFeatureService) - .entity(UserFeature, [AuthService, UserFeatureStore]) - .store(UserFeatureStore, [GraphQLService]) + .entity(UserFeature, [AuthService]) .service(InvoicesService) .store(InvoicesStore, [GraphQLService]) .entity(Invoices, [InvoicesStore]) @@ -188,9 +188,9 @@ export function configureCloudModule(framework: Framework) { .service(PublicUserService, [PublicUserStore]) .store(PublicUserStore, [GraphQLService]) .service(UserSettingsService, [UserSettingsStore]) - .store(UserSettingsStore, [GraphQLService]) + .store(UserSettingsStore, [GraphQLService, NbstoreService]) .service(AccessTokenService, [AccessTokenStore]) - .store(AccessTokenStore, [GraphQLService]); + .store(AccessTokenStore, [GraphQLService, NbstoreService]); framework .scope(WorkspaceScope) diff --git a/packages/frontend/core/src/modules/cloud/realtime/enum.ts b/packages/frontend/core/src/modules/cloud/realtime/enum.ts new file mode 100644 index 0000000000..510ad38348 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/realtime/enum.ts @@ -0,0 +1,10 @@ +export function mapRealtimeEnum>( + enumType: T, + value: string, + label: string +): T[keyof T] { + if (Object.prototype.hasOwnProperty.call(enumType, value)) { + return enumType[value as keyof T]; + } + throw new Error(`Unknown ${label}: ${value}`); +} diff --git a/packages/frontend/core/src/modules/cloud/services/access-token.spec.ts b/packages/frontend/core/src/modules/cloud/services/access-token.spec.ts new file mode 100644 index 0000000000..6ba6521c86 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/access-token.spec.ts @@ -0,0 +1,54 @@ +import { Framework } from '@toeverything/infra'; +import { Subject } from 'rxjs'; +import { describe, expect, test, vi } from 'vitest'; + +import { AccessTokenStore } from '../stores/access-token'; +import { AccessTokenService } from './access-token'; + +function createStore() { + return { + subscribeUserAccessTokens: vi.fn(() => new Subject()), + listUserAccessTokens: vi.fn().mockResolvedValue([ + { + id: 'token-1', + name: 'MCP', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: null, + }, + ]), + generateUserAccessToken: vi.fn().mockResolvedValue({ + id: 'token-1', + name: 'MCP', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: null, + token: 'secret-token', + }), + } as unknown as AccessTokenStore; +} + +describe('AccessTokenService', () => { + test('does not store generated plaintext token in the long-lived list', async () => { + const framework = new Framework(); + framework + .store(AccessTokenStore, createStore()) + .service(AccessTokenService, [AccessTokenStore]); + const service = framework.provider().get(AccessTokenService); + + const accessToken = await service.generateUserAccessToken('MCP'); + + expect(accessToken.token).toBe('secret-token'); + expect(service.accessTokens$.value).toEqual([ + { + id: 'token-1', + name: 'MCP', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: null, + }, + ]); + expect(JSON.stringify(service.accessTokens$.value)).not.toContain( + 'secret-token' + ); + + service.dispose(); + }); +}); diff --git a/packages/frontend/core/src/modules/cloud/services/access-token.ts b/packages/frontend/core/src/modules/cloud/services/access-token.ts index 71989da79c..ed41c44b9c 100644 --- a/packages/frontend/core/src/modules/cloud/services/access-token.ts +++ b/packages/frontend/core/src/modules/cloud/services/access-token.ts @@ -1,40 +1,48 @@ -import { - effect, - exhaustMapWithTrailing, - fromPromise, - LiveData, - onComplete, - OnEvent, - onStart, - Service, - smartRetry, -} from '@toeverything/infra'; -import { catchError, EMPTY, tap } from 'rxjs'; +import { LiveData, OnEvent, Service } from '@toeverything/infra'; import { AccountChanged } from '../events/account-changed'; -import type { AccessToken, AccessTokenStore } from '../stores/access-token'; +import { RealtimeLiveQuery } from '../realtime/live-query'; +import type { + AccessToken, + AccessTokenStore, + ListedAccessToken, +} from '../stores/access-token'; @OnEvent(AccountChanged, e => e.onAccountChanged) export class AccessTokenService extends Service { constructor(private readonly accessTokenStore: AccessTokenStore) { super(); + this.liveQuery.start(); } - accessTokens$ = new LiveData(null); + accessTokens$ = new LiveData(null); isRevalidating$ = new LiveData(false); error$ = new LiveData(null); + private readonly liveQuery = new RealtimeLiveQuery({ + request: signal => this.requestAccessTokens(signal), + subscribe: () => this.accessTokenStore.subscribeUserAccessTokens(), + applySnapshot: accessTokens => { + this.error$.value = null; + this.accessTokens$.value = accessTokens; + }, + applyEvent: () => 'revalidate' as const, + onError: error => { + this.error$.value = error; + }, + }); async generateUserAccessToken(name: string): Promise { const accessToken = await this.accessTokenStore.generateUserAccessToken(name); + const { token: _token, ...listedAccessToken } = accessToken; this.accessTokens$.value = [ ...(this.accessTokens$.value || []), - accessToken as AccessToken, + listedAccessToken, ]; await this.waitForRevalidation(); - return accessToken as AccessToken; + return accessToken; } async revokeUserAccessToken(id: string) { @@ -44,28 +52,9 @@ export class AccessTokenService extends Service { await this.waitForRevalidation(); } - revalidate = effect( - exhaustMapWithTrailing(() => { - return fromPromise(() => { - return this.accessTokenStore.listUserAccessTokens(); - }).pipe( - smartRetry(), - tap(accessTokens => { - this.accessTokens$.value = accessTokens; - }), - catchError(error => { - this.error$.value = error; - return EMPTY; - }), - onStart(() => { - this.isRevalidating$.value = true; - }), - onComplete(() => { - this.isRevalidating$.value = false; - }) - ); - }) - ); + revalidate = () => { + this.liveQuery.revalidate(); + }; private onAccountChanged() { this.accessTokens$.value = null; @@ -79,4 +68,18 @@ export class AccessTokenService extends Service { signal ); } + + override dispose(): void { + super.dispose(); + this.liveQuery.dispose(); + } + + private async requestAccessTokens(signal: AbortSignal) { + this.isRevalidating$.value = true; + try { + return await this.accessTokenStore.listUserAccessTokens(signal); + } finally { + this.isRevalidating$.value = false; + } + } } diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index 15822be2b7..dd640398fc 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -3,10 +3,11 @@ import type { OAuthProviderType } from '@affine/graphql'; import { track } from '@affine/track'; import { OnEvent, Service } from '@toeverything/infra'; import { nanoid } from 'nanoid'; -import { distinctUntilChanged, map, skip } from 'rxjs'; +import { distinctUntilChanged, map, skip, type Subscription } from 'rxjs'; import type { GlobalDialogService } from '../../dialogs'; import { ApplicationFocused } from '../../lifecycle'; +import type { NbstoreService } from '../../storage'; import type { UrlService } from '../../url'; import { AuthSession } from '../entities/session'; import { AccountChanged } from '../events/account-changed'; @@ -20,12 +21,14 @@ import type { FetchService } from './fetch'; @OnEvent(ServerStarted, e => e.onServerStarted) export class AuthService extends Service { session = this.framework.createEntity(AuthSession); + private profileSubscription?: Subscription; constructor( private readonly fetchService: FetchService, private readonly store: AuthStore, private readonly urlService: UrlService, - private readonly dialogService: GlobalDialogService + private readonly dialogService: GlobalDialogService, + private readonly nbstoreService: NbstoreService ) { super(); @@ -41,21 +44,53 @@ export class AuthService extends Service { .subscribe(({ account }) => { if (account === null) { this.eventBus.emit(AccountLoggedOut, account); + this.profileSubscription?.unsubscribe(); + this.profileSubscription = undefined; } else { this.eventBus.emit(AccountLoggedIn, account); + this.subscribeProfile(); } this.eventBus.emit(AccountChanged, account); }); + + this.subscribeProfile(); } private onServerStarted() { this.session.revalidate(); + this.subscribeProfile(); } private onApplicationFocused() { this.session.revalidate(); } + private subscribeProfile() { + this.profileSubscription?.unsubscribe(); + this.profileSubscription = undefined; + if (!this.session.account$.value) return; + this.profileSubscription = this.nbstoreService.realtime + .subscribe('user.profile.changed', {}) + .subscribe({ + next: () => { + void (async () => { + this.session.revalidate(); + await this.session.waitForRevalidation(); + this.eventBus.emit(AccountChanged, this.session.account$.value); + })().catch(() => {}); + }, + error: () => { + this.profileSubscription = undefined; + }, + }); + } + + override dispose() { + super.dispose(); + this.profileSubscription?.unsubscribe(); + this.profileSubscription = undefined; + } + async sendEmailMagicLink( email: string, verifyToken?: string, diff --git a/packages/frontend/core/src/modules/cloud/services/user-settings.ts b/packages/frontend/core/src/modules/cloud/services/user-settings.ts index 33126a036f..f77a90df4a 100644 --- a/packages/frontend/core/src/modules/cloud/services/user-settings.ts +++ b/packages/frontend/core/src/modules/cloud/services/user-settings.ts @@ -1,15 +1,6 @@ -import { - effect, - exhaustMapWithTrailing, - fromPromise, - LiveData, - onComplete, - onStart, - Service, - smartRetry, -} from '@toeverything/infra'; -import { catchError, EMPTY, tap } from 'rxjs'; +import { LiveData, Service } from '@toeverything/infra'; +import { RealtimeLiveQuery } from '../realtime/live-query'; import type { UpdateUserSettingsInput, UserSettings, @@ -21,34 +12,28 @@ export type { UserSettings }; export class UserSettingsService extends Service { constructor(private readonly store: UserSettingsStore) { super(); + this.liveQuery.start(); } userSettings$ = new LiveData(undefined); isLoading$ = new LiveData(false); error$ = new LiveData(undefined); + private readonly liveQuery = new RealtimeLiveQuery({ + request: signal => this.requestUserSettings(signal), + subscribe: () => this.store.subscribeUserSettings(), + applySnapshot: settings => { + this.error$.value = undefined; + this.userSettings$.value = settings; + }, + applyEvent: () => 'revalidate' as const, + onError: error => { + this.error$.value = error; + }, + }); - revalidate = effect( - exhaustMapWithTrailing(() => { - return fromPromise(() => { - return this.store.getUserSettings(); - }).pipe( - smartRetry(), - tap(settings => { - this.userSettings$.value = settings; - }), - catchError(error => { - this.error$.value = error; - return EMPTY; - }), - onStart(() => { - this.isLoading$.value = true; - }), - onComplete(() => { - this.isLoading$.value = false; - }) - ); - }) - ); + revalidate = () => { + this.liveQuery.revalidate(); + }; async updateUserSettings(settings: UpdateUserSettingsInput) { await this.store.updateUserSettings(settings); @@ -58,4 +43,18 @@ export class UserSettingsService extends Service { }; this.revalidate(); } + + override dispose(): void { + super.dispose(); + this.liveQuery.dispose(); + } + + private async requestUserSettings(signal: AbortSignal) { + this.isLoading$.value = true; + try { + return await this.store.getUserSettings(signal); + } finally { + this.isLoading$.value = false; + } + } } diff --git a/packages/frontend/core/src/modules/cloud/stores/access-token.ts b/packages/frontend/core/src/modules/cloud/stores/access-token.ts index e96142dffc..4278f28759 100644 --- a/packages/frontend/core/src/modules/cloud/stores/access-token.ts +++ b/packages/frontend/core/src/modules/cloud/stores/access-token.ts @@ -1,29 +1,40 @@ import { generateUserAccessTokenMutation, - type ListUserAccessTokensQuery, - listUserAccessTokensQuery, revokeUserAccessTokenMutation, } from '@affine/graphql'; +import type { AccessTokenSnapshot } from '@affine/realtime'; import { Store } from '@toeverything/infra'; +import type { NbstoreService } from '../../storage'; import type { GraphQLService } from '../services/graphql'; -export type AccessToken = NonNullable< - ListUserAccessTokensQuery['currentUser'] ->['revealedAccessTokens'][number]; +export type AccessToken = AccessTokenSnapshot & { token: string }; +export type ListedAccessToken = AccessTokenSnapshot; export class AccessTokenStore extends Store { - constructor(private readonly gqlService: GraphQLService) { + constructor( + private readonly gqlService: GraphQLService, + private readonly nbstoreService: NbstoreService + ) { super(); } - async listUserAccessTokens(signal?: AbortSignal): Promise { - const data = await this.gqlService.gql({ - query: listUserAccessTokensQuery, - context: { signal }, - }); + async listUserAccessTokens( + signal?: AbortSignal + ): Promise { + const { tokens } = await this.nbstoreService.realtime.request( + 'user.access-tokens.get', + {}, + { signal, timeoutMs: 10000 } + ); + return tokens; + } - return data.currentUser?.revealedAccessTokens ?? []; + subscribeUserAccessTokens() { + return this.nbstoreService.realtime.subscribe( + 'user.access-tokens.changed', + {} + ); } async generateUserAccessToken( diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index 2578db8669..9874b5b138 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -1,12 +1,13 @@ import { deleteAccountMutation, removeAvatarMutation, + ServerDeploymentType, updateUserProfileMutation, uploadAvatarMutation, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; -import type { GlobalState } from '../../storage'; +import type { GlobalState, NbstoreService } from '../../storage'; import type { AuthSessionInfo } from '../entities/session'; import type { AuthProvider } from '../provider/auth'; import type { FetchService } from '../services/fetch'; @@ -20,6 +21,7 @@ export interface AccountProfile { hasPassword: boolean; avatarUrl: string | null; emailVerified: string | null; + features?: string[]; } export class AuthStore extends Store { @@ -28,7 +30,8 @@ export class AuthStore extends Store { private readonly gqlService: GraphQLService, private readonly globalState: GlobalState, private readonly serverService: ServerService, - private readonly authProvider: AuthProvider + private readonly authProvider: AuthProvider, + private readonly nbstoreService: NbstoreService ) { super(); } @@ -58,20 +61,20 @@ export class AuthStore extends Store { } async fetchSession() { - const url = `/api/auth/session`; - const options: RequestInit = { - headers: { - 'Content-Type': 'application/json', - }, + const { user } = await this.nbstoreService.realtime.request( + 'user.profile.get', + {}, + { timeoutMs: 10000 } + ); + return { + user: user + ? { + ...user, + hasPassword: Boolean(user.hasPassword), + emailVerified: user.emailVerified ? 'true' : null, + } + : null, }; - - const res = await this.fetchService.fetch(url, options); - const data = (await res.json()) as { - user?: AccountProfile | null; - }; - if (!res.ok) - throw new Error('Get session fetch error: ' + JSON.stringify(data)); - return data; // Return null if data empty } async signInMagicLink(email: string, token: string) { @@ -102,6 +105,13 @@ export class AuthStore extends Store { async signOut() { await this.authProvider.signOut(); + await this.nbstoreService.realtime.configure({ + endpoint: this.serverService.server.baseUrl, + authenticated: false, + isSelfHosted: + this.serverService.server.config$.value.type === + ServerDeploymentType.Selfhosted, + }); } async uploadAvatar(file: File) { diff --git a/packages/frontend/core/src/modules/cloud/stores/user-copilot-quota.ts b/packages/frontend/core/src/modules/cloud/stores/user-copilot-quota.ts index 1879256712..ebe5b57374 100644 --- a/packages/frontend/core/src/modules/cloud/stores/user-copilot-quota.ts +++ b/packages/frontend/core/src/modules/cloud/stores/user-copilot-quota.ts @@ -1,4 +1,4 @@ -import { getCurrentUserProfileQuery } from '@affine/graphql'; +import { copilotQuotaQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; import type { GraphQLService } from '../services/graphql'; @@ -10,7 +10,7 @@ export class UserCopilotQuotaStore extends Store { async fetchUserCopilotQuota(abortSignal?: AbortSignal) { const data = await this.graphqlService.gql({ - query: getCurrentUserProfileQuery, + query: copilotQuotaQuery, context: { signal: abortSignal, }, diff --git a/packages/frontend/core/src/modules/cloud/stores/user-feature.ts b/packages/frontend/core/src/modules/cloud/stores/user-feature.ts deleted file mode 100644 index e720bb27f2..0000000000 --- a/packages/frontend/core/src/modules/cloud/stores/user-feature.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getCurrentUserProfileQuery } from '@affine/graphql'; -import { Store } from '@toeverything/infra'; - -import type { GraphQLService } from '../services/graphql'; - -export class UserFeatureStore extends Store { - constructor(private readonly gqlService: GraphQLService) { - super(); - } - - async getUserFeatures(signal: AbortSignal) { - const data = await this.gqlService.gql({ - query: getCurrentUserProfileQuery, - context: { - signal, - }, - }); - return { - userId: data.currentUser?.id, - features: data.currentUser?.features, - }; - } -} diff --git a/packages/frontend/core/src/modules/cloud/stores/user-quota.ts b/packages/frontend/core/src/modules/cloud/stores/user-quota.ts index a3515eb63a..a603b9a86b 100644 --- a/packages/frontend/core/src/modules/cloud/stores/user-quota.ts +++ b/packages/frontend/core/src/modules/cloud/stores/user-quota.ts @@ -1,14 +1,9 @@ -import { getCurrentUserProfileQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; import type { NbstoreService } from '../../storage'; -import type { GraphQLService } from '../services/graphql'; export class UserQuotaStore extends Store { - constructor( - private readonly graphqlService: GraphQLService, - private readonly nbstoreService: NbstoreService - ) { + constructor(private readonly nbstoreService: NbstoreService) { super(); } @@ -27,23 +22,4 @@ export class UserQuotaStore extends Store { {} ); } - - async fetchUserQuota(abortSignal?: AbortSignal) { - const data = await this.graphqlService.gql({ - query: getCurrentUserProfileQuery, - context: { - signal: abortSignal, - }, - }); - - if (!data.currentUser) { - throw new Error('No logged in'); - } - - return { - userId: data.currentUser.id, - quota: data.currentUser.quota, - used: data.currentUser.quotaUsage.storageQuota, - }; - } } diff --git a/packages/frontend/core/src/modules/cloud/stores/user-settings.ts b/packages/frontend/core/src/modules/cloud/stores/user-settings.ts index 5e93a222be..9abb730eec 100644 --- a/packages/frontend/core/src/modules/cloud/stores/user-settings.ts +++ b/packages/frontend/core/src/modules/cloud/stores/user-settings.ts @@ -1,29 +1,38 @@ import { - type GetCurrentUserProfileQuery, - getCurrentUserProfileQuery, type UpdateUserSettingsInput, updateUserSettingsMutation, } from '@affine/graphql'; +import type { UserSettingsSnapshot } from '@affine/realtime'; import { Store } from '@toeverything/infra'; +import type { NbstoreService } from '../../storage'; import type { GraphQLService } from '../services/graphql'; -export type UserSettings = NonNullable< - GetCurrentUserProfileQuery['currentUser'] ->['settings']; +export type UserSettings = UserSettingsSnapshot; export type { UpdateUserSettingsInput }; export class UserSettingsStore extends Store { - constructor(private readonly gqlService: GraphQLService) { + constructor( + private readonly gqlService: GraphQLService, + private readonly nbstoreService: NbstoreService + ) { super(); } - async getUserSettings(): Promise { - const result = await this.gqlService.gql({ - query: getCurrentUserProfileQuery, - }); - return result.currentUser?.settings; + async getUserSettings( + signal?: AbortSignal + ): Promise { + const { settings } = await this.nbstoreService.realtime.request( + 'user.settings.get', + {}, + { signal, timeoutMs: 10000 } + ); + return settings; + } + + subscribeUserSettings() { + return this.nbstoreService.realtime.subscribe('user.settings.changed', {}); } async updateUserSettings(settings: UpdateUserSettingsInput) { diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts index d324eeef41..46f6eddb06 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts @@ -150,7 +150,8 @@ export class DocCommentStore extends Entity<{ return { changes: commentChanges.changes.map(change => ({ id: change.id, - action: change.action, + action: + change.action as DocCommentChangeListResult['changes'][number]['action'], comment: normalizeComment(change.item as GQLCommentType), commentId: change.commentId ?? undefined, })), diff --git a/packages/frontend/core/src/modules/permissions/entities/members.ts b/packages/frontend/core/src/modules/permissions/entities/members.ts index ba09106c4a..9d9effec99 100644 --- a/packages/frontend/core/src/modules/permissions/entities/members.ts +++ b/packages/frontend/core/src/modules/permissions/entities/members.ts @@ -1,21 +1,22 @@ -import type { GetMembersByWorkspaceIdQuery } from '@affine/graphql'; -import { - catchErrorInto, - effect, - Entity, - fromPromise, - LiveData, - onComplete, - onStart, - smartRetry, -} from '@toeverything/infra'; -import { map, switchMap, tap } from 'rxjs'; +import type { WorkspaceMemberStatus } from '@affine/graphql'; +import type { + RealtimeTopicEventOf, + WorkspaceMemberSnapshot, +} from '@affine/realtime'; +import { Entity, LiveData } from '@toeverything/infra'; +import { RealtimeLiveQuery } from '../../cloud/realtime/live-query'; import type { WorkspaceService } from '../../workspace'; import type { WorkspaceMembersStore } from '../stores/members'; -export type Member = - GetMembersByWorkspaceIdQuery['workspace']['members'][number]; +export type Member = Omit< + WorkspaceMemberSnapshot, + 'permission' | 'role' | 'status' +> & { + permission: string; + role: string; + status: WorkspaceMemberStatus; +}; export class WorkspaceMembers extends Entity { constructor( @@ -23,6 +24,7 @@ export class WorkspaceMembers extends Entity { private readonly workspaceService: WorkspaceService ) { super(); + this.liveQuery.start(); } pageNum$ = new LiveData(0); @@ -34,37 +36,48 @@ export class WorkspaceMembers extends Entity { readonly PAGE_SIZE = 8; - readonly revalidate = effect( - map(() => this.pageNum$.value), - switchMap(pageNum => { - return fromPromise(async signal => { - return this.store.fetchMembers( - this.workspaceService.workspace.id, - pageNum * this.PAGE_SIZE, - this.PAGE_SIZE, - signal - ); - }).pipe( - tap(data => { - this.memberCount$.setValue(data.memberCount); - this.pageMembers$.setValue(data.members); - }), - smartRetry(), - catchErrorInto(this.error$), - onStart(() => { - this.pageMembers$.setValue(undefined); - this.isLoading$.setValue(true); - }), - onComplete(() => this.isLoading$.setValue(false)) - ); - }) - ); + private readonly liveQuery = new RealtimeLiveQuery< + { members: Member[]; memberCount: number }, + RealtimeTopicEventOf<'workspace.members.changed'> + >({ + request: signal => this.requestMembers(signal), + subscribe: () => + this.store.subscribeMembers(this.workspaceService.workspace.id), + applySnapshot: data => { + this.error$.next(null); + this.memberCount$.setValue(data.memberCount); + this.pageMembers$.setValue(data.members); + }, + applyEvent: () => 'revalidate', + onError: error => this.error$.setValue(error), + }); + + revalidate = () => { + this.liveQuery.revalidate(); + }; setPageNum(pageNum: number) { this.pageNum$.setValue(pageNum); + this.revalidate(); } override dispose(): void { - this.revalidate.unsubscribe(); + this.liveQuery.dispose(); + } + + private async requestMembers(signal: AbortSignal) { + this.pageMembers$.setValue(undefined); + this.isLoading$.setValue(true); + try { + const pageNum = this.pageNum$.value; + return await this.store.fetchMembers( + this.workspaceService.workspace.id, + pageNum * this.PAGE_SIZE, + this.PAGE_SIZE, + signal + ); + } finally { + this.isLoading$.setValue(false); + } } } diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts index 4f84d22469..4a987de636 100644 --- a/packages/frontend/core/src/modules/permissions/entities/permission.ts +++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts @@ -8,6 +8,7 @@ import { onComplete, onStart, } from '@toeverything/infra'; +import type { Subscription } from 'rxjs'; import { tap } from 'rxjs'; import type { WorkspaceService } from '../../workspace'; @@ -25,12 +26,24 @@ export class WorkspacePermission extends Entity { ); isTeam$ = this.cache$.map(cache => cache?.isTeam ?? null); isRevalidating$ = new LiveData(false); + private readonly subscription?: Subscription; constructor( private readonly workspaceService: WorkspaceService, private readonly store: WorkspacePermissionStore ) { super(); + if ( + this.workspaceService.workspace.flavour !== 'local' && + !this.workspaceService.workspace.openOptions.isSharedMode + ) { + this.subscription = this.store + .subscribeWorkspaceAccess(this.workspaceService.workspace.id) + .subscribe({ + next: () => this.revalidate(), + error: () => {}, + }); + } } revalidate = effect( @@ -82,5 +95,6 @@ export class WorkspacePermission extends Entity { override dispose(): void { this.revalidate.unsubscribe(); + this.subscription?.unsubscribe(); } } diff --git a/packages/frontend/core/src/modules/permissions/index.ts b/packages/frontend/core/src/modules/permissions/index.ts index 0205d98800..ab6be0c456 100644 --- a/packages/frontend/core/src/modules/permissions/index.ts +++ b/packages/frontend/core/src/modules/permissions/index.ts @@ -16,6 +16,7 @@ import { type Framework } from '@toeverything/infra'; import { WorkspaceServerService } from '../cloud/services/workspace-server'; import { DocScope, DocService } from '../doc'; +import { NbstoreService } from '../storage'; import { WorkspaceLocalState, WorkspaceScope, @@ -46,19 +47,24 @@ export function configurePermissionsModule(framework: Framework) { .store(WorkspacePermissionStore, [ WorkspaceServerService, WorkspaceLocalState, + NbstoreService, ]) .entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore]) .service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService]) - .store(WorkspaceMembersStore, [WorkspaceServerService]) + .store(WorkspaceMembersStore, [WorkspaceServerService, NbstoreService]) .entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]) .service(MemberSearchService, [MemberSearchStore, WorkspaceService]) - .store(MemberSearchStore, [WorkspaceServerService]) + .store(MemberSearchStore, [NbstoreService]) .service(GuardService, [ GuardStore, WorkspaceService, WorkspacePermissionService, ]) - .store(GuardStore, [WorkspaceService, WorkspaceServerService]); + .store(GuardStore, [ + WorkspaceService, + WorkspaceServerService, + NbstoreService, + ]); framework .scope(WorkspaceScope) @@ -68,5 +74,5 @@ export function configurePermissionsModule(framework: Framework) { WorkspaceService, DocService, ]) - .store(DocGrantedUsersStore, [WorkspaceServerService]); + .store(DocGrantedUsersStore, [WorkspaceServerService, NbstoreService]); } diff --git a/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts b/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts index eee86bb8a6..50afd6b37c 100644 --- a/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts +++ b/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts @@ -1,4 +1,5 @@ -import { DocRole, type GetPageGrantedUsersListQuery } from '@affine/graphql'; +import { DocRole } from '@affine/graphql'; +import type { DocGrantedUserSnapshot } from '@affine/realtime'; import { catchErrorInto, effect, @@ -9,14 +10,16 @@ import { Service, smartRetry, } from '@toeverything/infra'; +import type { Subscription } from 'rxjs'; import { EMPTY, exhaustMap, tap } from 'rxjs'; import type { DocService } from '../../doc'; import type { WorkspaceService } from '../../workspace'; import type { DocGrantedUsersStore } from '../stores/doc-granted-users'; -export type GrantedUser = - GetPageGrantedUsersListQuery['workspace']['doc']['grantedUsersList']['edges'][number]['node']; +export type GrantedUser = Omit & { + role: DocRole; +}; export class DocGrantedUsersService extends Service { constructor( @@ -25,8 +28,22 @@ export class DocGrantedUsersService extends Service { private readonly docService: DocService ) { super(); + this.subscription = this.store + .subscribeDocGrants( + this.workspaceService.workspace.id, + this.docService.doc.id + ) + .subscribe({ + next: () => { + this.reset(); + this.loadMore(); + }, + error: error => this.error$.setValue(error), + }); } + private readonly subscription: Subscription; + readonly PAGE_SIZE = 8; nextCursor$ = new LiveData(undefined); @@ -149,5 +166,6 @@ export class DocGrantedUsersService extends Service { override dispose(): void { this.loadMore.unsubscribe(); + this.subscription.unsubscribe(); } } diff --git a/packages/frontend/core/src/modules/permissions/services/member-search.ts b/packages/frontend/core/src/modules/permissions/services/member-search.ts index b5a2f7c760..6e5b420fbc 100644 --- a/packages/frontend/core/src/modules/permissions/services/member-search.ts +++ b/packages/frontend/core/src/modules/permissions/services/member-search.ts @@ -8,6 +8,7 @@ import { Service, smartRetry, } from '@toeverything/infra'; +import type { Subscription } from 'rxjs'; import { EMPTY, exhaustMap, tap } from 'rxjs'; import type { WorkspaceService } from '../../workspace'; @@ -20,8 +21,20 @@ export class MemberSearchService extends Service { private readonly workspaceService: WorkspaceService ) { super(); + this.subscription = this.store + .subscribeMembers(this.workspaceService.workspace.id) + .subscribe({ + next: () => { + if (this.searchText$.value) { + this.search(this.searchText$.value); + } + }, + error: error => this.error$.setValue(error), + }); } + private readonly subscription: Subscription; + readonly PAGE_SIZE = 8; readonly searchText$ = new LiveData(''); readonly isLoading$ = new LiveData(false); @@ -70,4 +83,9 @@ export class MemberSearchService extends Service { this.searchText$.setValue(searchText ?? ''); this.loadMore(); } + + override dispose(): void { + this.loadMore.unsubscribe(); + this.subscription.unsubscribe(); + } } diff --git a/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts b/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts index e4baed92e5..ae88bf6f99 100644 --- a/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts +++ b/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts @@ -1,7 +1,5 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { - type DocRole, - getPageGrantedUsersListQuery, type GrantDocUserRolesInput, grantDocUserRolesMutation, type PaginationInput, @@ -12,8 +10,14 @@ import { } from '@affine/graphql'; import { Store } from '@toeverything/infra'; +import type { NbstoreService } from '../../storage'; +import { mapDocGrantedUserSnapshot } from './realtime-mappers'; + export class DocGrantedUsersStore extends Store { - constructor(private readonly workspaceServerService: WorkspaceServerService) { + constructor( + private readonly workspaceServerService: WorkspaceServerService, + private readonly nbstoreService: NbstoreService + ) { super(); } @@ -23,20 +27,33 @@ export class DocGrantedUsersStore extends Store { pagination: PaginationInput, signal?: AbortSignal ) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const res = await this.workspaceServerService.server.gql({ - query: getPageGrantedUsersListQuery, - variables: { - workspaceId, - docId, - pagination, - }, - context: { signal }, - }); + return await this.nbstoreService.realtime + .request( + 'doc.grants.get', + { + workspaceId, + docId, + pagination: { + first: pagination.first ?? 10, + offset: pagination.offset ?? 0, + after: pagination.after ?? undefined, + }, + }, + { signal, timeoutMs: 10000 } + ) + .then(data => ({ + ...data, + edges: data.edges.map(edge => ({ + node: mapDocGrantedUserSnapshot(edge.node), + })), + })); + } - return res.workspace.doc.grantedUsersList; + subscribeDocGrants(workspaceId: string, docId: string) { + return this.nbstoreService.realtime.subscribe('doc.grants.changed', { + workspaceId, + docId, + }); } async grantDocUserRoles(input: GrantDocUserRolesInput) { @@ -75,7 +92,7 @@ export class DocGrantedUsersStore extends Store { workspaceId: string, docId: string, userId: string, - role: DocRole + role: GrantDocUserRolesInput['role'] ) { if (!this.workspaceServerService.server) { throw new Error('No Server'); diff --git a/packages/frontend/core/src/modules/permissions/stores/guard.ts b/packages/frontend/core/src/modules/permissions/stores/guard.ts index 1a8a93f880..d065731dda 100644 --- a/packages/frontend/core/src/modules/permissions/stores/guard.ts +++ b/packages/frontend/core/src/modules/permissions/stores/guard.ts @@ -1,18 +1,14 @@ import { type GetDocRolePermissionsQuery, getDocRolePermissionsQuery, - type GetWorkspaceInfoQuery, - getWorkspaceInfoQuery, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; import type { WorkspaceServerService } from '../../cloud'; +import type { NbstoreService } from '../../storage'; import type { WorkspaceService } from '../../workspace'; -export type WorkspacePermissionActions = keyof Omit< - GetWorkspaceInfoQuery['workspace']['permissions'], - '__typename' ->; +export type WorkspacePermissionActions = string; export type DocPermissionActions = keyof Omit< GetDocRolePermissionsQuery['workspace']['doc']['permissions'], @@ -22,7 +18,8 @@ export type DocPermissionActions = keyof Omit< export class GuardStore extends Store { constructor( private readonly workspaceService: WorkspaceService, - private readonly workspaceServerService: WorkspaceServerService + private readonly workspaceServerService: WorkspaceServerService, + private readonly nbstoreService: NbstoreService ) { super(); } @@ -30,16 +27,12 @@ export class GuardStore extends Store { async getWorkspacePermissions(): Promise< Record > { - if (!this.workspaceServerService.server) { - throw new Error('No server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getWorkspaceInfoQuery, - variables: { - workspaceId: this.workspaceService.workspace.id, - }, - }); - return data.workspace.permissions; + const { access } = await this.nbstoreService.realtime.request( + 'workspace.access.get', + { workspaceId: this.workspaceService.workspace.id }, + { timeoutMs: 10000 } + ); + return access.permissions as Record; } async getDocPermissions( diff --git a/packages/frontend/core/src/modules/permissions/stores/member-search.ts b/packages/frontend/core/src/modules/permissions/stores/member-search.ts index 81480b6751..a0ac49b293 100644 --- a/packages/frontend/core/src/modules/permissions/stores/member-search.ts +++ b/packages/frontend/core/src/modules/permissions/stores/member-search.ts @@ -1,10 +1,10 @@ -import { getMembersByWorkspaceIdQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; -import type { WorkspaceServerService } from '../../cloud'; +import type { NbstoreService } from '../../storage'; +import { mapWorkspaceMemberSnapshot } from './realtime-mappers'; export class MemberSearchStore extends Store { - constructor(private readonly workspaceServerService: WorkspaceServerService) { + constructor(private readonly nbstoreService: NbstoreService) { super(); } @@ -15,22 +15,21 @@ export class MemberSearchStore extends Store { take?: number, signal?: AbortSignal ) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId, - skip, - take, - query, - }, - context: { - signal, - }, - }); + return await this.nbstoreService.realtime + .request( + 'workspace.members.get', + { workspaceId, skip, take, query }, + { signal, timeoutMs: 10000 } + ) + .then(data => ({ + ...data, + members: data.members.map(mapWorkspaceMemberSnapshot), + })); + } - return data.workspace; + subscribeMembers(workspaceId: string) { + return this.nbstoreService.realtime.subscribe('workspace.members.changed', { + workspaceId, + }); } } diff --git a/packages/frontend/core/src/modules/permissions/stores/members.ts b/packages/frontend/core/src/modules/permissions/stores/members.ts index 0fe6fff410..85af13c527 100644 --- a/packages/frontend/core/src/modules/permissions/stores/members.ts +++ b/packages/frontend/core/src/modules/permissions/stores/members.ts @@ -1,7 +1,6 @@ import { approveWorkspaceTeamMemberMutation, createInviteLinkMutation, - getMembersByWorkspaceIdQuery, grantWorkspaceTeamMemberMutation, inviteByEmailsMutation, type Permission, @@ -12,9 +11,14 @@ import { import { Store } from '@toeverything/infra'; import type { WorkspaceServerService } from '../../cloud'; +import type { NbstoreService } from '../../storage'; +import { mapWorkspaceMemberSnapshot } from './realtime-mappers'; export class WorkspaceMembersStore extends Store { - constructor(private readonly workspaceServerService: WorkspaceServerService) { + constructor( + private readonly workspaceServerService: WorkspaceServerService, + private readonly nbstoreService: NbstoreService + ) { super(); } @@ -24,22 +28,22 @@ export class WorkspaceMembersStore extends Store { take: number, signal?: AbortSignal ) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getMembersByWorkspaceIdQuery, - variables: { - workspaceId, - skip, - take, - }, - context: { - signal, - }, - }); + return await this.nbstoreService.realtime + .request( + 'workspace.members.get', + { workspaceId, skip, take }, + { signal, timeoutMs: 10000 } + ) + .then(data => ({ + ...data, + members: data.members.map(mapWorkspaceMemberSnapshot), + })); + } - return data.workspace; + subscribeMembers(workspaceId: string) { + return this.nbstoreService.realtime.subscribe('workspace.members.changed', { + workspaceId, + }); } async inviteBatch(workspaceId: string, emails: string[]) { diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts index 7460c4b93e..c65d3ee685 100644 --- a/packages/frontend/core/src/modules/permissions/stores/permission.ts +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -1,30 +1,32 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud'; -import { getWorkspaceInfoQuery, leaveWorkspaceMutation } from '@affine/graphql'; +import { leaveWorkspaceMutation } from '@affine/graphql'; import { Store } from '@toeverything/infra'; +import type { NbstoreService } from '../../storage'; import type { WorkspaceLocalState } from '../../workspace'; export class WorkspacePermissionStore extends Store { constructor( private readonly workspaceServerService: WorkspaceServerService, - private readonly workspaceLocalState: WorkspaceLocalState + private readonly workspaceLocalState: WorkspaceLocalState, + private readonly nbstoreService: NbstoreService ) { super(); } async fetchWorkspaceInfo(workspaceId: string, signal?: AbortSignal) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const info = await this.workspaceServerService.server.gql({ - query: getWorkspaceInfoQuery, - variables: { - workspaceId, - }, - context: { signal }, - }); + const { access } = await this.nbstoreService.realtime.request( + 'workspace.access.get', + { workspaceId }, + { signal, timeoutMs: 10000 } + ); + return { workspace: access }; + } - return info; + subscribeWorkspaceAccess(workspaceId: string) { + return this.nbstoreService.realtime.subscribe('workspace.access.changed', { + workspaceId, + }); } /** diff --git a/packages/frontend/core/src/modules/permissions/stores/realtime-mappers.ts b/packages/frontend/core/src/modules/permissions/stores/realtime-mappers.ts new file mode 100644 index 0000000000..f54d72ccbd --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/stores/realtime-mappers.ts @@ -0,0 +1,24 @@ +import { DocRole, Permission, WorkspaceMemberStatus } from '@affine/graphql'; +import type { + DocGrantedUserSnapshot, + WorkspaceMemberSnapshot, +} from '@affine/realtime'; + +import { mapRealtimeEnum } from '../../cloud/realtime/enum'; + +export function mapWorkspaceMemberSnapshot(member: WorkspaceMemberSnapshot) { + return { + ...member, + permission: mapRealtimeEnum(Permission, member.permission, 'permission'), + role: mapRealtimeEnum(Permission, member.role, 'permission'), + status: mapRealtimeEnum( + WorkspaceMemberStatus, + member.status, + 'workspace member status' + ), + }; +} + +export function mapDocGrantedUserSnapshot(node: DocGrantedUserSnapshot) { + return { ...node, role: mapRealtimeEnum(DocRole, node.role, 'doc role') }; +} diff --git a/packages/frontend/core/src/modules/quota/entities/quota.spec.ts b/packages/frontend/core/src/modules/quota/entities/quota.spec.ts index 906b142ab2..742b190796 100644 --- a/packages/frontend/core/src/modules/quota/entities/quota.spec.ts +++ b/packages/frontend/core/src/modules/quota/entities/quota.spec.ts @@ -1,5 +1,4 @@ import { WorkspaceService } from '@affine/core/modules/workspace'; -import type { WorkspaceQuotaQuery } from '@affine/graphql'; import type { WorkspaceQuotaStateSnapshot } from '@affine/realtime'; import { Framework } from '@toeverything/infra'; import { Subject } from 'rxjs'; @@ -8,8 +7,6 @@ import { describe, expect, test, vi } from 'vitest'; import { WorkspaceQuotaStore } from '../stores/quota'; import { WorkspaceQuota } from './quota'; -type Quota = WorkspaceQuotaQuery['workspace']['quota']; - const workspaceService = { workspace: { id: 'workspace-1' }, } as unknown as WorkspaceService; @@ -43,29 +40,6 @@ function createQuotaState( }; } -function createQuota(overrides: Partial = {}): Quota { - return { - name: 'Legacy', - blobLimit: 1024, - storageQuota: 2048, - usedStorageQuota: 256, - historyPeriod: 30 * 24 * 60 * 60 * 1000, - memberLimit: 8, - memberCount: 2, - overcapacityMemberCount: 0, - humanReadable: { - name: 'Legacy', - blobLimit: '1 KB', - storageQuota: '2 KB', - historyPeriod: '30 days', - memberLimit: '8', - memberCount: '2', - overcapacityMemberCount: '0', - }, - ...overrides, - }; -} - function createStore( overrides: Partial, eventSubject = new Subject<{ type: 'ready' } | { changed: true }>() @@ -73,7 +47,6 @@ function createStore( return { fetchWorkspaceQuotaState: vi.fn(), subscribeWorkspaceQuotaState: vi.fn(() => eventSubject), - fetchWorkspaceQuota: vi.fn(), ...overrides, } as unknown as WorkspaceQuotaStore; } @@ -110,21 +83,20 @@ describe('WorkspaceQuota', () => { await vi.waitFor(() => expect(quota.quota$.value?.memberCount).toBe(4)); expect(store.fetchWorkspaceQuotaState).toHaveBeenCalledTimes(2); - expect(store.fetchWorkspaceQuota).not.toHaveBeenCalled(); quota.dispose(); }); - test('falls back to legacy GraphQL quota when realtime request fails', async () => { + test('surfaces realtime quota errors without GraphQL fallback', async () => { + const error = new Error('offline'); const store = createStore({ - fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(new Error('offline')), - fetchWorkspaceQuota: vi.fn().mockResolvedValue(createQuota()), + fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(error), }); const quota = createEntity(store); quota.revalidate(); - await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy')); - expect(quota.error$.value).toBeNull(); + await vi.waitFor(() => expect(quota.error$.value).toBe(error)); + expect(quota.quota$.value).toBeNull(); quota.dispose(); }); }); diff --git a/packages/frontend/core/src/modules/quota/entities/quota.ts b/packages/frontend/core/src/modules/quota/entities/quota.ts index 502e10f0f1..2adef771c3 100644 --- a/packages/frontend/core/src/modules/quota/entities/quota.ts +++ b/packages/frontend/core/src/modules/quota/entities/quota.ts @@ -1,5 +1,4 @@ import { DebugLogger } from '@affine/debug'; -import type { WorkspaceQuotaQuery } from '@affine/graphql'; import type { RealtimeTopicEventOf, WorkspaceQuotaStateSnapshot, @@ -12,7 +11,25 @@ import { RealtimeLiveQuery } from '../../cloud/realtime/live-query'; import type { WorkspaceService } from '../../workspace'; import type { WorkspaceQuotaStore } from '../stores/quota'; -type QuotaType = WorkspaceQuotaQuery['workspace']['quota']; +type QuotaType = { + name: string; + blobLimit: number; + storageQuota: number; + usedStorageQuota: number; + historyPeriod: number; + memberLimit: number; + memberCount: number; + overcapacityMemberCount: number; + humanReadable: { + name: string; + blobLimit: string; + storageQuota: string; + historyPeriod: string; + memberLimit: string; + memberCount: string; + overcapacityMemberCount: string; + }; +}; const logger = new DebugLogger('affine:workspace-permission'); const DAY_SECONDS = 24 * 60 * 60; @@ -172,11 +189,6 @@ export class WorkspaceQuota extends Entity { signal ) ); - } catch { - return await this.store.fetchWorkspaceQuota( - this.workspaceService.workspace.id, - signal - ); } finally { this.isRevalidating$.setValue(false); } diff --git a/packages/frontend/core/src/modules/quota/index.ts b/packages/frontend/core/src/modules/quota/index.ts index 294493c2ab..135cb9c519 100644 --- a/packages/frontend/core/src/modules/quota/index.ts +++ b/packages/frontend/core/src/modules/quota/index.ts @@ -3,7 +3,6 @@ export { QuotaCheck } from './views/quota-check'; import { type Framework } from '@toeverything/infra'; -import { WorkspaceServerService } from '../cloud'; import { NbstoreService } from '../storage'; import { WorkspaceScope, WorkspaceService } from '../workspace'; import { WorkspaceQuota } from './entities/quota'; @@ -14,6 +13,6 @@ export function configureQuotaModule(framework: Framework) { framework .scope(WorkspaceScope) .service(WorkspaceQuotaService) - .store(WorkspaceQuotaStore, [WorkspaceServerService, NbstoreService]) + .store(WorkspaceQuotaStore, [NbstoreService]) .entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]); } diff --git a/packages/frontend/core/src/modules/quota/stores/quota.ts b/packages/frontend/core/src/modules/quota/stores/quota.ts index 595b1aa828..2d1d3c2461 100644 --- a/packages/frontend/core/src/modules/quota/stores/quota.ts +++ b/packages/frontend/core/src/modules/quota/stores/quota.ts @@ -1,14 +1,9 @@ -import type { WorkspaceServerService } from '@affine/core/modules/cloud'; -import { workspaceQuotaQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; import type { NbstoreService } from '../../storage'; export class WorkspaceQuotaStore extends Store { - constructor( - private readonly workspaceServerService: WorkspaceServerService, - private readonly nbstoreService: NbstoreService - ) { + constructor(private readonly nbstoreService: NbstoreService) { super(); } @@ -27,20 +22,4 @@ export class WorkspaceQuotaStore extends Store { { workspaceId } ); } - - async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: workspaceQuotaQuery, - variables: { - id: workspaceId, - }, - context: { - signal, - }, - }); - return data.workspace.quota; - } } diff --git a/packages/frontend/core/src/modules/share-doc/entities/share-info.ts b/packages/frontend/core/src/modules/share-doc/entities/share-info.ts index 1fbbd8ccda..a9220489c3 100644 --- a/packages/frontend/core/src/modules/share-doc/entities/share-info.ts +++ b/packages/frontend/core/src/modules/share-doc/entities/share-info.ts @@ -1,22 +1,19 @@ -import type { GetWorkspacePageByIdQuery, PublicDocMode } from '@affine/graphql'; -import { - catchErrorInto, - effect, - Entity, - fromPromise, - LiveData, - mapInto, - onComplete, - onStart, - smartRetry, -} from '@toeverything/infra'; -import { switchMap } from 'rxjs'; +import type { DocRole, PublicDocMode } from '@affine/graphql'; +import type { + DocShareStateSnapshot, + RealtimeTopicEventOf, +} from '@affine/realtime'; +import { Entity, LiveData } from '@toeverything/infra'; +import { RealtimeLiveQuery } from '../../cloud/realtime/live-query'; import type { DocService } from '../../doc'; import type { WorkspaceService } from '../../workspace'; import type { ShareStore } from '../stores/share'; -type ShareInfoType = GetWorkspacePageByIdQuery['workspace']['doc']; +type ShareInfoType = Omit & { + defaultRole: DocRole; + mode: PublicDocMode; +}; export class ShareInfo extends Entity { info$ = new LiveData(null); @@ -32,25 +29,30 @@ export class ShareInfo extends Entity { private readonly store: ShareStore ) { super(); + this.liveQuery.start(); } - revalidate = effect( - switchMap(() => { - return fromPromise(signal => - this.store.getShareInfoByDocId( - this.workspaceService.workspace.id, - this.docService.doc.id, - signal - ) - ).pipe( - smartRetry(), - mapInto(this.info$), - catchErrorInto(this.error$), - onStart(() => this.isRevalidating$.next(true)), - onComplete(() => this.isRevalidating$.next(false)) - ); - }) - ); + private readonly liveQuery = new RealtimeLiveQuery< + ShareInfoType | undefined, + RealtimeTopicEventOf<'doc.share-state.changed'> + >({ + request: signal => this.requestShareInfo(signal), + subscribe: () => + this.store.subscribeShareState( + this.workspaceService.workspace.id, + this.docService.doc.id + ), + applySnapshot: info => { + this.error$.next(null); + this.info$.next(info); + }, + applyEvent: () => 'revalidate', + onError: error => this.error$.setValue(error), + }); + + revalidate = () => { + this.liveQuery.revalidate(); + }; waitForRevalidation(signal?: AbortSignal) { this.revalidate(); @@ -77,4 +79,21 @@ export class ShareInfo extends Entity { ); await this.waitForRevalidation(); } + + override dispose(): void { + this.liveQuery.dispose(); + } + + private async requestShareInfo(signal: AbortSignal) { + this.isRevalidating$.next(true); + try { + return await this.store.getShareInfoByDocId( + this.workspaceService.workspace.id, + this.docService.doc.id, + signal + ); + } finally { + this.isRevalidating$.next(false); + } + } } diff --git a/packages/frontend/core/src/modules/share-doc/index.ts b/packages/frontend/core/src/modules/share-doc/index.ts index 772b11b6cb..da1037b0c8 100644 --- a/packages/frontend/core/src/modules/share-doc/index.ts +++ b/packages/frontend/core/src/modules/share-doc/index.ts @@ -5,6 +5,7 @@ import { type Framework } from '@toeverything/infra'; import { WorkspaceServerService } from '../cloud'; import { DocScope, DocService } from '../doc'; +import { NbstoreService } from '../storage'; import { WorkspaceLocalCache, WorkspaceScope, @@ -30,5 +31,5 @@ export function configureShareDocsModule(framework: Framework) { .scope(DocScope) .service(ShareInfoService) .entity(ShareInfo, [WorkspaceService, DocService, ShareStore]) - .store(ShareStore, [WorkspaceServerService]); + .store(ShareStore, [WorkspaceServerService, NbstoreService]); } diff --git a/packages/frontend/core/src/modules/share-doc/stores/share.ts b/packages/frontend/core/src/modules/share-doc/stores/share.ts index 68cb66ba35..bc97e2618b 100644 --- a/packages/frontend/core/src/modules/share-doc/stores/share.ts +++ b/packages/frontend/core/src/modules/share-doc/stores/share.ts @@ -1,15 +1,20 @@ -import type { PublicDocMode } from '@affine/graphql'; import { - getWorkspacePageByIdQuery, + DocRole, + PublicDocMode, publishPageMutation, revokePublicPageMutation, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; import type { WorkspaceServerService } from '../../cloud'; +import { mapRealtimeEnum } from '../../cloud/realtime/enum'; +import type { NbstoreService } from '../../storage'; export class ShareStore extends Store { - constructor(private readonly workspaceServerService: WorkspaceServerService) { + constructor( + private readonly workspaceServerService: WorkspaceServerService, + private readonly nbstoreService: NbstoreService + ) { super(); } @@ -18,20 +23,28 @@ export class ShareStore extends Store { docId: string, signal?: AbortSignal ) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getWorkspacePageByIdQuery, - variables: { - pageId: docId, - workspaceId, - }, - context: { - signal, - }, + const { state } = await this.nbstoreService.realtime.request( + 'doc.share-state.get', + { workspaceId, docId }, + { signal, timeoutMs: 10000 } + ); + return state + ? { + id: docId, + ...state, + mode: mapRealtimeEnum(PublicDocMode, state.mode, 'public doc mode'), + defaultRole: mapRealtimeEnum(DocRole, state.defaultRole, 'doc role'), + title: null, + summary: null, + } + : undefined; + } + + subscribeShareState(workspaceId: string, docId: string) { + return this.nbstoreService.realtime.subscribe('doc.share-state.changed', { + workspaceId, + docId, }); - return data.workspace.doc ?? undefined; } async enableSharePage( diff --git a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts index e94b9ba185..0445329334 100644 --- a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts +++ b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts @@ -1,90 +1,91 @@ import { DebugLogger } from '@affine/debug'; -import type { GetWorkspaceConfigQuery, InviteLink } from '@affine/graphql'; -import { - catchErrorInto, - effect, - Entity, - fromPromise, - LiveData, - onComplete, - onStart, - smartRetry, -} from '@toeverything/infra'; -import { exhaustMap, tap } from 'rxjs'; +import type { + RealtimeTopicEventOf, + WorkspaceConfigSnapshot, + WorkspaceInviteLinkSnapshot, +} from '@affine/realtime'; +import { Entity, LiveData } from '@toeverything/infra'; +import { RealtimeLiveQuery } from '../../cloud/realtime/live-query'; import type { WorkspaceService } from '../../workspace'; import type { WorkspaceShareSettingStore } from '../stores/share-setting'; -type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi']; -type EnableSharing = GetWorkspaceConfigQuery['workspace']['enableSharing']; -type EnableUrlPreview = - GetWorkspaceConfigQuery['workspace']['enableUrlPreview']; - const logger = new DebugLogger('affine:workspace-permission'); +type InviteLink = WorkspaceInviteLinkSnapshot; export class WorkspaceShareSetting extends Entity { - enableAi$ = new LiveData(null); - enableSharing$ = new LiveData(null); - enableUrlPreview$ = new LiveData(null); + enableAi$ = new LiveData(null); + enableSharing$ = new LiveData(null); + enableUrlPreview$ = new LiveData(null); inviteLink$ = new LiveData(null); isLoading$ = new LiveData(false); error$ = new LiveData(null); + private inviteLinkStarted = false; + private inviteLinkExpireTimer?: ReturnType; + private readonly configLiveQuery = new RealtimeLiveQuery< + WorkspaceConfigSnapshot, + RealtimeTopicEventOf<'workspace.config.changed'> + >({ + request: signal => this.requestWorkspaceConfig(signal), + subscribe: () => + this.store.subscribeWorkspaceConfig(this.workspaceService.workspace.id), + applySnapshot: value => { + this.error$.next(null); + this.enableAi$.next(value.enableAi); + this.enableSharing$.next(value.enableSharing); + this.enableUrlPreview$.next(value.enableUrlPreview); + }, + applyEvent: () => 'revalidate', + onError: error => { + logger.error('Failed to fetch workspace share settings', error); + this.error$.setValue(error); + }, + }); + private readonly inviteLinkLiveQuery = new RealtimeLiveQuery< + InviteLink | null, + RealtimeTopicEventOf<'workspace.invite-link.changed'> + >({ + request: signal => + this.store.fetchInviteLink(this.workspaceService.workspace.id, signal), + subscribe: () => + this.store.subscribeInviteLink(this.workspaceService.workspace.id), + applySnapshot: value => { + this.error$.next(null); + this.inviteLink$.next(value); + this.scheduleInviteLinkExpiry(value); + }, + applyEvent: () => 'revalidate', + onError: error => { + logger.error('Failed to fetch workspace invite link', error); + this.error$.setValue(error); + this.inviteLinkLiveQuery.stop(); + this.inviteLinkStarted = false; + }, + }); constructor( private readonly workspaceService: WorkspaceService, private readonly store: WorkspaceShareSettingStore ) { super(); - this.revalidate(); + this.configLiveQuery.start(); } - revalidate = effect( - exhaustMap(() => { - return fromPromise(signal => - this.store.fetchWorkspaceConfig( - this.workspaceService.workspace.id, - signal - ) - ).pipe( - smartRetry(), - tap(value => { - if (value) { - this.enableAi$.next(value.enableAi); - this.enableSharing$.next(value.enableSharing); - this.enableUrlPreview$.next(value.enableUrlPreview); - } - }), - catchErrorInto(this.error$, error => { - logger.error('Failed to fetch workspace share settings', error); - }), - onStart(() => this.isLoading$.setValue(true)), - onComplete(() => this.isLoading$.setValue(false)) - ); - }) - ); + revalidate = () => { + this.configLiveQuery.revalidate(); + }; - revalidateInviteLink = effect( - exhaustMap(() => { - return fromPromise(signal => - this.store.fetchInviteLink(this.workspaceService.workspace.id, signal) - ).pipe( - smartRetry(), - tap(value => { - this.inviteLink$.next(value); - }), - catchErrorInto(this.error$, error => { - logger.error('Failed to fetch workspace invite link', error); - }) - ); - }) - ); + revalidateInviteLink = () => { + this.ensureInviteLinkStarted(); + this.inviteLinkLiveQuery.revalidate(); + }; async waitForRevalidation(signal?: AbortSignal) { this.revalidate(); await this.isLoading$.waitFor(isLoading => !isLoading, signal); } - async setEnableUrlPreview(enableUrlPreview: EnableUrlPreview) { + async setEnableUrlPreview(enableUrlPreview: boolean) { await this.store.updateWorkspaceEnableUrlPreview( this.workspaceService.workspace.id, enableUrlPreview @@ -92,7 +93,7 @@ export class WorkspaceShareSetting extends Entity { await this.waitForRevalidation(); } - async setEnableSharing(enableSharing: EnableSharing) { + async setEnableSharing(enableSharing: boolean) { await this.store.updateWorkspaceEnableSharing( this.workspaceService.workspace.id, enableSharing @@ -100,7 +101,7 @@ export class WorkspaceShareSetting extends Entity { await this.waitForRevalidation(); } - async setEnableAi(enableAi: EnableAi) { + async setEnableAi(enableAi: boolean) { await this.store.updateWorkspaceEnableAi( this.workspaceService.workspace.id, enableAi @@ -109,7 +110,55 @@ export class WorkspaceShareSetting extends Entity { } override dispose(): void { - this.revalidate.unsubscribe(); - this.revalidateInviteLink.unsubscribe(); + this.configLiveQuery.dispose(); + this.inviteLinkLiveQuery.dispose(); + this.clearInviteLinkExpireTimer(); + } + + private ensureInviteLinkStarted() { + if (this.inviteLinkStarted) { + return; + } + this.inviteLinkStarted = true; + this.inviteLinkLiveQuery.start(); + } + + private scheduleInviteLinkExpiry(inviteLink: InviteLink | null) { + this.clearInviteLinkExpireTimer(); + if (!inviteLink) { + return; + } + const expireAt = new Date(inviteLink.expireTime).getTime(); + if (!Number.isFinite(expireAt)) { + return; + } + const delay = expireAt - Date.now(); + if (delay <= 0) { + this.inviteLink$.next(null); + return; + } + this.inviteLinkExpireTimer = setTimeout(() => { + this.inviteLink$.next(null); + }, delay); + this.inviteLinkExpireTimer.unref?.(); + } + + private clearInviteLinkExpireTimer() { + if (this.inviteLinkExpireTimer) { + clearTimeout(this.inviteLinkExpireTimer); + this.inviteLinkExpireTimer = undefined; + } + } + + private async requestWorkspaceConfig(signal: AbortSignal) { + this.isLoading$.setValue(true); + try { + return await this.store.fetchWorkspaceConfig( + this.workspaceService.workspace.id, + signal + ); + } finally { + this.isLoading$.setValue(false); + } } } diff --git a/packages/frontend/core/src/modules/share-setting/index.ts b/packages/frontend/core/src/modules/share-setting/index.ts index 7297c8db90..acde9f6300 100644 --- a/packages/frontend/core/src/modules/share-setting/index.ts +++ b/packages/frontend/core/src/modules/share-setting/index.ts @@ -3,6 +3,7 @@ export { WorkspaceShareSettingService } from './services/share-setting'; import { type Framework } from '@toeverything/infra'; import { WorkspaceServerService } from '../cloud'; +import { NbstoreService } from '../storage'; import { WorkspaceScope, WorkspaceService } from '../workspace'; import { WorkspaceShareSetting } from './entities/share-setting'; import { WorkspaceShareSettingService } from './services/share-setting'; @@ -12,7 +13,7 @@ export function configureShareSettingModule(framework: Framework) { framework .scope(WorkspaceScope) .service(WorkspaceShareSettingService) - .store(WorkspaceShareSettingStore, [WorkspaceServerService]) + .store(WorkspaceShareSettingStore, [WorkspaceServerService, NbstoreService]) .entity(WorkspaceShareSetting, [ WorkspaceService, WorkspaceShareSettingStore, diff --git a/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts b/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts index 70270023d5..ae3bf6ce7e 100644 --- a/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts +++ b/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts @@ -1,48 +1,50 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { - getWorkspaceConfigQuery, - getWorkspaceInviteLinkQuery, setEnableAiMutation, setEnableSharingMutation, setEnableUrlPreviewMutation, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; +import type { NbstoreService } from '../../storage'; + export class WorkspaceShareSettingStore extends Store { - constructor(private readonly workspaceServerService: WorkspaceServerService) { + constructor( + private readonly workspaceServerService: WorkspaceServerService, + private readonly nbstoreService: NbstoreService + ) { super(); } async fetchWorkspaceConfig(workspaceId: string, signal?: AbortSignal) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getWorkspaceConfigQuery, - variables: { - id: workspaceId, - }, - context: { - signal, - }, + const { config } = await this.nbstoreService.realtime.request( + 'workspace.config.get', + { workspaceId }, + { signal, timeoutMs: 10000 } + ); + return config; + } + + subscribeWorkspaceConfig(workspaceId: string) { + return this.nbstoreService.realtime.subscribe('workspace.config.changed', { + workspaceId, }); - return data.workspace; } async fetchInviteLink(workspaceId: string, signal?: AbortSignal) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getWorkspaceInviteLinkQuery, - variables: { - id: workspaceId, - }, - context: { - signal, - }, - }); - return data.workspace.inviteLink; + const { inviteLink } = await this.nbstoreService.realtime.request( + 'workspace.invite-link.get', + { workspaceId }, + { signal, timeoutMs: 10000 } + ); + return inviteLink; + } + + subscribeInviteLink(workspaceId: string) { + return this.nbstoreService.realtime.subscribe( + 'workspace.invite-link.changed', + { workspaceId } + ); } async updateWorkspaceEnableAi( diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts index 1abe526c89..63bde8245d 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -3,7 +3,6 @@ import { DebugLogger } from '@affine/debug'; import { createWorkspaceMutation, deleteWorkspaceMutation, - getWorkspaceInfoQuery, getWorkspacesQuery, ServerDeploymentType, ServerFeature, @@ -67,7 +66,7 @@ import { GraphQLService, WorkspaceServerService, } from '../../cloud'; -import type { GlobalState } from '../../storage'; +import { type GlobalState, NbstoreService } from '../../storage'; import type { Workspace, WorkspaceFlavourProvider, @@ -90,6 +89,7 @@ const logger = new DebugLogger('affine:cloud-workspace-flavour-provider'); class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { private readonly authService: AuthService; private readonly graphqlService: GraphQLService; + private readonly nbstoreService: NbstoreService; private readonly unsubscribeAccountChanged: () => void; constructor( @@ -98,6 +98,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ) { this.authService = server.scope.get(AuthService); this.graphqlService = server.scope.get(GraphQLService); + this.nbstoreService = server.scope.get(NbstoreService); this.unsubscribeAccountChanged = this.server.scope.eventBus.on( AccountChanged, () => { @@ -444,13 +445,12 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { } private async getWorkspaceInfo(workspaceId: string, signal?: AbortSignal) { - return await this.graphqlService.gql({ - query: getWorkspaceInfoQuery, - variables: { - workspaceId, - }, - context: { signal }, - }); + const { access } = await this.nbstoreService.realtime.request( + 'workspace.access.get', + { workspaceId }, + { signal, timeoutMs: 10000 } + ); + return { workspace: access }; } getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions { diff --git a/packages/frontend/core/src/modules/workspace-indexer-embedding/stores/embedding.ts b/packages/frontend/core/src/modules/workspace-indexer-embedding/stores/embedding.ts index b6a9f70b91..c5250a6a1d 100644 --- a/packages/frontend/core/src/modules/workspace-indexer-embedding/stores/embedding.ts +++ b/packages/frontend/core/src/modules/workspace-indexer-embedding/stores/embedding.ts @@ -4,7 +4,6 @@ import { addWorkspaceEmbeddingFilesMutation, addWorkspaceEmbeddingIgnoredDocsMutation, getAllWorkspaceEmbeddingIgnoredDocsQuery, - getWorkspaceConfigQuery, getWorkspaceEmbeddingFilesQuery, type PaginationInput, removeWorkspaceEmbeddingFilesMutation, @@ -22,19 +21,12 @@ export class EmbeddingStore extends Store { } async getEnabled(workspaceId: string, signal?: AbortSignal) { - if (!this.workspaceServerService.server) { - throw new Error('No Server'); - } - const data = await this.workspaceServerService.server.gql({ - query: getWorkspaceConfigQuery, - variables: { - id: workspaceId, - }, - context: { - signal, - }, - }); - return data.workspace.enableDocEmbedding; + const { config } = await this.nbstoreService.realtime.request( + 'workspace.config.get', + { workspaceId }, + { signal, timeoutMs: 10000 } + ); + return config.enableDocEmbedding; } async updateEnabled(