feat(taktik): add detail pages, duration rounding, confirmation dialogs

- Project detail page (/projects/[id]): stats, budget progress, inline
  edit, full entry list with billing value calculation
- Client detail page (/clients/[id]): stats, project cards, entry list,
  billing value summary
- Duration rounding: configurable increment (1-15 min) and method
  (up/down/nearest), applied automatically when timer stops
- ConfirmDialog component: reusable modal for destructive actions
- Confirmation required before deleting entries, projects, and clients
- 18 new rounding tests (67 total, all passing)
- i18n: added deleteConfirm keys for DE and EN

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 08:44:59 +02:00
parent 7552c351c0
commit 49df3ead09
28 changed files with 1874 additions and 18 deletions

View file

@ -0,0 +1,57 @@
{
"name": "@manacore/nestjs-integration",
"version": "1.0.0",
"private": true,
"description": "NestJS integration package for Mana Core authentication and credits",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./guards": {
"types": "./dist/guards/index.d.ts",
"import": "./dist/guards/index.js",
"require": "./dist/guards/index.js"
},
"./decorators": {
"types": "./dist/decorators/index.d.ts",
"import": "./dist/decorators/index.js",
"require": "./dist/decorators/index.js"
},
"./interceptors": {
"types": "./dist/interceptors/index.d.ts",
"import": "./dist/interceptors/index.js",
"require": "./dist/interceptors/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/credit-operations": "workspace:*",
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0",
"rxjs": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
},
"files": [
"dist"
]
}

View file

@ -0,0 +1,24 @@
import { createParamDecorator } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
export interface JwtPayload {
sub: string;
email: string;
role?: string;
app_id?: string;
iat?: number;
exp?: number;
}
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext): JwtPayload | string => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (data) {
return user[data] as string;
}
return user;
}
);

View file

@ -0,0 +1,3 @@
export { CurrentUser, JwtPayload } from './current-user.decorator';
export { Public, IS_PUBLIC_KEY } from './public.decorator';
export { UseCredits, CreditOperationConfig, CREDIT_OPERATION_KEY } from './use-credits.decorator';

View file

@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* Decorator to mark a route as public (no authentication required)
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -0,0 +1,97 @@
import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common';
import { CreditInterceptor } from '../interceptors/credit.interceptor';
import { type CreditOperationType } from '@manacore/credit-operations';
/**
* Metadata key for credit operation configuration.
*/
export const CREDIT_OPERATION_KEY = 'credit_operation';
/**
* Configuration for credit consumption.
*/
export interface CreditOperationConfig {
/**
* The operation type from the credit-operations package.
*/
operation: CreditOperationType;
/**
* Custom cost override. If not specified, uses the default from CREDIT_COSTS.
*/
customCost?: number;
/**
* Whether to consume credits before or after the handler execution.
* - 'before': Validate and reserve credits before execution (default)
* - 'after': Consume credits only after successful execution
*/
consumeMode?: 'before' | 'after';
/**
* Optional function to calculate cost dynamically based on request.
* Receives the request object and should return the credit cost.
*/
dynamicCost?: (request: any) => number;
/**
* Optional function to generate description for the transaction.
* Receives the request object and should return a description string.
*/
descriptionFn?: (request: any) => string;
/**
* Whether to skip the credit check in development mode.
* Default: false
*/
skipInDev?: boolean;
}
/**
* Decorator to require credits for an endpoint.
*
* @example Simple usage with operation type:
* ```typescript
* @Post('tasks')
* @UseCredits(CreditOperationType.TASK_CREATE)
* async createTask(@Body() dto: CreateTaskDto) {
* return this.taskService.create(dto);
* }
* ```
*
* @example With configuration object:
* ```typescript
* @Post('generate')
* @UseCredits({
* operation: CreditOperationType.AI_IMAGE_GENERATION,
* consumeMode: 'after',
* descriptionFn: (req) => `Generated image: ${req.body.prompt}`,
* })
* async generateImage(@Body() dto: GenerateDto) {
* return this.imageService.generate(dto);
* }
* ```
*
* @example With dynamic cost:
* ```typescript
* @Post('bulk-import')
* @UseCredits({
* operation: CreditOperationType.BULK_IMPORT,
* dynamicCost: (req) => Math.ceil(req.body.items.length / 10) * 0.2,
* })
* async bulkImport(@Body() dto: BulkImportDto) {
* return this.importService.import(dto);
* }
* ```
*/
export function UseCredits(
operationOrConfig: CreditOperationType | CreditOperationConfig
): MethodDecorator {
const config: CreditOperationConfig =
typeof operationOrConfig === 'string' ? { operation: operationOrConfig } : operationOrConfig;
return applyDecorators(
SetMetadata(CREDIT_OPERATION_KEY, config),
UseInterceptors(CreditInterceptor)
);
}

