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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import Stripe from 'stripe';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { FeatureManagementService } from '../features';
import { QuotaService, QuotaType } from '../quota';
import { ScheduleManager } from './schedule';
const OnEvent = (
@@ -60,6 +61,11 @@ export enum SubscriptionStatus {
Trialing = 'trialing',
}
const SubscriptionActivated: Stripe.Subscription.Status[] = [
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
];
export enum InvoiceStatus {
Draft = 'draft',
Open = 'open',
@@ -83,7 +89,8 @@ export class SubscriptionService {
private readonly stripe: Stripe,
private readonly db: PrismaService,
private readonly scheduleManager: ScheduleManager,
private readonly features: FeatureManagementService
private readonly features: FeatureManagementService,
private readonly quota: QuotaService
) {
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(
user: User,
subscription: Stripe.Subscription,
@@ -483,23 +500,28 @@ export class SubscriptionService {
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;
if (!price.lookup_key) {
throw new Error('Unexpected subscription with no 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 = {
start: new Date(subscription.current_period_start * 1000),

View File

@@ -2,7 +2,13 @@ import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { FeatureKind } from '../features';
import { Quota, QuotaType } from './types';
import {
formatDate,
formatSize,
getQuotaName,
Quota,
QuotaType,
} from './types';
@Injectable()
export class QuotaService {
@@ -32,11 +38,22 @@ export class QuotaService {
},
},
});
console.error(userId, quota);
return quota as typeof quota & {
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
async getUserQuotas(userId: string) {
const quotas = await this.prisma.userFeatures.findMany({

View File

@@ -5,15 +5,48 @@ export enum QuotaType {
Quota_ProPlanV1 = 'pro_plan_v1',
}
export enum QuotaName {
free_plan_v1 = 'Free Plan',
pro_plan_v1 = 'Pro Plan',
}
export type Quota = CommonFeature & {
type: FeatureKind.Quota;
feature: QuotaType;
configs: {
blobLimit: 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[] = [
{
feature: QuotaType.Quota_FreePlanV1,
@@ -21,9 +54,13 @@ export const Quotas: Quota[] = [
version: 1,
configs: {
// single blob limit 10MB
blobLimit: 10 * 1024 * 1024,
blobLimit: 10 * OneMB,
// 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,
configs: {
// single blob limit 100MB
blobLimit: 100 * 1024 * 1024,
blobLimit: 100 * OneMB,
// 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 { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
import { UserResolver } from './resolver';
import { UsersService } from './users';
@Module({
imports: [StorageModule, FeatureModule],
imports: [StorageModule, FeatureModule, QuotaModule],
providers: [UserResolver, UsersService],
exports: [UsersService],
})
export class UsersModule {}
export { UserType } from './resolver';
export { UserType } from './types';
export { UsersService } from './users';

View File

@@ -7,11 +7,8 @@ import {
import {
Args,
Context,
Field,
ID,
Int,
Mutation,
ObjectType,
Query,
ResolveField,
Resolver,
@@ -26,47 +23,11 @@ import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { AuthService } from '../auth/service';
import { FeatureManagementService } from '../features';
import { QuotaService } from '../quota';
import { StorageService } from '../storage/storage.service';
import { DeleteAccount, RemoveAvatar, UserQuotaType, UserType } from './types';
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
* All op rate limit: 10 req/m
@@ -80,7 +41,8 @@ export class UserResolver {
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly users: UsersService,
private readonly feature: FeatureManagementService
private readonly feature: FeatureManagementService,
private readonly quota: QuotaService
) {}
@Throttle({
@@ -148,6 +110,24 @@ export class UserResolver {
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 } })
@ResolveField(() => Int, {
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 { AuthService } from '../auth/service';
import { QuotaManagementService } from '../quota';
import { UsersService } from '../users';
import { UserType } from '../users/resolver';
import { UsersService, UserType } from '../users';
import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types';
import { defaultWorkspaceAvatar } from './utils';