feat: add business blob limit (#5734)

This commit is contained in:
DarkSky
2024-01-29 16:32:35 +08:00
committed by GitHub
parent 0044be972f
commit fc8a48fb43
10 changed files with 136 additions and 70 deletions
@@ -57,6 +57,12 @@ export class QuotaConfig {
return this.config.configs.blobLimit; return this.config.configs.blobLimit;
} }
get businessBlobLimit() {
return (
this.config.configs.businessBlobLimit || this.config.configs.blobLimit
);
}
get storageQuota() { get storageQuota() {
return this.config.configs.storageQuota; return this.config.configs.storageQuota;
} }
@@ -71,11 +71,33 @@ export const Quotas: Quota[] = [
memberLimit: 3, memberLimit: 3,
}, },
}, },
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 3,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 10 * OneMB,
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: 100 * OneMB,
// total blob limit 10GB
storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
},
},
]; ];
export const Quota_FreePlanV1_1 = { export const Quota_FreePlanV1_1 = {
feature: Quotas[3].feature, feature: Quotas[4].feature,
version: Quotas[3].version, version: Quotas[4].version,
}; };
export const Quota_ProPlanV1 = { export const Quota_ProPlanV1 = {
@@ -7,6 +7,8 @@ import { OneGB } from './constant';
import { QuotaService } from './service'; import { QuotaService } from './service';
import { formatSize, QuotaQueryType } from './types'; import { formatSize, QuotaQueryType } from './types';
type QuotaBusinessType = QuotaQueryType & { businessBlobLimit: number };
@Injectable() @Injectable()
export class QuotaManagementService { export class QuotaManagementService {
constructor( constructor(
@@ -25,6 +27,7 @@ export class QuotaManagementService {
createAt: quota.createdAt, createAt: quota.createdAt,
expiredAt: quota.expiredAt, expiredAt: quota.expiredAt,
blobLimit: quota.feature.blobLimit, blobLimit: quota.feature.blobLimit,
businessBlobLimit: quota.feature.businessBlobLimit,
storageQuota: quota.feature.storageQuota, storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod, historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit, memberLimit: quota.feature.memberLimit,
@@ -44,7 +47,7 @@ export class QuotaManagementService {
// get workspace's owner quota and total size of used // get workspace's owner quota and total size of used
// quota was apply to owner's account // quota was apply to owner's account
async getWorkspaceUsage(workspaceId: string): Promise<QuotaQueryType> { async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
const { user: owner } = const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId); await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found'); if (!owner) throw new NotFoundException('Workspace owner not found');
@@ -52,6 +55,7 @@ export class QuotaManagementService {
feature: { feature: {
name, name,
blobLimit, blobLimit,
businessBlobLimit,
historyPeriod, historyPeriod,
memberLimit, memberLimit,
storageQuota, storageQuota,
@@ -64,6 +68,7 @@ export class QuotaManagementService {
const quota = { const quota = {
name, name,
blobLimit, blobLimit,
businessBlobLimit,
historyPeriod, historyPeriod,
memberLimit, memberLimit,
storageQuota, storageQuota,
@@ -84,7 +89,7 @@ export class QuotaManagementService {
return quota; return quota;
} }
private mergeUnlimitedQuota(orig: QuotaQueryType) { private mergeUnlimitedQuota(orig: QuotaBusinessType) {
return { return {
...orig, ...orig,
storageQuota: 1000 * OneGB, storageQuota: 1000 * OneGB,
@@ -33,6 +33,7 @@ const quotaPlan = z.object({
storageQuota: z.number().positive().int(), storageQuota: z.number().positive().int(),
historyPeriod: z.number().positive().int(), historyPeriod: z.number().positive().int(),
memberLimit: z.number().positive().int(), memberLimit: z.number().positive().int(),
businessBlobLimit: z.number().positive().int().nullish(),
}), }),
}); });
@@ -128,7 +128,7 @@ export class WorkspaceBlobResolver {
Permission.Write Permission.Write
); );
const { storageQuota, usedSize, blobLimit } = const { storageQuota, usedSize, businessBlobLimit } =
await this.quota.getWorkspaceUsage(workspaceId); await this.quota.getWorkspaceUsage(workspaceId);
const unlimited = await this.feature.hasWorkspaceFeature( const unlimited = await this.feature.hasWorkspaceFeature(
@@ -152,8 +152,10 @@ export class WorkspaceBlobResolver {
`storage size limit exceeded: ${total} > ${storageQuota}` `storage size limit exceeded: ${total} > ${storageQuota}`
); );
return true; return true;
} else if (recvSize > blobLimit) { } else if (recvSize > businessBlobLimit) {
this.logger.log(`blob size limit exceeded: ${recvSize} > ${blobLimit}`); this.logger.log(
`blob size limit exceeded: ${recvSize} > ${businessBlobLimit}`
);
return true; return true;
} else { } else {
return false; return false;
@@ -1,65 +1,14 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { FeatureKind } from '../../core/features';
import { Quotas } from '../../core/quota'; import { Quotas } from '../../core/quota';
import { upsertFeature } from './utils/user-features'; import { upgradeQuotaVersion } from './utils/user-quotas';
export class NewFreePlan1705395933447 { export class NewFreePlan1705395933447 {
// do the migration // do the migration
static async up(db: PrismaClient) { static async up(db: PrismaClient) {
// add new free plan // free plan 1.0
await upsertFeature(db, Quotas[3]); const quota = Quotas[3];
// migrate all free plan users to new free plan await upgradeQuotaVersion(db, quota, 'free plan 1.0 migration');
await db.$transaction(async tx => {
const latestFreePlan = await tx.features.findFirstOrThrow({
where: { feature: Quotas[3].feature },
orderBy: { version: 'desc' },
select: { id: true },
});
// find all users that have old free plan
const userIds = await db.user.findMany({
where: {
features: {
every: {
feature: {
type: FeatureKind.Quota,
feature: Quotas[3].feature,
version: { lt: Quotas[3].version },
},
activated: true,
},
},
},
select: { id: true },
});
// deactivate all old quota for the user
await tx.userFeatures.updateMany({
where: {
id: undefined,
userId: {
in: userIds.map(({ id }) => id),
},
feature: {
type: FeatureKind.Quota,
},
activated: true,
},
data: {
activated: false,
},
});
await tx.userFeatures.createMany({
data: userIds.map(({ id: userId }) => ({
userId,
featureId: latestFreePlan.id,
reason: 'free plan 1.0 migration',
activated: true,
})),
});
});
} }
// revert the migration // revert the migration
@@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client';
import { Quotas } from '../../core/quota';
import { upgradeQuotaVersion } from './utils/user-quotas';
export class BusinessBlobLimit1706513866287 {
// do the migration
static async up(db: PrismaClient) {
// free plan 1.1
const quota = Quotas[4];
await upgradeQuotaVersion(db, quota, 'free plan 1.1 migration');
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,65 @@
import { PrismaClient } from '@prisma/client';
import { FeatureKind } from '../../../core/features';
import { Quota } from '../../../core/quota/types';
import { upsertFeature } from './user-features';
export async function upgradeQuotaVersion(
db: PrismaClient,
quota: Quota,
reason: string
) {
// add new quota
await upsertFeature(db, quota);
// migrate all users that using old quota to new quota
await db.$transaction(async tx => {
const latestQuotaVersion = await tx.features.findFirstOrThrow({
where: { feature: quota.feature },
orderBy: { version: 'desc' },
select: { id: true },
});
// find all users that have old free plan
const userIds = await db.user.findMany({
where: {
features: {
every: {
feature: {
type: FeatureKind.Quota,
feature: quota.feature,
version: { lt: quota.version },
},
activated: true,
},
},
},
select: { id: true },
});
// deactivate all old quota for the user
await tx.userFeatures.updateMany({
where: {
id: undefined,
userId: {
in: userIds.map(({ id }) => id),
},
feature: {
type: FeatureKind.Quota,
},
activated: true,
},
data: {
activated: false,
},
});
await tx.userFeatures.createMany({
data: userIds.map(({ id: userId }) => ({
userId,
featureId: latestQuotaVersion.id,
reason,
activated: true,
})),
});
});
}
+2 -2
View File
@@ -199,9 +199,9 @@ type Query {
type QuotaQueryType { type QuotaQueryType {
blobLimit: SafeInt! blobLimit: SafeInt!
historyPeriod: Int! historyPeriod: SafeInt!
humanReadable: HumanReadableQuotaType! humanReadable: HumanReadableQuotaType!
memberLimit: Int! memberLimit: SafeInt!
name: String! name: String!
storageQuota: SafeInt! storageQuota: SafeInt!
usedSize: SafeInt! usedSize: SafeInt!
+6 -6
View File
@@ -48,7 +48,7 @@ test('should be able to set quota', async t => {
const q1 = await quota.getUserQuota(u1.id); const q1 = await quota.getUserQuota(u1.id);
t.truthy(q1, 'should have quota'); t.truthy(q1, 'should have quota');
t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan'); t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan');
t.is(q1?.feature.version, 2, 'should be version 2'); t.is(q1?.feature.version, 3, 'should be version 2');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
@@ -64,8 +64,8 @@ test('should be able to check storage quota', async t => {
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
const q1 = await storageQuota.getUserQuota(u1.id); const q1 = await storageQuota.getUserQuota(u1.id);
t.is(q1?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan'); t.is(q1?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[3].configs.storageQuota, 'should be free plan'); t.is(q1?.storageQuota, Quotas[4].configs.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
const q2 = await storageQuota.getUserQuota(u1.id); const q2 = await storageQuota.getUserQuota(u1.id);
@@ -78,8 +78,8 @@ test('should be able revert quota', async t => {
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
const q1 = await storageQuota.getUserQuota(u1.id); const q1 = await storageQuota.getUserQuota(u1.id);
t.is(q1?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan'); t.is(q1?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[3].configs.storageQuota, 'should be free plan'); t.is(q1?.storageQuota, Quotas[4].configs.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
const q2 = await storageQuota.getUserQuota(u1.id); const q2 = await storageQuota.getUserQuota(u1.id);
@@ -88,7 +88,7 @@ test('should be able revert quota', async t => {
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1); await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
const q3 = await storageQuota.getUserQuota(u1.id); const q3 = await storageQuota.getUserQuota(u1.id);
t.is(q3?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan'); t.is(q3?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
const quotas = await quota.getUserQuotas(u1.id); const quotas = await quota.getUserQuotas(u1.id);
t.is(quotas.length, 3, 'should have 3 quotas'); t.is(quotas.length, 3, 'should have 3 quotas');