diff --git a/packages/cloud/src/libraries/services.ts b/packages/cloud/src/libraries/services.ts index fe543a0aa..4937273b6 100644 --- a/packages/cloud/src/libraries/services.ts +++ b/packages/cloud/src/libraries/services.ts @@ -1,6 +1,8 @@ import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js'; import type { AllConnector, EmailConnector, SendMessagePayload } from '@logto/connector-kit'; import { ConnectorType, validateConfig } from '@logto/connector-kit'; +import { generateStandardId } from '@logto/core-kit'; +import type { ServiceLogType } from '@logto/schemas'; import { adminTenantId } from '@logto/schemas'; import { trySafe } from '@logto/shared'; import { RequestError } from '@withtyped/server'; @@ -9,6 +11,8 @@ import type { Queries } from '#src/queries/index.js'; import type { LogtoConnector } from '#src/utils/connector/index.js'; import { loadConnectorFactories } from '#src/utils/connector/index.js'; +export const serviceCountLimitForTenant = 100; + export class ServicesLibrary { constructor(public readonly queries: Queries) {} @@ -96,4 +100,18 @@ export class ServicesLibrary { return sendMessage(data); } + + async addLog(tenantId: string, type: ServiceLogType) { + return this.queries.serviceLogs.insertLog({ + id: generateStandardId(), + type, + tenantId, + }); + } + + async getTenantBalanceForType(tenantId: string, type: ServiceLogType) { + const usedCount = await this.queries.serviceLogs.countTenantLogs(tenantId, type); + + return serviceCountLimitForTenant - usedCount; + } } diff --git a/packages/cloud/src/queries/index.ts b/packages/cloud/src/queries/index.ts index 9c271ff44..9c85122fa 100644 --- a/packages/cloud/src/queries/index.ts +++ b/packages/cloud/src/queries/index.ts @@ -5,6 +5,7 @@ import { parseDsn } from '#src/utils/postgres.js'; import { createApplicationsQueries } from './application.js'; import { createConnectorsQuery } from './connector.js'; +import { createServiceLogsQueries } from './service-logs.js'; import { createTenantsQueries } from './tenants.js'; import { createUsersQueries } from './users.js'; @@ -16,4 +17,5 @@ export class Queries { public readonly users = createUsersQueries(this.client); public readonly applications = createApplicationsQueries(this.client); public readonly connectors = createConnectorsQuery(this.client); + public readonly serviceLogs = createServiceLogsQueries(this.client); } diff --git a/packages/cloud/src/queries/service-logs.ts b/packages/cloud/src/queries/service-logs.ts new file mode 100644 index 000000000..c52746271 --- /dev/null +++ b/packages/cloud/src/queries/service-logs.ts @@ -0,0 +1,29 @@ +import type { CreateServiceLog, ServiceLogType } from '@logto/schemas'; +import type { PostgreSql } from '@withtyped/postgres'; +import { sql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; + +import { insertInto } from '#src/utils/query.js'; + +export type ServiceLogsQueries = ReturnType; + +export const createServiceLogsQueries = (client: Queryable) => { + const insertLog = async (data: Omit) => + client.query(insertInto(data, 'service_logs')); + + const countTenantLogs = async (tenantId: string, type: ServiceLogType) => { + const { rows } = await client.query<{ count: number }>(sql` + select count(id) as count + from service_logs + where tenantId = ${tenantId} + and type = ${type} + `); + + return rows[0]?.count ?? 0; + }; + + return { + insertLog, + countTenantLogs, + }; +}; diff --git a/packages/cloud/src/routes/service.test.ts b/packages/cloud/src/routes/service.test.ts index 1d258277a..2db621c0b 100644 --- a/packages/cloud/src/routes/service.test.ts +++ b/packages/cloud/src/routes/service.test.ts @@ -1,4 +1,4 @@ -import { CloudScope } from '@logto/schemas'; +import { CloudScope, ServiceLogType } from '@logto/schemas'; import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js'; import { noop } from '#src/test-utils/function.js'; @@ -28,6 +28,33 @@ describe('POST /api/services/send-email', () => { ).rejects.toMatchObject({ status: 403 }); }); + it('should throw 403 when tenant id not found', async () => { + await expect( + router.routes()( + buildRequestAuthContext('POST /services/send-email', { + body: { data: mockSendMessagePayload }, + })([CloudScope.SendEmail]), + noop, + createHttpContext() + ) + ).rejects.toMatchObject({ status: 403 }); + }); + + it('should throw 403 when insufficient funds', async () => { + library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId'); + library.getTenantBalanceForType.mockResolvedValueOnce(0); + + await expect( + router.routes()( + buildRequestAuthContext('POST /services/send-email', { + body: { data: mockSendMessagePayload }, + })([CloudScope.SendEmail]), + noop, + createHttpContext() + ) + ).rejects.toMatchObject({ status: 403 }); + }); + it('should return 201', async () => { library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId'); @@ -38,6 +65,7 @@ describe('POST /api/services/send-email', () => { async ({ status }) => { expect(status).toBe(201); expect(library.sendEmail).toBeCalledWith(mockSendMessagePayload); + expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendEmail); }, createHttpContext() ); diff --git a/packages/cloud/src/routes/services.ts b/packages/cloud/src/routes/services.ts index 7a4766b72..01a008b8e 100644 --- a/packages/cloud/src/routes/services.ts +++ b/packages/cloud/src/routes/services.ts @@ -1,5 +1,5 @@ import { sendMessagePayloadGuard } from '@logto/connector-kit'; -import { CloudScope } from '@logto/schemas'; +import { CloudScope, ServiceLogType } from '@logto/schemas'; import { createRouter, RequestError } from '@withtyped/server'; import { z } from 'zod'; @@ -17,10 +17,18 @@ export const servicesRoutes = (library: ServicesLibrary) => const tenantId = await library.getTenantIdFromApplicationId(context.auth.id); - // TODO limitation control - console.log(tenantId); + if (!tenantId) { + throw new RequestError('Unable to find tenant id.', 403); + } + + const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendEmail); + + if (!balance) { + throw new RequestError('Service usage limit reached.', 403); + } await library.sendEmail(context.guarded.body.data); + await library.addLog(tenantId, ServiceLogType.SendEmail); return next({ ...context, status: 201 }); } diff --git a/packages/cloud/src/test-utils/libraries.ts b/packages/cloud/src/test-utils/libraries.ts index 0607c76a3..c5d6d33f9 100644 --- a/packages/cloud/src/test-utils/libraries.ts +++ b/packages/cloud/src/test-utils/libraries.ts @@ -1,4 +1,4 @@ -import type { TenantInfo } from '@logto/schemas'; +import type { ServiceLogType, TenantInfo } from '@logto/schemas'; import type { ServicesLibrary } from '#src/libraries/services.js'; import type { TenantsLibrary } from '#src/libraries/tenants.js'; @@ -25,4 +25,10 @@ export class MockServicesLibrary implements ServicesLibrary { public sendEmail = jest.fn(); public getAdminTenantLogtoConnectors = jest.fn(); + + public addLog = jest.fn(); + + public getTenantBalanceForType = jest + .fn, [string, ServiceLogType]>() + .mockResolvedValue(100); } diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index f244182d2..fed470f27 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -14,3 +14,4 @@ export * from './system.js'; export * from './tenant.js'; export * from './user-assets.js'; export * from './hook.js'; +export * from './service-log.js'; diff --git a/packages/schemas/src/types/service-log.ts b/packages/schemas/src/types/service-log.ts new file mode 100644 index 000000000..48d0298ca --- /dev/null +++ b/packages/schemas/src/types/service-log.ts @@ -0,0 +1,4 @@ +export enum ServiceLogType { + SendEmail = 'sendEmail', + SendSms = 'sendSms', +}