diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts index c08620f49b..feb6e44091 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts @@ -9,11 +9,18 @@ import { EnterpriseResolver } from 'src/engine/core-modules/enterprise/enterpris import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ imports: [ TwentyConfigModule, - TypeOrmModule.forFeature([UserWorkspaceEntity, AppTokenEntity]), + TypeOrmModule.forFeature([ + UserWorkspaceEntity, + AppTokenEntity, + UserEntity, + WorkspaceEntity, + ]), ], providers: [ EnterprisePlanService, diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts index 9c8e785bc1..309ad3ca35 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts @@ -11,6 +11,9 @@ import { ConfigVariableExceptionCode, } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; const mockCryptoVerify = jest.fn(); @@ -61,6 +64,10 @@ describe('EnterprisePlanService', () => { const appTokenFindOneMock = jest.fn(); const transactionMock = jest.fn(); const fetchMock = jest.fn(); + const workspaceCountMock = jest.fn(); + const userCountMock = jest.fn(); + const userWorkspaceCountMock = jest.fn(); + const userFindOneMock = jest.fn(); let originalFetch: typeof global.fetch; @@ -109,6 +116,10 @@ describe('EnterprisePlanService', () => { }); appTokenFindOneMock.mockResolvedValue(null); + workspaceCountMock.mockResolvedValue(0); + userCountMock.mockResolvedValue(0); + userWorkspaceCountMock.mockResolvedValue(0); + userFindOneMock.mockResolvedValue(null); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -130,6 +141,18 @@ describe('EnterprisePlanService', () => { }, }, }, + { + provide: getRepositoryToken(UserWorkspaceEntity), + useValue: { count: userWorkspaceCountMock }, + }, + { + provide: getRepositoryToken(UserEntity), + useValue: { count: userCountMock, findOne: userFindOneMock }, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: { count: workspaceCountMock }, + }, ], }).compile(); @@ -483,11 +506,74 @@ describe('EnterprisePlanService', () => { const result = await service.refreshValidityToken(); expect(result).toBe(true); - expect(fetchMock).toHaveBeenCalledWith(`${MOCK_API_URL}/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enterpriseKey: fakeKey }), + + const [validateUrl, validateOptions] = fetchMock.mock.calls[0] as [ + string, + { method: string; headers: Record; body: string }, + ]; + + expect(validateUrl).toBe(`${MOCK_API_URL}/validate`); + expect(validateOptions.method).toBe('POST'); + + const validateBody = JSON.parse(validateOptions.body); + + expect(validateBody.enterpriseKey).toBe(fakeKey); + expect(validateBody.instanceMetadata).toBeDefined(); + }); + + it('should include instance metadata in the validate payload', async () => { + const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD); + + configGetMock.mockImplementation((key: string) => { + if (key === 'ENTERPRISE_KEY') return fakeKey; + if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL; + if (key === 'SERVER_ID') return 'server-abc'; + if (key === 'SERVER_URL') return 'https://crm.example.com'; + if (key === 'APP_VERSION') return '1.2.3'; + if (key === 'NODE_ENV') return NodeEnvironment.PRODUCTION; + if (key === 'TELEMETRY_ENABLED') return true; + + return undefined; }); + mockCryptoVerify.mockReturnValue(true); + workspaceCountMock.mockResolvedValue(2); + userWorkspaceCountMock.mockResolvedValue(7); + userCountMock.mockResolvedValue(5); + userFindOneMock.mockResolvedValue({ email: 'admin@example.com' }); + + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + validityToken: createFakeJwt(MOCK_VALIDITY_PAYLOAD), + }), + }); + transactionMock.mockImplementation( + async (callback: (manager: Record) => void) => { + await callback({ update: jest.fn(), save: jest.fn() }); + }, + ); + appTokenFindOneMock.mockResolvedValue({ + value: createFakeJwt(MOCK_VALIDITY_PAYLOAD), + }); + + await service.refreshValidityToken(); + + const [, options] = fetchMock.mock.calls[0] as [string, { body: string }]; + const body = JSON.parse(options.body); + + expect(body.instanceMetadata).toMatchObject({ + serverId: 'server-abc', + serverUrl: 'https://crm.example.com', + appVersion: '1.2.3', + nodeEnv: NodeEnvironment.PRODUCTION, + telemetryEnabled: true, + workspaceCount: 2, + activeUserWorkspaceCount: 7, + distinctUserCount: 5, + adminContactEmail: 'admin@example.com', + }); + expect(typeof body.instanceMetadata.sentAt).toBe('string'); }); it('should return false when API returns non-OK response', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts index 997c4182b1..56f89f4c6c 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts @@ -17,6 +17,7 @@ import { ENTERPRISE_JWT_PUBLIC_KEY, } from 'src/engine/core-modules/enterprise/constants/enterprise-public-key.constant'; import { + type EnterpriseInstanceMetadata, type EnterpriseKeyPayload, type EnterpriseLicenseInfo, type EnterpriseValidityPayload, @@ -27,6 +28,9 @@ import { ConfigVariableExceptionCode, } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class EnterprisePlanService implements OnModuleInit { @@ -38,6 +42,12 @@ export class EnterprisePlanService implements OnModuleInit { private readonly twentyConfigService: TwentyConfigService, @InjectRepository(AppTokenEntity) private readonly appTokenRepository: Repository, + @InjectRepository(UserWorkspaceEntity) + private readonly userWorkspaceRepository: Repository, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, ) {} async onModuleInit() { @@ -215,10 +225,12 @@ export class EnterprisePlanService implements OnModuleInit { const validateUrl = `${apiUrl}/validate`; try { + const instanceMetadata = await this.gatherInstanceMetadata(); + const response = await fetch(validateUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enterpriseKey }), + body: JSON.stringify({ enterpriseKey, instanceMetadata }), }); if (!response.ok) { @@ -269,10 +281,12 @@ export class EnterprisePlanService implements OnModuleInit { const seatsUrl = `${apiUrl}/seats`; try { + const instanceMetadata = await this.gatherInstanceMetadata(); + const response = await fetch(seatsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enterpriseKey, seatCount }), + body: JSON.stringify({ enterpriseKey, seatCount, instanceMetadata }), }); if (!response.ok) { @@ -441,6 +455,53 @@ export class EnterprisePlanService implements OnModuleInit { } } + // Best-effort only: must never throw and fail a license refresh. + private async gatherInstanceMetadata(): Promise { + return { + serverId: this.twentyConfigService.get('SERVER_ID') ?? null, + serverUrl: this.twentyConfigService.get('SERVER_URL') ?? null, + appVersion: this.twentyConfigService.get('APP_VERSION') ?? null, + nodeEnv: this.twentyConfigService.get('NODE_ENV') ?? null, + telemetryEnabled: + this.twentyConfigService.get('TELEMETRY_ENABLED') ?? null, + workspaceCount: await this.safeCount(() => + this.workspaceRepository.count(), + ), + activeUserWorkspaceCount: await this.safeCount(() => + this.userWorkspaceRepository.count({ where: { deletedAt: IsNull() } }), + ), + distinctUserCount: await this.safeCount(() => + this.userRepository.count({ where: { deletedAt: IsNull() } }), + ), + adminContactEmail: await this.getAdminContactEmail(), + sentAt: new Date().toISOString(), + }; + } + + private async safeCount( + countFn: () => Promise, + ): Promise { + try { + return await countFn(); + } catch { + return null; + } + } + + private async getAdminContactEmail(): Promise { + try { + const user = await this.userRepository.findOne({ + where: { deletedAt: IsNull() }, + order: { createdAt: 'ASC' }, + select: { email: true }, + }); + + return user?.email ?? null; + } catch { + return null; + } + } + // In development and Jest integration tests, try both keys so production keys // work locally private getPublicKeysToTry(): string[] { diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts b/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts index 64a30d523f..f798aff24e 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts @@ -17,3 +17,16 @@ export type EnterpriseLicenseInfo = { expiresAt: Date | null; subscriptionId: string | null; }; + +export type EnterpriseInstanceMetadata = { + serverId: string | null; + serverUrl: string | null; + appVersion: string | null; + nodeEnv: string | null; + telemetryEnabled: boolean | null; + workspaceCount: number | null; + activeUserWorkspaceCount: number | null; + distinctUserCount: number | null; + adminContactEmail: string | null; + sentAt: string; +};