mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
feat(server): entitlement based model (#14996)
#### PR Dependency Tree * **PR #14996** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin mutations to grant/revoke commercial entitlements. * New Doc comment-update permission. * Realtime user/workspace quota-state endpoints and live-update rooms. * **Bug Fixes** * More accurate readable-doc filtering and permission evaluation. * **Refactor** * Workspace feature management moved to entitlement-based model; permission and quota pipelines redesigned. * Admin workspace UI now edits flags only (feature toggles removed). * **Tests** * Extensive new and updated tests for permissions, entitlements, quota, projection, and backfills. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
|
||||
import type { UserQuotaStateSnapshot } from '@affine/realtime';
|
||||
import { Framework } from '@toeverything/infra';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthService } from '../services/auth';
|
||||
import { UserQuotaStore } from '../stores/user-quota';
|
||||
import { UserQuota } from './user-quota';
|
||||
|
||||
type Quota = NonNullable<GetCurrentUserProfileQuery['currentUser']>['quota'];
|
||||
|
||||
const authService = {
|
||||
session: {
|
||||
['account$']: {
|
||||
value: { id: 'user-1' },
|
||||
},
|
||||
},
|
||||
} as unknown as AuthService;
|
||||
|
||||
function createQuotaState(
|
||||
overrides: Partial<UserQuotaStateSnapshot> = {}
|
||||
): UserQuotaStateSnapshot {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
plan: 'pro',
|
||||
sourceEntitlementId: 'entitlement-1',
|
||||
blobLimit: 1024,
|
||||
storageQuota: 2048,
|
||||
usedStorageQuota: 512,
|
||||
historyPeriodSeconds: 30 * 24 * 60 * 60,
|
||||
copilotActionLimit: null,
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createQuota(overrides: Partial<Quota> = {}): 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<UserQuotaStore>,
|
||||
eventSubject = new Subject<{ type: 'ready' } | { changed: true }>()
|
||||
) {
|
||||
return {
|
||||
fetchUserQuotaState: vi.fn(),
|
||||
subscribeUserQuotaState: vi.fn(() => eventSubject),
|
||||
fetchUserQuota: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as UserQuotaStore;
|
||||
}
|
||||
|
||||
function createEntity(store: UserQuotaStore) {
|
||||
const framework = new Framework();
|
||||
framework
|
||||
.service(AuthService, authService)
|
||||
.store(UserQuotaStore, store)
|
||||
.entity(UserQuota, [AuthService, UserQuotaStore]);
|
||||
|
||||
return framework.provider().createEntity(UserQuota);
|
||||
}
|
||||
|
||||
describe('UserQuota', () => {
|
||||
test('uses realtime quota state snapshots and refreshes on quota events', async () => {
|
||||
const events$ = new Subject<{ type: 'ready' } | { changed: true }>();
|
||||
const store = createStore(
|
||||
{
|
||||
fetchUserQuotaState: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createQuotaState({ usedStorageQuota: 512 }))
|
||||
.mockResolvedValueOnce(createQuotaState({ usedStorageQuota: 768 })),
|
||||
},
|
||||
events$
|
||||
);
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
await vi.waitFor(() => expect(quota.used$.value).toBe(512));
|
||||
expect(quota.quota$.value?.humanReadable.historyPeriod).toBe('30 days');
|
||||
|
||||
events$.next({ changed: true });
|
||||
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 () => {
|
||||
const store = createStore({
|
||||
fetchUserQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
|
||||
fetchUserQuota: vi.fn().mockResolvedValue({
|
||||
quota: createQuota(),
|
||||
used: 256,
|
||||
}),
|
||||
});
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
|
||||
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
|
||||
expect(quota.used$.value).toBe(256);
|
||||
quota.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,74 @@
|
||||
import type { QuotaQuery } from '@affine/graphql';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
|
||||
import type {
|
||||
RealtimeTopicEventOf,
|
||||
UserQuotaStateSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import bytes from 'bytes';
|
||||
import { map, tap } from 'rxjs';
|
||||
|
||||
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'];
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function formatSize(size: number) {
|
||||
return size === 0 ? '0 B' : (bytes.format(size) ?? '0 B');
|
||||
}
|
||||
|
||||
function formatHistoryPeriod(value: number) {
|
||||
return `${(value / DAY_SECONDS).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
function userMemberLimit(plan: string) {
|
||||
return plan === 'pro' || plan === 'lifetime_pro' || plan === 'selfhost_free'
|
||||
? 10
|
||||
: 3;
|
||||
}
|
||||
|
||||
function planName(plan: string) {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
case 'selfhost_free':
|
||||
return 'Pro';
|
||||
case 'lifetime_pro':
|
||||
return 'Lifetime Pro';
|
||||
case 'ai':
|
||||
return 'AI';
|
||||
default:
|
||||
return 'Free';
|
||||
}
|
||||
}
|
||||
|
||||
function userQuotaFromState(state: UserQuotaStateSnapshot): QuotaType {
|
||||
const name = planName(state.plan);
|
||||
const memberLimit = userMemberLimit(state.plan);
|
||||
return {
|
||||
name,
|
||||
blobLimit: state.blobLimit,
|
||||
storageQuota: state.storageQuota,
|
||||
historyPeriod: state.historyPeriodSeconds,
|
||||
memberLimit,
|
||||
humanReadable: {
|
||||
name,
|
||||
blobLimit: formatSize(state.blobLimit),
|
||||
storageQuota: formatSize(state.storageQuota),
|
||||
historyPeriod: formatHistoryPeriod(state.historyPeriodSeconds),
|
||||
memberLimit: memberLimit.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class UserQuota extends Entity {
|
||||
quota$ = new LiveData<NonNullable<QuotaQuery['currentUser']>['quota'] | null>(
|
||||
null
|
||||
);
|
||||
quota$ = new LiveData<QuotaType | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
/** Used storage in bytes */
|
||||
used$ = new LiveData<number | null>(null);
|
||||
/** Formatted used storage */
|
||||
@@ -53,8 +101,17 @@ export class UserQuota extends Entity {
|
||||
: null
|
||||
);
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
private started = false;
|
||||
private readonly liveQuery = new RealtimeLiveQuery<
|
||||
{ quota: QuotaType; used: number },
|
||||
RealtimeTopicEventOf<'user.quota-state.changed'>
|
||||
>({
|
||||
request: signal => this.requestQuota(signal),
|
||||
subscribe: () => this.store.subscribeUserQuotaState(),
|
||||
applySnapshot: data => this.applyQuota(data.quota, data.used),
|
||||
applyEvent: () => 'revalidate',
|
||||
onError: error => this.error$.next(error),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
@@ -63,42 +120,19 @@ export class UserQuota extends Entity {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) =>
|
||||
fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return; // no quota if no user
|
||||
}
|
||||
const { quota, used } = await this.store.fetchUserQuota(signal);
|
||||
|
||||
return { quota, used };
|
||||
}).pipe(
|
||||
smartRetry(),
|
||||
tap(data => {
|
||||
if (data) {
|
||||
const { quota, used } = data;
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
} else {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
}
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
),
|
||||
() => {
|
||||
// Reset the state when the user is changed
|
||||
this.reset();
|
||||
}
|
||||
)
|
||||
);
|
||||
revalidate = () => {
|
||||
if (!this.authService.session.account$.value?.id) {
|
||||
this.liveQuery.stop();
|
||||
this.started = false;
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
this.liveQuery.start();
|
||||
}
|
||||
this.liveQuery.revalidate();
|
||||
};
|
||||
|
||||
reset() {
|
||||
this.quota$.next(null);
|
||||
@@ -108,6 +142,28 @@ export class UserQuota extends Entity {
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
this.liveQuery.dispose();
|
||||
}
|
||||
|
||||
private applyQuota(quota: QuotaType | null, used: number | null) {
|
||||
this.error$.next(null);
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
}
|
||||
|
||||
private async requestQuota(signal: AbortSignal) {
|
||||
this.isRevalidating$.setValue(true);
|
||||
try {
|
||||
const state = await this.store.fetchUserQuotaState(signal);
|
||||
return {
|
||||
quota: userQuotaFromState(state),
|
||||
used: state.usedStorageQuota,
|
||||
};
|
||||
} catch {
|
||||
const { quota, used } = await this.store.fetchUserQuota(signal);
|
||||
return { quota, used };
|
||||
} finally {
|
||||
this.isRevalidating$.setValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export function configureCloudModule(framework: Framework) {
|
||||
.entity(Subscription, [AuthService, ServerService, SubscriptionStore])
|
||||
.entity(SubscriptionPrices, [ServerService, SubscriptionStore])
|
||||
.service(UserQuotaService)
|
||||
.store(UserQuotaStore, [GraphQLService])
|
||||
.store(UserQuotaStore, [GraphQLService, NbstoreService])
|
||||
.entity(UserQuota, [AuthService, UserQuotaStore])
|
||||
.service(UserCopilotQuotaService)
|
||||
.store(UserCopilotQuotaStore, [GraphQLService])
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
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) {
|
||||
constructor(
|
||||
private readonly graphqlService: GraphQLService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchUserQuotaState(abortSignal?: AbortSignal) {
|
||||
const response = await this.nbstoreService.realtime.request(
|
||||
'user.quota-state.get',
|
||||
{},
|
||||
{ signal: abortSignal, timeoutMs: 10000 }
|
||||
);
|
||||
return response.state;
|
||||
}
|
||||
|
||||
subscribeUserQuotaState() {
|
||||
return this.nbstoreService.realtime.subscribe(
|
||||
'user.quota-state.changed',
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async fetchUserQuota(abortSignal?: AbortSignal) {
|
||||
const data = await this.graphqlService.gql({
|
||||
query: getCurrentUserProfileQuery,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Permission } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
@@ -46,9 +45,11 @@ export class WorkspacePermission extends Entity {
|
||||
signal
|
||||
);
|
||||
|
||||
const isOwner = info.workspace.permissions.Workspace_Delete;
|
||||
return {
|
||||
isOwner: info.workspace.role === Permission.Owner,
|
||||
isAdmin: info.workspace.role === Permission.Admin,
|
||||
isOwner,
|
||||
isAdmin:
|
||||
!isOwner && info.workspace.permissions.Workspace_Settings_Update,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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';
|
||||
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;
|
||||
|
||||
function createQuotaState(
|
||||
overrides: Partial<WorkspaceQuotaStateSnapshot> = {}
|
||||
): WorkspaceQuotaStateSnapshot {
|
||||
return {
|
||||
workspaceId: 'workspace-1',
|
||||
plan: 'Team',
|
||||
sourceEntitlementId: 'entitlement-1',
|
||||
ownerUserId: 'user-1',
|
||||
usesOwnerQuota: false,
|
||||
seatLimit: 10,
|
||||
memberCount: 3,
|
||||
overcapacityMemberCount: 0,
|
||||
blobLimit: 1024,
|
||||
storageQuota: 2048,
|
||||
usedStorageQuota: 512,
|
||||
historyPeriodSeconds: 30 * 24 * 60 * 60,
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createQuota(overrides: Partial<Quota> = {}): 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<WorkspaceQuotaStore>,
|
||||
eventSubject = new Subject<{ type: 'ready' } | { changed: true }>()
|
||||
) {
|
||||
return {
|
||||
fetchWorkspaceQuotaState: vi.fn(),
|
||||
subscribeWorkspaceQuotaState: vi.fn(() => eventSubject),
|
||||
fetchWorkspaceQuota: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as WorkspaceQuotaStore;
|
||||
}
|
||||
|
||||
function createEntity(store: WorkspaceQuotaStore) {
|
||||
const framework = new Framework();
|
||||
framework
|
||||
.service(WorkspaceService, workspaceService)
|
||||
.store(WorkspaceQuotaStore, store)
|
||||
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
|
||||
|
||||
return framework.provider().createEntity(WorkspaceQuota);
|
||||
}
|
||||
|
||||
describe('WorkspaceQuota', () => {
|
||||
test('uses realtime quota state snapshots and refreshes on quota events', async () => {
|
||||
const events$ = new Subject<{ type: 'ready' } | { changed: true }>();
|
||||
const store = createStore(
|
||||
{
|
||||
fetchWorkspaceQuotaState: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createQuotaState({ memberCount: 3 }))
|
||||
.mockResolvedValueOnce(createQuotaState({ memberCount: 4 })),
|
||||
},
|
||||
events$
|
||||
);
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
await vi.waitFor(() => expect(quota.quota$.value?.memberCount).toBe(3));
|
||||
expect(quota.quota$.value?.humanReadable.historyPeriod).toBe('30 days');
|
||||
|
||||
events$.next({ changed: true });
|
||||
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 () => {
|
||||
const store = createStore({
|
||||
fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
|
||||
fetchWorkspaceQuota: vi.fn().mockResolvedValue(createQuota()),
|
||||
});
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
|
||||
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
|
||||
expect(quota.error$.value).toBeNull();
|
||||
quota.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,90 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { WorkspaceQuotaQuery } from '@affine/graphql';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import type {
|
||||
RealtimeTopicEventOf,
|
||||
WorkspaceQuotaStateSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import bytes from 'bytes';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { WorkspaceQuotaStore } from '../stores/quota';
|
||||
|
||||
type QuotaType = WorkspaceQuotaQuery['workspace']['quota'];
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function formatSize(size: number) {
|
||||
return size === 0 ? '0 B' : (bytes.format(size) ?? '0 B');
|
||||
}
|
||||
|
||||
function formatHistoryPeriod(value: number) {
|
||||
return `${(value / DAY_SECONDS).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
function planName(plan: string) {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
case 'selfhost_free':
|
||||
return 'Pro';
|
||||
case 'lifetime_pro':
|
||||
return 'Lifetime Pro';
|
||||
case 'ai':
|
||||
return 'AI';
|
||||
case 'team':
|
||||
case 'selfhost_team':
|
||||
return 'Team';
|
||||
default:
|
||||
return 'Free';
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceQuotaFromState(
|
||||
state: WorkspaceQuotaStateSnapshot
|
||||
): QuotaType {
|
||||
const name = planName(state.plan);
|
||||
return {
|
||||
name,
|
||||
blobLimit: state.blobLimit,
|
||||
storageQuota: state.storageQuota,
|
||||
usedStorageQuota: state.usedStorageQuota,
|
||||
historyPeriod: state.historyPeriodSeconds,
|
||||
memberLimit: state.seatLimit,
|
||||
memberCount: state.memberCount,
|
||||
overcapacityMemberCount: state.overcapacityMemberCount,
|
||||
humanReadable: {
|
||||
name,
|
||||
blobLimit: formatSize(state.blobLimit),
|
||||
storageQuota: formatSize(state.storageQuota),
|
||||
historyPeriod: formatHistoryPeriod(state.historyPeriodSeconds),
|
||||
memberLimit: state.seatLimit.toString(),
|
||||
memberCount: state.memberCount.toString(),
|
||||
overcapacityMemberCount: state.overcapacityMemberCount.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkspaceQuota extends Entity {
|
||||
quota$ = new LiveData<QuotaType | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
private started = false;
|
||||
private readonly liveQuery = new RealtimeLiveQuery<
|
||||
QuotaType,
|
||||
RealtimeTopicEventOf<'workspace.quota-state.changed'>
|
||||
>({
|
||||
request: signal => this.requestQuota(signal),
|
||||
subscribe: () =>
|
||||
this.store.subscribeWorkspaceQuotaState(
|
||||
this.workspaceService.workspace.id
|
||||
),
|
||||
applySnapshot: quota => this.applyQuota(quota),
|
||||
applyEvent: () => 'revalidate',
|
||||
onError: error => this.handleError(error),
|
||||
});
|
||||
|
||||
/** Used storage in bytes */
|
||||
used$ = new LiveData<number | null>(null);
|
||||
@@ -66,34 +125,13 @@ export class WorkspaceQuota extends Entity {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMapWithTrailing(() => {
|
||||
return fromPromise(async signal => {
|
||||
const data = await this.store.fetchWorkspaceQuota(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
return { quota: data, used: data.usedStorageQuota };
|
||||
}).pipe(
|
||||
smartRetry(),
|
||||
tap(data => {
|
||||
if (data) {
|
||||
const { quota, used } = data;
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
} else {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
}
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch workspace quota', error);
|
||||
}),
|
||||
onStart(() => this.isRevalidating$.setValue(true)),
|
||||
onComplete(() => this.isRevalidating$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
revalidate = () => {
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
this.liveQuery.start();
|
||||
}
|
||||
this.liveQuery.revalidate();
|
||||
};
|
||||
|
||||
waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
@@ -111,6 +149,36 @@ export class WorkspaceQuota extends Entity {
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
this.liveQuery.dispose();
|
||||
}
|
||||
|
||||
private applyQuota(quota: QuotaType | null) {
|
||||
this.error$.next(null);
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(quota?.usedStorageQuota ?? null);
|
||||
}
|
||||
|
||||
private handleError(error: unknown) {
|
||||
logger.error('Failed to fetch workspace quota', error);
|
||||
this.error$.next(error);
|
||||
}
|
||||
|
||||
private async requestQuota(signal: AbortSignal) {
|
||||
this.isRevalidating$.setValue(true);
|
||||
try {
|
||||
return workspaceQuotaFromState(
|
||||
await this.store.fetchWorkspaceQuotaState(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return await this.store.fetchWorkspaceQuota(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
this.isRevalidating$.setValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { WorkspaceQuotaService } from './services/quota';
|
||||
@@ -13,6 +14,6 @@ export function configureQuotaModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceQuotaService)
|
||||
.store(WorkspaceQuotaStore, [WorkspaceServerService])
|
||||
.store(WorkspaceQuotaStore, [WorkspaceServerService, NbstoreService])
|
||||
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,32 @@ 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) {
|
||||
constructor(
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchWorkspaceQuotaState(workspaceId: string, signal?: AbortSignal) {
|
||||
const response = await this.nbstoreService.realtime.request(
|
||||
'workspace.quota-state.get',
|
||||
{ workspaceId },
|
||||
{ signal, timeoutMs: 10000 }
|
||||
);
|
||||
return response.state;
|
||||
}
|
||||
|
||||
subscribeWorkspaceQuotaState(workspaceId: string) {
|
||||
return this.nbstoreService.realtime.subscribe(
|
||||
'workspace.quota-state.changed',
|
||||
{ workspaceId }
|
||||
);
|
||||
}
|
||||
|
||||
async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
deleteWorkspaceMutation,
|
||||
getWorkspaceInfoQuery,
|
||||
getWorkspacesQuery,
|
||||
Permission,
|
||||
ServerDeploymentType,
|
||||
ServerFeature,
|
||||
} from '@affine/graphql';
|
||||
@@ -355,13 +354,12 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
|
||||
const info = await this.getWorkspaceInfo(id, signal);
|
||||
|
||||
const isOwner = info.workspace.permissions.Workspace_Delete;
|
||||
const isAdmin =
|
||||
!isOwner && info.workspace.permissions.Workspace_Settings_Update;
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return {
|
||||
isOwner: info.workspace.role === Permission.Owner,
|
||||
isAdmin: info.workspace.role === Permission.Admin,
|
||||
isTeam: info.workspace.team,
|
||||
isEmpty,
|
||||
};
|
||||
return { isOwner, isAdmin, isTeam: info.workspace.team, isEmpty };
|
||||
}
|
||||
|
||||
const client = getWorkspaceProfileWorker();
|
||||
@@ -374,8 +372,8 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
return {
|
||||
name: result.name,
|
||||
avatar: result.avatar,
|
||||
isOwner: info.workspace.role === Permission.Owner,
|
||||
isAdmin: info.workspace.role === Permission.Admin,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
isEmpty,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user