Add billing usage analytics dashboard with ClickHouse integration (#18592)
## Summary This PR adds a comprehensive billing usage analytics feature that provides detailed breakdowns of credit consumption across execution types, users, resources, and time periods. The implementation includes a new ClickHouse-backed analytics service, GraphQL API endpoint, and a frontend dashboard component. ## Key Changes ### Backend - **New BillingAnalyticsService**: Queries ClickHouse for usage breakdowns by user, resource, execution type, and time series data - **BillingEventWriterService**: Writes billing events to ClickHouse for analytics while maintaining best-effort semantics (never blocks Stripe billing) - **ClickHouse Schema**: Added `billingEvent` table with 3-year TTL for storing detailed billing event data - **GraphQL Resolver**: New `getBillingAnalytics` query that aggregates usage data for the current billing period, protected by feature flag and billing permissions - **Enhanced BillingUsageEvent**: Added `userWorkspaceId` field to track per-user credit consumption - **AI Billing Integration**: Updated AI billing service to pass `userWorkspaceId` when recording usage events ### Frontend - **SettingsBillingAnalyticsSection**: New component displaying: - Usage breakdown by execution type with progress bars - Daily usage time series chart (28-day view) - Per-user credit consumption breakdown - Per-resource (agent/workflow) credit consumption breakdown - **SettingsUsage Page**: Dedicated page for viewing usage analytics - **GraphQL Query**: `GetBillingAnalytics` query with generated hooks - **Navigation**: Added Usage menu item in settings (feature-flagged) - **Mock Data**: Included screenshot mock data for preview/testing ### Feature Flag - Added `IS_USAGE_ANALYTICS_ENABLED` feature flag to control visibility and access to analytics features ## Implementation Details - Analytics data is queried in parallel for performance - ClickHouse writes are non-blocking to ensure billing operations never fail - Progress bars use dynamic coloring from a predefined palette - Time series visualization normalizes bar heights relative to max value - Empty state handling when no analytics data is available - Responsive UI with proper text truncation for long names https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+3
@@ -0,0 +1,3 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const USAGE_RECORDED = 'USAGE_RECORDED';
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType()
|
||||
export class UsageAnalyticsInput {
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodStart?: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodEnd?: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
userWorkspaceId?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto';
|
||||
import { UsageTimeSeriesDTO } from 'src/engine/core-modules/usage/dtos/usage-time-series.dto';
|
||||
import { UsageUserDailyDTO } from 'src/engine/core-modules/usage/dtos/usage-user-daily.dto';
|
||||
|
||||
@ObjectType('UsageAnalytics')
|
||||
export class UsageAnalyticsDTO {
|
||||
@Field(() => [UsageBreakdownItemDTO])
|
||||
usageByUser: UsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [UsageBreakdownItemDTO])
|
||||
usageByOperationType: UsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [UsageTimeSeriesDTO])
|
||||
timeSeries: UsageTimeSeriesDTO[];
|
||||
|
||||
@Field(() => Date)
|
||||
periodStart: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
periodEnd: Date;
|
||||
|
||||
@Field(() => UsageUserDailyDTO, { nullable: true })
|
||||
userDailyUsage?: UsageUserDailyDTO;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('UsageBreakdownItem')
|
||||
export class UsageBreakdownItemDTO {
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
label?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('UsageTimeSeries')
|
||||
export class UsageTimeSeriesDTO {
|
||||
@Field(() => String)
|
||||
date: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UsageTimeSeriesDTO } from 'src/engine/core-modules/usage/dtos/usage-time-series.dto';
|
||||
|
||||
@ObjectType('UsageUserDaily')
|
||||
export class UsageUserDailyDTO {
|
||||
@Field(() => String)
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => [UsageTimeSeriesDTO])
|
||||
dailyUsage: UsageTimeSeriesDTO[];
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum UsageOperationType {
|
||||
AI_TOKEN = 'AI_TOKEN',
|
||||
WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION',
|
||||
CODE_EXECUTION = 'CODE_EXECUTION',
|
||||
}
|
||||
|
||||
registerEnumType(UsageOperationType, {
|
||||
name: 'UsageOperationType',
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum UsageResourceType {
|
||||
AI = 'AI',
|
||||
WORKFLOW = 'WORKFLOW',
|
||||
APP = 'APP',
|
||||
STORAGE = 'STORAGE',
|
||||
API = 'API',
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum UsageUnit {
|
||||
CREDIT = 'CREDIT',
|
||||
TOKEN = 'TOKEN',
|
||||
INVOCATION = 'INVOCATION',
|
||||
MINUTE = 'MINUTE',
|
||||
BYTE = 'BYTE',
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
import { UsageEventWriterService } from 'src/engine/core-modules/usage/services/usage-event-writer.service';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
|
||||
|
||||
@Injectable()
|
||||
export class UsageEventListener {
|
||||
constructor(
|
||||
private readonly usageEventWriterService: UsageEventWriterService,
|
||||
) {}
|
||||
|
||||
@OnCustomBatchEvent(USAGE_RECORDED)
|
||||
handleUsageRecordedEvent(payload: CustomWorkspaceEventBatch<UsageEvent>) {
|
||||
if (!isDefined(payload.workspaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.usageEventWriterService.writeToClickHouse(
|
||||
payload.workspaceId,
|
||||
payload.events,
|
||||
);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
|
||||
export type UsageBreakdownItem = {
|
||||
key: string;
|
||||
label?: string;
|
||||
creditsUsed: number;
|
||||
};
|
||||
|
||||
export type UsageTimeSeriesPoint = {
|
||||
date: string;
|
||||
creditsUsed: number;
|
||||
};
|
||||
|
||||
type BreakdownRowMicro = {
|
||||
key: string;
|
||||
creditsUsedMicro: number;
|
||||
};
|
||||
|
||||
type TimeSeriesRowMicro = {
|
||||
date: string;
|
||||
creditsUsedMicro: number;
|
||||
};
|
||||
|
||||
type PeriodParams = {
|
||||
workspaceId: string;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
};
|
||||
|
||||
const ALLOWED_GROUP_BY_FIELDS = [
|
||||
'userWorkspaceId',
|
||||
'resourceId',
|
||||
'operationType',
|
||||
'resourceType',
|
||||
] as const;
|
||||
|
||||
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
|
||||
|
||||
const BREAKDOWN_QUERY_LIMIT = 50;
|
||||
|
||||
@Injectable()
|
||||
export class UsageAnalyticsService {
|
||||
constructor(private readonly clickHouseService: ClickHouseService) {}
|
||||
|
||||
async getUsageByUser(params: PeriodParams): Promise<UsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'userWorkspaceId',
|
||||
extraWhere: "AND userWorkspaceId != ''",
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByOperationType(
|
||||
params: PeriodParams & { userWorkspaceId?: string },
|
||||
): Promise<UsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'operationType',
|
||||
...(params.userWorkspaceId && {
|
||||
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
|
||||
extraParams: { userWorkspaceId: params.userWorkspaceId },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByUserTimeSeries(
|
||||
params: PeriodParams & { userWorkspaceId: string },
|
||||
): Promise<UsageTimeSeriesPoint[]> {
|
||||
return this.queryTimeSeries({
|
||||
...params,
|
||||
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
|
||||
extraParams: { userWorkspaceId: params.userWorkspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageTimeSeries(
|
||||
params: PeriodParams,
|
||||
): Promise<UsageTimeSeriesPoint[]> {
|
||||
return this.queryTimeSeries(params);
|
||||
}
|
||||
|
||||
private async queryBreakdown({
|
||||
workspaceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
groupByField,
|
||||
extraWhere = '',
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
groupByField: GroupByField;
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<UsageBreakdownItem[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
${groupByField} AS key,
|
||||
sum(creditsUsedMicro) AS creditsUsedMicro
|
||||
FROM usageEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:String}
|
||||
AND timestamp < {periodEnd:String}
|
||||
${extraWhere}
|
||||
GROUP BY ${groupByField}
|
||||
ORDER BY creditsUsedMicro DESC
|
||||
LIMIT ${BREAKDOWN_QUERY_LIMIT}
|
||||
`;
|
||||
|
||||
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(extraParams ?? {}),
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: row.key,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
|
||||
}));
|
||||
}
|
||||
|
||||
private async queryTimeSeries({
|
||||
workspaceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
extraWhere = '',
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<UsageTimeSeriesPoint[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
formatDateTime(timestamp, '%Y-%m-%d') AS date,
|
||||
sum(creditsUsedMicro) AS creditsUsedMicro
|
||||
FROM usageEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:String}
|
||||
AND timestamp < {periodEnd:String}
|
||||
${extraWhere}
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
|
||||
query,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(extraParams ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
|
||||
}));
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsageEventWriterService {
|
||||
private readonly logger = new Logger(UsageEventWriterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
writeToClickHouse(workspaceId: string, usageEvents: UsageEvent[]): void {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = formatDateForClickHouse(new Date());
|
||||
|
||||
const rows = usageEvents.map((usageEvent) => ({
|
||||
timestamp: now,
|
||||
workspaceId,
|
||||
userWorkspaceId: usageEvent.userWorkspaceId ?? '',
|
||||
resourceType: usageEvent.resourceType,
|
||||
operationType: usageEvent.operationType,
|
||||
quantity: usageEvent.quantity,
|
||||
unit: usageEvent.unit,
|
||||
creditsUsedMicro: usageEvent.creditsUsedMicro,
|
||||
resourceId: usageEvent.resourceId ?? '',
|
||||
resourceContext: usageEvent.resourceContext ?? '',
|
||||
metadata: '{}',
|
||||
}));
|
||||
|
||||
this.clickHouseService.insert('usageEvent', rows).catch((error) => {
|
||||
this.logger.error('Failed to write usage events to ClickHouse', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { type UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
|
||||
import { type UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { type UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
|
||||
|
||||
export type UsageEvent = {
|
||||
resourceType: UsageResourceType;
|
||||
operationType: UsageOperationType;
|
||||
creditsUsedMicro: number;
|
||||
quantity: number;
|
||||
unit: UsageUnit;
|
||||
resourceId?: string | null;
|
||||
resourceContext?: string | null;
|
||||
userWorkspaceId?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UsageResolver } from 'src/engine/core-modules/usage/usage.resolver';
|
||||
import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service';
|
||||
import { UsageEventWriterService } from 'src/engine/core-modules/usage/services/usage-event-writer.service';
|
||||
import { UsageEventListener } from 'src/engine/core-modules/usage/listeners/usage-event.listener';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClickHouseModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity]),
|
||||
],
|
||||
providers: [
|
||||
UsageResolver,
|
||||
UsageAnalyticsService,
|
||||
UsageEventWriterService,
|
||||
UsageEventListener,
|
||||
],
|
||||
exports: [UsageEventWriterService, UsageAnalyticsService],
|
||||
})
|
||||
export class UsageModule {}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Query } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
|
||||
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
|
||||
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
|
||||
import {
|
||||
type UsageBreakdownItem,
|
||||
UsageAnalyticsService,
|
||||
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class UsageResolver {
|
||||
constructor(
|
||||
private readonly usageAnalyticsService: UsageAnalyticsService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
@Query(() => UsageAnalyticsDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED)
|
||||
async getUsageAnalytics(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args('input', { nullable: true }) input?: UsageAnalyticsInput,
|
||||
): Promise<UsageAnalyticsDTO> {
|
||||
const defaultPeriodEnd = new Date();
|
||||
const defaultPeriodStart = new Date();
|
||||
|
||||
defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30);
|
||||
|
||||
const periodStart = input?.periodStart ?? defaultPeriodStart;
|
||||
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
|
||||
|
||||
const periodParams = {
|
||||
workspaceId: workspace.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
|
||||
const [usageByUser, usageByOperationType, timeSeries] = await Promise.all([
|
||||
this.usageAnalyticsService.getUsageByUser(periodParams),
|
||||
this.usageAnalyticsService.getUsageByOperationType({
|
||||
...periodParams,
|
||||
userWorkspaceId: input?.userWorkspaceId ?? undefined,
|
||||
}),
|
||||
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
|
||||
]);
|
||||
|
||||
const resolvedUsageByUser = await this.resolveBreakdownKeys(
|
||||
usageByUser,
|
||||
(ids) => this.resolveUserNames(ids, workspace.id),
|
||||
);
|
||||
|
||||
const result: UsageAnalyticsDTO = {
|
||||
usageByUser: resolvedUsageByUser,
|
||||
usageByOperationType,
|
||||
timeSeries,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
|
||||
if (input?.userWorkspaceId) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: input.userWorkspaceId, workspaceId: workspace.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
const dailyUsage =
|
||||
await this.usageAnalyticsService.getUsageByUserTimeSeries({
|
||||
...periodParams,
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
});
|
||||
|
||||
result.userDailyUsage = {
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
dailyUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async resolveBreakdownKeys(
|
||||
items: UsageBreakdownItem[],
|
||||
resolveNames: (ids: string[]) => Promise<Map<string, string>>,
|
||||
): Promise<UsageBreakdownItem[]> {
|
||||
if (items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const ids = items.map((item) => item.key);
|
||||
const nameMap = await resolveNames(ids);
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
label: nameMap.get(item.key),
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveUserNames(
|
||||
userWorkspaceIds: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const nameMap = new Map<string, string>();
|
||||
|
||||
if (userWorkspaceIds.length === 0) {
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { id: In(userWorkspaceIds), workspaceId },
|
||||
relations: ['user'],
|
||||
select: {
|
||||
id: true,
|
||||
user: { firstName: true, lastName: true, email: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const userWorkspace of userWorkspaces) {
|
||||
if (!isDefined(userWorkspace.user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { firstName, lastName, email } = userWorkspace.user;
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
nameMap.set(userWorkspace.id, fullName || email);
|
||||
}
|
||||
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Internal credits use micro-precision: $1 = 1,000,000 internal credits
|
||||
// Display credits are 1000x coarser: $1 = 1,000 display credits
|
||||
// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars)
|
||||
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000;
|
||||
|
||||
// Converts internal (high-precision) credits to user-facing display credits.
|
||||
// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5).
|
||||
export const toDisplayCredits = (internalCredits: number): number =>
|
||||
Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10;
|
||||
Reference in New Issue
Block a user