feat(infra): framework
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
OauthProvidersQuery,
|
||||
ServerConfigQuery,
|
||||
ServerFeature,
|
||||
} from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import type { ServerConfigStore } from '../stores/server-config';
|
||||
|
||||
type LowercaseServerFeature = Lowercase<ServerFeature>;
|
||||
type ServerFeatureRecord = {
|
||||
[key in LowercaseServerFeature]: boolean;
|
||||
};
|
||||
|
||||
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
|
||||
OauthProvidersQuery['serverConfig'];
|
||||
|
||||
export class ServerConfig extends Entity {
|
||||
readonly config$ = new LiveData<ServerConfigType | null>(null);
|
||||
|
||||
readonly features$ = this.config$.map(config => {
|
||||
return config
|
||||
? Array.from(new Set(config.features)).reduce((acc, cur) => {
|
||||
acc[cur.toLowerCase() as LowercaseServerFeature] = true;
|
||||
return acc;
|
||||
}, {} as ServerFeatureRecord)
|
||||
: null;
|
||||
});
|
||||
|
||||
readonly credentialsRequirement$ = this.config$.map(config => {
|
||||
return config ? config.credentialsRequirement : null;
|
||||
});
|
||||
|
||||
constructor(private readonly store: ServerConfigStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise<ServerConfigType>(signal =>
|
||||
this.store.fetchServerConfig(signal)
|
||||
).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(config => {
|
||||
this.config$.next(config);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
revalidateIfNeeded = () => {
|
||||
if (!this.config$.value) {
|
||||
this.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
134
packages/frontend/core/src/modules/cloud/entities/session.ts
Normal file
134
packages/frontend/core/src/modules/cloud/entities/session.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { validateAndReduceImage } from '../../../utils/reduce-image';
|
||||
import type { AccountProfile, AuthStore } from '../stores/auth';
|
||||
|
||||
export interface AuthSessionInfo {
|
||||
account: AuthAccountInfo;
|
||||
}
|
||||
|
||||
export interface AuthAccountInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
email?: string;
|
||||
info?: AccountProfile | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthSessionUnauthenticated {
|
||||
status: 'unauthenticated';
|
||||
}
|
||||
|
||||
export interface AuthSessionAuthenticated {
|
||||
status: 'authenticated';
|
||||
session: AuthSessionInfo;
|
||||
}
|
||||
|
||||
export class AuthSession extends Entity {
|
||||
id = 'affine-cloud' as const;
|
||||
|
||||
session$: LiveData<AuthSessionUnauthenticated | AuthSessionAuthenticated> =
|
||||
LiveData.from(this.store.watchCachedAuthSession(), null).map(session =>
|
||||
session
|
||||
? {
|
||||
status: 'authenticated',
|
||||
session: session as AuthSessionInfo,
|
||||
}
|
||||
: {
|
||||
status: 'unauthenticated',
|
||||
}
|
||||
);
|
||||
|
||||
status$ = this.session$.map(session => session.status);
|
||||
|
||||
account$ = this.session$.map(session =>
|
||||
session.status === 'authenticated' ? session.session.account : null
|
||||
);
|
||||
|
||||
waitForAuthenticated = (signal?: AbortSignal) =>
|
||||
this.session$.waitFor(
|
||||
session => session.status === 'authenticated',
|
||||
signal
|
||||
) as Promise<AuthSessionAuthenticated>;
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
|
||||
constructor(private readonly store: AuthStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() =>
|
||||
fromPromise(this.getSession()).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(sessionInfo => {
|
||||
this.store.setCachedAuthSession(sessionInfo);
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => {
|
||||
this.isRevalidating$.next(true);
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.isRevalidating$.next(false);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private async getSession(): Promise<AuthSessionInfo | null> {
|
||||
const session = await this.store.fetchSession();
|
||||
|
||||
if (session?.user) {
|
||||
const account = {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
label: session.user.name,
|
||||
avatar: session.user.avatarUrl,
|
||||
info: session.user,
|
||||
};
|
||||
const result = {
|
||||
account,
|
||||
};
|
||||
return result;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForRevalidation() {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
|
||||
}
|
||||
|
||||
async removeAvatar() {
|
||||
await this.store.removeAvatar();
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
const reducedFile = await validateAndReduceImage(file);
|
||||
await this.store.uploadAvatar(reducedFile);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async updateLabel(label: string) {
|
||||
await this.store.updateLabel(label);
|
||||
console.log('updateLabel');
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { PricesQuery } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { ServerConfigService } from '../services/server-config';
|
||||
import type { SubscriptionStore } from '../stores/subscription';
|
||||
|
||||
export class SubscriptionPrices extends Entity {
|
||||
prices$ = new LiveData<PricesQuery['prices'] | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
proPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'Pro') : null
|
||||
);
|
||||
aiPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'AI') : null
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly store: SubscriptionStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
// ensure server config is loaded
|
||||
this.serverConfigService.serverConfig.revalidateIfNeeded();
|
||||
|
||||
const serverConfig =
|
||||
await this.serverConfigService.serverConfig.features$.waitForNonNull(
|
||||
signal
|
||||
);
|
||||
|
||||
if (!serverConfig.payment) {
|
||||
// No payment feature, no subscription
|
||||
return [];
|
||||
}
|
||||
return this.store.fetchSubscriptionPrices(signal);
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mapInto(this.prices$),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { SubscriptionPlan } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { AuthService } from '../services/auth';
|
||||
import type { ServerConfigService } from '../services/server-config';
|
||||
import type { SubscriptionStore } from '../stores/subscription';
|
||||
|
||||
export type SubscriptionType = NonNullable<
|
||||
SubscriptionQuery['currentUser']
|
||||
>['subscriptions'][number];
|
||||
|
||||
export class Subscription extends Entity {
|
||||
// undefined means no user, null means loading
|
||||
subscription$ = new LiveData<SubscriptionType[] | null | undefined>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
/**
|
||||
* Primary subscription is the subscription that is not AI.
|
||||
*/
|
||||
primary$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.find(sub => sub.plan !== SubscriptionPlan.AI)
|
||||
: null
|
||||
);
|
||||
isFree$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.some(sub => sub.plan === SubscriptionPlan.Free)
|
||||
: null
|
||||
);
|
||||
isPro$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.some(sub => sub.plan === SubscriptionPlan.Pro)
|
||||
: null
|
||||
);
|
||||
isSelfHosted$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.some(sub => sub.plan === SubscriptionPlan.SelfHosted)
|
||||
: null
|
||||
);
|
||||
ai$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
|
||||
: null
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly store: SubscriptionStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async resumeSubscription(idempotencyKey: string, plan?: SubscriptionPlan) {
|
||||
await this.store.mutateResumeSubscription(idempotencyKey, plan);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async cancelSubscription(idempotencyKey: string, plan?: SubscriptionPlan) {
|
||||
await this.store.mutateCancelSubscription(idempotencyKey, plan);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async setSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
recurring: SubscriptionRecurring,
|
||||
plan?: SubscriptionPlan
|
||||
) {
|
||||
await this.store.setSubscriptionRecurring(idempotencyKey, recurring, plan);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async waitForRevalidation() {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) => {
|
||||
return fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return undefined; // no subscription if no user
|
||||
}
|
||||
|
||||
// ensure server config is loaded
|
||||
this.serverConfigService.serverConfig.revalidateIfNeeded();
|
||||
|
||||
const serverConfig =
|
||||
await this.serverConfigService.serverConfig.features$.waitForNonNull(
|
||||
signal
|
||||
);
|
||||
|
||||
if (!serverConfig.payment) {
|
||||
// No payment feature, no subscription
|
||||
return {
|
||||
userId: accountId,
|
||||
subscriptions: [],
|
||||
};
|
||||
}
|
||||
const { userId, subscriptions } =
|
||||
await this.store.fetchSubscriptions(signal);
|
||||
if (userId !== accountId) {
|
||||
// The user has changed, ignore the result
|
||||
this.authService.session.revalidate();
|
||||
await this.authService.session.waitForRevalidation();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: userId,
|
||||
subscriptions: subscriptions,
|
||||
};
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
this.store.setCachedSubscriptions(
|
||||
data.userId,
|
||||
data.subscriptions
|
||||
);
|
||||
this.subscription$.next(data.subscriptions);
|
||||
} else {
|
||||
this.subscription$.next(undefined);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
},
|
||||
({ accountId }) => {
|
||||
this.reset();
|
||||
if (!accountId) {
|
||||
this.subscription$.next(null);
|
||||
} else {
|
||||
this.subscription$.next(this.store.getCachedSubscriptions(accountId));
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.subscription$.next(null);
|
||||
this.isRevalidating$.next(false);
|
||||
this.error$.next(null);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
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
|
||||
features$ = new LiveData<FeatureType[] | null | undefined>(null);
|
||||
isEarlyAccess$ = this.features$.map(features =>
|
||||
features === null
|
||||
? null
|
||||
: features?.some(f => f === FeatureType.EarlyAccess)
|
||||
);
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly store: UserFeatureStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) => {
|
||||
return fromPromise(async signal => {
|
||||
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;
|
||||
}
|
||||
return {
|
||||
userId: userId,
|
||||
features: features,
|
||||
};
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
this.features$.next(data.features);
|
||||
} else {
|
||||
this.features$.next(null);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// Reset the state when the user is changed
|
||||
this.reset();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.features$.next(null);
|
||||
this.error$.next(null);
|
||||
this.isRevalidating$.next(false);
|
||||
}
|
||||
}
|
||||
131
packages/frontend/core/src/modules/cloud/entities/user-quota.ts
Normal file
131
packages/frontend/core/src/modules/cloud/entities/user-quota.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { QuotaQuery } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import bytes from 'bytes';
|
||||
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { AuthService } from '../services/auth';
|
||||
import type { UserQuotaStore } from '../stores/user-quota';
|
||||
|
||||
export class UserQuota extends Entity {
|
||||
quota$ = new LiveData<NonNullable<QuotaQuery['currentUser']>['quota']>(null);
|
||||
/** Used storage in bytes */
|
||||
used$ = new LiveData<number | null>(null);
|
||||
/** Formatted used storage */
|
||||
usedFormatted$ = this.used$.map(used =>
|
||||
used !== null ? bytes.format(used) : null
|
||||
);
|
||||
/** Maximum storage limit in bytes */
|
||||
max$ = this.quota$.map(quota => (quota ? quota.storageQuota : null));
|
||||
/** Maximum storage limit formatted */
|
||||
maxFormatted$ = this.max$.map(max => (max ? bytes.format(max) : null));
|
||||
|
||||
aiActionLimit$ = new LiveData<number | 'unlimited' | null>(null);
|
||||
aiActionUsed$ = new LiveData<number | null>(null);
|
||||
|
||||
/** Percentage of storage used */
|
||||
percent$ = LiveData.computed(get => {
|
||||
const max = get(this.max$);
|
||||
const used = get(this.used$);
|
||||
if (max === null || used === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
100,
|
||||
Math.max(0.5, Number(((used / max) * 100).toFixed(4)))
|
||||
);
|
||||
});
|
||||
|
||||
color$ = this.percent$.map(percent =>
|
||||
percent !== null
|
||||
? percent > 80
|
||||
? cssVar('errorColor')
|
||||
: cssVar('processingColor')
|
||||
: null
|
||||
);
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly store: UserQuotaStore
|
||||
) {
|
||||
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, aiQuota, used } =
|
||||
await this.store.fetchUserQuota(signal);
|
||||
|
||||
return { quota, aiQuota, used };
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
const { aiQuota, quota, used } = data;
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
this.aiActionUsed$.next(aiQuota.used);
|
||||
this.aiActionLimit$.next(
|
||||
aiQuota.limit === null ? 'unlimited' : aiQuota.limit
|
||||
); // fix me: unlimited status
|
||||
} else {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
this.aiActionUsed$.next(null);
|
||||
this.aiActionLimit$.next(null);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
),
|
||||
() => {
|
||||
// Reset the state when the user is changed
|
||||
this.reset();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
this.aiActionUsed$.next(null);
|
||||
this.aiActionLimit$.next(null);
|
||||
this.error$.next(null);
|
||||
this.isRevalidating$.next(false);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
21
packages/frontend/core/src/modules/cloud/error.ts
Normal file
21
packages/frontend/core/src/modules/cloud/error.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export class NetworkError extends Error {
|
||||
constructor(public readonly originError: Error) {
|
||||
super(`Network error: ${originError.message}`);
|
||||
this.stack = originError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNetworkError(error: Error): error is NetworkError {
|
||||
return error instanceof NetworkError;
|
||||
}
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(public readonly originError: Error) {
|
||||
super(`Server error: ${originError.message}`);
|
||||
this.stack = originError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackendError(error: Error): error is BackendError {
|
||||
return error instanceof BackendError;
|
||||
}
|
||||
64
packages/frontend/core/src/modules/cloud/index.ts
Normal file
64
packages/frontend/core/src/modules/cloud/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type { AuthAccountInfo } from './entities/session';
|
||||
export {
|
||||
BackendError,
|
||||
isBackendError,
|
||||
isNetworkError,
|
||||
NetworkError,
|
||||
} from './error';
|
||||
export { AccountChanged, AuthService } from './services/auth';
|
||||
export { FetchService } from './services/fetch';
|
||||
export { GraphQLService } from './services/graphql';
|
||||
export { ServerConfigService } from './services/server-config';
|
||||
export { SubscriptionService } from './services/subscription';
|
||||
export { UserFeatureService } from './services/user-feature';
|
||||
export { UserQuotaService } from './services/user-quota';
|
||||
export { WebSocketService } from './services/websocket';
|
||||
|
||||
import {
|
||||
type Framework,
|
||||
GlobalCacheService,
|
||||
GlobalStateService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { ServerConfig } from './entities/server-config';
|
||||
import { AuthSession } from './entities/session';
|
||||
import { Subscription } from './entities/subscription';
|
||||
import { SubscriptionPrices } from './entities/subscription-prices';
|
||||
import { UserFeature } from './entities/user-feature';
|
||||
import { UserQuota } from './entities/user-quota';
|
||||
import { AuthService } from './services/auth';
|
||||
import { FetchService } from './services/fetch';
|
||||
import { GraphQLService } from './services/graphql';
|
||||
import { ServerConfigService } from './services/server-config';
|
||||
import { SubscriptionService } from './services/subscription';
|
||||
import { UserFeatureService } from './services/user-feature';
|
||||
import { UserQuotaService } from './services/user-quota';
|
||||
import { WebSocketService } from './services/websocket';
|
||||
import { AuthStore } from './stores/auth';
|
||||
import { ServerConfigStore } from './stores/server-config';
|
||||
import { SubscriptionStore } from './stores/subscription';
|
||||
import { UserFeatureStore } from './stores/user-feature';
|
||||
import { UserQuotaStore } from './stores/user-quota';
|
||||
|
||||
export function configureCloudModule(framework: Framework) {
|
||||
framework
|
||||
.service(FetchService)
|
||||
.service(GraphQLService, [FetchService])
|
||||
.service(WebSocketService)
|
||||
.service(ServerConfigService)
|
||||
.entity(ServerConfig, [ServerConfigStore])
|
||||
.store(ServerConfigStore, [GraphQLService])
|
||||
.service(AuthService, [FetchService, AuthStore])
|
||||
.store(AuthStore, [FetchService, GraphQLService, GlobalStateService])
|
||||
.entity(AuthSession, [AuthStore])
|
||||
.service(SubscriptionService, [SubscriptionStore])
|
||||
.store(SubscriptionStore, [GraphQLService, GlobalCacheService])
|
||||
.entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore])
|
||||
.entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore])
|
||||
.service(UserQuotaService)
|
||||
.store(UserQuotaStore, [GraphQLService])
|
||||
.entity(UserQuota, [AuthService, UserQuotaStore])
|
||||
.service(UserFeatureService)
|
||||
.entity(UserFeature, [AuthService, UserFeatureStore])
|
||||
.store(UserFeatureStore, [GraphQLService]);
|
||||
}
|
||||
161
packages/frontend/core/src/modules/cloud/services/auth.ts
Normal file
161
packages/frontend/core/src/modules/cloud/services/auth.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { OAuthProviderType } from '@affine/graphql';
|
||||
import {
|
||||
ApplicationFocused,
|
||||
ApplicationStarted,
|
||||
createEvent,
|
||||
OnEvent,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { distinctUntilChanged, map, skip } from 'rxjs';
|
||||
|
||||
import { type AuthAccountInfo, AuthSession } from '../entities/session';
|
||||
import type { AuthStore } from '../stores/auth';
|
||||
import type { FetchService } from './fetch';
|
||||
|
||||
// Emit when account changed
|
||||
export const AccountChanged = createEvent<AuthAccountInfo | null>(
|
||||
'AccountChanged'
|
||||
);
|
||||
|
||||
export const AccountLoggedIn = createEvent<AuthAccountInfo>('AccountLoggedIn');
|
||||
|
||||
export const AccountLoggedOut =
|
||||
createEvent<AuthAccountInfo>('AccountLoggedOut');
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
|
||||
export class AuthService extends Service {
|
||||
session = this.framework.createEntity(AuthSession);
|
||||
|
||||
constructor(
|
||||
private readonly fetchService: FetchService,
|
||||
private readonly store: AuthStore
|
||||
) {
|
||||
super();
|
||||
|
||||
this.session.account$
|
||||
.pipe(
|
||||
map(a => ({
|
||||
id: a?.id,
|
||||
account: a,
|
||||
})),
|
||||
distinctUntilChanged((a, b) => a.id === b.id), // only emit when the value changes
|
||||
skip(1) // skip the initial value
|
||||
)
|
||||
.subscribe(({ account }) => {
|
||||
if (account === null) {
|
||||
this.eventBus.emit(AccountLoggedOut, account);
|
||||
} else {
|
||||
this.eventBus.emit(AccountLoggedIn, account);
|
||||
}
|
||||
this.eventBus.emit(AccountChanged, account);
|
||||
});
|
||||
}
|
||||
|
||||
private onApplicationStart() {
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
private onApplicationFocused() {
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
async sendEmailMagicLink(
|
||||
email: string,
|
||||
verifyToken: string,
|
||||
challenge?: string
|
||||
) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (challenge) {
|
||||
searchParams.set('challenge', challenge);
|
||||
}
|
||||
searchParams.set('token', verifyToken);
|
||||
const redirectUri = new URL(location.href);
|
||||
if (environment.isDesktop) {
|
||||
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
|
||||
}
|
||||
searchParams.set('redirect_uri', redirectUri.toString());
|
||||
|
||||
const res = await this.fetchService.fetch(
|
||||
'/api/auth/sign-in?' + searchParams.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res?.ok) {
|
||||
throw new Error('Failed to send email');
|
||||
}
|
||||
}
|
||||
|
||||
async signInOauth(provider: OAuthProviderType) {
|
||||
if (environment.isDesktop) {
|
||||
await apis?.ui.openExternal(
|
||||
`${
|
||||
runtimeConfig.serverUrlPrefix
|
||||
}/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri(
|
||||
'/open-app/signin-redirect'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
location.href = `${
|
||||
runtimeConfig.serverUrlPrefix
|
||||
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
|
||||
location.pathname
|
||||
)}`;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async signInPassword(credential: { email: string; password: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
const redirectUri = new URL(location.href);
|
||||
if (environment.isDesktop) {
|
||||
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
|
||||
}
|
||||
searchParams.set('redirect_uri', redirectUri.toString());
|
||||
|
||||
const res = await this.fetchService.fetch(
|
||||
'/api/auth/sign-in?' + searchParams.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credential),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to sign in');
|
||||
}
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.fetchService.fetch('/api/auth/sign-out');
|
||||
this.store.setCachedAuthSession(null);
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
private buildRedirectUri(callbackUrl: string) {
|
||||
const params: string[][] = [];
|
||||
if (environment.isDesktop && window.appInfo.schema) {
|
||||
params.push(['schema', window.appInfo.schema]);
|
||||
}
|
||||
const query =
|
||||
params.length > 0
|
||||
? '?' +
|
||||
params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
return callbackUrl + query;
|
||||
}
|
||||
|
||||
checkUserByEmail(email: string) {
|
||||
return this.store.checkUserByEmail(email);
|
||||
}
|
||||
}
|
||||
84
packages/frontend/core/src/modules/cloud/services/fetch.ts
Normal file
84
packages/frontend/core/src/modules/cloud/services/fetch.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { fromPromise, Service } from '@toeverything/infra';
|
||||
|
||||
import { BackendError, NetworkError } from '../error';
|
||||
|
||||
export function getAffineCloudBaseUrl(): string {
|
||||
if (environment.isDesktop) {
|
||||
return runtimeConfig.serverUrlPrefix;
|
||||
}
|
||||
const { protocol, hostname, port } = window.location;
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
||||
}
|
||||
|
||||
const logger = new DebugLogger('affine:fetch');
|
||||
|
||||
export type FetchInit = RequestInit & { timeout?: number };
|
||||
|
||||
export class FetchService extends Service {
|
||||
rxFetch = (
|
||||
input: string,
|
||||
init?: RequestInit & {
|
||||
// https://github.com/microsoft/TypeScript/issues/54472
|
||||
priority?: 'auto' | 'low' | 'high';
|
||||
} & {
|
||||
traceEvent?: string;
|
||||
}
|
||||
) => {
|
||||
return fromPromise(signal => {
|
||||
return this.fetch(input, { signal, ...init });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch with custom custom timeout and error handling.
|
||||
*/
|
||||
fetch = async (input: string, init?: FetchInit): Promise<Response> => {
|
||||
logger.debug('fetch', input);
|
||||
const externalSignal = init?.signal;
|
||||
if (externalSignal?.aborted) {
|
||||
throw externalSignal.reason;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
externalSignal?.addEventListener('abort', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
const timeout = init?.timeout ?? 15000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort('timeout');
|
||||
}, timeout);
|
||||
|
||||
const res = await fetch(new URL(input, getAffineCloudBaseUrl()), {
|
||||
...init,
|
||||
signal: abortController.signal,
|
||||
}).catch(err => {
|
||||
logger.debug('network error', err);
|
||||
throw new NetworkError(err);
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (res.status === 504) {
|
||||
const error = new Error('Gateway Timeout');
|
||||
logger.debug('network error', error);
|
||||
throw new NetworkError(error);
|
||||
}
|
||||
if (!res.ok) {
|
||||
logger.warn(
|
||||
'backend error',
|
||||
new Error(`${res.status} ${res.statusText}`)
|
||||
);
|
||||
let reason: string | any = '';
|
||||
if (res.headers.get('Content-Type')?.includes('application/json')) {
|
||||
try {
|
||||
reason = await res.json();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw new BackendError(
|
||||
new Error(`${res.status} ${res.statusText}`, reason)
|
||||
);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
53
packages/frontend/core/src/modules/cloud/services/graphql.ts
Normal file
53
packages/frontend/core/src/modules/cloud/services/graphql.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
gqlFetcherFactory,
|
||||
GraphQLError,
|
||||
type GraphQLQuery,
|
||||
type QueryOptions,
|
||||
type QueryResponse,
|
||||
} from '@affine/graphql';
|
||||
import { fromPromise, Service } from '@toeverything/infra';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import { BackendError } from '../error';
|
||||
import { AuthService } from './auth';
|
||||
import type { FetchService } from './fetch';
|
||||
|
||||
export class GraphQLService extends Service {
|
||||
constructor(private readonly fetcher: FetchService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly rawGql = gqlFetcherFactory('/graphql', this.fetcher.fetch);
|
||||
|
||||
rxGql = <Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>
|
||||
): Observable<QueryResponse<Query>> => {
|
||||
return fromPromise(signal => {
|
||||
return this.gql({
|
||||
...options,
|
||||
context: {
|
||||
signal,
|
||||
...options.context,
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
};
|
||||
|
||||
gql = async <Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>
|
||||
): Promise<QueryResponse<Query>> => {
|
||||
try {
|
||||
return await this.rawGql(options);
|
||||
} catch (err) {
|
||||
if (err instanceof Array) {
|
||||
for (const error of err) {
|
||||
if (error instanceof GraphQLError && error.extensions?.code === 403) {
|
||||
this.framework.get(AuthService).session.revalidate();
|
||||
}
|
||||
}
|
||||
throw new BackendError(new Error('Graphql Error'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { ServerConfig } from '../entities/server-config';
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||
export class ServerConfigService extends Service {
|
||||
serverConfig = this.framework.createEntity(ServerConfig);
|
||||
|
||||
private onApplicationStart() {
|
||||
this.serverConfig.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type CreateCheckoutSessionInput } from '@affine/graphql';
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { Subscription } from '../entities/subscription';
|
||||
import { SubscriptionPrices } from '../entities/subscription-prices';
|
||||
import type { SubscriptionStore } from '../stores/subscription';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class SubscriptionService extends Service {
|
||||
subscription = this.framework.createEntity(Subscription);
|
||||
prices = this.framework.createEntity(SubscriptionPrices);
|
||||
|
||||
constructor(private readonly store: SubscriptionStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
async createCheckoutSession(input: CreateCheckoutSessionInput) {
|
||||
return await this.store.createCheckoutSession(input);
|
||||
}
|
||||
|
||||
private onAccountChanged() {
|
||||
this.subscription.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { UserFeature } from '../entities/user-feature';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class UserFeatureService extends Service {
|
||||
userFeature = this.framework.createEntity(UserFeature);
|
||||
|
||||
private onAccountChanged() {
|
||||
this.userFeature.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { UserQuota } from '../entities/user-quota';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class UserQuotaService extends Service {
|
||||
quota = this.framework.createEntity(UserQuota);
|
||||
|
||||
private onAccountChanged() {
|
||||
this.quota.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { Manager } from 'socket.io-client';
|
||||
|
||||
import { getAffineCloudBaseUrl } from '../services/fetch';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.reconnect)
|
||||
export class WebSocketService extends Service {
|
||||
ioManager: Manager = new Manager(`${getAffineCloudBaseUrl()}/`, {
|
||||
autoConnect: false,
|
||||
transports: ['websocket'],
|
||||
secure: location.protocol === 'https:',
|
||||
});
|
||||
sockets: Set<Socket> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
newSocket(): Socket {
|
||||
const socket = this.ioManager.socket('/');
|
||||
this.sockets.add(socket);
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
reconnect(): void {
|
||||
for (const socket of this.sockets) {
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
for (const socket of this.sockets) {
|
||||
socket.connect();
|
||||
}
|
||||
}
|
||||
}
|
||||
97
packages/frontend/core/src/modules/cloud/stores/auth.ts
Normal file
97
packages/frontend/core/src/modules/cloud/stores/auth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
getUserQuery,
|
||||
removeAvatarMutation,
|
||||
updateUserProfileMutation,
|
||||
uploadAvatarMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { GlobalStateService } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { AuthSessionInfo } from '../entities/session';
|
||||
import type { FetchService } from '../services/fetch';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export interface AccountProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
hasPassword: boolean;
|
||||
avatarUrl: string | null;
|
||||
emailVerified: string | null;
|
||||
}
|
||||
|
||||
export class AuthStore extends Store {
|
||||
constructor(
|
||||
private readonly fetchService: FetchService,
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly globalStateService: GlobalStateService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
watchCachedAuthSession() {
|
||||
return this.globalStateService.globalState.watch<AuthSessionInfo>(
|
||||
'affine-cloud-auth'
|
||||
);
|
||||
}
|
||||
|
||||
setCachedAuthSession(session: AuthSessionInfo | null) {
|
||||
this.globalStateService.globalState.set('affine-cloud-auth', session);
|
||||
}
|
||||
|
||||
async fetchSession() {
|
||||
const url = `/api/auth/session`;
|
||||
const options: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
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 uploadAvatar(file: File) {
|
||||
await this.gqlService.gql({
|
||||
query: uploadAvatarMutation,
|
||||
variables: {
|
||||
avatar: file,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeAvatar() {
|
||||
await this.gqlService.gql({
|
||||
query: removeAvatarMutation,
|
||||
});
|
||||
}
|
||||
|
||||
async updateLabel(label: string) {
|
||||
await this.gqlService.gql({
|
||||
query: updateUserProfileMutation,
|
||||
variables: {
|
||||
input: {
|
||||
name: label,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async checkUserByEmail(email: string) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: getUserQuery,
|
||||
variables: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
return {
|
||||
isExist: !!data.user,
|
||||
hasPassword: !!data.user?.hasPassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
oauthProvidersQuery,
|
||||
serverConfigQuery,
|
||||
ServerFeature,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { ServerConfigType } from '../entities/server-config';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class ServerConfigStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchServerConfig(
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<ServerConfigType> {
|
||||
const serverConfigData = await this.gqlService.gql({
|
||||
query: serverConfigQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
if (serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)) {
|
||||
const oauthProvidersData = await this.gqlService.gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...serverConfigData.serverConfig,
|
||||
...oauthProvidersData.serverConfig,
|
||||
};
|
||||
}
|
||||
return { ...serverConfigData.serverConfig, oauthProviders: [] };
|
||||
}
|
||||
}
|
||||
130
packages/frontend/core/src/modules/cloud/stores/subscription.ts
Normal file
130
packages/frontend/core/src/modules/cloud/stores/subscription.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
CreateCheckoutSessionInput,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
createCheckoutSessionMutation,
|
||||
pricesQuery,
|
||||
resumeSubscriptionMutation,
|
||||
subscriptionQuery,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { GlobalCacheService } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { SubscriptionType } from '../entities/subscription';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
const SUBSCRIPTION_CACHE_KEY = 'subscription:';
|
||||
|
||||
export class SubscriptionStore extends Store {
|
||||
constructor(
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly globalCacheService: GlobalCacheService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchSubscriptions(abortSignal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: subscriptionQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data.currentUser) {
|
||||
throw new Error('No logged in');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: data.currentUser?.id,
|
||||
subscriptions: data.currentUser?.subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
async mutateResumeSubscription(
|
||||
idempotencyKey: string,
|
||||
plan?: SubscriptionPlan,
|
||||
abortSignal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: resumeSubscriptionMutation,
|
||||
variables: {
|
||||
idempotencyKey,
|
||||
plan,
|
||||
},
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
return data.resumeSubscription;
|
||||
}
|
||||
|
||||
async mutateCancelSubscription(
|
||||
idempotencyKey: string,
|
||||
plan?: SubscriptionPlan,
|
||||
abortSignal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: cancelSubscriptionMutation,
|
||||
variables: {
|
||||
idempotencyKey,
|
||||
plan,
|
||||
},
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
return data.cancelSubscription;
|
||||
}
|
||||
|
||||
getCachedSubscriptions(userId: string) {
|
||||
return this.globalCacheService.globalCache.get<SubscriptionType[]>(
|
||||
SUBSCRIPTION_CACHE_KEY + userId
|
||||
);
|
||||
}
|
||||
|
||||
setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) {
|
||||
return this.globalCacheService.globalCache.set(
|
||||
SUBSCRIPTION_CACHE_KEY + userId,
|
||||
subscriptions
|
||||
);
|
||||
}
|
||||
|
||||
setSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
recurring: SubscriptionRecurring,
|
||||
plan?: SubscriptionPlan
|
||||
) {
|
||||
return this.gqlService.gql({
|
||||
query: updateSubscriptionMutation,
|
||||
variables: {
|
||||
idempotencyKey,
|
||||
plan,
|
||||
recurring,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createCheckoutSession(input: CreateCheckoutSessionInput) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: createCheckoutSessionMutation,
|
||||
variables: { input },
|
||||
});
|
||||
return data.createCheckoutSession;
|
||||
}
|
||||
|
||||
async fetchSubscriptionPrices(abortSignal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: pricesQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
|
||||
return data.prices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getUserFeaturesQuery } 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: getUserFeaturesQuery,
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
return {
|
||||
userId: data.currentUser?.id,
|
||||
features: data.currentUser?.features,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { quotaQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class UserQuotaStore extends Store {
|
||||
constructor(private readonly graphqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchUserQuota(abortSignal?: AbortSignal) {
|
||||
const data = await this.graphqlService.gql({
|
||||
query: quotaQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data.currentUser) {
|
||||
throw new Error('No logged in');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: data.currentUser.id,
|
||||
aiQuota: data.currentUser.copilot.quota,
|
||||
quota: data.currentUser.quota,
|
||||
used: data.collectAllBlobSizes.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user