feat: check quota correctly (#6561)
This commit is contained in:
@@ -54,10 +54,23 @@ export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UnlimitedCopilotFeatureConfig extends FeatureConfig {
|
||||||
|
override config!: Feature & { feature: FeatureType.UnlimitedCopilot };
|
||||||
|
|
||||||
|
constructor(data: any) {
|
||||||
|
super(data);
|
||||||
|
|
||||||
|
if (this.config.feature !== FeatureType.UnlimitedCopilot) {
|
||||||
|
throw new Error('Invalid feature config: type is not UnlimitedWorkspace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FeatureConfigMap = {
|
const FeatureConfigMap = {
|
||||||
[FeatureType.Copilot]: CopilotFeatureConfig,
|
[FeatureType.Copilot]: CopilotFeatureConfig,
|
||||||
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
|
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
|
||||||
[FeatureType.UnlimitedWorkspace]: UnlimitedWorkspaceFeatureConfig,
|
[FeatureType.UnlimitedWorkspace]: UnlimitedWorkspaceFeatureConfig,
|
||||||
|
[FeatureType.UnlimitedCopilot]: UnlimitedCopilotFeatureConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeatureConfigType<F extends FeatureType> = InstanceType<
|
export type FeatureConfigType<F extends FeatureType> = InstanceType<
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export class FeatureManagementService {
|
|||||||
return this.feature.addUserFeature(
|
return this.feature.addUserFeature(
|
||||||
userId,
|
userId,
|
||||||
FeatureType.EarlyAccess,
|
FeatureType.EarlyAccess,
|
||||||
2,
|
|
||||||
'Early access user'
|
'Early access user'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,9 +115,9 @@ export class FeatureManagementService {
|
|||||||
return this.feature.listFeatureWorkspaces(feature);
|
return this.feature.listFeatureWorkspaces(feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserFeatures(userId: string): Promise<FeatureType[]> {
|
// ======== User Feature ========
|
||||||
return (await this.feature.getUserFeatures(userId)).map(
|
async getActivatedUserFeatures(userId: string): Promise<FeatureType[]> {
|
||||||
f => f.feature.name
|
const features = await this.feature.getActivatedUserFeatures(userId);
|
||||||
);
|
return features.map(f => f.feature.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,17 @@ export class FeatureService {
|
|||||||
async addUserFeature(
|
async addUserFeature(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: FeatureType,
|
feature: FeatureType,
|
||||||
version: number,
|
|
||||||
reason: string,
|
reason: string,
|
||||||
expiredAt?: Date | string
|
expiredAt?: Date | string
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async tx => {
|
return this.prisma.$transaction(async tx => {
|
||||||
|
const latestVersion = await tx.features
|
||||||
|
.aggregate({
|
||||||
|
where: { feature },
|
||||||
|
_max: { version: true },
|
||||||
|
})
|
||||||
|
.then(r => r._max.version || 1);
|
||||||
|
|
||||||
const latestFlag = await tx.userFeatures.findFirst({
|
const latestFlag = await tx.userFeatures.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -95,7 +101,7 @@ export class FeatureService {
|
|||||||
connect: {
|
connect: {
|
||||||
feature_version: {
|
feature_version: {
|
||||||
feature,
|
feature,
|
||||||
version,
|
version: latestVersion,
|
||||||
},
|
},
|
||||||
type: FeatureKind.Feature,
|
type: FeatureKind.Feature,
|
||||||
},
|
},
|
||||||
@@ -157,6 +163,33 @@ export class FeatureService {
|
|||||||
return configs.filter(feature => !!feature.feature);
|
return configs.filter(feature => !!feature.feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivatedUserFeatures(userId: string) {
|
||||||
|
const features = await this.prisma.userFeatures.findMany({
|
||||||
|
where: {
|
||||||
|
user: { id: userId },
|
||||||
|
feature: { type: FeatureKind.Feature },
|
||||||
|
activated: true,
|
||||||
|
OR: [{ expiredAt: null }, { expiredAt: { gt: new Date() } }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
activated: true,
|
||||||
|
reason: true,
|
||||||
|
createdAt: true,
|
||||||
|
expiredAt: true,
|
||||||
|
featureId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const configs = await Promise.all(
|
||||||
|
features.map(async feature => ({
|
||||||
|
...feature,
|
||||||
|
feature: await getFeature(this.prisma, feature.featureId),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return configs.filter(feature => !!feature.feature);
|
||||||
|
}
|
||||||
|
|
||||||
async listFeatureUsers(feature: FeatureType) {
|
async listFeatureUsers(feature: FeatureType) {
|
||||||
return this.prisma.userFeatures
|
return this.prisma.userFeatures
|
||||||
.findMany({
|
.findMany({
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { registerEnumType } from '@nestjs/graphql';
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
export enum FeatureType {
|
export enum FeatureType {
|
||||||
Copilot = 'copilot',
|
// user feature
|
||||||
EarlyAccess = 'early_access',
|
EarlyAccess = 'early_access',
|
||||||
|
UnlimitedCopilot = 'unlimited_copilot',
|
||||||
|
// workspace feature
|
||||||
|
Copilot = 'copilot',
|
||||||
UnlimitedWorkspace = 'unlimited_workspace',
|
UnlimitedWorkspace = 'unlimited_workspace',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import { FeatureType } from './common';
|
import { FeatureType } from './common';
|
||||||
import { featureCopilot } from './copilot';
|
import { featureCopilot } from './copilot';
|
||||||
import { featureEarlyAccess } from './early-access';
|
import { featureEarlyAccess } from './early-access';
|
||||||
|
import { featureUnlimitedCopilot } from './unlimited-copilot';
|
||||||
import { featureUnlimitedWorkspace } from './unlimited-workspace';
|
import { featureUnlimitedWorkspace } from './unlimited-workspace';
|
||||||
|
|
||||||
/// ======== common schema ========
|
/// ======== common schema ========
|
||||||
@@ -52,6 +53,12 @@ export const Features: Feature[] = [
|
|||||||
version: 1,
|
version: 1,
|
||||||
configs: {},
|
configs: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
feature: FeatureType.UnlimitedCopilot,
|
||||||
|
type: FeatureKind.Feature,
|
||||||
|
version: 1,
|
||||||
|
configs: {},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// ======== schema infer ========
|
/// ======== schema infer ========
|
||||||
@@ -65,6 +72,7 @@ export const FeatureSchema = commonFeatureSchema
|
|||||||
featureCopilot,
|
featureCopilot,
|
||||||
featureEarlyAccess,
|
featureEarlyAccess,
|
||||||
featureUnlimitedWorkspace,
|
featureUnlimitedWorkspace,
|
||||||
|
featureUnlimitedCopilot,
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FeatureType } from './common';
|
||||||
|
|
||||||
|
export const featureUnlimitedCopilot = z.object({
|
||||||
|
feature: z.literal(FeatureType.UnlimitedCopilot),
|
||||||
|
configs: z.object({}),
|
||||||
|
});
|
||||||
@@ -20,5 +20,5 @@ import { QuotaManagementService } from './storage';
|
|||||||
export class QuotaModule {}
|
export class QuotaModule {}
|
||||||
|
|
||||||
export { QuotaManagementService, QuotaService };
|
export { QuotaManagementService, QuotaService };
|
||||||
export { Quota_FreePlanV1_1, Quota_ProPlanV1, Quotas } from './schema';
|
export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema';
|
||||||
export { QuotaQueryType, QuotaType } from './types';
|
export { QuotaQueryType, QuotaType } from './types';
|
||||||
|
|||||||
@@ -117,14 +117,61 @@ export const Quotas: Quota[] = [
|
|||||||
copilotActionLimit: 10,
|
copilotActionLimit: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
feature: QuotaType.ProPlanV1,
|
||||||
|
type: FeatureKind.Quota,
|
||||||
|
version: 2,
|
||||||
|
configs: {
|
||||||
|
// quota name
|
||||||
|
name: 'Pro',
|
||||||
|
// single blob limit 100MB
|
||||||
|
blobLimit: 100 * OneMB,
|
||||||
|
// total blob limit 100GB
|
||||||
|
storageQuota: 100 * OneGB,
|
||||||
|
// history period of validity 30 days
|
||||||
|
historyPeriod: 30 * OneDay,
|
||||||
|
// member limit 10
|
||||||
|
memberLimit: 10,
|
||||||
|
// copilot action limit 10
|
||||||
|
copilotActionLimit: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feature: QuotaType.RestrictedPlanV1,
|
||||||
|
type: FeatureKind.Quota,
|
||||||
|
version: 2,
|
||||||
|
configs: {
|
||||||
|
// quota name
|
||||||
|
name: 'Restricted',
|
||||||
|
// single blob limit 10MB
|
||||||
|
blobLimit: OneMB,
|
||||||
|
// total blob limit 1GB
|
||||||
|
storageQuota: 10 * OneMB,
|
||||||
|
// history period of validity 30 days
|
||||||
|
historyPeriod: 30 * OneDay,
|
||||||
|
// member limit 10
|
||||||
|
memberLimit: 10,
|
||||||
|
// copilot action limit 10
|
||||||
|
copilotActionLimit: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function getLatestQuota(type: QuotaType) {
|
||||||
|
const quota = Quotas.filter(f => f.feature === type);
|
||||||
|
quota.sort((a, b) => b.version - a.version);
|
||||||
|
return quota[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
||||||
|
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
|
||||||
|
|
||||||
export const Quota_FreePlanV1_1 = {
|
export const Quota_FreePlanV1_1 = {
|
||||||
feature: Quotas[5].feature,
|
feature: Quotas[5].feature,
|
||||||
version: Quotas[5].version,
|
version: Quotas[5].version,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Quota_ProPlanV1 = {
|
export const Quota_ProPlanV1 = {
|
||||||
feature: Quotas[1].feature,
|
feature: Quotas[6].feature,
|
||||||
version: Quotas[1].version,
|
version: Quotas[6].version,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
import type { EventPayload } from '../../fundamentals';
|
import type { EventPayload } from '../../fundamentals';
|
||||||
import { OnEvent, PrismaTransaction } from '../../fundamentals';
|
import { OnEvent, PrismaTransaction } from '../../fundamentals';
|
||||||
import { FeatureKind } from '../features';
|
import { SubscriptionPlan } from '../../plugins/payment/types';
|
||||||
|
import { FeatureKind, FeatureService, FeatureType } from '../features';
|
||||||
import { QuotaConfig } from './quota';
|
import { QuotaConfig } from './quota';
|
||||||
import { QuotaType } from './types';
|
import { QuotaType } from './types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QuotaService {
|
export class QuotaService {
|
||||||
constructor(private readonly prisma: PrismaClient) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly feature: FeatureService
|
||||||
|
) {}
|
||||||
|
|
||||||
// get activated user quota
|
// get activated user quota
|
||||||
async getUserQuota(userId: string) {
|
async getUserQuota(userId: string) {
|
||||||
@@ -159,22 +163,49 @@ export class QuotaService {
|
|||||||
@OnEvent('user.subscription.activated')
|
@OnEvent('user.subscription.activated')
|
||||||
async onSubscriptionUpdated({
|
async onSubscriptionUpdated({
|
||||||
userId,
|
userId,
|
||||||
|
plan,
|
||||||
}: EventPayload<'user.subscription.activated'>) {
|
}: EventPayload<'user.subscription.activated'>) {
|
||||||
|
switch (plan) {
|
||||||
|
case SubscriptionPlan.AI:
|
||||||
|
await this.feature.addUserFeature(
|
||||||
|
userId,
|
||||||
|
FeatureType.UnlimitedCopilot,
|
||||||
|
'subscription activated'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case SubscriptionPlan.Pro:
|
||||||
await this.switchUserQuota(
|
await this.switchUserQuota(
|
||||||
userId,
|
userId,
|
||||||
QuotaType.ProPlanV1,
|
QuotaType.ProPlanV1,
|
||||||
'subscription activated'
|
'subscription activated'
|
||||||
);
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('user.subscription.canceled')
|
@OnEvent('user.subscription.canceled')
|
||||||
async onSubscriptionCanceled(
|
async onSubscriptionCanceled({
|
||||||
userId: EventPayload<'user.subscription.canceled'>
|
userId,
|
||||||
) {
|
plan,
|
||||||
|
}: EventPayload<'user.subscription.canceled'>) {
|
||||||
|
switch (plan) {
|
||||||
|
case SubscriptionPlan.AI:
|
||||||
|
await this.feature.removeUserFeature(
|
||||||
|
userId,
|
||||||
|
FeatureType.UnlimitedCopilot
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case SubscriptionPlan.Pro:
|
||||||
await this.switchUserQuota(
|
await this.switchUserQuota(
|
||||||
userId,
|
userId,
|
||||||
QuotaType.FreePlanV1,
|
QuotaType.FreePlanV1,
|
||||||
'subscription canceled'
|
'subscription canceled'
|
||||||
);
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export class UserResolver {
|
|||||||
description: 'Enabled features of a user',
|
description: 'Enabled features of a user',
|
||||||
})
|
})
|
||||||
async userFeatures(@CurrentUser() user: CurrentUser) {
|
async userFeatures(@CurrentUser() user: CurrentUser) {
|
||||||
return this.feature.getUserFeatures(user.id);
|
return this.feature.getActivatedUserFeatures(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({
|
@Throttle({
|
||||||
|
|||||||
@@ -117,12 +117,7 @@ export class WorkspaceManagementResolver {
|
|||||||
async availableFeatures(
|
async availableFeatures(
|
||||||
@CurrentUser() user: CurrentUser
|
@CurrentUser() user: CurrentUser
|
||||||
): Promise<FeatureType[]> {
|
): Promise<FeatureType[]> {
|
||||||
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
|
return await this.feature.getActivatedUserFeatures(user.id);
|
||||||
if (isEarlyAccessUser) {
|
|
||||||
return [FeatureType.Copilot];
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => [FeatureType], {
|
@ResolveField(() => [FeatureType], {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { Quotas } from '../../core/quota';
|
import { Quotas } from '../../core/quota/schema';
|
||||||
import { upgradeQuotaVersion } from './utils/user-quotas';
|
import { upgradeQuotaVersion } from './utils/user-quotas';
|
||||||
|
|
||||||
export class NewFreePlan1705395933447 {
|
export class NewFreePlan1705395933447 {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { Quotas } from '../../core/quota';
|
import { Quotas } from '../../core/quota/schema';
|
||||||
import { upgradeQuotaVersion } from './utils/user-quotas';
|
import { upgradeQuotaVersion } from './utils/user-quotas';
|
||||||
|
|
||||||
export class BusinessBlobLimit1706513866287 {
|
export class BusinessBlobLimit1706513866287 {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { QuotaType } from '../../core/quota/types';
|
||||||
|
import { upgradeLatestQuotaVersion } from './utils/user-quotas';
|
||||||
|
|
||||||
|
export class CopilotFeature1713164714634 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaClient) {
|
||||||
|
await upgradeLatestQuotaVersion(
|
||||||
|
db,
|
||||||
|
QuotaType.ProPlanV1,
|
||||||
|
'pro plan 1.1 migration'
|
||||||
|
);
|
||||||
|
await upgradeLatestQuotaVersion(
|
||||||
|
db,
|
||||||
|
QuotaType.RestrictedPlanV1,
|
||||||
|
'restricted plan 1.1 migration'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down(_db: PrismaClient) {}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { FeatureKind } from '../../../core/features';
|
import { FeatureKind } from '../../../core/features';
|
||||||
import { Quotas } from '../../../core/quota/schema';
|
import { getLatestQuota } from '../../../core/quota/schema';
|
||||||
import { Quota, QuotaType } from '../../../core/quota/types';
|
import { Quota, QuotaType } from '../../../core/quota/types';
|
||||||
import { upsertFeature } from './user-features';
|
import { upsertFeature } from './user-features';
|
||||||
|
|
||||||
@@ -21,10 +21,10 @@ export async function upgradeQuotaVersion(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// find all users that have old free plan
|
// find all users that have old free plan
|
||||||
const userIds = await db.user.findMany({
|
const userIds = await tx.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
features: {
|
features: {
|
||||||
every: {
|
some: {
|
||||||
feature: {
|
feature: {
|
||||||
type: FeatureKind.Quota,
|
type: FeatureKind.Quota,
|
||||||
feature: quota.feature,
|
feature: quota.feature,
|
||||||
@@ -65,13 +65,19 @@ export async function upgradeQuotaVersion(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upsertLatestQuotaVersion(
|
||||||
|
db: PrismaClient,
|
||||||
|
type: QuotaType
|
||||||
|
) {
|
||||||
|
const latestQuota = getLatestQuota(type);
|
||||||
|
await upsertFeature(db, latestQuota);
|
||||||
|
}
|
||||||
|
|
||||||
export async function upgradeLatestQuotaVersion(
|
export async function upgradeLatestQuotaVersion(
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
type: QuotaType,
|
type: QuotaType,
|
||||||
reason: string
|
reason: string
|
||||||
) {
|
) {
|
||||||
const quota = Quotas.filter(f => f.feature === type);
|
const latestQuota = getLatestQuota(type);
|
||||||
quota.sort((a, b) => b.version - a.version);
|
|
||||||
const latestQuota = quota[0];
|
|
||||||
await upgradeQuotaVersion(db, latestQuota, reason);
|
await upgradeQuotaVersion(db, latestQuota, reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
toArray,
|
toArray,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import { Public } from '../../core/auth';
|
|
||||||
import { CurrentUser } from '../../core/auth/current-user';
|
import { CurrentUser } from '../../core/auth/current-user';
|
||||||
import { CopilotProviderService } from './providers';
|
import { CopilotProviderService } from './providers';
|
||||||
import { ChatSession, ChatSessionService } from './session';
|
import { ChatSession, ChatSessionService } from './session';
|
||||||
@@ -79,7 +78,6 @@ export class CopilotController {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get('/chat/:sessionId')
|
@Get('/chat/:sessionId')
|
||||||
async chat(
|
async chat(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@@ -89,6 +87,8 @@ export class CopilotController {
|
|||||||
@Query('messageId') messageId: string | undefined,
|
@Query('messageId') messageId: string | undefined,
|
||||||
@Query() params: Record<string, string | string[]>
|
@Query() params: Record<string, string | string[]>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
await this.chatSession.checkQuota(user.id);
|
||||||
|
|
||||||
const model = await this.chatSession.get(sessionId).then(s => s?.model);
|
const model = await this.chatSession.get(sessionId).then(s => s?.model);
|
||||||
const provider = this.provider.getProviderByCapability(
|
const provider = this.provider.getProviderByCapability(
|
||||||
CopilotCapability.TextToText,
|
CopilotCapability.TextToText,
|
||||||
@@ -131,7 +131,6 @@ export class CopilotController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Sse('/chat/:sessionId/stream')
|
@Sse('/chat/:sessionId/stream')
|
||||||
async chatStream(
|
async chatStream(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@@ -141,6 +140,8 @@ export class CopilotController {
|
|||||||
@Query('messageId') messageId: string | undefined,
|
@Query('messageId') messageId: string | undefined,
|
||||||
@Query() params: Record<string, string>
|
@Query() params: Record<string, string>
|
||||||
): Promise<Observable<ChatEvent>> {
|
): Promise<Observable<ChatEvent>> {
|
||||||
|
await this.chatSession.checkQuota(user.id);
|
||||||
|
|
||||||
const model = await this.chatSession.get(sessionId).then(s => s?.model);
|
const model = await this.chatSession.get(sessionId).then(s => s?.model);
|
||||||
const provider = this.provider.getProviderByCapability(
|
const provider = this.provider.getProviderByCapability(
|
||||||
CopilotCapability.TextToText,
|
CopilotCapability.TextToText,
|
||||||
@@ -188,16 +189,17 @@ export class CopilotController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Sse('/chat/:sessionId/images')
|
@Sse('/chat/:sessionId/images')
|
||||||
async chatImagesStream(
|
async chatImagesStream(
|
||||||
@CurrentUser() user: CurrentUser | undefined,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Param('sessionId') sessionId: string,
|
@Param('sessionId') sessionId: string,
|
||||||
@Query('message') message: string | undefined,
|
@Query('message') message: string | undefined,
|
||||||
@Query('messageId') messageId: string | undefined,
|
@Query('messageId') messageId: string | undefined,
|
||||||
@Query() params: Record<string, string>
|
@Query() params: Record<string, string>
|
||||||
): Promise<Observable<ChatEvent>> {
|
): Promise<Observable<ChatEvent>> {
|
||||||
|
await this.chatSession.checkQuota(user.id);
|
||||||
|
|
||||||
const hasAttachment = await this.hasAttachment(sessionId, messageId);
|
const hasAttachment = await this.hasAttachment(sessionId, messageId);
|
||||||
const model = await this.chatSession.get(sessionId).then(s => s?.model);
|
const model = await this.chatSession.get(sessionId).then(s => s?.model);
|
||||||
const provider = this.provider.getProviderByCapability(
|
const provider = this.provider.getProviderByCapability(
|
||||||
@@ -221,7 +223,7 @@ export class CopilotController {
|
|||||||
return from(
|
return from(
|
||||||
provider.generateImagesStream(session.finish(params), session.model, {
|
provider.generateImagesStream(session.finish(params), session.model, {
|
||||||
signal: req.signal,
|
signal: req.signal,
|
||||||
user: user?.id,
|
user: user.id,
|
||||||
})
|
})
|
||||||
).pipe(
|
).pipe(
|
||||||
connect(shared$ =>
|
connect(shared$ =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ServerFeature } from '../../core/config';
|
import { ServerFeature } from '../../core/config';
|
||||||
|
import { FeatureManagementService, FeatureService } from '../../core/features';
|
||||||
import { QuotaService } from '../../core/quota';
|
import { QuotaService } from '../../core/quota';
|
||||||
import { PermissionService } from '../../core/workspaces/permission';
|
import { PermissionService } from '../../core/workspaces/permission';
|
||||||
import { Plugin } from '../registry';
|
import { Plugin } from '../registry';
|
||||||
@@ -22,6 +23,8 @@ registerCopilotProvider(OpenAIProvider);
|
|||||||
name: 'copilot',
|
name: 'copilot',
|
||||||
providers: [
|
providers: [
|
||||||
PermissionService,
|
PermissionService,
|
||||||
|
FeatureService,
|
||||||
|
FeatureManagementService,
|
||||||
QuotaService,
|
QuotaService,
|
||||||
ChatSessionService,
|
ChatSessionService,
|
||||||
CopilotResolver,
|
CopilotResolver,
|
||||||
|
|||||||
@@ -14,14 +14,9 @@ import {
|
|||||||
import { GraphQLJSON, SafeIntResolver } from 'graphql-scalars';
|
import { GraphQLJSON, SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
import { QuotaService } from '../../core/quota';
|
|
||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { PermissionService } from '../../core/workspaces/permission';
|
import { PermissionService } from '../../core/workspaces/permission';
|
||||||
import {
|
import { MutexService, TooManyRequestsException } from '../../fundamentals';
|
||||||
MutexService,
|
|
||||||
PaymentRequiredException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
} from '../../fundamentals';
|
|
||||||
import { ChatSessionService } from './session';
|
import { ChatSessionService } from './session';
|
||||||
import {
|
import {
|
||||||
AvailableModels,
|
AvailableModels,
|
||||||
@@ -123,8 +118,8 @@ class CopilotHistoriesType implements Partial<ChatHistory> {
|
|||||||
|
|
||||||
@ObjectType('CopilotQuota')
|
@ObjectType('CopilotQuota')
|
||||||
class CopilotQuotaType {
|
class CopilotQuotaType {
|
||||||
@Field(() => SafeIntResolver)
|
@Field(() => SafeIntResolver, { nullable: true })
|
||||||
limit!: number;
|
limit?: number;
|
||||||
|
|
||||||
@Field(() => SafeIntResolver)
|
@Field(() => SafeIntResolver)
|
||||||
used!: number;
|
used!: number;
|
||||||
@@ -144,7 +139,6 @@ export class CopilotResolver {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
private readonly quota: QuotaService,
|
|
||||||
private readonly mutex: MutexService,
|
private readonly mutex: MutexService,
|
||||||
private readonly chatSession: ChatSessionService
|
private readonly chatSession: ChatSessionService
|
||||||
) {}
|
) {}
|
||||||
@@ -155,20 +149,7 @@ export class CopilotResolver {
|
|||||||
complexity: 2,
|
complexity: 2,
|
||||||
})
|
})
|
||||||
async getQuota(@CurrentUser() user: CurrentUser) {
|
async getQuota(@CurrentUser() user: CurrentUser) {
|
||||||
const quota = await this.quota.getUserQuota(user.id);
|
return await this.chatSession.getQuota(user.id);
|
||||||
const limit = quota.feature.copilotActionLimit;
|
|
||||||
|
|
||||||
const actions = await this.chatSession.countUserActions(user.id);
|
|
||||||
const chats = await this.chatSession
|
|
||||||
.listHistories(user.id)
|
|
||||||
.then(histories =>
|
|
||||||
histories.reduce(
|
|
||||||
(acc, h) => acc + h.messages.filter(m => m.role === 'user').length,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { limit, used: actions + chats };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => [String], {
|
@ResolveField(() => [String], {
|
||||||
@@ -257,12 +238,7 @@ export class CopilotResolver {
|
|||||||
return new TooManyRequestsException('Server is busy');
|
return new TooManyRequestsException('Server is busy');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { limit, used } = await this.getQuota(user);
|
await this.chatSession.checkQuota(user.id);
|
||||||
if (limit && Number.isFinite(limit) && used >= limit) {
|
|
||||||
return new PaymentRequiredException(
|
|
||||||
`You have reached the limit of actions in this workspace, please upgrade your plan.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.chatSession.create({
|
const session = await this.chatSession.create({
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { AiPromptRole, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { FeatureManagementService, FeatureType } from '../../core/features';
|
||||||
|
import { QuotaService } from '../../core/quota';
|
||||||
|
import { PaymentRequiredException } from '../../fundamentals';
|
||||||
import { ChatMessageCache } from './message';
|
import { ChatMessageCache } from './message';
|
||||||
import { ChatPrompt, PromptService } from './prompt';
|
import { ChatPrompt, PromptService } from './prompt';
|
||||||
import {
|
import {
|
||||||
@@ -120,6 +123,8 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: PrismaClient,
|
private readonly db: PrismaClient,
|
||||||
|
private readonly feature: FeatureManagementService,
|
||||||
|
private readonly quota: QuotaService,
|
||||||
private readonly messageCache: ChatMessageCache,
|
private readonly messageCache: ChatMessageCache,
|
||||||
private readonly prompt: PromptService
|
private readonly prompt: PromptService
|
||||||
) {}
|
) {}
|
||||||
@@ -242,12 +247,24 @@ export class ChatSessionService {
|
|||||||
.reduce((total, length) => total + length, 0);
|
.reduce((total, length) => total + length, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUserActions(userId: string): Promise<number> {
|
private async countUserActions(userId: string): Promise<number> {
|
||||||
return await this.db.aiSession.count({
|
return await this.db.aiSession.count({
|
||||||
where: { userId, prompt: { action: { not: null } } },
|
where: { userId, prompt: { action: { not: null } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countUserChats(userId: string): Promise<number> {
|
||||||
|
const chats = await this.db.aiSession.findMany({
|
||||||
|
where: { userId, prompt: { action: null } },
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: { messages: { where: { role: AiPromptRole.user } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chats.reduce((prev, chat) => prev + chat._count.messages, 0);
|
||||||
|
}
|
||||||
|
|
||||||
async listSessions(
|
async listSessions(
|
||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -347,6 +364,32 @@ export class ChatSessionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getQuota(userId: string) {
|
||||||
|
const hasCopilotFeature = await this.feature
|
||||||
|
.getActivatedUserFeatures(userId)
|
||||||
|
.then(f => f.includes(FeatureType.UnlimitedCopilot));
|
||||||
|
|
||||||
|
let limit: number | undefined;
|
||||||
|
if (!hasCopilotFeature) {
|
||||||
|
const quota = await this.quota.getUserQuota(userId);
|
||||||
|
limit = quota.feature.copilotActionLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = await this.countUserActions(userId);
|
||||||
|
const chats = await this.countUserChats(userId);
|
||||||
|
|
||||||
|
return { limit, used: actions + chats };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkQuota(userId: string) {
|
||||||
|
const { limit, used } = await this.getQuota(userId);
|
||||||
|
if (limit && Number.isFinite(limit) && used >= limit) {
|
||||||
|
throw new PaymentRequiredException(
|
||||||
|
`You have reached the limit of actions in this workspace, please upgrade your plan.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(options: ChatSessionOptions): Promise<string> {
|
async create(options: ChatSessionOptions): Promise<string> {
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const prompt = await this.prompt.get(options.promptName);
|
const prompt = await this.prompt.get(options.promptName);
|
||||||
|
|||||||
@@ -527,7 +527,10 @@ export class SubscriptionService {
|
|||||||
nextBillAt = new Date(subscription.current_period_end * 1000);
|
nextBillAt = new Date(subscription.current_period_end * 1000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.event.emit('user.subscription.canceled', user.id);
|
this.event.emit('user.subscription.canceled', {
|
||||||
|
userId: user.id,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonData = {
|
const commonData = {
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ declare module '../../fundamentals/event/def' {
|
|||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
plan: SubscriptionPlan;
|
plan: SubscriptionPlan;
|
||||||
}>;
|
}>;
|
||||||
canceled: Payload<User['id']>;
|
canceled: Payload<{
|
||||||
|
userId: User['id'];
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ type CopilotHistories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CopilotQuota {
|
type CopilotQuota {
|
||||||
limit: SafeInt!
|
limit: SafeInt
|
||||||
used: SafeInt!
|
used: SafeInt!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +84,7 @@ type DocHistoryType {
|
|||||||
enum FeatureType {
|
enum FeatureType {
|
||||||
Copilot
|
Copilot
|
||||||
EarlyAccess
|
EarlyAccess
|
||||||
|
UnlimitedCopilot
|
||||||
UnlimitedWorkspace
|
UnlimitedWorkspace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ test('should be able to set user feature', async t => {
|
|||||||
const f1 = await feature.getUserFeatures(u1.id);
|
const f1 = await feature.getUserFeatures(u1.id);
|
||||||
t.is(f1.length, 0, 'should be empty');
|
t.is(f1.length, 0, 'should be empty');
|
||||||
|
|
||||||
await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 2, 'test');
|
await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 'test');
|
||||||
|
|
||||||
const f2 = await feature.getUserFeatures(u1.id);
|
const f2 = await feature.getUserFeatures(u1.id);
|
||||||
t.is(f2.length, 1, 'should have 1 feature');
|
t.is(f2.length, 1, 'should have 1 feature');
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { AuthService } from '../src/core/auth';
|
|||||||
import {
|
import {
|
||||||
QuotaManagementService,
|
QuotaManagementService,
|
||||||
QuotaModule,
|
QuotaModule,
|
||||||
Quotas,
|
|
||||||
QuotaService,
|
QuotaService,
|
||||||
QuotaType,
|
QuotaType,
|
||||||
} from '../src/core/quota';
|
} from '../src/core/quota';
|
||||||
|
import { FreePlan, ProPlan } from '../src/core/quota/schema';
|
||||||
import { StorageModule } from '../src/core/storage';
|
import { StorageModule } from '../src/core/storage';
|
||||||
import { createTestingModule } from './utils';
|
import { createTestingModule } from './utils';
|
||||||
|
|
||||||
@@ -63,33 +63,43 @@ test('should be able to set quota', async t => {
|
|||||||
test('should be able to check storage quota', async t => {
|
test('should be able to check storage quota', async t => {
|
||||||
const { auth, quota, quotaManager } = t.context;
|
const { auth, quota, quotaManager } = t.context;
|
||||||
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
||||||
|
const freePlan = FreePlan.configs;
|
||||||
|
const proPlan = ProPlan.configs;
|
||||||
|
|
||||||
const q1 = await quotaManager.getUserQuota(u1.id);
|
const q1 = await quotaManager.getUserQuota(u1.id);
|
||||||
t.is(q1?.blobLimit, Quotas[5].configs.blobLimit, 'should be free plan');
|
t.is(q1?.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||||
t.is(q1?.storageQuota, Quotas[5].configs.storageQuota, 'should be free plan');
|
t.is(q1?.storageQuota, freePlan.storageQuota, 'should be free plan');
|
||||||
|
|
||||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||||
const q2 = await quotaManager.getUserQuota(u1.id);
|
const q2 = await quotaManager.getUserQuota(u1.id);
|
||||||
t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan');
|
t.is(q2?.blobLimit, proPlan.blobLimit, 'should be pro plan');
|
||||||
t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan');
|
t.is(q2?.storageQuota, proPlan.storageQuota, 'should be pro plan');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able revert quota', async t => {
|
test('should be able revert quota', async t => {
|
||||||
const { auth, quota, quotaManager } = t.context;
|
const { auth, quota, quotaManager } = t.context;
|
||||||
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
||||||
|
const freePlan = FreePlan.configs;
|
||||||
|
const proPlan = ProPlan.configs;
|
||||||
|
|
||||||
const q1 = await quotaManager.getUserQuota(u1.id);
|
const q1 = await quotaManager.getUserQuota(u1.id);
|
||||||
t.is(q1?.blobLimit, Quotas[5].configs.blobLimit, 'should be free plan');
|
|
||||||
t.is(q1?.storageQuota, Quotas[5].configs.storageQuota, 'should be free plan');
|
t.is(q1?.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||||
|
t.is(q1?.storageQuota, freePlan.storageQuota, 'should be free plan');
|
||||||
|
|
||||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||||
const q2 = await quotaManager.getUserQuota(u1.id);
|
const q2 = await quotaManager.getUserQuota(u1.id);
|
||||||
t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan');
|
t.is(q2?.blobLimit, proPlan.blobLimit, 'should be pro plan');
|
||||||
t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan');
|
t.is(q2?.storageQuota, proPlan.storageQuota, 'should be pro plan');
|
||||||
|
t.is(
|
||||||
|
q2?.copilotActionLimit,
|
||||||
|
proPlan.copilotActionLimit!,
|
||||||
|
'should be pro plan'
|
||||||
|
);
|
||||||
|
|
||||||
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
|
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
|
||||||
const q3 = await quotaManager.getUserQuota(u1.id);
|
const q3 = await quotaManager.getUserQuota(u1.id);
|
||||||
t.is(q3?.blobLimit, Quotas[5].configs.blobLimit, 'should be free plan');
|
t.is(q3?.blobLimit, freePlan.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');
|
||||||
@@ -104,9 +114,9 @@ test('should be able revert quota', async t => {
|
|||||||
test('should be able to check quota', async t => {
|
test('should be able to check quota', async t => {
|
||||||
const { auth, quotaManager } = t.context;
|
const { auth, quotaManager } = t.context;
|
||||||
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
||||||
|
const freePlan = FreePlan.configs;
|
||||||
|
|
||||||
const q1 = await quotaManager.getUserQuota(u1.id);
|
const q1 = await quotaManager.getUserQuota(u1.id);
|
||||||
const freePlan = Quotas[5].configs;
|
|
||||||
t.assert(q1, 'should have quota');
|
t.assert(q1, 'should have quota');
|
||||||
t.is(q1.blobLimit, freePlan.blobLimit, 'should be free plan');
|
t.is(q1.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||||
t.is(q1.storageQuota, freePlan.storageQuota, 'should be free plan');
|
t.is(q1.storageQuota, freePlan.storageQuota, 'should be free plan');
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export const AIUsagePanelNotSubscripted = () => {
|
|||||||
const { data: quota } = useQuery({
|
const { data: quota } = useQuery({
|
||||||
query: getCopilotQuotaQuery,
|
query: getCopilotQuotaQuery,
|
||||||
});
|
});
|
||||||
const { limit = 10, used = 0 } = quota.currentUser?.copilot.quota || {};
|
const { limit: nullableLimit, used = 0 } =
|
||||||
|
quota.currentUser?.copilot.quota || {};
|
||||||
|
const limit = nullableLimit || 10;
|
||||||
const percent = Math.min(
|
const percent = Math.min(
|
||||||
100,
|
100,
|
||||||
Math.max(0.5, Number(((used / limit) * 100).toFixed(4)))
|
Math.max(0.5, Number(((used / limit) * 100).toFixed(4)))
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface CreateCheckoutSessionInput {
|
|||||||
export enum FeatureType {
|
export enum FeatureType {
|
||||||
Copilot = 'Copilot',
|
Copilot = 'Copilot',
|
||||||
EarlyAccess = 'EarlyAccess',
|
EarlyAccess = 'EarlyAccess',
|
||||||
|
UnlimitedCopilot = 'UnlimitedCopilot',
|
||||||
UnlimitedWorkspace = 'UnlimitedWorkspace',
|
UnlimitedWorkspace = 'UnlimitedWorkspace',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +388,11 @@ export type GetCopilotQuotaQuery = {
|
|||||||
__typename?: 'UserType';
|
__typename?: 'UserType';
|
||||||
copilot: {
|
copilot: {
|
||||||
__typename?: 'Copilot';
|
__typename?: 'Copilot';
|
||||||
quota: { __typename?: 'CopilotQuota'; limit: number; used: number };
|
quota: {
|
||||||
|
__typename?: 'CopilotQuota';
|
||||||
|
limit: number | null;
|
||||||
|
used: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user