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

View File

@@ -57,6 +57,12 @@ export class QuotaConfig {
return this.config.configs.blobLimit;
}
get businessBlobLimit() {
return (
this.config.configs.businessBlobLimit || this.config.configs.blobLimit
);
}
get storageQuota() {
return this.config.configs.storageQuota;
}

View File

@@ -71,11 +71,33 @@ export const Quotas: Quota[] = [
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 = {
feature: Quotas[3].feature,
version: Quotas[3].version,
feature: Quotas[4].feature,
version: Quotas[4].version,
};
export const Quota_ProPlanV1 = {

View File

@@ -7,6 +7,8 @@ import { OneGB } from './constant';
import { QuotaService } from './service';
import { formatSize, QuotaQueryType } from './types';
type QuotaBusinessType = QuotaQueryType & { businessBlobLimit: number };
@Injectable()
export class QuotaManagementService {
constructor(
@@ -25,6 +27,7 @@ export class QuotaManagementService {
createAt: quota.createdAt,
expiredAt: quota.expiredAt,
blobLimit: quota.feature.blobLimit,
businessBlobLimit: quota.feature.businessBlobLimit,
storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit,
@@ -44,7 +47,7 @@ export class QuotaManagementService {
// get workspace's owner quota and total size of used
// quota was apply to owner's account
async getWorkspaceUsage(workspaceId: string): Promise<QuotaQueryType> {
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found');
@@ -52,6 +55,7 @@ export class QuotaManagementService {
feature: {
name,
blobLimit,
businessBlobLimit,
historyPeriod,
memberLimit,
storageQuota,
@@ -64,6 +68,7 @@ export class QuotaManagementService {
const quota = {
name,
blobLimit,
businessBlobLimit,
historyPeriod,
memberLimit,
storageQuota,
@@ -84,7 +89,7 @@ export class QuotaManagementService {
return quota;
}
private mergeUnlimitedQuota(orig: QuotaQueryType) {
private mergeUnlimitedQuota(orig: QuotaBusinessType) {
return {
...orig,
storageQuota: 1000 * OneGB,

View File

@@ -33,6 +33,7 @@ const quotaPlan = z.object({
storageQuota: z.number().positive().int(),
historyPeriod: z.number().positive().int(),
memberLimit: z.number().positive().int(),
businessBlobLimit: z.number().positive().int().nullish(),
}),
});

View File

@@ -128,7 +128,7 @@ export class WorkspaceBlobResolver {
Permission.Write
);
const { storageQuota, usedSize, blobLimit } =
const { storageQuota, usedSize, businessBlobLimit } =
await this.quota.getWorkspaceUsage(workspaceId);
const unlimited = await this.feature.hasWorkspaceFeature(
@@ -152,8 +152,10 @@ export class WorkspaceBlobResolver {
`storage size limit exceeded: ${total} > ${storageQuota}`
);
return true;
} else if (recvSize > blobLimit) {
this.logger.log(`blob size limit exceeded: ${recvSize} > ${blobLimit}`);
} else if (recvSize > businessBlobLimit) {
this.logger.log(
`blob size limit exceeded: ${recvSize} > ${businessBlobLimit}`
);
return true;
} else {
return false;

View File

@@ -1,65 +1,14 @@
import { PrismaClient } from '@prisma/client';
import { FeatureKind } from '../../core/features';
import { Quotas } from '../../core/quota';
import { upsertFeature } from './utils/user-features';
import { upgradeQuotaVersion } from './utils/user-quotas';
export class NewFreePlan1705395933447 {
// do the migration
static async up(db: PrismaClient) {
// add new free plan
await upsertFeature(db, Quotas[3]);
// migrate all free plan users to new free plan
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,
})),
});
});
// free plan 1.0
const quota = Quotas[3];
await upgradeQuotaVersion(db, quota, 'free plan 1.0 migration');
}
// revert the migration

View File

@@ -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) {}
}

View File

@@ -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,
})),
});
});
}

View File

@@ -199,9 +199,9 @@ type Query {
type QuotaQueryType {
blobLimit: SafeInt!
historyPeriod: Int!
historyPeriod: SafeInt!
humanReadable: HumanReadableQuotaType!
memberLimit: Int!
memberLimit: SafeInt!
name: String!
storageQuota: SafeInt!
usedSize: SafeInt!

View File

@@ -48,7 +48,7 @@ test('should be able to set quota', async t => {
const q1 = await quota.getUserQuota(u1.id);
t.truthy(q1, 'should have quota');
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);
@@ -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 q1 = await storageQuota.getUserQuota(u1.id);
t.is(q1?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[3].configs.storageQuota, 'should be free plan');
t.is(q1?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[4].configs.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
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 q1 = await storageQuota.getUserQuota(u1.id);
t.is(q1?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[3].configs.storageQuota, 'should be free plan');
t.is(q1?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[4].configs.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
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);
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);
t.is(quotas.length, 3, 'should have 3 quotas');