mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 03:26:47 +08:00
feat(server): entitlement based model (#14996)
#### PR Dependency Tree * **PR #14996** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin mutations to grant/revoke commercial entitlements. * New Doc comment-update permission. * Realtime user/workspace quota-state endpoints and live-update rooms. * **Bug Fixes** * More accurate readable-doc filtering and permission evaluation. * **Refactor** * Workspace feature management moved to entitlement-based model; permission and quota pipelines redesigned. * Admin workspace UI now edits flags only (feature toggles removed). * **Tests** * Extensive new and updated tests for permissions, entitlements, quota, projection, and backfills. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
import { ActionForbidden, AuthenticationRequired, Config } from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { ServerConfigType } from '../../core/config/types';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { PermissionAccess } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
@@ -113,7 +113,7 @@ export class CalendarAccountResolver {
|
||||
export class WorkspaceCalendarResolver {
|
||||
constructor(
|
||||
private readonly calendar: CalendarService,
|
||||
private readonly access: AccessController
|
||||
private readonly access: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => [WorkspaceCalendarObjectType])
|
||||
@@ -133,7 +133,7 @@ export class WorkspaceCalendarResolver {
|
||||
export class WorkspaceCalendarEventsResolver {
|
||||
constructor(
|
||||
private readonly calendar: CalendarService,
|
||||
private readonly access: AccessController
|
||||
private readonly access: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => [CalendarEventObjectType])
|
||||
@@ -162,7 +162,7 @@ export class CalendarMutationResolver {
|
||||
private readonly calendar: CalendarService,
|
||||
private readonly oauth: CalendarOAuthService,
|
||||
private readonly models: Models,
|
||||
private readonly access: AccessController
|
||||
private readonly access: PermissionAccess
|
||||
) {}
|
||||
|
||||
@Mutation(() => String)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ActionForbidden } from '../../../base';
|
||||
import { QuotaStateService } from '../../../core/quota/state';
|
||||
import { Models, WorkspaceRole } from '../../../models';
|
||||
|
||||
@Injectable()
|
||||
export class ByokEntitlementPolicy {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
private isUserPlanEntitled(features: string[]) {
|
||||
return (
|
||||
features.includes('pro_plan_v1') ||
|
||||
features.includes('lifetime_pro_plan_v1') ||
|
||||
features.includes('unlimited_copilot')
|
||||
);
|
||||
}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly quotaState: QuotaStateService
|
||||
) {}
|
||||
|
||||
async hasAiPlan(userId?: string) {
|
||||
if (!userId) return false;
|
||||
const features = await this.models.userFeature.list(userId);
|
||||
return this.isUserPlanEntitled(features);
|
||||
const state = await this.quotaState.reconcileUserQuotaState(userId);
|
||||
const flags = state.flags as { unlimitedCopilot?: boolean };
|
||||
return (
|
||||
flags.unlimitedCopilot ||
|
||||
['pro', 'lifetime_pro', 'ai'].includes(state.plan)
|
||||
);
|
||||
}
|
||||
|
||||
async hasManagementAccess(workspaceId: string, userId?: string) {
|
||||
@@ -59,7 +59,7 @@ export class ByokEntitlementPolicy {
|
||||
async hasLocalEntitlement(workspaceId: string, userId?: string) {
|
||||
if (env.selfhosted) return true;
|
||||
|
||||
if (await this.models.workspaceFeature.has(workspaceId, 'team_plan_v1')) {
|
||||
if (await this.hasWorkspaceTeamPlan(workspaceId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export class ByokEntitlementPolicy {
|
||||
async hasServerEntitlement(workspaceId: string) {
|
||||
if (env.selfhosted) return true;
|
||||
|
||||
if (await this.models.workspaceFeature.has(workspaceId, 'team_plan_v1')) {
|
||||
if (await this.hasWorkspaceTeamPlan(workspaceId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -102,4 +102,20 @@ export class ByokEntitlementPolicy {
|
||||
throw new ActionForbidden('BYOK requires Pro, Team, or Believer.');
|
||||
}
|
||||
}
|
||||
|
||||
private async hasWorkspaceTeamPlan(workspaceId: string) {
|
||||
try {
|
||||
const state =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
return ['team', 'selfhost_team'].includes(state.plan);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'Workspace owner not found'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { Throttle } from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { WorkspaceType } from '../../../core/workspaces';
|
||||
import { ByokEntitlementPolicy } from './policy';
|
||||
import { ByokKeyConfig, ByokLocalLeaseProvider, ByokService } from './service';
|
||||
@@ -259,7 +259,7 @@ class CreateWorkspaceByokLocalLeaseInput {
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceByokResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly entitlement: ByokEntitlementPolicy,
|
||||
private readonly byok: ByokService
|
||||
) {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OnEvent } from '../../../base';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import {
|
||||
RealtimePublisher,
|
||||
RealtimeRegistry,
|
||||
@@ -19,7 +19,7 @@ export function workspaceEmbeddingRoom(workspaceId: string) {
|
||||
@Injectable()
|
||||
export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
private readonly context: CopilotContextService,
|
||||
private readonly registry: RealtimeRegistry,
|
||||
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import {
|
||||
AccessController,
|
||||
WorkspacePolicyService,
|
||||
} from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import {
|
||||
ContextBlob,
|
||||
ContextCategories,
|
||||
@@ -60,7 +57,7 @@ import { getSignal, MAX_EMBEDDABLE_SIZE, readStream } from '../utils';
|
||||
import { CopilotContextService } from './service';
|
||||
|
||||
async function assertAccess(
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
) {
|
||||
@@ -73,7 +70,7 @@ async function assertAccess(
|
||||
|
||||
async function getSession(
|
||||
context: CopilotContextService,
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
userId: string,
|
||||
contextId: string,
|
||||
options: { workspaceId?: string; sessionId?: string } = {}
|
||||
@@ -292,7 +289,7 @@ class ContextMatchedDocChunk implements DocChunkSimilarity {
|
||||
@Resolver(() => CopilotType)
|
||||
export class CopilotContextRootResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly event: EventBus,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
@@ -441,8 +438,7 @@ export class CopilotContextRootResolver {
|
||||
@Resolver(() => CopilotContextType)
|
||||
export class CopilotContextResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly context: CopilotContextService,
|
||||
@@ -745,7 +741,11 @@ export class CopilotContextResolver {
|
||||
const blobId = createHash('sha256').update(buffer).digest('base64url');
|
||||
const { filename, mimetype } = content;
|
||||
|
||||
await this.policy.assertCanUploadBlob(user.id, session.workspaceId);
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(session.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Blobs.Write');
|
||||
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
|
||||
const file = await session.addFile(
|
||||
blobId,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ImageFormatNotSupported,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { WorkspacePolicyService } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { processImage } from '../../../native';
|
||||
import { CompatSubmissionStore } from '../compat/submission-store';
|
||||
import type { PromptMessage } from '../providers/types';
|
||||
@@ -29,7 +29,7 @@ type CreateInboxMessage = {
|
||||
export class ConversationInboxService {
|
||||
constructor(
|
||||
private readonly chatSession: ChatSessionService,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly storage: CopilotStorage,
|
||||
private readonly submissions: CompatSubmissionStore
|
||||
) {}
|
||||
@@ -49,7 +49,11 @@ export class ConversationInboxService {
|
||||
);
|
||||
|
||||
if (blobs.length) {
|
||||
await this.policy.assertCanUploadBlob(userId, session.config.workspaceId);
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(session.config.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Blobs.Write');
|
||||
}
|
||||
|
||||
for (const blob of blobs) {
|
||||
|
||||
@@ -14,16 +14,8 @@ export class ConversationPolicy {
|
||||
) {}
|
||||
|
||||
async getQuota(userId: string) {
|
||||
const isCopilotUser = await this.models.userFeature.has(
|
||||
userId,
|
||||
'unlimited_copilot'
|
||||
);
|
||||
|
||||
let limit: number | undefined;
|
||||
if (!isCopilotUser) {
|
||||
const quota = await this.quota.getUserQuota(userId);
|
||||
limit = quota.copilotActionLimit;
|
||||
}
|
||||
const quota = await this.quota.getUserQuota(userId);
|
||||
const limit = quota.copilotActionLimit;
|
||||
|
||||
const used = await this.models.copilotSession.countUserMessages(userId);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pick } from 'lodash-es';
|
||||
import z from 'zod/v3';
|
||||
|
||||
import { DocReader, DocWriter } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { clearEmbeddingChunk } from '../../../models';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import { CopilotContextService } from '../context/service';
|
||||
@@ -99,7 +99,7 @@ function defineTool<T extends z.ZodTypeAny>(
|
||||
@Injectable()
|
||||
export class WorkspaceMcpProvider {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly reader: DocReader,
|
||||
private readonly writer: DocWriter,
|
||||
private readonly context: CopilotContextService,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
TooManyRequest,
|
||||
} from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AccessController, DocAction } from '../../core/permission';
|
||||
import { DocAction, PermissionAccess } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import type { ListSessionOptions, UpdateChatSession } from '../../models';
|
||||
import { CompatHistoryProjector } from './compat/history-projector';
|
||||
@@ -365,7 +365,7 @@ export class CopilotResolver {
|
||||
private readonly modelNames = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly prompt: PromptService,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
@@ -815,7 +815,7 @@ export class CopilotResolver {
|
||||
@Throttle()
|
||||
@Resolver(() => UserType)
|
||||
export class UserCopilotResolver {
|
||||
constructor(private readonly ac: AccessController) {}
|
||||
constructor(private readonly ac: PermissionAccess) {}
|
||||
|
||||
@ResolveField(() => CopilotType)
|
||||
async copilot(
|
||||
|
||||
+9
-11
@@ -2,8 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ServerFeature, ServerService } from '../../../../core';
|
||||
import { SubscriptionService } from '../../../payment/service';
|
||||
import { SubscriptionPlan, SubscriptionStatus } from '../../../payment/types';
|
||||
import { QuotaStateService } from '../../../../core/quota/state';
|
||||
import type { ChatSession } from '../../session';
|
||||
import { type ToolsConfig } from '../../types';
|
||||
import { getTools } from '../../utils';
|
||||
@@ -47,15 +46,14 @@ export class CapabilityPolicyHost {
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await this.moduleRef
|
||||
.get(SubscriptionService, { strict: false })
|
||||
.select(SubscriptionPlan.AI)
|
||||
.getSubscription({
|
||||
userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
} as never);
|
||||
|
||||
return subscription?.status === SubscriptionStatus.Active;
|
||||
const state = await this.moduleRef
|
||||
.get(QuotaStateService, { strict: false })
|
||||
.reconcileUserQuotaState(userId);
|
||||
const flags = state.flags as { unlimitedCopilot?: boolean };
|
||||
return (
|
||||
!!flags.unlimitedCopilot ||
|
||||
['pro', 'lifetime_pro', 'ai'].includes(state.plan)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Models } from '../../../models';
|
||||
import { QuotaStateService } from '../../../core/quota/state';
|
||||
import { PromptService } from '../prompt/service';
|
||||
|
||||
export const DEFAULT_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
@@ -9,7 +9,7 @@ export const DEFAULT_RERANK_MODEL = 'gpt-4o-mini';
|
||||
@Injectable()
|
||||
export class TaskPolicy {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly quotaState: QuotaStateService,
|
||||
private readonly prompts: PromptService
|
||||
) {}
|
||||
|
||||
@@ -25,10 +25,11 @@ export class TaskPolicy {
|
||||
const prompt = await this.prompts.get('Transcript audio');
|
||||
if (!prompt) return;
|
||||
|
||||
const hasAccess = await this.models.userFeature.has(
|
||||
userId,
|
||||
'unlimited_copilot'
|
||||
);
|
||||
const state = await this.quotaState.reconcileUserQuotaState(userId);
|
||||
const flags = state.flags as { unlimitedCopilot?: boolean };
|
||||
const hasAccess =
|
||||
!!flags.unlimitedCopilot ||
|
||||
['pro', 'lifetime_pro', 'ai'].includes(state.plan);
|
||||
return prompt.optionalModels[hasAccess ? 1 : 0] ?? prompt.model;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { DocReader, DocWriter } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { Models } from '../../../models';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import type { NodeTextMiddleware } from '../config';
|
||||
@@ -48,7 +48,7 @@ export type ProviderSpecificToolResolver = (
|
||||
export class ToolRuntime {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly context: CopilotContextService,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly docWriter: DocWriter,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { toolError } from './error';
|
||||
import { defineTool } from './tool';
|
||||
import type { ContextSession, CopilotChatOptions } from './types';
|
||||
@@ -9,7 +9,7 @@ import type { ContextSession, CopilotChatOptions } from './types';
|
||||
const logger = new Logger('ContextBlobReadTool');
|
||||
|
||||
export const buildBlobContentGetter = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
context: ContextSession | null
|
||||
) => {
|
||||
const getBlobContent = async (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AccessController } from '../../../core/permission';
|
||||
import type { PermissionAccess } from '../../../core/permission';
|
||||
import type { Models } from '../../../models';
|
||||
import type { IndexerService, SearchDoc } from '../../indexer';
|
||||
import { workspaceSyncRequiredError } from './doc-sync';
|
||||
@@ -9,7 +9,7 @@ import { defineTool } from './tool';
|
||||
import type { CopilotChatOptions } from './types';
|
||||
|
||||
export const buildDocKeywordSearchGetter = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
indexerService: IndexerService,
|
||||
models: Models
|
||||
) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
documentSyncPendingError,
|
||||
@@ -18,7 +18,7 @@ const isToolError = (result: ToolError | object): result is ToolError =>
|
||||
'type' in result && result.type === 'error';
|
||||
|
||||
export const buildDocContentGetter = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
docReader: DocReader,
|
||||
models: Models
|
||||
) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { omit } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AccessController } from '../../../core/permission';
|
||||
import type { PermissionAccess } from '../../../core/permission';
|
||||
import {
|
||||
type ChunkSimilarity,
|
||||
clearEmbeddingChunk,
|
||||
@@ -19,7 +19,7 @@ const getEmbeddingRouteContext = (options: CopilotChatOptions) => ({
|
||||
});
|
||||
|
||||
export const buildDocSearchGetter = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
context: CopilotContextService,
|
||||
sessionId: string | undefined,
|
||||
models: Models
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocWriter } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { toolError } from './error';
|
||||
import { defineTool } from './tool';
|
||||
import type { CopilotChatOptions } from './types';
|
||||
@@ -15,7 +15,7 @@ const stripLeadingH1 = (content: string) =>
|
||||
const sanitizeTitle = (title: string) => title.replace(/[\r\n]+/g, ' ').trim();
|
||||
|
||||
export const buildDocCreateHandler = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
writer: DocWriter
|
||||
) => {
|
||||
return async (
|
||||
@@ -57,7 +57,7 @@ export const buildDocCreateHandler = (
|
||||
};
|
||||
|
||||
export const buildDocUpdateHandler = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
writer: DocWriter
|
||||
) => {
|
||||
return async (
|
||||
@@ -95,7 +95,7 @@ export const buildDocUpdateHandler = (
|
||||
};
|
||||
|
||||
export const buildDocUpdateMetaHandler = (
|
||||
ac: AccessController,
|
||||
ac: PermissionAccess,
|
||||
writer: DocWriter
|
||||
) => {
|
||||
return async (options: CopilotChatOptions, docId: string, title: string) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CopilotTranscriptionJobNotFound } from '../../../base';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import {
|
||||
RealtimeRegistry,
|
||||
realtimeTranscriptTaskRoom,
|
||||
@@ -13,7 +13,7 @@ import { CopilotTranscriptionReader } from './reader';
|
||||
@Injectable()
|
||||
export class CopilotTranscriptRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly transcript: CopilotTranscriptionReader,
|
||||
private readonly registry: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type FileUpload,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { CopilotType } from '../resolver';
|
||||
import type { TranscriptionJob } from './job';
|
||||
import { buildLegacyProjection } from './projection';
|
||||
@@ -295,7 +295,7 @@ const FinishedStatus: Set<AiJobStatus> = new Set([
|
||||
@Resolver(() => CopilotType)
|
||||
export class CopilotTranscriptionResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly transcript: CopilotTranscriptionService
|
||||
) {}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import { WorkspaceType } from '../../../core/workspaces';
|
||||
import { COPILOT_LOCKER } from '../resolver';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../utils';
|
||||
@@ -49,7 +49,7 @@ export class CopilotWorkspaceConfigType {
|
||||
*/
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class CopilotWorkspaceEmbeddingResolver {
|
||||
constructor(private readonly ac: AccessController) {}
|
||||
constructor(private readonly ac: PermissionAccess) {}
|
||||
|
||||
@ResolveField(() => CopilotWorkspaceConfigType, {
|
||||
complexity: 2,
|
||||
@@ -70,7 +70,7 @@ export class CopilotWorkspaceEmbeddingResolver {
|
||||
@Resolver(() => CopilotWorkspaceConfigType)
|
||||
export class CopilotWorkspaceEmbeddingConfigResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly copilotWorkspace: CopilotWorkspaceService
|
||||
) {}
|
||||
|
||||
@@ -2313,6 +2313,67 @@ test('should search docs by keyword work', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should search docs by keyword with doc id filter', async t => {
|
||||
const workspaceId = workspace.id;
|
||||
const docId1 = randomUUID();
|
||||
const docId2 = randomUUID();
|
||||
|
||||
await module.create(Mockers.DocMeta, {
|
||||
workspaceId,
|
||||
docId: docId1,
|
||||
title: 'hello filtered 1',
|
||||
});
|
||||
await module.create(Mockers.DocMeta, {
|
||||
workspaceId,
|
||||
docId: docId2,
|
||||
title: 'hello filtered 2',
|
||||
});
|
||||
|
||||
await indexerService.write(
|
||||
SearchTable.block,
|
||||
[
|
||||
{
|
||||
workspaceId,
|
||||
docId: docId1,
|
||||
blockId: 'filtered-block1',
|
||||
content: 'hello filtered',
|
||||
flavour: 'affine:text',
|
||||
createdByUserId: user.id,
|
||||
updatedByUserId: user.id,
|
||||
createdAt: new Date('2025-06-20T00:00:00.000Z'),
|
||||
updatedAt: new Date('2025-06-20T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
workspaceId,
|
||||
docId: docId2,
|
||||
blockId: 'filtered-block2',
|
||||
content: 'hello filtered',
|
||||
flavour: 'affine:text',
|
||||
createdByUserId: user.id,
|
||||
updatedByUserId: user.id,
|
||||
createdAt: new Date('2025-06-20T00:00:01.000Z'),
|
||||
updatedAt: new Date('2025-06-20T00:00:01.000Z'),
|
||||
},
|
||||
],
|
||||
{
|
||||
refresh: true,
|
||||
}
|
||||
);
|
||||
|
||||
const rows = await indexerService.searchDocsByKeyword(
|
||||
workspaceId,
|
||||
'hello filtered',
|
||||
{
|
||||
docIds: [docId2],
|
||||
}
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
rows.map(row => row.docId),
|
||||
[docId2]
|
||||
);
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
test('should rebuild manticore indexes and requeue workspaces', async t => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { ServerConfigModule } from '../../core/config';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
import { QuotaServiceModule } from '../../core/quota';
|
||||
import { IndexerEvent } from './event';
|
||||
import { SearchProviderFactory } from './factory';
|
||||
import { IndexerJob } from './job';
|
||||
@@ -12,7 +13,7 @@ import { IndexerResolver } from './resolver';
|
||||
import { IndexerService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [ServerConfigModule, PermissionModule],
|
||||
imports: [ServerConfigModule, PermissionModule, QuotaServiceModule],
|
||||
providers: [
|
||||
IndexerResolver,
|
||||
IndexerService,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { PermissionAccess, PermissionService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { AggregateBucket } from './providers';
|
||||
import { IndexerService, SearchNodeWithMeta } from './service';
|
||||
import { IndexerService } from './service';
|
||||
import {
|
||||
AggregateInput,
|
||||
AggregateResultObjectType,
|
||||
SearchDocObjectType,
|
||||
SearchDocsInput,
|
||||
SearchInput,
|
||||
SearchQuery,
|
||||
SearchQueryOccur,
|
||||
SearchQueryType,
|
||||
SearchResultObjectType,
|
||||
@@ -22,8 +23,10 @@ import {
|
||||
export class IndexerResolver {
|
||||
constructor(
|
||||
private readonly indexer: IndexerService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly models: Models
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly permission: PermissionService,
|
||||
private readonly quotaState: QuotaStateService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => SearchResultObjectType, {
|
||||
@@ -37,18 +40,22 @@ export class IndexerResolver {
|
||||
// currentUser can read the workspace
|
||||
await this.ac.user(me.id).workspace(workspace.id).assert('Workspace.Read');
|
||||
this.#addWorkspaceFilter(workspace, input);
|
||||
if (!(await this.#addReadableDocFilter(workspace, me, input))) {
|
||||
return {
|
||||
nodes: [],
|
||||
pagination: {
|
||||
count: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.indexer.search(input);
|
||||
const nodes = await this.#filterUserReadableDocs(
|
||||
workspace,
|
||||
me,
|
||||
result.nodes
|
||||
);
|
||||
return {
|
||||
nodes,
|
||||
nodes: result.nodes,
|
||||
pagination: {
|
||||
count: result.total,
|
||||
hasMore: nodes.length > 0,
|
||||
hasMore: result.nodes.length > 0,
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
};
|
||||
@@ -65,24 +72,22 @@ export class IndexerResolver {
|
||||
// currentUser can read the workspace
|
||||
await this.ac.user(me.id).workspace(workspace.id).assert('Workspace.Read');
|
||||
this.#addWorkspaceFilter(workspace, input);
|
||||
if (!(await this.#addReadableDocFilter(workspace, me, input))) {
|
||||
return {
|
||||
buckets: [],
|
||||
pagination: {
|
||||
count: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.indexer.aggregate(input);
|
||||
const needs: AggregateBucket[] = [];
|
||||
for (const bucket of result.buckets) {
|
||||
bucket.hits.nodes = await this.#filterUserReadableDocs(
|
||||
workspace,
|
||||
me,
|
||||
bucket.hits.nodes as SearchNodeWithMeta[]
|
||||
);
|
||||
if (bucket.hits.nodes.length > 0) {
|
||||
needs.push(bucket);
|
||||
}
|
||||
}
|
||||
return {
|
||||
buckets: needs,
|
||||
buckets: result.buckets,
|
||||
pagination: {
|
||||
count: result.total,
|
||||
hasMore: needs.length > 0,
|
||||
hasMore: result.buckets.length > 0,
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
};
|
||||
@@ -96,19 +101,17 @@ export class IndexerResolver {
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('input') input: SearchDocsInput
|
||||
): Promise<SearchDocObjectType[]> {
|
||||
const readableDocIds = await this.#readableDocIdsForSearch(workspace, me);
|
||||
const docs = await this.indexer.searchDocsByKeyword(
|
||||
workspace.id,
|
||||
input.keyword,
|
||||
{
|
||||
limit: input.limit,
|
||||
docIds: readableDocIds ?? undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const needs = await this.ac
|
||||
.user(me.id)
|
||||
.workspace(workspace.id)
|
||||
.docs(docs, 'Doc.Read');
|
||||
return needs;
|
||||
return docs;
|
||||
}
|
||||
|
||||
#addWorkspaceFilter(
|
||||
@@ -130,36 +133,84 @@ export class IndexerResolver {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* filter user readable docs on team workspace
|
||||
*/
|
||||
async #filterUserReadableDocs(
|
||||
async #addReadableDocFilter(
|
||||
workspace: WorkspaceType,
|
||||
user: UserType,
|
||||
nodes: SearchNodeWithMeta[]
|
||||
input: SearchInput | AggregateInput
|
||||
) {
|
||||
if (nodes.length === 0) {
|
||||
return nodes;
|
||||
const docIds = await this.#readableDocIdsForSearch(workspace, user);
|
||||
if (docIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isTeamWorkspace = await this.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
if (docIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.query = {
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.must,
|
||||
queries: [input.query, this.#docIdFilterQuery(docIds)],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
async #readableDocIdsForSearch(workspace: WorkspaceType, user: UserType) {
|
||||
const state = await this.quotaState.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
const isTeamWorkspace =
|
||||
state.plan === 'team' || state.plan === 'selfhost_team';
|
||||
if (!isTeamWorkspace) {
|
||||
return nodes;
|
||||
return null;
|
||||
}
|
||||
|
||||
const needs = await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspace.id)
|
||||
.docs(
|
||||
nodes.map(node => ({
|
||||
node,
|
||||
docId: node._source.docId,
|
||||
})),
|
||||
'Doc.Read'
|
||||
);
|
||||
return needs.map(node => node.node);
|
||||
return await this.#listReadableDocIds(workspace, user);
|
||||
}
|
||||
|
||||
#docIdFilterQuery(docIds: string[]): SearchQuery {
|
||||
return {
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.should,
|
||||
queries: docIds.map(docId => ({
|
||||
type: SearchQueryType.match,
|
||||
field: 'docId',
|
||||
match: docId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async #listReadableDocIds(workspace: WorkspaceType, user: UserType) {
|
||||
const input = {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
action: 'Doc.Read',
|
||||
docIdColumn: Prisma.raw('candidate_docs.doc_id'),
|
||||
} as const;
|
||||
const predicate = this.permission.docReadableSqlPredicate(input);
|
||||
const fallbackPredicate =
|
||||
this.permission.fallbackDocReadableSqlPredicate(input);
|
||||
const query = (predicate: Prisma.Sql) =>
|
||||
this.db.$queryRaw<{ docId: string }[]>`
|
||||
WITH candidate_docs AS (
|
||||
SELECT "workspace_pages"."page_id" AS doc_id
|
||||
FROM "workspace_pages"
|
||||
WHERE "workspace_pages"."workspace_id" = ${workspace.id}
|
||||
UNION
|
||||
SELECT "snapshots"."guid" AS doc_id
|
||||
FROM "snapshots"
|
||||
WHERE "snapshots"."workspace_id" = ${workspace.id}
|
||||
)
|
||||
SELECT candidate_docs.doc_id AS "docId"
|
||||
FROM candidate_docs
|
||||
WHERE ${predicate}
|
||||
`;
|
||||
const rows = await query(predicate).catch(error => {
|
||||
if (!fallbackPredicate) {
|
||||
throw error;
|
||||
}
|
||||
return query(fallbackPredicate);
|
||||
});
|
||||
return rows.map(row => row.docId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,8 +497,13 @@ export class IndexerService {
|
||||
keyword: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
docIds?: string[];
|
||||
}
|
||||
): Promise<SearchDoc[]> {
|
||||
if (options?.docIds?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const result = await this.aggregate({
|
||||
table: SearchTable.block,
|
||||
@@ -512,6 +517,19 @@ export class IndexerService {
|
||||
field: 'workspaceId',
|
||||
match: workspaceId,
|
||||
},
|
||||
...(options?.docIds
|
||||
? [
|
||||
{
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.should,
|
||||
queries: options.docIds.map(docId => ({
|
||||
type: SearchQueryType.match,
|
||||
field: 'docId',
|
||||
match: docId,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.must,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { EntitlementModule } from '../../core/entitlement';
|
||||
import { FeatureModule } from '../../core/features';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
@@ -8,7 +9,13 @@ import { AdminLicenseResolver, LicenseResolver } from './resolver';
|
||||
import { LicenseService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [FeatureModule, QuotaModule, PermissionModule, WorkspaceModule],
|
||||
imports: [
|
||||
FeatureModule,
|
||||
QuotaModule,
|
||||
PermissionModule,
|
||||
WorkspaceModule,
|
||||
EntitlementModule,
|
||||
],
|
||||
providers: [LicenseService, LicenseResolver, AdminLicenseResolver],
|
||||
})
|
||||
export class LicenseModule {}
|
||||
|
||||
@@ -15,7 +15,7 @@ import GraphQLUpload, {
|
||||
import { toBuffer, UseNamedGuard } from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { Admin } from '../../core/common';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { PermissionAccess } from '../../core/permission';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
@@ -86,7 +86,7 @@ export class AdminLicensePreview {
|
||||
export class LicenseResolver {
|
||||
constructor(
|
||||
private readonly service: LicenseService,
|
||||
private readonly ac: AccessController
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => License, {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createDecipheriv, createVerify } from 'node:crypto';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InstalledLicense, PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CryptoHelper,
|
||||
@@ -16,8 +13,11 @@ import {
|
||||
UserFriendlyError,
|
||||
WorkspaceLicenseAlreadyExists,
|
||||
} from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { Models } from '../../models';
|
||||
import { ResolvedEntitlement, resolveEntitlementV1 } from '../../native';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
@@ -31,6 +31,8 @@ interface License {
|
||||
endAt: number;
|
||||
}
|
||||
|
||||
const AFFINE_PRO_REQUEST_TIMEOUT = 10_000;
|
||||
|
||||
export interface LicensePreview {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
@@ -45,27 +47,6 @@ export interface LicensePreview {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
const BaseLicenseSchema = z.object({
|
||||
entity: z.string().nonempty(),
|
||||
issuer: z.string().nonempty(),
|
||||
issuedAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
const TeamLicenseSchema = z
|
||||
.object({
|
||||
subject: z.literal(SubscriptionPlan.SelfHostedTeam),
|
||||
data: z.object({
|
||||
id: z.string().nonempty(),
|
||||
workspaceId: z.string().nonempty(),
|
||||
plan: z.literal(SubscriptionPlan.SelfHostedTeam),
|
||||
recurring: z.nativeEnum(SubscriptionRecurring),
|
||||
quantity: z.number().positive(),
|
||||
endAt: z.string().datetime(),
|
||||
}),
|
||||
})
|
||||
.extend(BaseLicenseSchema.shape);
|
||||
|
||||
@Injectable()
|
||||
export class LicenseService {
|
||||
private readonly logger = new Logger(LicenseService.name);
|
||||
@@ -75,37 +56,11 @@ export class LicenseService {
|
||||
private readonly event: EventBus,
|
||||
private readonly models: Models,
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly quotaState: QuotaStateService
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
async onWorkspaceSubscriptionUpdated({
|
||||
workspaceId,
|
||||
plan,
|
||||
recurring,
|
||||
quantity,
|
||||
}: Events['workspace.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.SelfHostedTeam:
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'team_plan_v1',
|
||||
`${recurring} team subscription activated`,
|
||||
{
|
||||
memberLimit: quantity,
|
||||
}
|
||||
);
|
||||
this.event.emit('workspace.members.allocateSeats', {
|
||||
workspaceId,
|
||||
quantity,
|
||||
});
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('workspace.subscription.canceled')
|
||||
async onWorkspaceSubscriptionCanceled({
|
||||
workspaceId,
|
||||
@@ -137,74 +92,54 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
async installLicense(workspaceId: string, license: Buffer) {
|
||||
const payload = this.decryptWorkspaceTeamLicense(workspaceId, license);
|
||||
const data = payload.data;
|
||||
const now = new Date();
|
||||
|
||||
if (new Date(payload.expiresAt) < now || new Date(data.endAt) < now) {
|
||||
const resolved = this.resolveWorkspaceTeamLicense(workspaceId, license);
|
||||
if (!resolved.valid) {
|
||||
throw new LicenseExpired();
|
||||
}
|
||||
|
||||
const installed = await this.db.installedLicense.upsert({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
update: {
|
||||
key: data.id,
|
||||
expiredAt: new Date(data.endAt),
|
||||
validatedAt: new Date(),
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
license,
|
||||
},
|
||||
create: {
|
||||
key: data.id,
|
||||
workspaceId,
|
||||
expiredAt: new Date(data.endAt),
|
||||
validateKey: '',
|
||||
validatedAt: new Date(),
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
license,
|
||||
},
|
||||
});
|
||||
const validatedAt = new Date();
|
||||
|
||||
await this.event.emitAsync('workspace.subscription.activated', {
|
||||
workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: this.licenseRecurring(resolved),
|
||||
quantity: this.licenseQuantity(resolved),
|
||||
});
|
||||
await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId,
|
||||
recurring: this.licenseRecurring(resolved),
|
||||
quantity: this.licenseQuantity(resolved),
|
||||
expiresAt: this.licenseExpiresAt(resolved),
|
||||
validatedAt,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
license,
|
||||
});
|
||||
|
||||
return installed;
|
||||
return this.db.installedLicense.findUniqueOrThrow({
|
||||
where: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
previewLicense(license: Buffer): LicensePreview {
|
||||
const payload = this.decryptWorkspaceTeamLicensePayload(license);
|
||||
const data = payload.data;
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(payload.expiresAt);
|
||||
const endAt = new Date(data.endAt);
|
||||
|
||||
if (expiresAt < now || endAt < now) {
|
||||
const resolved = this.resolveWorkspaceTeamLicense(null, license);
|
||||
if (!resolved.valid) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license.',
|
||||
});
|
||||
}
|
||||
const expiresAt = this.licenseExpiresAt(resolved);
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
workspaceId: data.workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
issuedAt: new Date(payload.issuedAt),
|
||||
id: this.licenseSubjectId(resolved),
|
||||
workspaceId: this.licenseWorkspaceId(resolved),
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: this.licenseRecurring(resolved),
|
||||
quantity: this.licenseQuantity(resolved),
|
||||
issuedAt: new Date(resolved.issuedAt ?? ''),
|
||||
expiresAt,
|
||||
endAt,
|
||||
entity: payload.entity,
|
||||
issuer: payload.issuer,
|
||||
endAt: expiresAt,
|
||||
entity: resolved.entity ?? '',
|
||||
issuer: resolved.issuer ?? '',
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
@@ -215,6 +150,12 @@ export class LicenseService {
|
||||
if (installedLicense) {
|
||||
throw new WorkspaceLicenseAlreadyExists();
|
||||
}
|
||||
const occupiedLicense = await this.db.installedLicense.findUnique({
|
||||
where: { key: licenseKey },
|
||||
});
|
||||
if (occupiedLicense) {
|
||||
throw new WorkspaceLicenseAlreadyExists();
|
||||
}
|
||||
|
||||
const data = await this.fetchAffinePro<License>(
|
||||
`/api/team/licenses/${licenseKey}/activate`,
|
||||
@@ -223,28 +164,9 @@ export class LicenseService {
|
||||
}
|
||||
);
|
||||
|
||||
const license = await this.db.installedLicense.upsert({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
update: {
|
||||
key: licenseKey,
|
||||
validatedAt: new Date(),
|
||||
validateKey: data.res.headers.get('x-next-validate-key') ?? '',
|
||||
expiredAt: new Date(data.endAt),
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
key: licenseKey,
|
||||
expiredAt: new Date(data.endAt),
|
||||
validatedAt: new Date(),
|
||||
validateKey: data.res.headers.get('x-next-validate-key') ?? '',
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
},
|
||||
});
|
||||
const validatedAt = new Date();
|
||||
const expiresAt = this.remoteLicenseExpiresAt(data);
|
||||
const validateKey = data.res.headers.get('x-next-validate-key') ?? '';
|
||||
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId,
|
||||
@@ -252,7 +174,40 @@ export class LicenseService {
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
return license;
|
||||
await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
workspaceId,
|
||||
licenseKey,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
expiresAt,
|
||||
validatedAt,
|
||||
validateKey,
|
||||
});
|
||||
|
||||
return this.db.installedLicense.upsert({
|
||||
where: { workspaceId },
|
||||
update: {
|
||||
key: licenseKey,
|
||||
quantity: data.quantity,
|
||||
recurring: data.recurring,
|
||||
variant: null,
|
||||
validateKey,
|
||||
validatedAt,
|
||||
expiredAt: expiresAt,
|
||||
license: null,
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
key: licenseKey,
|
||||
quantity: data.quantity,
|
||||
recurring: data.recurring,
|
||||
variant: null,
|
||||
validateKey,
|
||||
validatedAt,
|
||||
expiredAt: expiresAt,
|
||||
license: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeTeamLicense(workspaceId: string) {
|
||||
@@ -271,6 +226,7 @@ export class LicenseService {
|
||||
workspaceId: license.workspaceId,
|
||||
},
|
||||
});
|
||||
await this.entitlement.revokeBySubject('selfhost_license', license.key);
|
||||
|
||||
if (license.variant !== SubscriptionVariant.Onetime) {
|
||||
await this.deactivateTeamLicense(license);
|
||||
@@ -334,9 +290,11 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
if (license.variant === SubscriptionVariant.Onetime) {
|
||||
const state =
|
||||
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
this.event.emit('workspace.members.allocateSeats', {
|
||||
workspaceId,
|
||||
quantity: license.quantity,
|
||||
quantity: state.seatLimit ?? 0,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -389,7 +347,7 @@ export class LicenseService {
|
||||
|
||||
for (const license of licenses) {
|
||||
if (license.variant === SubscriptionVariant.Onetime) {
|
||||
this.revalidateOnetimeLicense(license);
|
||||
await this.revalidateOnetimeLicense(license);
|
||||
} else {
|
||||
await this.revalidateRecurringLicense(license);
|
||||
}
|
||||
@@ -407,18 +365,9 @@ export class LicenseService {
|
||||
}
|
||||
);
|
||||
|
||||
await this.db.installedLicense.update({
|
||||
where: {
|
||||
key: license.key,
|
||||
},
|
||||
data: {
|
||||
validatedAt: new Date(),
|
||||
validateKey: res.res.headers.get('x-next-validate-key') ?? '',
|
||||
quantity: res.quantity,
|
||||
recurring: res.recurring,
|
||||
expiredAt: new Date(res.endAt),
|
||||
},
|
||||
});
|
||||
const validatedAt = new Date();
|
||||
const expiresAt = this.remoteLicenseExpiresAt(res);
|
||||
const validateKey = res.res.headers.get('x-next-validate-key') ?? '';
|
||||
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId: license.workspaceId,
|
||||
@@ -426,6 +375,39 @@ export class LicenseService {
|
||||
recurring: res.recurring,
|
||||
quantity: res.quantity,
|
||||
});
|
||||
await this.db.installedLicense.update({
|
||||
where: { key: license.key },
|
||||
data: {
|
||||
quantity: res.quantity,
|
||||
recurring: res.recurring,
|
||||
validateKey,
|
||||
validatedAt,
|
||||
expiredAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (license.license) {
|
||||
await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: res.recurring,
|
||||
quantity: res.quantity,
|
||||
expiresAt,
|
||||
validatedAt,
|
||||
validateKey,
|
||||
license: Buffer.from(license.license),
|
||||
});
|
||||
} else {
|
||||
await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: res.recurring,
|
||||
quantity: res.quantity,
|
||||
expiresAt,
|
||||
validatedAt,
|
||||
validateKey,
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
@@ -441,12 +423,21 @@ export class LicenseService {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await this.entitlement.revokeBySubject('selfhost_license', license.key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private remoteLicenseExpiresAt(license: Pick<License, 'endAt'>) {
|
||||
const expiresAt = new Date(license.endAt);
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= new Date()) {
|
||||
throw new LicenseExpired();
|
||||
}
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
private async fetchAffinePro<T = any>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
@@ -455,8 +446,14 @@ export class LicenseService {
|
||||
process.env.AFFINE_PRO_SERVER_ENDPOINT ?? 'https://app.affine.pro';
|
||||
|
||||
try {
|
||||
const signal =
|
||||
init?.signal ??
|
||||
(AbortSignal.timeout
|
||||
? AbortSignal.timeout(AFFINE_PRO_REQUEST_TIMEOUT)
|
||||
: undefined);
|
||||
const res = await fetch(endpoint + path, {
|
||||
...init,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
@@ -486,25 +483,33 @@ export class LicenseService {
|
||||
}
|
||||
}
|
||||
|
||||
private revalidateOnetimeLicense(license: InstalledLicense) {
|
||||
private async revalidateOnetimeLicense(license: InstalledLicense) {
|
||||
const buf = license.license;
|
||||
let valid = !!buf;
|
||||
|
||||
if (buf) {
|
||||
try {
|
||||
const { data } = this.decryptWorkspaceTeamLicense(
|
||||
const resolved = this.resolveWorkspaceTeamLicense(
|
||||
license.workspaceId,
|
||||
Buffer.from(buf)
|
||||
);
|
||||
|
||||
if (new Date(data.endAt) < new Date()) {
|
||||
if (!resolved.valid) {
|
||||
valid = false;
|
||||
} else {
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId: license.workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: this.licenseRecurring(resolved),
|
||||
quantity: this.licenseQuantity(resolved),
|
||||
});
|
||||
await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
recurring: this.licenseRecurring(resolved),
|
||||
quantity: this.licenseQuantity(resolved),
|
||||
expiresAt: this.licenseExpiresAt(resolved),
|
||||
validatedAt: new Date(),
|
||||
license: Buffer.from(buf),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -518,116 +523,101 @@ export class LicenseService {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await this.entitlement.revokeBySubject('selfhost_license', license.key);
|
||||
}
|
||||
}
|
||||
|
||||
private decryptWorkspaceTeamLicense(workspaceId: string, buf: Buffer) {
|
||||
const payload = this.decryptWorkspaceTeamLicensePayload(buf);
|
||||
|
||||
if (new Date(payload.expiresAt) < new Date()) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason:
|
||||
'License file has expired. Please contact with Affine support to fetch a latest one.',
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.data.workspaceId !== workspaceId) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Workspace mismatched with license.',
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private decryptWorkspaceTeamLicensePayload(buf: Buffer) {
|
||||
private resolveWorkspaceTeamLicense(workspaceId: string | null, buf: Buffer) {
|
||||
if (!this.crypto.AFFiNEProPublicKey) {
|
||||
throw new InternalServerError(
|
||||
'License public key is not loaded. Please contact with Affine support.'
|
||||
);
|
||||
}
|
||||
|
||||
// we use workspace id as aes key hash plain text content
|
||||
// verify signature to make sure the payload or signature is not forged
|
||||
const { payload: payloadStr, signature, iv } = this.decryptLicense(buf);
|
||||
|
||||
const verifier = createVerify('rsa-sha256');
|
||||
verifier.update(iv);
|
||||
verifier.update(payloadStr);
|
||||
const valid = verifier.verify(
|
||||
this.crypto.AFFiNEProPublicKey,
|
||||
signature,
|
||||
'hex'
|
||||
);
|
||||
if (!valid) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license signature.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadStr);
|
||||
|
||||
const parseResult = TeamLicenseSchema.safeParse(payload);
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
private decryptLicense(buf: Buffer) {
|
||||
if (!this.crypto.AFFiNEProLicenseAESKey) {
|
||||
throw new InternalServerError(
|
||||
'License AES key is not loaded. Please contact with Affine support.'
|
||||
);
|
||||
}
|
||||
|
||||
if (buf.length < 2) {
|
||||
const resolved = resolveEntitlementV1({
|
||||
deploymentType: 'selfhosted',
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId ?? undefined,
|
||||
signedPayload: buf,
|
||||
publicKey: this.crypto.AFFiNEProPublicKey.toString(),
|
||||
licenseAesKey: this.crypto.AFFiNEProLicenseAESKey.toString('hex'),
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (resolved.errorCode === 'workspace_mismatch') {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license file.',
|
||||
reason: 'Workspace mismatched with license.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ivLength = buf.readUint8(0);
|
||||
const authTagLength = buf.readUInt8(1);
|
||||
if (!resolved.valid && resolved.errorCode === 'expired_end_at') {
|
||||
throw new LicenseExpired();
|
||||
}
|
||||
|
||||
const iv = buf.subarray(2, 2 + ivLength);
|
||||
const tag = buf.subarray(2 + ivLength, 2 + ivLength + authTagLength);
|
||||
const payload = buf.subarray(2 + ivLength + authTagLength);
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.crypto.AFFiNEProLicenseAESKey,
|
||||
iv,
|
||||
{
|
||||
authTagLength,
|
||||
}
|
||||
);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(payload),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
const data = JSON.parse(decrypted.toString('utf-8')) as {
|
||||
payload: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
iv,
|
||||
};
|
||||
} catch {
|
||||
// we use workspace id as aes key hash plain text content
|
||||
if (!resolved.valid && resolved.status === 'expired') {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Failed to verify the license.',
|
||||
reason:
|
||||
'License file has expired. Please contact with Affine support to fetch a latest one.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!resolved.valid && resolved.status !== 'expired') {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: resolved.errorMessage ?? 'Failed to verify the license.',
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private licenseSubjectId(resolved: ResolvedEntitlement) {
|
||||
if (!resolved.subjectId) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
return resolved.subjectId;
|
||||
}
|
||||
|
||||
private licenseWorkspaceId(resolved: ResolvedEntitlement) {
|
||||
if (!resolved.targetId) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
return resolved.targetId;
|
||||
}
|
||||
|
||||
private licenseRecurring(resolved: ResolvedEntitlement) {
|
||||
if (!resolved.recurring) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
return resolved.recurring as SubscriptionRecurring;
|
||||
}
|
||||
|
||||
private licenseQuantity(resolved: ResolvedEntitlement) {
|
||||
if (!resolved.quantity) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
return resolved.quantity;
|
||||
}
|
||||
|
||||
private licenseExpiresAt(resolved: ResolvedEntitlement) {
|
||||
if (!resolved.expiresAt) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license payload.',
|
||||
});
|
||||
}
|
||||
return new Date(resolved.expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
import { EventBus, JobQueue, OneHour, OnJob } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { SubscriptionService } from './service';
|
||||
import { StripeFactory } from './stripe';
|
||||
@@ -34,7 +35,8 @@ export class SubscriptionCronJobs {
|
||||
private readonly queue: JobQueue,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler,
|
||||
private readonly stripeFactory: StripeFactory,
|
||||
private readonly subscription: SubscriptionService
|
||||
private readonly subscription: SubscriptionService,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {}
|
||||
|
||||
private getDateRange(after: number, base: number | Date = Date.now()) {
|
||||
@@ -157,6 +159,12 @@ export class SubscriptionCronJobs {
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
});
|
||||
await this.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: {
|
||||
|
||||
@@ -2,47 +2,32 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EventBus, OnEvent } from '../../base';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from './types';
|
||||
import { SubscriptionPlan } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentEventHandlers {
|
||||
constructor(
|
||||
private readonly workspace: WorkspaceService,
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly quotaState: QuotaStateService,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
async onWorkspaceSubscriptionUpdated({
|
||||
workspaceId,
|
||||
plan,
|
||||
recurring,
|
||||
quantity,
|
||||
}: Events['workspace.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case 'team': {
|
||||
const isTeam = await this.workspace.isTeamWorkspace(workspaceId);
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'team_plan_v1',
|
||||
`${recurring} team subscription activated`,
|
||||
{
|
||||
memberLimit: quantity,
|
||||
}
|
||||
);
|
||||
this.event.emit('workspace.members.allocateSeats', {
|
||||
workspaceId,
|
||||
quantity,
|
||||
});
|
||||
if (!isTeam) {
|
||||
// this event will triggered when subscription is activated or changed
|
||||
// we only send emails when the team workspace is activated
|
||||
await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId);
|
||||
}
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -68,22 +53,10 @@ export class PaymentEventHandlers {
|
||||
async onUserSubscriptionUpdated({
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: Events['user.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.models.userFeature.add(
|
||||
userId,
|
||||
'unlimited_copilot',
|
||||
'subscription activated'
|
||||
);
|
||||
break;
|
||||
case SubscriptionPlan.Pro:
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1',
|
||||
'subscription activated'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
default:
|
||||
@@ -95,43 +68,35 @@ export class PaymentEventHandlers {
|
||||
async onUserSubscriptionCanceled({
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: Events['user.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.models.userFeature.remove(userId, 'unlimited_copilot');
|
||||
break;
|
||||
case SubscriptionPlan.Pro: {
|
||||
// if user disputed a lifetime plan, we just switch them to free plan directly
|
||||
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'free_plan_v1',
|
||||
'lifetime subscription canceled'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
}
|
||||
|
||||
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
|
||||
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
|
||||
const isLifetimeUser = await this.models.userFeature.has(
|
||||
userId,
|
||||
'lifetime_pro_plan_v1'
|
||||
);
|
||||
|
||||
if (!isLifetimeUser) {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'free_plan_v1',
|
||||
'subscription canceled'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
}
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('entitlement.changed')
|
||||
async onEntitlementChanged({
|
||||
targetType,
|
||||
targetId,
|
||||
}: Events['entitlement.changed']) {
|
||||
if (targetType !== 'workspace') {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await this.quotaState.reconcileWorkspaceQuotaState(targetId);
|
||||
if (state.plan !== 'team' && state.plan !== 'selfhost_team') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.event.emit('workspace.members.allocateSeats', {
|
||||
workspaceId: targetId,
|
||||
quantity: state.seatLimit ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import './config';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ServerConfigModule } from '../../core';
|
||||
import { EntitlementModule } from '../../core/entitlement';
|
||||
import { FeatureModule } from '../../core/features';
|
||||
import { MailModule } from '../../core/mail';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
@@ -41,6 +42,7 @@ import { StripeWebhook } from './webhook';
|
||||
WorkspaceModule,
|
||||
MailModule,
|
||||
ServerConfigModule,
|
||||
EntitlementModule,
|
||||
],
|
||||
providers: [
|
||||
StripeFactory,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TooManyRequest,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { EarlyAccessType, FeatureService } from '../../../core/features';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
@@ -64,7 +65,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
private readonly feature: FeatureService,
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper,
|
||||
private readonly mutex: Mutex
|
||||
private readonly mutex: Mutex,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {
|
||||
super(stripeProvider, db);
|
||||
}
|
||||
@@ -254,7 +256,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
|
||||
return this.db.subscription.upsert({
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
@@ -270,6 +272,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deleteStripeSubscription({
|
||||
@@ -285,6 +289,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId,
|
||||
plan: lookupKey.plan,
|
||||
@@ -416,7 +425,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
if (prevSubscription) {
|
||||
if (prevSubscription.stripeSubscriptionId) {
|
||||
await this.db.subscription.update({
|
||||
const subscription = await this.db.subscription.update({
|
||||
where: {
|
||||
id: prevSubscription.id,
|
||||
},
|
||||
@@ -431,6 +440,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
|
||||
await this.stripe.subscriptions.cancel(
|
||||
prevSubscription.stripeSubscriptionId,
|
||||
@@ -440,7 +450,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.db.subscription.create({
|
||||
const subscription = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: knownInvoice.userId,
|
||||
stripeSubscriptionId: null,
|
||||
@@ -452,6 +462,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
}
|
||||
|
||||
this.event.emit('user.subscription.activated', {
|
||||
@@ -552,6 +563,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription({
|
||||
...subscription,
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
@@ -582,6 +597,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
});
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
});
|
||||
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId,
|
||||
@@ -621,7 +642,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
await this.db.subscription.update({
|
||||
const saved = await this.db.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: {
|
||||
status: SubscriptionStatus.Active,
|
||||
@@ -631,8 +652,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
end,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
} else {
|
||||
await this.db.subscription.create({
|
||||
const saved = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: userId,
|
||||
stripeSubscriptionId: null,
|
||||
@@ -643,6 +665,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
}
|
||||
|
||||
this.event.emit('user.subscription.activated', {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SubscriptionPlanNotFound,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { Models } from '../../../models';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
@@ -50,7 +51,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
db: PrismaClient,
|
||||
private readonly url: URLHelper,
|
||||
private readonly event: EventBus,
|
||||
private readonly models: Models
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {
|
||||
super(stripeProvider, db);
|
||||
}
|
||||
@@ -155,7 +157,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
return this.db.subscription.upsert({
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -175,6 +177,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deleteStripeSubscription({
|
||||
@@ -194,6 +198,11 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
this.event.emit('workspace.subscription.canceled', {
|
||||
workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { PermissionAccess } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||
@@ -665,7 +665,7 @@ export class WorkspaceSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: WorkspaceSubscriptionManager,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly ac: AccessController
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => SubscriptionType, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
OnJob,
|
||||
sleep,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
@@ -32,7 +33,8 @@ export class RevenueCatWebhookHandler {
|
||||
private readonly db: PrismaClient,
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus,
|
||||
private readonly queue: JobQueue
|
||||
private readonly queue: JobQueue,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {}
|
||||
|
||||
@OnEvent('revenuecat.webhook')
|
||||
@@ -197,6 +199,10 @@ export class RevenueCatWebhookHandler {
|
||||
},
|
||||
});
|
||||
if (result.count > 0) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
});
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId: appUserId,
|
||||
plan: mapping.plan,
|
||||
@@ -207,7 +213,7 @@ export class RevenueCatWebhookHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.db.subscription.upsert({
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
||||
},
|
||||
@@ -252,6 +258,7 @@ export class RevenueCatWebhookHandler {
|
||||
trialEnd: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
|
||||
if (
|
||||
status === SubscriptionStatus.Active ||
|
||||
@@ -278,6 +285,12 @@ export class RevenueCatWebhookHandler {
|
||||
if (toBeCleanup.length) {
|
||||
for (const sub of toBeCleanup) {
|
||||
await this.db.subscription.deleteMany({ where: { id: sub.id } });
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: appUserId,
|
||||
plan: sub.plan as SubscriptionPlan,
|
||||
subscriptionId: sub.id,
|
||||
stripeSubscriptionId: sub.stripeSubscriptionId,
|
||||
});
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId: appUserId,
|
||||
plan: sub.plan as SubscriptionPlan,
|
||||
|
||||
Reference in New Issue
Block a user