View file

@ -0,0 +1,22 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export interface InsufficientCreditsDetails {
requiredCredits: number;
availableCredits: number;
creditType: 'user' | 'app';
operation: string;
}
export class InsufficientCreditsException extends HttpException {
constructor(details: InsufficientCreditsDetails) {
super(
{
statusCode: HttpStatus.PAYMENT_REQUIRED,
error: 'Insufficient Credits',
message: `Not enough credits for ${details.operation}. Required: ${details.requiredCredits}, Available: ${details.availableCredits}`,
details,
},
HttpStatus.PAYMENT_REQUIRED
);
}
}

View file

@ -0,0 +1,176 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Inject,
Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
interface TokenValidationResponse {
valid: boolean;
payload?: {
sub: string;
email: string;
role: string;
sessionId?: string;
sid?: string;
app_id?: string;
iat?: number;
exp?: number;
};
error?: string;
}
// Default development test user ID
const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000';
/**
* JWT Authentication Guard for NestJS backends.
*
* Validates JWT tokens by calling the Mana Core Auth service.
* Supports development mode bypass via DEV_BYPASS_AUTH=true.
*/
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly reflector?: Reflector,
@Optional()
private readonly configService?: ConfigService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check if route is marked as public
if (this.reflector) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
}
const request = context.switchToHttp().getRequest();
// Development mode: bypass auth if DEV_BYPASS_AUTH is set
if (this.shouldBypassAuth()) {
request.user = this.getDevUser();
return true;
}
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No authorization token provided');
}
try {
const userData = await this.validateToken(token);
request.user = userData;
request.accessToken = token;
if (this.options?.debug) {
console.log('[AuthGuard] User authenticated:', userData.sub);
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
if (this.options?.debug) {
console.error('[AuthGuard] Token validation failed:', error);
}
throw new UnauthorizedException('Invalid or expired token');
}
}
/**
* Check if auth should be bypassed (development mode)
*/
private shouldBypassAuth(): boolean {
const isDev =
this.configService?.get<string>('NODE_ENV') === 'development' ||
process.env.NODE_ENV === 'development';
const bypassAuth =
this.configService?.get<string>('DEV_BYPASS_AUTH') === 'true' ||
process.env.DEV_BYPASS_AUTH === 'true';
return isDev && bypassAuth;
}
/**
* Get development user data
*/
private getDevUser() {
const devUserId =
this.configService?.get<string>('DEV_USER_ID') ||
process.env.DEV_USER_ID ||
DEFAULT_DEV_USER_ID;
return {
sub: devUserId,
email: 'dev@example.com',
role: 'user',
app_id: this.options?.appId,
};
}
/**
* Validate token with Mana Core Auth service
*/
private async validateToken(token: string): Promise<any> {
const authUrl =
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
process.env.MANA_CORE_AUTH_URL ||
'http://localhost:3001';
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
if (this.options?.debug) {
console.error('[AuthGuard] Token validation failed:', response.status, errorText);
}
throw new UnauthorizedException('Invalid token');
}
const result = (await response.json()) as TokenValidationResponse;
if (!result.valid || !result.payload) {
throw new UnauthorizedException(result.error || 'Invalid token');
}
return {
sub: result.payload.sub,
email: result.payload.email,
role: result.payload.role,
app_id: result.payload.app_id || this.options?.appId,
sessionId: result.payload.sessionId || result.payload.sid,
iat: result.payload.iat,
exp: result.payload.exp,
};
}
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -0,0 +1,2 @@
export { AuthGuard } from './auth.guard';
export { OptionalAuthGuard } from './optional-auth.guard';

View file

