feat: integrate new modules (#5087)

This commit is contained in:
DarkSky
2023-12-14 09:50:46 +00:00
parent a93c12e122
commit 2b7f6f8b74
26 changed files with 424 additions and 149 deletions

View File

@@ -8,6 +8,20 @@ async function main() {
data: { data: {
...userA, ...userA,
password: await hash(userA.password), password: await hash(userA.password),
features: {
create: {
reason: 'created by api sign up',
activated: true,
feature: {
connect: {
feature_version: {
feature: 'free_plan_v1',
version: 1,
},
},
},
},
},
}, },
}); });
} }

View File

@@ -3,10 +3,30 @@ import {
FeatureKind, FeatureKind,
Features, Features,
FeatureType, FeatureType,
} from '../../modules/features/types'; } from '../../modules/features';
import { Quotas } from '../../modules/quota/types'; import { Quotas } from '../../modules/quota/types';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
export class UserFeaturesInit1698652531198 {
// do the migration
static async up(db: PrismaService) {
// upgrade features from lower version to higher version
for (const feature of Features) {
await upsertFeature(db, feature);
}
await migrateNewFeatureTable(db);
for (const quota of Quotas) {
await upsertFeature(db, quota);
}
}
// revert the migration
static async down(_db: PrismaService) {
// TODO: revert the migration
}
}
// upgrade features from lower version to higher version // upgrade features from lower version to higher version
async function upsertFeature( async function upsertFeature(
db: PrismaService, db: PrismaService,
@@ -34,26 +54,6 @@ async function upsertFeature(
} }
} }
export class UserFeaturesInit1698652531198 {
// do the migration
static async up(db: PrismaService) {
// upgrade features from lower version to higher version
for (const feature of Features) {
await upsertFeature(db, feature);
}
await migrateNewFeatureTable(db);
for (const quota of Quotas) {
await upsertFeature(db, quota);
}
}
// revert the migration
static async down(_db: PrismaService) {
// TODO: revert the migration
}
}
async function migrateNewFeatureTable(prisma: PrismaService) { async function migrateNewFeatureTable(prisma: PrismaService) {
const waitingList = await prisma.newFeaturesWaitingList.findMany(); const waitingList = await prisma.newFeaturesWaitingList.findMany();
for (const oldUser of waitingList) { for (const oldUser of waitingList) {

View File

@@ -19,7 +19,7 @@ import { nanoid } from 'nanoid';
import { Config } from '../../config'; import { Config } from '../../config';
import { SessionService } from '../../session'; import { SessionService } from '../../session';
import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { UserType } from '../users/resolver'; import { UserType } from '../users';
import { Auth, CurrentUser } from './guard'; import { Auth, CurrentUser } from './guard';
import { AuthService } from './service'; import { AuthService } from './service';

View File

@@ -7,7 +7,7 @@ import { Config } from '../../config';
import { type EventPayload, OnEvent } from '../../event'; import { type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics'; import { metrics } from '../../metrics';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { SubscriptionStatus } from '../payment/service'; import { QuotaService } from '../quota';
import { Permission } from '../workspaces/types'; import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager'; import { isEmptyBuffer } from './manager';
@@ -16,7 +16,8 @@ export class DocHistoryManager {
private readonly logger = new Logger(DocHistoryManager.name); private readonly logger = new Logger(DocHistoryManager.name);
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly db: PrismaService private readonly db: PrismaService,
private readonly quota: QuotaService
) {} ) {}
@OnEvent('workspace.deleted') @OnEvent('workspace.deleted')
@@ -222,9 +223,6 @@ export class DocHistoryManager {
return history.timestamp; return history.timestamp;
} }
/**
* @todo(@darkskygit) refactor with [Usage Control] system
*/
async getExpiredDateFromNow(workspaceId: string) { async getExpiredDateFromNow(workspaceId: string) {
const permission = await this.db.workspaceUserPermission.findFirst({ const permission = await this.db.workspaceUserPermission.findFirst({
select: { select: {
@@ -241,25 +239,9 @@ export class DocHistoryManager {
throw new Error('Workspace owner not found'); throw new Error('Workspace owner not found');
} }
const sub = await this.db.userSubscription.findFirst({ const quota = await this.quota.getUserQuota(permission.userId);
select: {
id: true,
},
where: {
userId: permission.userId,
status: SubscriptionStatus.Active,
},
});
return new Date( return new Date(Date.now() + quota.feature.configs.historyPeriod);
Date.now() +
1000 *
60 *
60 *
24 *
// 30 days for subscription user, 7 days for free user
(sub ? 30 : 7)
);
} }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT /* everyday at 12am */) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT /* everyday at 12am */)

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { QuotaModule } from '../quota';
import { DocHistoryManager } from './history'; import { DocHistoryManager } from './history';
import { DocManager } from './manager'; import { DocManager } from './manager';
@Module({ @Module({
imports: [QuotaModule],
providers: [DocManager, DocHistoryManager], providers: [DocManager, DocHistoryManager],
exports: [DocManager, DocHistoryManager], exports: [DocManager, DocHistoryManager],
}) })

View File

@@ -5,7 +5,7 @@ import { PrismaService } from '../../prisma';
import { FeatureService } from './configure'; import { FeatureService } from './configure';
import { FeatureType } from './types'; import { FeatureType } from './types';
export enum NewFeaturesKind { enum NewFeaturesKind {
EarlyAccess, EarlyAccess,
} }

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FeatureModule } from '../features'; import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
import { ScheduleManager } from './schedule'; import { ScheduleManager } from './schedule';
import { SubscriptionService } from './service'; import { SubscriptionService } from './service';
@@ -8,7 +9,7 @@ import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook'; import { StripeWebhook } from './webhook';
@Module({ @Module({
imports: [FeatureModule], imports: [FeatureModule, QuotaModule],
providers: [ providers: [
ScheduleManager, ScheduleManager,
StripeProvider, StripeProvider,

View File

@@ -12,6 +12,7 @@ import Stripe from 'stripe';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { FeatureManagementService } from '../features'; import { FeatureManagementService } from '../features';
import { QuotaService, QuotaType } from '../quota';
import { ScheduleManager } from './schedule'; import { ScheduleManager } from './schedule';
const OnEvent = ( const OnEvent = (
@@ -60,6 +61,11 @@ export enum SubscriptionStatus {
Trialing = 'trialing', Trialing = 'trialing',
} }
const SubscriptionActivated: Stripe.Subscription.Status[] = [
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
];
export enum InvoiceStatus { export enum InvoiceStatus {
Draft = 'draft', Draft = 'draft',
Open = 'open', Open = 'open',
@@ -83,7 +89,8 @@ export class SubscriptionService {
private readonly stripe: Stripe, private readonly stripe: Stripe,
private readonly db: PrismaService, private readonly db: PrismaService,
private readonly scheduleManager: ScheduleManager, private readonly scheduleManager: ScheduleManager,
private readonly features: FeatureManagementService private readonly features: FeatureManagementService,
private readonly quota: QuotaService
) { ) {
this.paymentConfig = config.payment; this.paymentConfig = config.payment;
@@ -471,6 +478,16 @@ export class SubscriptionService {
} }
} }
private getPlanQuota(plan: SubscriptionPlan) {
if (plan === SubscriptionPlan.Free) {
return QuotaType.Quota_FreePlanV1;
} else if (plan === SubscriptionPlan.Pro) {
return QuotaType.Quota_ProPlanV1;
} else {
throw new Error(`Unknown plan: ${plan}`);
}
}
private async saveSubscription( private async saveSubscription(
user: User, user: User,
subscription: Stripe.Subscription, subscription: Stripe.Subscription,
@@ -483,23 +500,28 @@ export class SubscriptionService {
subscription = await this.stripe.subscriptions.retrieve(subscription.id); subscription = await this.stripe.subscriptions.retrieve(subscription.id);
} }
// get next bill date from upcoming invoice
// see https://stripe.com/docs/api/invoices/upcoming
let nextBillAt: Date | null = null;
if (
(subscription.status === SubscriptionStatus.Active ||
subscription.status === SubscriptionStatus.Trialing) &&
!subscription.canceled_at
) {
nextBillAt = new Date(subscription.current_period_end * 1000);
}
const price = subscription.items.data[0].price; const price = subscription.items.data[0].price;
if (!price.lookup_key) { if (!price.lookup_key) {
throw new Error('Unexpected subscription with no key'); throw new Error('Unexpected subscription with no key');
} }
const [plan, recurring] = decodeLookupKey(price.lookup_key); const [plan, recurring] = decodeLookupKey(price.lookup_key);
const planActivated = SubscriptionActivated.includes(subscription.status);
let nextBillAt: Date | null = null;
if (planActivated) {
// update user's quota if plan activated
await this.quota.switchUserQuota(user.id, this.getPlanQuota(plan));
// get next bill date from upcoming invoice
// see https://stripe.com/docs/api/invoices/upcoming
if (!subscription.canceled_at) {
nextBillAt = new Date(subscription.current_period_end * 1000);
}
} else {
// switch to free plan if subscription is canceled
await this.quota.switchUserQuota(user.id, QuotaType.Quota_FreePlanV1);
}
const commonData = { const commonData = {
start: new Date(subscription.current_period_start * 1000), start: new Date(subscription.current_period_start * 1000),

View File

@@ -2,7 +2,13 @@ import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { FeatureKind } from '../features'; import { FeatureKind } from '../features';
import { Quota, QuotaType } from './types'; import {
formatDate,
formatSize,
getQuotaName,
Quota,
QuotaType,
} from './types';
@Injectable() @Injectable()
export class QuotaService { export class QuotaService {
@@ -32,11 +38,22 @@ export class QuotaService {
}, },
}, },
}); });
console.error(userId, quota);
return quota as typeof quota & { return quota as typeof quota & {
feature: Pick<Quota, 'feature' | 'configs'>; feature: Pick<Quota, 'feature' | 'configs'>;
}; };
} }
getHumanReadableQuota(feature: QuotaType, configs: Quota['configs']) {
return {
name: getQuotaName(feature),
blobLimit: formatSize(configs.blobLimit),
storageQuota: formatSize(configs.storageQuota),
historyPeriod: formatDate(configs.historyPeriod),
memberLimit: configs.memberLimit.toString(),
};
}
// get all user quota records // get all user quota records
async getUserQuotas(userId: string) { async getUserQuotas(userId: string) {
const quotas = await this.prisma.userFeatures.findMany({ const quotas = await this.prisma.userFeatures.findMany({

View File

@@ -5,15 +5,48 @@ export enum QuotaType {
Quota_ProPlanV1 = 'pro_plan_v1', Quota_ProPlanV1 = 'pro_plan_v1',
} }
export enum QuotaName {
free_plan_v1 = 'Free Plan',
pro_plan_v1 = 'Pro Plan',
}
export type Quota = CommonFeature & { export type Quota = CommonFeature & {
type: FeatureKind.Quota; type: FeatureKind.Quota;
feature: QuotaType; feature: QuotaType;
configs: { configs: {
blobLimit: number; blobLimit: number;
storageQuota: number; storageQuota: number;
historyPeriod: number;
memberLimit: number;
}; };
}; };
const OneKB = 1024;
const OneMB = OneKB * OneKB;
const OneGB = OneKB * OneMB;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
export function formatSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 B';
const dm = decimals < 0 ? 0 : decimals;
const i = Math.floor(Math.log(bytes) / Math.log(OneKB));
return parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + sizes[i];
}
const OneDay = 1000 * 60 * 60 * 24;
export function formatDate(ms: number): string {
return `${(ms / OneDay).toFixed(0)} days`;
}
export function getQuotaName(quota: QuotaType): string {
return QuotaName[quota];
}
export const Quotas: Quota[] = [ export const Quotas: Quota[] = [
{ {
feature: QuotaType.Quota_FreePlanV1, feature: QuotaType.Quota_FreePlanV1,
@@ -21,9 +54,13 @@ export const Quotas: Quota[] = [
version: 1, version: 1,
configs: { configs: {
// single blob limit 10MB // single blob limit 10MB
blobLimit: 10 * 1024 * 1024, blobLimit: 10 * OneMB,
// total blob limit 10GB // total blob limit 10GB
storageQuota: 10 * 1024 * 1024 * 1024, storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
}, },
}, },
{ {
@@ -32,9 +69,13 @@ export const Quotas: Quota[] = [
version: 1, version: 1,
configs: { configs: {
// single blob limit 100MB // single blob limit 100MB
blobLimit: 100 * 1024 * 1024, blobLimit: 100 * OneMB,
// total blob limit 100GB // total blob limit 100GB
storageQuota: 100 * 1024 * 1024 * 1024, storageQuota: 100 * OneGB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
}, },
}, },
]; ];

View File

@@ -1,16 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FeatureModule } from '../features'; import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { UserResolver } from './resolver'; import { UserResolver } from './resolver';
import { UsersService } from './users'; import { UsersService } from './users';
@Module({ @Module({
imports: [StorageModule, FeatureModule], imports: [StorageModule, FeatureModule, QuotaModule],
providers: [UserResolver, UsersService], providers: [UserResolver, UsersService],
exports: [UsersService], exports: [UsersService],
}) })
export class UsersModule {} export class UsersModule {}
export { UserType } from './resolver'; export { UserType } from './types';
export { UsersService } from './users'; export { UsersService } from './users';

View File

@@ -7,11 +7,8 @@ import {
import { import {
Args, Args,
Context, Context,
Field,
ID,
Int, Int,
Mutation, Mutation,
ObjectType,
Query, Query,
ResolveField, ResolveField,
Resolver, Resolver,
@@ -26,47 +23,11 @@ import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { AuthService } from '../auth/service'; import { AuthService } from '../auth/service';
import { FeatureManagementService } from '../features'; import { FeatureManagementService } from '../features';
import { QuotaService } from '../quota';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { DeleteAccount, RemoveAvatar, UserQuotaType, UserType } from './types';
import { UsersService } from './users'; import { UsersService } from './users';
@ObjectType()
export class UserType implements Partial<User> {
@Field(() => ID)
id!: string;
@Field({ description: 'User name' })
name!: string;
@Field({ description: 'User email' })
email!: string;
@Field(() => String, { description: 'User avatar url', nullable: true })
avatarUrl: string | null = null;
@Field(() => Date, { description: 'User email verified', nullable: true })
emailVerified: Date | null = null;
@Field({ description: 'User created date', nullable: true })
createdAt!: Date;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
}
@ObjectType()
export class DeleteAccount {
@Field()
success!: boolean;
}
@ObjectType()
export class RemoveAvatar {
@Field()
success!: boolean;
}
/** /**
* User resolver * User resolver
* All op rate limit: 10 req/m * All op rate limit: 10 req/m
@@ -80,7 +41,8 @@ export class UserResolver {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly storage: StorageService, private readonly storage: StorageService,
private readonly users: UsersService, private readonly users: UsersService,
private readonly feature: FeatureManagementService private readonly feature: FeatureManagementService,
private readonly quota: QuotaService
) {} ) {}
@Throttle({ @Throttle({
@@ -148,6 +110,24 @@ export class UserResolver {
return user; return user;
} }
@Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true })
async getQuota(@CurrentUser() me: User) {
const quota = await this.quota.getUserQuota(me.id);
const configs = quota.feature.configs;
return Object.assign(
{
name: quota.feature.feature,
humanReadable: this.quota.getHumanReadableQuota(
quota.feature.feature,
configs
),
},
configs
);
}
@Throttle({ default: { limit: 10, ttl: 60 } }) @Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => Int, { @ResolveField(() => Int, {
name: 'invoiceCount', name: 'invoiceCount',

View File

@@ -0,0 +1,79 @@
import { Field, Float, ID, ObjectType } from '@nestjs/graphql';
import type { User } from '@prisma/client';
@ObjectType('UserQuotaHumanReadable')
export class UserQuotaHumanReadableType {
@Field({ name: 'name' })
name!: string;
@Field({ name: 'blobLimit' })
blobLimit!: string;
@Field({ name: 'storageQuota' })
storageQuota!: string;
@Field({ name: 'historyPeriod' })
historyPeriod!: string;
@Field({ name: 'memberLimit' })
memberLimit!: string;
}
@ObjectType('UserQuota')
export class UserQuotaType {
@Field({ name: 'name' })
name!: string;
@Field(() => Float, { name: 'blobLimit' })
blobLimit!: number;
@Field(() => Float, { name: 'storageQuota' })
storageQuota!: number;
@Field(() => Float, { name: 'historyPeriod' })
historyPeriod!: number;
@Field({ name: 'memberLimit' })
memberLimit!: number;
@Field({ name: 'humanReadable' })
humanReadable!: UserQuotaHumanReadableType;
}
@ObjectType()
export class UserType implements Partial<User> {
@Field(() => ID)
id!: string;
@Field({ description: 'User name' })
name!: string;
@Field({ description: 'User email' })
email!: string;
@Field(() => String, { description: 'User avatar url', nullable: true })
avatarUrl: string | null = null;
@Field(() => Date, { description: 'User email verified', nullable: true })
emailVerified: Date | null = null;
@Field({ description: 'User created date', nullable: true })
createdAt!: Date;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
}
@ObjectType()
export class DeleteAccount {
@Field()
success!: boolean;
}
@ObjectType()
export class RemoveAvatar {
@Field()
success!: boolean;
}

View File

@@ -43,8 +43,7 @@ import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer'; import { MailService } from '../auth/mailer';
import { AuthService } from '../auth/service'; import { AuthService } from '../auth/service';
import { QuotaManagementService } from '../quota'; import { QuotaManagementService } from '../quota';
import { UsersService } from '../users'; import { UsersService, UserType } from '../users';
import { UserType } from '../users/resolver';
import { PermissionService, PublicPageMode } from './permission'; import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types'; import { Permission } from './types';
import { defaultWorkspaceAvatar } from './utils'; import { defaultWorkspaceAvatar } from './utils';

View File

@@ -10,6 +10,23 @@ type ServerConfigType {
flavor: String! flavor: String!
} }
type UserQuotaHumanReadable {
name: String!
blobLimit: String!
storageQuota: String!
historyPeriod: String!
memberLimit: String!
}
type UserQuota {
name: String!
blobLimit: Float!
storageQuota: Float!
historyPeriod: Float!
memberLimit: Int!
humanReadable: UserQuotaHumanReadable!
}
type UserType { type UserType {
id: ID! id: ID!
@@ -31,6 +48,7 @@ type UserType {
"""User password has been set""" """User password has been set"""
hasPassword: Boolean hasPassword: Boolean
token: TokenType! token: TokenType!
quota: UserQuota
"""Get user invoice count""" """Get user invoice count"""
invoiceCount: Int! invoiceCount: Int!

View File

@@ -14,10 +14,16 @@ import {
import { CacheModule } from '../src/cache'; import { CacheModule } from '../src/cache';
import { Config, ConfigModule } from '../src/config'; import { Config, ConfigModule } from '../src/config';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { EventModule } from '../src/event'; import { EventModule } from '../src/event';
import { DocManager, DocModule } from '../src/modules/doc'; import { DocManager, DocModule } from '../src/modules/doc';
import { QuotaModule } from '../src/modules/quota';
import { PrismaModule, PrismaService } from '../src/prisma'; import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils'; import { FakeStorageModule, flushDB } from './utils';
const createModule = () => { const createModule = () => {
return Test.createTestingModule({ return Test.createTestingModule({
@@ -25,8 +31,12 @@ const createModule = () => {
PrismaModule, PrismaModule,
CacheModule, CacheModule,
EventModule, EventModule,
QuotaModule,
FakeStorageModule.forRoot(),
ConfigModule.forRoot(), ConfigModule.forRoot(),
DocModule, DocModule,
RevertCommand,
RunCommand,
], ],
}).compile(); }).compile();
}; };
@@ -45,6 +55,13 @@ test.beforeEach(async () => {
app = m.createNestApplication(); app = m.createNestApplication();
app.enableShutdownHooks(); app.enableShutdownHooks();
await app.init(); await app.init();
// init features
const run = m.get(RunCommand);
const revert = m.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
}); });
test.afterEach.always(async () => { test.afterEach.always(async () => {

View File

@@ -57,14 +57,10 @@ test.beforeEach(async t => {
], ],
}).compile(); }).compile();
const quota = module.get(FeatureService);
const storageQuota = module.get(FeatureManagementService);
const auth = module.get(AuthService);
t.context.app = module; t.context.app = module;
t.context.feature = quota; t.context.auth = module.get(AuthService);
t.context.early_access = storageQuota; t.context.feature = module.get(FeatureService);
t.context.auth = auth; t.context.early_access = module.get(FeatureManagementService);
// init features // init features
await initFeatureConfigs(module); await initFeatureConfigs(module);

View File

@@ -8,8 +8,9 @@ import * as Sinon from 'sinon';
import { ConfigModule } from '../src/config'; import { ConfigModule } from '../src/config';
import type { EventPayload } from '../src/event'; import type { EventPayload } from '../src/event';
import { DocHistoryManager } from '../src/modules/doc'; import { DocHistoryManager } from '../src/modules/doc';
import { QuotaModule } from '../src/modules/quota';
import { PrismaModule, PrismaService } from '../src/prisma'; import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils'; import { FakeStorageModule, flushDB } from './utils';
let app: INestApplication; let app: INestApplication;
let m: TestingModule; let m: TestingModule;
@@ -20,7 +21,13 @@ let db: PrismaService;
test.beforeEach(async () => { test.beforeEach(async () => {
await flushDB(); await flushDB();
m = await Test.createTestingModule({ m = await Test.createTestingModule({
imports: [PrismaModule, ScheduleModule.forRoot(), ConfigModule.forRoot()], imports: [
PrismaModule,
QuotaModule,
FakeStorageModule.forRoot(),
ScheduleModule.forRoot(),
ConfigModule.forRoot(),
],
providers: [DocHistoryManager], providers: [DocHistoryManager],
}).compile(); }).compile();
@@ -277,8 +284,8 @@ test('should be able to recover from history', async t => {
t.is(history2.timestamp.getTime(), snapshot.updatedAt.getTime()); t.is(history2.timestamp.getTime(), snapshot.updatedAt.getTime());
// new history data force created with snapshot state before recovered // new history data force created with snapshot state before recovered
t.deepEqual(history2?.blob, Buffer.from([1, 1])); t.deepEqual(history2.blob, Buffer.from([1, 1]));
t.deepEqual(history2?.state, Buffer.from([1, 1])); t.deepEqual(history2.state, Buffer.from([1, 1]));
}); });
test('should be able to cleanup expired history', async t => { test('should be able to cleanup expired history', async t => {

View File

@@ -16,9 +16,8 @@ import {
QuotaType, QuotaType,
} from '../src/modules/quota'; } from '../src/modules/quota';
import { PrismaModule } from '../src/prisma'; import { PrismaModule } from '../src/prisma';
import { StorageModule } from '../src/storage';
import { RateLimiterModule } from '../src/throttler'; import { RateLimiterModule } from '../src/throttler';
import { initFeatureConfigs } from './utils'; import { FakeStorageModule, initFeatureConfigs } from './utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
@@ -47,10 +46,10 @@ test.beforeEach(async t => {
host: 'example.org', host: 'example.org',
https: true, https: true,
}), }),
StorageModule.forRoot(),
PrismaModule, PrismaModule,
AuthModule, AuthModule,
QuotaModule, QuotaModule,
FakeStorageModule.forRoot(),
RateLimiterModule, RateLimiterModule,
RevertCommand, RevertCommand,
RunCommand, RunCommand,

View File

@@ -6,7 +6,8 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../src/app'; import { AppModule } from '../src/app';
import { currentUser, signUp } from './utils'; import { RevertCommand, RunCommand } from '../src/data/commands/run';
import { currentUser, initFeatureConfigs, signUp } from './utils';
let app: INestApplication; let app: INestApplication;
@@ -21,6 +22,7 @@ test.beforeEach(async () => {
test.beforeEach(async () => { test.beforeEach(async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
providers: [RevertCommand, RunCommand],
}).compile(); }).compile();
app = module.createNestApplication(); app = module.createNestApplication();
app.use( app.use(
@@ -30,6 +32,9 @@ test.beforeEach(async () => {
}) })
); );
await app.init(); await app.init();
// init features
await initFeatureConfigs(module);
}); });
test.afterEach.always(async () => { test.afterEach.always(async () => {

View File

@@ -1,6 +1,10 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { INestApplication } from '@nestjs/common'; import type {
DynamicModule,
FactoryProvider,
INestApplication,
} from '@nestjs/common';
import { TestingModule } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing';
import { hashSync } from '@node-rs/argon2'; import { hashSync } from '@node-rs/argon2';
import { PrismaClient, type User } from '@prisma/client'; import { PrismaClient, type User } from '@prisma/client';
@@ -10,6 +14,7 @@ import { RevertCommand, RunCommand } from '../src/data/commands/run';
import type { TokenType } from '../src/modules/auth'; import type { TokenType } from '../src/modules/auth';
import type { UserType } from '../src/modules/users'; import type { UserType } from '../src/modules/users';
import type { InvitationType, WorkspaceType } from '../src/modules/workspaces'; import type { InvitationType, WorkspaceType } from '../src/modules/workspaces';
import { StorageProvide } from '../src/storage';
const gql = '/graphql'; const gql = '/graphql';
@@ -563,6 +568,24 @@ export class FakePrisma {
} }
} }
export class FakeStorageModule {
static forRoot(): DynamicModule {
const storageProvider: FactoryProvider = {
provide: StorageProvide,
useFactory: async () => {
return null;
},
};
return {
global: true,
module: FakeStorageModule,
providers: [storageProvider],
exports: [storageProvider],
};
}
}
export async function initFeatureConfigs(module: TestingModule) { export async function initFeatureConfigs(module: TestingModule) {
const run = module.get(RunCommand); const run = module.get(RunCommand);
const revert = module.get(RevertCommand); const revert = module.get(RevertCommand);

View File

@@ -15,7 +15,7 @@ import { Menu, MenuItem } from '@affine/component/ui/menu';
import { Tooltip } from '@affine/component/ui/tooltip'; import { Tooltip } from '@affine/component/ui/tooltip';
import type { AffineOfficialWorkspace } from '@affine/env/workspace'; import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace';
import { Permission, SubscriptionPlan } from '@affine/graphql'; import { Permission } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightBigIcon, MoreVerticalIcon } from '@blocksuite/icons'; import { ArrowRightBigIcon, MoreVerticalIcon } from '@blocksuite/icons';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -37,17 +37,11 @@ import { useInviteMember } from '../../../hooks/affine/use-invite-member';
import { useMemberCount } from '../../../hooks/affine/use-member-count'; import { useMemberCount } from '../../../hooks/affine/use-member-count';
import { type Member, useMembers } from '../../../hooks/affine/use-members'; import { type Member, useMembers } from '../../../hooks/affine/use-members';
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission'; import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
import { useUserSubscription } from '../../../hooks/use-subscription'; import { useUserQuota } from '../../../hooks/use-quota';
import { AffineErrorBoundary } from '../affine-error-boundary'; import { AffineErrorBoundary } from '../affine-error-boundary';
import * as style from './style.css'; import * as style from './style.css';
import type { WorkspaceSettingDetailProps } from './types'; import type { WorkspaceSettingDetailProps } from './types';
enum MemberLimitCount {
Free = '3',
Pro = '10',
Other = '?',
}
const COUNT_PER_PAGE = 8; const COUNT_PER_PAGE = 8;
export interface MembersPanelProps extends WorkspaceSettingDetailProps { export interface MembersPanelProps extends WorkspaceSettingDetailProps {
upgradable: boolean; upgradable: boolean;
@@ -148,23 +142,17 @@ export const CloudWorkspaceMembersPanel = ({
}); });
}, [setSettingModalAtom]); }, [setSettingModalAtom]);
const [subscription] = useUserSubscription(); const quota = useUserQuota();
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const memberLimit = useMemo(() => {
if (plan === SubscriptionPlan.Free) {
return MemberLimitCount.Free;
}
if (plan === SubscriptionPlan.Pro) {
return MemberLimitCount.Pro;
}
return MemberLimitCount.Other;
}, [plan]);
const desc = useMemo(() => { const desc = useMemo(() => {
if (!quota) return null;
const humanReadable = quota.humanReadable;
return ( return (
<span> <span>
{t['com.affine.payment.member.description']({ {t['com.affine.payment.member.description']({
planName: plan, planName: humanReadable.name,
memberLimit, memberLimit: humanReadable.memberLimit,
})} })}
{upgradable ? ( {upgradable ? (
<> <>
@@ -179,7 +167,7 @@ export const CloudWorkspaceMembersPanel = ({
) : null} ) : null}
</span> </span>
); );
}, [handleUpgrade, memberLimit, plan, t, upgradable]); }, [handleUpgrade, quota, t, upgradable]);
return ( return (
<> <>

View File

@@ -0,0 +1,10 @@
import { quotaQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export const useUserQuota = () => {
const { data } = useQuery({
query: quotaQuery,
});
return data.currentUser?.quota || null;
};

View File

@@ -508,6 +508,32 @@ mutation publishPage($workspaceId: String!, $pageId: String!, $mode: PublicPageM
}`, }`,
}; };
export const quotaQuery = {
id: 'quotaQuery' as const,
operationName: 'quota',
definitionName: 'currentUser',
containsFile: false,
query: `
query quota {
currentUser {
quota {
name
blobLimit
storageQuota
historyPeriod
memberLimit
humanReadable {
name
blobLimit
storageQuota
historyPeriod
memberLimit
}
}
}
}`,
};
export const recoverDocMutation = { export const recoverDocMutation = {
id: 'recoverDocMutation' as const, id: 'recoverDocMutation' as const,
operationName: 'recoverDoc', operationName: 'recoverDoc',

View File

@@ -0,0 +1,18 @@
query quota {
currentUser {
quota {
name
blobLimit
storageQuota
historyPeriod
memberLimit
humanReadable {
name
blobLimit
storageQuota
historyPeriod
memberLimit
}
}
}
}

View File

@@ -499,6 +499,31 @@ export type PublishPageMutation = {
}; };
}; };
export type QuotaQueryVariables = Exact<{ [key: string]: never }>;
export type QuotaQuery = {
__typename?: 'Query';
currentUser: {
__typename?: 'UserType';
quota: {
__typename?: 'UserQuota';
name: string;
blobLimit: number;
storageQuota: number;
historyPeriod: number;
memberLimit: number;
humanReadable: {
__typename?: 'UserQuotaHumanReadable';
name: string;
blobLimit: string;
storageQuota: string;
historyPeriod: string;
memberLimit: string;
};
} | null;
} | null;
};
export type RecoverDocMutationVariables = Exact<{ export type RecoverDocMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input']; docId: Scalars['String']['input'];
@@ -819,6 +844,11 @@ export type Queries =
variables: PricesQueryVariables; variables: PricesQueryVariables;
response: PricesQuery; response: PricesQuery;
} }
| {
name: 'quotaQuery';
variables: QuotaQueryVariables;
response: QuotaQuery;
}
| { | {
name: 'serverConfigQuery'; name: 'serverConfigQuery';
variables: ServerConfigQueryVariables; variables: ServerConfigQueryVariables;