diff --git a/packages/backend/server/src/core/quota/quota.ts b/packages/backend/server/src/core/quota/quota.ts index 5139dd02c8..0aaf6d1282 100644 --- a/packages/backend/server/src/core/quota/quota.ts +++ b/packages/backend/server/src/core/quota/quota.ts @@ -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; } diff --git a/packages/backend/server/src/core/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts index a038ff26f3..8b122d6aed 100644 --- a/packages/backend/server/src/core/quota/schema.ts +++ b/packages/backend/server/src/core/quota/schema.ts @@ -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 = { diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts index 8e853e5174..234c24df5f 100644 --- a/packages/backend/server/src/core/quota/storage.ts +++ b/packages/backend/server/src/core/quota/storage.ts @@ -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 { + async getWorkspaceUsage(workspaceId: string): Promise { 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, diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index 8c5f83e292..888bedb446 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -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(), }), }); diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 7f3cba8b12..6429956db8 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -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; diff --git a/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts b/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts index 0bb5c2dcbf..dc6bf27966 100644 --- a/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts +++ b/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts @@ -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 diff --git a/packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts b/packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts new file mode 100644 index 0000000000..f19aec6fd2 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts @@ -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) {} +} diff --git a/packages/backend/server/src/data/migrations/utils/user-quotas.ts b/packages/backend/server/src/data/migrations/utils/user-quotas.ts new file mode 100644 index 0000000000..45a59e636c --- /dev/null +++ b/packages/backend/server/src/data/migrations/utils/user-quotas.ts @@ -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, + })), + }); + }); +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 23e21f2aaf..70cca83979 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! diff --git a/packages/backend/server/tests/quota.spec.ts b/packages/backend/server/tests/quota.spec.ts index d82663e0c0..9c01ebc09e 100644 --- a/packages/backend/server/tests/quota.spec.ts +++ b/packages/backend/server/tests/quota.spec.ts @@ -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');