@ -0,0 +1,117 @@
import { Injectable, CanActivate, ExecutionContext, Inject, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
interface TokenValidationResponse {
valid: boolean;
payload?: {
sub: string;
email: string;
role: string;
sessionId?: string;
sid?: string;
app_id?: string;
iat?: number;
exp?: number;
};
error?: string;
}
/**
* Optional auth guard - allows unauthenticated requests but still validates and extracts user info if token is present
*/
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly configService?: ConfigService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
// No token - allow request but user will be undefined
request.user = null;
return true;
}
try {
const userData = await this.validateToken(token);
if (userData) {
request.user = userData;
request.accessToken = token;
if (this.options?.debug) {
console.log('[OptionalAuthGuard] User authenticated:', userData.sub);
}
} else {
request.user = null;
}
} catch (error) {
if (this.options?.debug) {
console.error('[OptionalAuthGuard] Token validation failed:', error);
}
// For optional auth, we allow the request to proceed even if token validation fails
request.user = null;
}
return true;
}
/**
* Validate token with Mana Core Auth service
*/
private async validateToken(token: string): Promise<any | null> {
const authUrl =
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
process.env.MANA_CORE_AUTH_URL ||
'http://localhost:3001';
try {
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
return null;
}
const result = (await response.json()) as TokenValidationResponse;
if (!result.valid || !result.payload) {
return null;
}
return {
sub: result.payload.sub,
email: result.payload.email,
role: result.payload.role,
app_id: result.payload.app_id || this.options?.appId,
sessionId: result.payload.sessionId || result.payload.sid,
iat: result.payload.iat,
exp: result.payload.exp,
};
} catch {
return null;
}
}
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -0,0 +1,53 @@
// Module
export { ManaCoreModule, MANA_CORE_OPTIONS } from './mana-core.module';
// Interfaces
export {
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
} from './interfaces/mana-core-options.interface';
// Guards
export { AuthGuard } from './guards/auth.guard';
export { OptionalAuthGuard } from './guards/optional-auth.guard';
// Decorators
export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator';
export {
UseCredits,
CreditOperationConfig,
CREDIT_OPERATION_KEY,
} from './decorators/use-credits.decorator';
// Interceptors
export { CreditInterceptor } from './interceptors/credit.interceptor';
// Services
export {
CreditClientService,
CreditValidationResult,
CreditBalance,
} from './services/credit-client.service';
// Exceptions
export {
InsufficientCreditsException,
InsufficientCreditsDetails,
} from './exceptions/insufficient-credits.exception';
// Re-export credit operations for convenience
export {
CreditOperationType,
CREDIT_COSTS,
CreditCategory,
getCreditCost,
getOperationMetadata,
getOperationsForApp,
formatCreditCost,
getPricingTable,
isFreeOperation,
isMicroCreditOperation,
isAiOperation,
} from '@manacore/credit-operations';

View file

@ -0,0 +1,195 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
Inject,
Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap, catchError, throwError } from 'rxjs';
import { CreditClientService } from '../services/credit-client.service';
import {
InsufficientCreditsException,
InsufficientCreditsDetails,
} from '../exceptions/insufficient-credits.exception';
import { CREDIT_OPERATION_KEY, CreditOperationConfig } from '../decorators/use-credits.decorator';
import { CREDIT_COSTS, getOperationMetadata } from '@manacore/credit-operations';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
/**
* Interceptor that handles credit validation and consumption for decorated endpoints.
*
* This interceptor:
* 1. Checks if the user has sufficient credits before executing the handler
* 2. Consumes credits after successful execution (or before, depending on config)
* 3. Throws InsufficientCreditsException if the user doesn't have enough credits
*/
@Injectable()
export class CreditInterceptor implements NestInterceptor {
private readonly logger = new Logger(CreditInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly creditClient: CreditClientService,
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const config = this.reflector.get<CreditOperationConfig>(
CREDIT_OPERATION_KEY,
context.getHandler()
);
// If no config, just proceed (shouldn't happen if decorator is used correctly)
if (!config) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check if user is authenticated
if (!user?.sub) {
this.logger.warn('No authenticated user found for credit operation');
return next.handle();
}
const userId = user.sub;
const operationName = config.operation;
// Calculate cost
const cost = this.calculateCost(config, request);
const consumeMode = config.consumeMode || 'after';
// Skip in development if configured
if (config.skipInDev && this.isDevelopment()) {
this.logger.debug(`Skipping credit check in development for ${operationName}`);
return next.handle();
}
// Validate credits before execution
const validation = await this.creditClient.validateCredits(userId, operationName, cost);
if (!validation.hasCredits) {
const details: InsufficientCreditsDetails = {
requiredCredits: cost,
availableCredits: validation.availableCredits,
creditType: 'user',
operation: operationName,
};
throw new InsufficientCreditsException(details);
}
// If consume mode is 'before', consume now
if (consumeMode === 'before') {
const description = this.generateDescription(config, request);
const consumed = await this.creditClient.consumeCredits(
userId,
operationName,
cost,
description,
this.buildMetadata(config, request)
);
if (!consumed) {
this.logger.error(`Failed to consume credits for ${operationName}`);
// Still allow the operation to proceed - fail open
}
return next.handle();
}
// If consume mode is 'after', consume on success
return next.handle().pipe(
tap(async () => {
const description = this.generateDescription(config, request);
const consumed = await this.creditClient.consumeCredits(
userId,
operationName,
cost,
description,
this.buildMetadata(config, request)
);
if (!consumed) {
this.logger.error(`Failed to consume credits after success for ${operationName}`);
} else if (this.options?.debug) {
this.logger.log(`Consumed ${cost} credits for ${operationName} (user: ${userId})`);
}
}),
catchError((error) => {
// Don't consume credits if the operation failed
this.logger.debug(`Operation ${operationName} failed, credits not consumed`);
return throwError(() => error);
})
);
}
/**
* Calculate the credit cost for the operation.
*/
private calculateCost(config: CreditOperationConfig, request: any): number {
// Dynamic cost takes priority
if (config.dynamicCost) {
return config.dynamicCost(request);
}
// Custom cost override
if (config.customCost !== undefined) {
return config.customCost;
}
// Default cost from CREDIT_COSTS
return CREDIT_COSTS[config.operation] || 0;
}
/**
* Generate a description for the credit transaction.
*/
private generateDescription(config: CreditOperationConfig, request: any): string {
// Custom description function
if (config.descriptionFn) {
return config.descriptionFn(request);
}
// Default description from operation metadata
const metadata = getOperationMetadata(config.operation);
return metadata?.name || config.operation;
}
/**
* Build metadata for the credit transaction.
*/
private buildMetadata(config: CreditOperationConfig, request: any): Record<string, any> {
const metadata: Record<string, any> = {
operation: config.operation,
path: request.path,
method: request.method,
};
// Add app info from operation metadata
const opMeta = getOperationMetadata(config.operation);
if (opMeta) {
metadata.app = opMeta.app;
metadata.category = opMeta.category;
}
return metadata;
}
/**
* Check if running in development mode.
*/
private isDevelopment(): boolean {
return (
this.options?.debug ||
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'dev'
);
}
}

View file

@ -0,0 +1 @@
export { CreditInterceptor } from './credit.interceptor';

View file

@ -0,0 +1,24 @@
import { type ModuleMetadata } from '@nestjs/common';
import type { Type } from '@nestjs/common';
export interface ManaCoreModuleOptions {
/**
* @deprecated No longer used - auth URL is read from MANA_CORE_AUTH_URL env variable
*/
manaServiceUrl?: string;
appId: string;
serviceKey?: string;
signupRedirectUrl?: string;
debug?: boolean;
}
export interface ManaCoreOptionsFactory {
createManaCoreOptions(): Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
}
export interface ManaCoreModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useExisting?: Type<ManaCoreOptionsFactory>;
useClass?: Type<ManaCoreOptionsFactory>;
useFactory?: (...args: any[]) => Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
inject?: any[];
}

View file

@ -0,0 +1,83 @@
import { DynamicModule, Module, Global, Provider } from '@nestjs/common';
import {
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
} from './interfaces/mana-core-options.interface';
import { AuthGuard } from './guards/auth.guard';
import { CreditClientService } from './services/credit-client.service';
export const MANA_CORE_OPTIONS = 'MANA_CORE_OPTIONS';
@Global()
@Module({})
export class ManaCoreModule {
static forRoot(options: ManaCoreModuleOptions): DynamicModule {
return {
module: ManaCoreModule,
providers: [
{
provide: MANA_CORE_OPTIONS,
useValue: options,
},
AuthGuard,
CreditClientService,
],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
static forRootAsync(options: ManaCoreModuleAsyncOptions): DynamicModule {
const asyncProviders = this.createAsyncProviders(options);
return {
module: ManaCoreModule,
imports: options.imports || [],
providers: [...asyncProviders, AuthGuard, CreditClientService],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
private static createAsyncProviders(options: ManaCoreModuleAsyncOptions): Provider[] {
if (options.useFactory) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
];
}
const useClass = options.useClass;
const useExisting = options.useExisting;
if (useClass) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useClass],
},
{
provide: useClass,
useClass,
},
];
}
if (useExisting) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useExisting],
},
];
}
return [];
}
}

View file

@ -0,0 +1,243 @@
import { Injectable, Inject, Optional, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
export interface CreditValidationResult {
hasCredits: boolean;
availableCredits: number;
requiredCredits?: number;
}
export interface CreditBalance {
balance: number;
totalEarned: number;
totalSpent: number;
}
@Injectable()
export class CreditClientService {
private readonly logger = new Logger(CreditClientService.name);
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly configService?: ConfigService
) {}
private getAuthUrl(): string {
return (
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
process.env.MANA_CORE_AUTH_URL ||
'http://localhost:3001'
);
}
/**
* Get the credits service URL. Uses MANA_CREDITS_URL if available,
* falls back to MANA_CORE_AUTH_URL for backward compatibility.
*/
private getCreditsUrl(): string {
return (
this.configService?.get<string>('MANA_CREDITS_URL') ||
process.env.MANA_CREDITS_URL ||
this.getAuthUrl()
);
}
private getServiceKey(): string {
return (
this.options?.serviceKey ||
this.configService?.get<string>('MANA_CORE_SERVICE_KEY') ||
process.env.MANA_CORE_SERVICE_KEY ||
''
);
}
private getAppId(): string {
return (
this.options?.appId || this.configService?.get<string>('APP_ID') || process.env.APP_ID || ''
);
}
async validateCredits(
userId: string,
operation: string,
requiredAmount: number
): Promise<CreditValidationResult> {
try {
const balance = await this.getBalance(userId);
return {
hasCredits: balance.balance >= requiredAmount,
availableCredits: balance.balance,
requiredCredits: requiredAmount,
};
} catch (error) {
this.logger.error(`Failed to validate credits for user ${userId}:`, error);
// In case of error, we allow the operation to proceed
// The actual credit deduction will fail if there are no credits
return {
hasCredits: true,
availableCredits: 0,
requiredCredits: requiredAmount,
};
}
}
async getBalance(userId: string): Promise<CreditBalance> {
const creditsUrl = this.getCreditsUrl();
const serviceKey = this.getServiceKey();
if (!serviceKey) {
this.logger.warn('Service key not configured, returning default balance');
return {
balance: 1000,
totalEarned: 0,
totalSpent: 0,
};
}
try {
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/balance/${userId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': serviceKey,
'X-App-Id': this.getAppId(),
},
});
if (!response.ok) {
this.logger.warn(`Credit balance request failed: ${response.status}`);
return this.getDefaultBalance();
}
const {
balance = 0,
totalEarned = 0,
totalSpent = 0,
} = (await response.json()) as CreditBalance;
return {
balance,
totalEarned,
totalSpent,
};
} catch (error) {
this.logger.error(`Failed to get balance for user ${userId}:`, error);
return this.getDefaultBalance();
}
}
async consumeCredits(
userId: string,
operation: string,
amount: number,
description: string,
metadata?: Record<string, any>,
creditSource?: { type: 'personal' } | { type: 'guild'; guildId: string }
): Promise<boolean> {
const creditsUrl = this.getCreditsUrl();
const serviceKey = this.getServiceKey();
if (!serviceKey) {
this.logger.warn('Service key not configured, skipping credit consumption');
return true;
}
try {
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': serviceKey,
'X-App-Id': this.getAppId(),
},
body: JSON.stringify({
userId,
amount,
appId: this.getAppId(),
description,
metadata: {
operation,
...metadata,
},
...(creditSource && { creditSource }),
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
this.logger.error(`Failed to consume credits: ${response.status} ${errorText}`);
return false;
}
if (this.options?.debug) {
this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`);
}
return true;
} catch (error) {
this.logger.error(`Failed to consume credits for user ${userId}:`, error);
return false;
}
}
async refundCredits(
userId: string,
amount: number,
description: string,
metadata?: Record<string, any>
): Promise<boolean> {
const creditsUrl = this.getCreditsUrl();
const serviceKey = this.getServiceKey();
if (!serviceKey) {
this.logger.warn('Service key not configured, skipping credit refund');
return true;
}
try {
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': serviceKey,
'X-App-Id': this.getAppId(),
},
body: JSON.stringify({
userId,
amount,
appId: this.getAppId(),
description,
metadata,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
this.logger.error(`Failed to refund credits: ${response.status} ${errorText}`);
return false;
}
if (this.options?.debug) {
this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`);
}
return true;
} catch (error) {
this.logger.error(`Failed to refund credits for user ${userId}:`, error);
return false;
}
}
private getDefaultBalance(): CreditBalance {
return {
balance: 1000,
totalEarned: 0,
totalSpent: 0,
};
}
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"lib": ["ES2021"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}