refactor(packages): consolidate 4 help packages into @manacore/help

Merged shared-help-types + shared-help-content + shared-help-ui into
@manacore/help. Deleted shared-help-mobile (0 consumers).

Updated imports in all 20 web apps.

Package count: 53 → 49

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 17:36:32 +01:00
parent 1104c0489d
commit d70ab97a66
879 changed files with 368 additions and 69099 deletions

View file

@ -1,3 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({ dbName: 'context' });

View file

@ -1,21 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s', '!**/*.spec.ts', '!**/index.ts', '!main.ts'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
transformIgnorePatterns: ['node_modules/(?!(@context|@manacore)/)'],
};

View file

@ -1,10 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

View file

@ -1,68 +0,0 @@
{
"name": "@context/backend",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-llm": "workspace:^",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-setup": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.0.4"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -1,28 +0,0 @@
import type { Database } from '../../db/connection';
export function createMockDb(): any {
const mockChain = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([]),
onConflictDoUpdate: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue([]),
};
Object.keys(mockChain).forEach((key) => {
if (key !== 'returning' && key !== 'execute') {
(mockChain as any)[key].mockReturnValue(mockChain);
}
});
return mockChain;
}

View file

@ -1,88 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import type { Space } from '../../db/schema/spaces.schema';
import type { Document } from '../../db/schema/documents.schema';
import type { TokenTransaction } from '../../db/schema/token-transactions.schema';
import type { ModelPrice } from '../../db/schema/model-prices.schema';
import type { UserToken } from '../../db/schema/user-tokens.schema';
export const TEST_USER_ID = 'test-user-123';
export const TEST_USER_EMAIL = 'test@example.com';
export function createMockSpace(overrides: Partial<Space> = {}): Space {
return {
id: uuidv4(),
userId: TEST_USER_ID,
name: 'Test Space',
description: 'A test space',
settings: null,
pinned: true,
prefix: 'T',
textDocCounter: 0,
contextDocCounter: 0,
promptDocCounter: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export function createMockDocument(overrides: Partial<Document> = {}): Document {
return {
id: uuidv4(),
userId: TEST_USER_ID,
spaceId: uuidv4(),
title: 'Test Document',
content: 'This is test content for the document.',
type: 'text',
shortId: 'DOC-abc123',
pinned: false,
metadata: { word_count: 7, token_count: 10 },
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export function createMockTokenTransaction(
overrides: Partial<TokenTransaction> = {}
): TokenTransaction {
return {
id: uuidv4(),
userId: TEST_USER_ID,
amount: -5,
transactionType: 'usage',
modelUsed: 'gpt-4.1',
promptTokens: 100,
completionTokens: 200,
totalTokens: 300,
costUsd: '0.004000',
documentId: null,
createdAt: new Date(),
...overrides,
};
}
export function createMockModelPrice(overrides: Partial<ModelPrice> = {}): ModelPrice {
return {
id: uuidv4(),
modelName: 'gpt-4.1',
inputPricePer1kTokens: '0.010000',
outputPricePer1kTokens: '0.030000',
tokensPerDollar: 50000,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export function createMockUserToken(overrides: Partial<UserToken> = {}): UserToken {
return {
userId: TEST_USER_ID,
tokenBalance: 1000,
monthlyFreeTokens: 1000,
lastTokenReset: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}

View file

@ -1,115 +0,0 @@
import { Controller, Post, Body, UseGuards, BadRequestException, Req } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AiService } from './ai.service';
import type { Request } from 'express';
/**
* Extract userId from a JWT token payload without JWKS verification.
* Used as fallback for Supabase tokens when mana-core-auth guard fails.
*/
function extractUserIdFromToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith('Bearer ')) return null;
try {
const token = authHeader.slice(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub || null;
} catch {
return null;
}
}
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('generate')
@UseGuards(JwtAuthGuard)
async generate(
@CurrentUser() user: CurrentUserData,
@Body()
body: {
prompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
documentId?: string;
referencedDocuments?: { title: string; content: string }[];
}
) {
if (!body.prompt) {
throw new BadRequestException('prompt is required');
}
const result = await this.aiService.generate(user.userId, body);
return result;
}
/**
* Generate endpoint that accepts Supabase tokens (for mobile app).
* Falls back to extracting userId from JWT payload when mana-core-auth is not available.
*/
@Post('generate/mobile')
async generateMobile(
@Req() req: Request,
@Body()
body: {
prompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
documentId?: string;
referencedDocuments?: { title: string; content: string }[];
}
) {
if (!body.prompt) {
throw new BadRequestException('prompt is required');
}
const userId = extractUserIdFromToken(req.headers.authorization);
if (!userId) {
throw new BadRequestException('Authorization required');
}
const result = await this.aiService.generate(userId, body);
return result;
}
@Post('estimate')
@UseGuards(JwtAuthGuard)
async estimateCost(
@CurrentUser() user: CurrentUserData,
@Body()
body: {
prompt: string;
model?: string;
estimatedCompletionLength?: number;
referencedDocuments?: { title: string; content: string }[];
}
) {
const estimate = await this.aiService.estimateCost(user.userId, body);
return estimate;
}
/**
* Estimate endpoint that accepts Supabase tokens (for mobile app).
*/
@Post('estimate/mobile')
async estimateCostMobile(
@Req() req: Request,
@Body()
body: {
prompt: string;
model?: string;
estimatedCompletionLength?: number;
referencedDocuments?: { title: string; content: string }[];
}
) {
const userId = extractUserIdFromToken(req.headers.authorization);
if (!userId) {
throw new BadRequestException('Authorization required');
}
const estimate = await this.aiService.estimateCost(userId, body);
return estimate;
}
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { TokenModule } from '../token/token.module';
@Module({
imports: [TokenModule],
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View file

@ -1,231 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { BadRequestException } from '@nestjs/common';
import { AiService } from './ai.service';
import { TokenService } from '../token/token.service';
describe('AiService', () => {
let service: AiService;
let tokenService: any;
let configService: any;
const TEST_USER_ID = 'test-user-123';
beforeEach(async () => {
tokenService = {
calculateCost: jest.fn().mockResolvedValue({
inputTokens: 100,
outputTokens: 200,
totalTokens: 300,
costUsd: 0.007,
appTokens: 1,
}),
hasEnoughTokens: jest.fn().mockResolvedValue(true),
logUsage: jest.fn().mockResolvedValue({
tokensUsed: 1,
remainingBalance: 999,
}),
getBalance: jest.fn().mockResolvedValue(1000),
};
configService = {
get: jest.fn().mockReturnValue(''),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiService,
{ provide: TokenService, useValue: tokenService },
{ provide: ConfigService, useValue: configService },
],
}).compile();
service = module.get<AiService>(AiService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('generate', () => {
it('should throw BadRequestException when user has insufficient tokens', async () => {
tokenService.hasEnoughTokens.mockResolvedValueOnce(false);
await expect(service.generate(TEST_USER_ID, { prompt: 'Hello' })).rejects.toThrow(
BadRequestException
);
});
it('should check token balance before generating', async () => {
// Mock fetch to fail (we just want to test the token check)
const originalFetch = global.fetch;
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
try {
await service.generate(TEST_USER_ID, { prompt: 'Hello' });
} catch {
// Expected to fail since fetch is mocked
}
expect(tokenService.calculateCost).toHaveBeenCalled();
expect(tokenService.hasEnoughTokens).toHaveBeenCalled();
global.fetch = originalFetch;
});
it('should use gpt-4.1 as default model', async () => {
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: 'Generated text' } }],
}),
});
const result = await service.generate(TEST_USER_ID, { prompt: 'Hello' });
expect(result.text).toBe('Generated text');
expect(tokenService.logUsage).toHaveBeenCalledWith(
TEST_USER_ID,
'gpt-4.1',
expect.any(Number),
expect.any(Number),
undefined
);
global.fetch = originalFetch;
});
it('should use Google provider for gemini models', async () => {
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: 'Gemini response' }] } }],
}),
});
const result = await service.generate(TEST_USER_ID, {
prompt: 'Hello',
model: 'gemini-pro',
});
expect(result.text).toBe('Gemini response');
expect(tokenService.logUsage).toHaveBeenCalledWith(
TEST_USER_ID,
'gemini-pro',
expect.any(Number),
expect.any(Number),
undefined
);
global.fetch = originalFetch;
});
it('should include referenced documents in prompt', async () => {
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: 'Response with refs' } }],
}),
});
await service.generate(TEST_USER_ID, {
prompt: 'Summarize',
referencedDocuments: [
{ title: 'Doc 1', content: 'Content 1' },
{ title: 'Doc 2', content: 'Content 2' },
],
});
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
const userMessage = body.messages[1].content;
expect(userMessage).toContain('Dokument 1 (Doc 1)');
expect(userMessage).toContain('Dokument 2 (Doc 2)');
global.fetch = originalFetch;
});
it('should pass documentId to logUsage', async () => {
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: 'Response' } }],
}),
});
await service.generate(TEST_USER_ID, {
prompt: 'Hello',
documentId: 'doc-123',
});
expect(tokenService.logUsage).toHaveBeenCalledWith(
TEST_USER_ID,
'gpt-4.1',
expect.any(Number),
expect.any(Number),
'doc-123'
);
global.fetch = originalFetch;
});
it('should return token info in response', async () => {
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: 'Response' } }],
}),
});
const result = await service.generate(TEST_USER_ID, { prompt: 'Hello' });
expect(result.tokenInfo).toBeDefined();
expect(result.tokenInfo.tokensUsed).toBe(1);
expect(result.tokenInfo.remainingTokens).toBe(999);
global.fetch = originalFetch;
});
});
describe('estimateCost', () => {
it('should return cost estimate with balance check', async () => {
const result = await service.estimateCost(TEST_USER_ID, {
prompt: 'Hello world',
model: 'gpt-4.1',
});
expect(result.hasEnough).toBe(true);
expect(result.balance).toBe(1000);
expect(result.estimate).toBeDefined();
});
it('should account for referenced documents in estimate', async () => {
await service.estimateCost(TEST_USER_ID, {
prompt: 'Summarize',
referencedDocuments: [{ title: 'Doc 1', content: 'Long content here' }],
});
expect(tokenService.calculateCost).toHaveBeenCalled();
});
it('should return hasEnough=false when balance is insufficient', async () => {
tokenService.getBalance.mockResolvedValueOnce(0);
const result = await service.estimateCost(TEST_USER_ID, {
prompt: 'Hello',
});
expect(result.hasEnough).toBe(false);
});
});
});

View file

@ -1,115 +0,0 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { LlmClientService } from '@manacore/shared-llm';
import { TokenService } from '../token/token.service';
interface GenerateOptions {
prompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
documentId?: string;
referencedDocuments?: { title: string; content: string }[];
}
function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(
private readonly llm: LlmClientService,
private tokenService: TokenService
) {}
async generate(userId: string, options: GenerateOptions) {
const model = options.model || 'ollama/gemma3:4b';
// Build full prompt with referenced documents
let fullPrompt = options.prompt;
if (options.referencedDocuments?.length) {
fullPrompt += '\n\nReferenzierte Dokumente:\n\n';
options.referencedDocuments.forEach((doc, i) => {
fullPrompt += `Dokument ${i + 1} (${doc.title}):\n${doc.content || ''}\n\n`;
});
}
// Check balance
const promptTokens = estimateTokens(fullPrompt);
const estimatedCompletion = options.maxTokens || 2000;
const cost = await this.tokenService.calculateCost(model, promptTokens, estimatedCompletion);
const hasEnough = await this.tokenService.hasEnoughTokens(userId, cost.appTokens);
if (!hasEnough) {
throw new BadRequestException('Nicht genügend Tokens. Bitte kaufe weitere Tokens.');
}
// Generate text via mana-llm
const result = await this.llm.chat(fullPrompt, {
model,
systemPrompt: 'You are a helpful assistant.',
temperature: options.temperature || 0.7,
maxTokens: options.maxTokens || 2000,
});
// Use actual token counts from response when available, fall back to estimates
const actualPromptTokens = result.usage.prompt_tokens || estimateTokens(fullPrompt);
const completionTokens = result.usage.completion_tokens || estimateTokens(result.content);
const { tokensUsed, remainingBalance } = await this.tokenService.logUsage(
userId,
model,
actualPromptTokens,
completionTokens,
options.documentId
);
return {
text: result.content,
tokenInfo: {
promptTokens: actualPromptTokens,
completionTokens,
totalTokens: actualPromptTokens + completionTokens,
tokensUsed,
remainingTokens: remainingBalance,
},
};
}
async estimateCost(
userId: string,
options: {
prompt: string;
model?: string;
estimatedCompletionLength?: number;
referencedDocuments?: { title: string; content: string }[];
}
) {
const model = options.model || 'ollama/gemma3:4b';
let totalInputTokens = estimateTokens(options.prompt);
if (options.referencedDocuments?.length) {
const formattingOverhead = 20 + options.referencedDocuments.length * 10;
totalInputTokens += formattingOverhead;
options.referencedDocuments.forEach((doc) => {
totalInputTokens += estimateTokens(doc.content || '');
});
}
const estimate = await this.tokenService.calculateCost(
model,
totalInputTokens,
options.estimatedCompletionLength || 500
);
const balance = await this.tokenService.getBalance(userId);
return {
hasEnough: balance >= estimate.appTokens,
estimate,
balance,
};
}
}

View file

@ -1,48 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { LlmModule } from '@manacore/shared-llm';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { AiModule } from './ai/ai.module';
import { TokenModule } from './token/token.module';
import { HttpExceptionFilter } from './common/http-exception.filter';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 100,
},
]),
LlmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
manaLlmUrl: config.get('MANA_LLM_URL') || 'https://gpu-llm.mana.how',
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
DatabaseModule,
HealthModule.forRoot({ serviceName: 'context-backend' }),
SpaceModule,
DocumentModule,
AiModule,
TokenModule,
],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}

View file

@ -1,60 +0,0 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response, Request } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let error = 'Internal Server Error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const res = exceptionResponse as Record<string, any>;
message = res.message || message;
error = res.error || error;
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} else {
this.logger.error('Unknown exception', exception);
}
if (status >= 500) {
this.logger.error(
`[${request.method}] ${request.url} - ${status}: ${message}`,
exception instanceof Error ? exception.stack : undefined
);
} else {
this.logger.warn(`[${request.method}] ${request.url} - ${status}: ${message}`);
}
response.status(status).json({
statusCode: status,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View file

@ -1,38 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -1,29 +0,0 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -1,177 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { sql } from 'drizzle-orm';
import postgres from 'postgres';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
dotenv.config();
const MIGRATION_LOCK_ID = 320202020; // Unique lock ID for context migrations
const MAX_LOCK_WAIT_MS = parseInt(process.env.MIGRATION_TIMEOUT || '300', 10) * 1000;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
async function withRetry<T>(
operation: () => Promise<T>,
operationName: string,
maxRetries = MAX_RETRIES
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
const isTransient =
lastError.message?.includes('ECONNREFUSED') ||
lastError.message?.includes('ETIMEDOUT') ||
lastError.message?.includes('ENOTFOUND') ||
lastError.message?.includes('connection') ||
(lastError as any).code === '57P03';
if (!isTransient || attempt === maxRetries) {
throw error;
}
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1);
console.log(
`\u26a0\ufe0f [${operationName}] Transient error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError!;
}
async function acquireLock(db: ReturnType<typeof drizzle>): Promise<boolean> {
const result = await db.execute(
sql`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID}) as acquired`
);
return (result as any)[0]?.acquired === true;
}
async function releaseLock(db: ReturnType<typeof drizzle>): Promise<void> {
await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`);
}
async function waitForLock(db: ReturnType<typeof drizzle>): Promise<boolean> {
const startTime = Date.now();
while (Date.now() - startTime < MAX_LOCK_WAIT_MS) {
const acquired = await acquireLock(db);
if (acquired) {
return true;
}
const elapsed = Math.round((Date.now() - startTime) / 1000);
console.log(`\u23f3 Waiting for migration lock... (${elapsed}s / ${MAX_LOCK_WAIT_MS / 1000}s)`);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return false;
}
async function runMigrations(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('\n\ud83d\udd04 Starting Context database migration process...');
console.log(` Lock ID: ${MIGRATION_LOCK_ID}`);
console.log(` Timeout: ${MAX_LOCK_WAIT_MS / 1000}s`);
console.log('');
const connection = postgres(databaseUrl, {
max: 1,
idle_timeout: 20,
connect_timeout: 30,
});
const db = drizzle(connection);
let lockAcquired = false;
try {
console.log('\ud83d\udd0c Testing database connection...');
await withRetry(async () => {
await db.execute(sql`SELECT 1`);
}, 'Database connection');
console.log('\u2705 Database connection successful\n');
console.log('\ud83d\udd12 Attempting to acquire migration lock...');
lockAcquired = await withRetry(() => acquireLock(db), 'Acquire lock');
if (!lockAcquired) {
console.log('\u23f3 Another instance is running migrations. Waiting for lock...');
lockAcquired = await waitForLock(db);
if (!lockAcquired) {
throw new Error(
`Migration lock timeout after ${MAX_LOCK_WAIT_MS / 1000}s - another migration may be stuck`
);
}
}
console.log('\u2705 Migration lock acquired\n');
const migrationsFolder = './src/db/migrations';
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
if (!fs.existsSync(journalPath)) {
console.log('\u26a0\ufe0f No migration files found (meta/_journal.json missing)');
console.log(' To generate migrations, run: pnpm migration:generate');
console.log(' For development, you can use: pnpm db:push');
console.log('\n\u2705 No migrations to run\n');
return;
}
console.log('\ud83d\udce6 Running database migrations...');
await withRetry(
async () => {
await migrate(db, { migrationsFolder });
},
'Run migrations',
1
);
console.log('\u2705 Migrations completed successfully\n');
} catch (error) {
console.error('\n\u274c Migration failed:', error);
throw error;
} finally {
if (lockAcquired) {
try {
await releaseLock(db);
console.log('\ud83d\udd13 Migration lock released');
} catch (unlockError) {
console.error('\u26a0\ufe0f Failed to release lock:', unlockError);
}
}
try {
await connection.end();
console.log('\ud83d\udd0c Database connection closed\n');
} catch (closeError) {
console.error('\u26a0\ufe0f Failed to close connection:', closeError);
}
}
}
runMigrations()
.then(() => {
console.log('\ud83c\udf89 Context migration process completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n\ud83d\udca5 Migration process failed:', error.message);
process.exit(1);
});

View file

@ -1,56 +0,0 @@
import {
pgTable,
uuid,
text,
timestamp,
varchar,
boolean,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { spaces } from './spaces.schema';
export interface DocumentMetadata {
tags?: string[];
word_count?: number;
token_count?: number;
parent_document?: string;
version?: number;
generation_type?: 'summary' | 'continuation' | 'rewrite' | 'ideas';
model_used?: string;
prompt_used?: string;
original_title?: string;
version_history?: Array<{
id: string;
title: string;
type: string;
created_at: string;
is_original: boolean;
}>;
[key: string]: unknown;
}
export const documents = pgTable(
'documents',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 500 }).notNull(),
content: text('content'),
type: varchar('type', { length: 20 }).notNull().default('text'),
shortId: varchar('short_id', { length: 20 }),
pinned: boolean('pinned').default(false),
metadata: jsonb('metadata').$type<DocumentMetadata>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('documents_user_id_idx').on(table.userId),
index('documents_space_id_idx').on(table.spaceId),
index('documents_type_idx').on(table.type),
]
);
export type Document = typeof documents.$inferSelect;
export type NewDocument = typeof documents.$inferInsert;

View file

@ -1,5 +0,0 @@
export * from './spaces.schema';
export * from './documents.schema';
export * from './token-transactions.schema';
export * from './model-prices.schema';
export * from './user-tokens.schema';

View file

@ -1,20 +0,0 @@
import { pgTable, uuid, timestamp, varchar, integer, numeric } from 'drizzle-orm/pg-core';
export const modelPrices = pgTable('model_prices', {
id: uuid('id').primaryKey().defaultRandom(),
modelName: varchar('model_name', { length: 100 }).unique().notNull(),
inputPricePer1kTokens: numeric('input_price_per_1k_tokens', {
precision: 10,
scale: 6,
}).notNull(),
outputPricePer1kTokens: numeric('output_price_per_1k_tokens', {
precision: 10,
scale: 6,
}).notNull(),
tokensPerDollar: integer('tokens_per_dollar').notNull().default(50000),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type ModelPrice = typeof modelPrices.$inferSelect;
export type NewModelPrice = typeof modelPrices.$inferInsert;

View file

@ -1,38 +0,0 @@
import {
pgTable,
uuid,
text,
timestamp,
varchar,
boolean,
jsonb,
integer,
index,
} from 'drizzle-orm/pg-core';
export interface SpaceSettings {
defaultDocType?: 'text' | 'context' | 'prompt';
[key: string]: unknown;
}
export const spaces = pgTable(
'spaces',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
settings: jsonb('settings').$type<SpaceSettings>(),
pinned: boolean('pinned').default(true),
prefix: varchar('prefix', { length: 10 }),
textDocCounter: integer('text_doc_counter').default(0),
contextDocCounter: integer('context_doc_counter').default(0),
promptDocCounter: integer('prompt_doc_counter').default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('spaces_user_id_idx').on(table.userId)]
);
export type Space = typeof spaces.$inferSelect;
export type NewSpace = typeof spaces.$inferInsert;

View file

@ -1,34 +0,0 @@
import {
pgTable,
uuid,
text,
timestamp,
varchar,
integer,
numeric,
index,
} from 'drizzle-orm/pg-core';
export const tokenTransactions = pgTable(
'token_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
amount: integer('amount').notNull(),
transactionType: varchar('transaction_type', { length: 50 }).notNull(),
modelUsed: varchar('model_used', { length: 100 }),
promptTokens: integer('prompt_tokens'),
completionTokens: integer('completion_tokens'),
totalTokens: integer('total_tokens'),
costUsd: numeric('cost_usd', { precision: 10, scale: 6 }),
documentId: uuid('document_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('token_transactions_user_id_idx').on(table.userId),
index('token_transactions_created_at_idx').on(table.createdAt),
]
);
export type TokenTransaction = typeof tokenTransactions.$inferSelect;
export type NewTokenTransaction = typeof tokenTransactions.$inferInsert;

View file

@ -1,13 +0,0 @@
import { pgTable, text, timestamp, integer } from 'drizzle-orm/pg-core';
export const userTokens = pgTable('user_tokens', {
userId: text('user_id').primaryKey(),
tokenBalance: integer('token_balance').default(0),
monthlyFreeTokens: integer('monthly_free_tokens').default(1000),
lastTokenReset: timestamp('last_token_reset', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type UserToken = typeof userTokens.$inferSelect;
export type NewUserToken = typeof userTokens.$inferInsert;

View file

@ -1,69 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as dotenv from 'dotenv';
import { modelPrices } from './schema/model-prices.schema';
dotenv.config();
async function seed() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('\ud83c\udf31 Seeding Context database...');
const connection = postgres(databaseUrl, { max: 1 });
const db = drizzle(connection);
try {
// Seed model prices
const models = [
{
modelName: 'gpt-4.1',
inputPricePer1kTokens: '0.010000',
outputPricePer1kTokens: '0.030000',
tokensPerDollar: 50000,
},
{
modelName: 'gemini-pro',
inputPricePer1kTokens: '0.000500',
outputPricePer1kTokens: '0.001500',
tokensPerDollar: 50000,
},
{
modelName: 'gemini-flash',
inputPricePer1kTokens: '0.000100',
outputPricePer1kTokens: '0.000400',
tokensPerDollar: 50000,
},
];
for (const model of models) {
await db
.insert(modelPrices)
.values(model)
.onConflictDoUpdate({
target: modelPrices.modelName,
set: {
inputPricePer1kTokens: model.inputPricePer1kTokens,
outputPricePer1kTokens: model.outputPricePer1kTokens,
tokensPerDollar: model.tokensPerDollar,
updatedAt: new Date(),
},
});
console.log(` \u2705 ${model.modelName}`);
}
console.log('\n\ud83c\udf89 Seed completed successfully!');
} catch (error) {
console.error('\u274c Seed failed:', error);
throw error;
} finally {
await connection.end();
}
}
seed()
.then(() => process.exit(0))
.catch(() => process.exit(1));

View file

@ -1,117 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { DocumentService } from './document.service';
@Controller('documents')
@UseGuards(JwtAuthGuard)
export class DocumentController {
constructor(private readonly documentService: DocumentService) {}
@Get()
async findAll(
@CurrentUser() user: CurrentUserData,
@Query('spaceId') spaceId?: string,
@Query('limit') limit?: string,
@Query('preview') preview?: string
) {
if (preview === 'true') {
const documents = await this.documentService.findAllWithPreview(
user.userId,
spaceId,
limit ? parseInt(limit, 10) : 50
);
return { documents };
}
const documents = await this.documentService.findAll(user.userId, spaceId);
return { documents };
}
@Get('recent')
async getRecent(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: string) {
const documents = await this.documentService.findRecent(
user.userId,
limit ? parseInt(limit, 10) : 5
);
return { documents };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const document = await this.documentService.findByIdOrThrow(id, user.userId);
return { document };
}
@Get(':id/versions')
async getVersions(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const documents = await this.documentService.getVersions(id, user.userId);
return { documents };
}
@Post()
async create(
@CurrentUser() user: CurrentUserData,
@Body()
body: {
content: string;
type: 'text' | 'context' | 'prompt';
spaceId?: string;
title?: string;
metadata?: Record<string, unknown>;
}
) {
const document = await this.documentService.create(user.userId, body);
return { document };
}
@Post(':id/versions')
async createVersion(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body()
body: {
content: string;
generationType: 'summary' | 'continuation' | 'rewrite' | 'ideas';
model: string;
prompt: string;
}
) {
const document = await this.documentService.createVersion(id, user.userId, body);
return { document };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() body: Record<string, unknown>
) {
const document = await this.documentService.update(id, user.userId, body);
return { document };
}
@Put(':id/tags')
async updateTags(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() body: { tags: string[] }
) {
const document = await this.documentService.updateTags(id, user.userId, body.tags);
return { document };
}
@Put(':id/pinned')
async togglePinned(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() body: { pinned: boolean }
) {
const document = await this.documentService.togglePinned(id, user.userId, body.pinned);
return { document };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.documentService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { DocumentController } from './document.controller';
import { DocumentService } from './document.service';
import { SpaceModule } from '../space/space.module';
@Module({
imports: [SpaceModule],
controllers: [DocumentController],
providers: [DocumentService],
exports: [DocumentService],
})
export class DocumentModule {}

View file

@ -1,359 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { DocumentService } from './document.service';
import { SpaceService } from '../space/space.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import {
createMockDocument,
createMockSpace,
TEST_USER_ID,
} from '../__tests__/utils/mock-factories';
import { createMockDb } from '../__tests__/utils/mock-db';
describe('DocumentService', () => {
let service: DocumentService;
let spaceService: SpaceService;
let mockDb: any;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [
DocumentService,
{
provide: SpaceService,
useValue: {
incrementDocCounter: jest.fn().mockResolvedValue({ counter: 1, prefix: 'A' }),
},
},
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<DocumentService>(DocumentService);
spaceService = module.get<SpaceService>(SpaceService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return all documents for a user', async () => {
const docs = [createMockDocument({ title: 'Doc 1' }), createMockDocument({ title: 'Doc 2' })];
mockDb.orderBy.mockResolvedValueOnce(docs);
const result = await service.findAll(TEST_USER_ID);
expect(result).toEqual(docs);
expect(mockDb.select).toHaveBeenCalled();
});
it('should filter by spaceId when provided', async () => {
const spaceId = 'space-123';
mockDb.orderBy.mockResolvedValueOnce([]);
await service.findAll(TEST_USER_ID, spaceId);
expect(mockDb.where).toHaveBeenCalled();
});
it('should return empty array when no documents found', async () => {
mockDb.orderBy.mockResolvedValueOnce([]);
const result = await service.findAll(TEST_USER_ID);
expect(result).toEqual([]);
});
});
describe('findAllWithPreview', () => {
it('should truncate content longer than 200 chars', async () => {
const longContent = 'A'.repeat(300);
const doc = createMockDocument({ content: longContent });
mockDb.orderBy.mockResolvedValueOnce([doc]);
const result = await service.findAllWithPreview(TEST_USER_ID);
expect(result[0].content!.length).toBeLessThanOrEqual(203); // 200 + '...'
expect(result[0].content!.endsWith('...')).toBe(true);
});
it('should not truncate short content', async () => {
const doc = createMockDocument({ content: 'Short content' });
mockDb.orderBy.mockResolvedValueOnce([doc]);
const result = await service.findAllWithPreview(TEST_USER_ID);
expect(result[0].content).toBe('Short content');
});
it('should limit results', async () => {
const docs = Array.from({ length: 10 }, (_, i) => createMockDocument({ title: `Doc ${i}` }));
mockDb.orderBy.mockResolvedValueOnce(docs);
const result = await service.findAllWithPreview(TEST_USER_ID, undefined, 5);
expect(result.length).toBe(5);
});
});
describe('findRecent', () => {
it('should return recent documents', async () => {
const docs = [createMockDocument()];
mockDb.limit.mockResolvedValueOnce(docs);
const result = await service.findRecent(TEST_USER_ID, 5);
expect(result).toEqual(docs);
});
});
describe('findById', () => {
it('should return document when found', async () => {
const doc = createMockDocument();
mockDb.where.mockResolvedValueOnce([doc]);
const result = await service.findById(doc.id, TEST_USER_ID);
expect(result).toEqual(doc);
});
it('should return null when not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
const result = await service.findById('non-existent', TEST_USER_ID);
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should throw NotFoundException when not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.findByIdOrThrow('non-existent', TEST_USER_ID)).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('should create a document with calculated metadata', async () => {
const newDoc = createMockDocument({ title: 'New Doc' });
mockDb.returning.mockResolvedValueOnce([newDoc]);
const result = await service.create(TEST_USER_ID, {
content: 'Hello world content',
type: 'text',
title: 'New Doc',
});
expect(result).toEqual(newDoc);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should generate short_id with space prefix when spaceId is given', async () => {
const newDoc = createMockDocument({ shortId: 'AD1' });
mockDb.returning.mockResolvedValueOnce([newDoc]);
await service.create(TEST_USER_ID, {
content: 'Content',
type: 'text',
spaceId: 'space-123',
});
expect(spaceService.incrementDocCounter).toHaveBeenCalledWith('space-123', 'text');
});
it('should use extractTitleFromMarkdown when no title provided', async () => {
const newDoc = createMockDocument({ title: 'Hello World' });
mockDb.returning.mockResolvedValueOnce([newDoc]);
const result = await service.create(TEST_USER_ID, {
content: 'Hello World is a great start',
type: 'text',
});
expect(result).toBeDefined();
expect(mockDb.insert).toHaveBeenCalled();
});
it('should create context document', async () => {
const newDoc = createMockDocument({ type: 'context' });
mockDb.returning.mockResolvedValueOnce([newDoc]);
const result = await service.create(TEST_USER_ID, {
content: 'Context info',
type: 'context',
spaceId: 'space-123',
});
expect(result.type).toBe('context');
expect(spaceService.incrementDocCounter).toHaveBeenCalledWith('space-123', 'context');
});
it('should create prompt document', async () => {
const newDoc = createMockDocument({ type: 'prompt' });
mockDb.returning.mockResolvedValueOnce([newDoc]);
const result = await service.create(TEST_USER_ID, {
content: 'Generate ideas for...',
type: 'prompt',
spaceId: 'space-123',
});
expect(result.type).toBe('prompt');
expect(spaceService.incrementDocCounter).toHaveBeenCalledWith('space-123', 'prompt');
});
});
describe('update', () => {
it('should update document', async () => {
const doc = createMockDocument();
const updated = { ...doc, title: 'Updated Title' };
mockDb.where.mockResolvedValueOnce([doc]); // findByIdOrThrow
mockDb.returning.mockResolvedValueOnce([updated]);
const result = await service.update(doc.id, TEST_USER_ID, { title: 'Updated Title' });
expect(result.title).toBe('Updated Title');
});
it('should recalculate word/token count when content changes', async () => {
const doc = createMockDocument();
const updated = { ...doc, content: 'New content here' };
mockDb.where.mockResolvedValueOnce([doc]); // findByIdOrThrow
mockDb.returning.mockResolvedValueOnce([updated]);
await service.update(doc.id, TEST_USER_ID, { content: 'New content here' });
expect(mockDb.set).toHaveBeenCalled();
});
it('should throw NotFoundException for non-existent document', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.update('non-existent', TEST_USER_ID, { title: 'New' })).rejects.toThrow(
NotFoundException
);
});
});
describe('updateTags', () => {
it('should update document tags', async () => {
const doc = createMockDocument({ metadata: { word_count: 5 } });
const updated = { ...doc, metadata: { word_count: 5, tags: ['tag1', 'tag2'] } };
mockDb.where.mockResolvedValueOnce([doc]); // findByIdOrThrow
mockDb.returning.mockResolvedValueOnce([updated]);
const result = await service.updateTags(doc.id, TEST_USER_ID, ['tag1', 'tag2']);
expect(result.metadata?.tags).toEqual(['tag1', 'tag2']);
});
it('should throw NotFoundException for non-existent document', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.updateTags('non-existent', TEST_USER_ID, ['tag'])).rejects.toThrow(
NotFoundException
);
});
});
describe('togglePinned', () => {
it('should toggle pinned status', async () => {
const doc = createMockDocument({ pinned: false });
const updated = { ...doc, pinned: true };
mockDb.where.mockResolvedValueOnce([doc]); // findByIdOrThrow
mockDb.returning.mockResolvedValueOnce([updated]);
const result = await service.togglePinned(doc.id, TEST_USER_ID, true);
expect(result.pinned).toBe(true);
});
});
describe('delete', () => {
it('should delete document', async () => {
const doc = createMockDocument();
mockDb.where.mockResolvedValueOnce([doc]); // findByIdOrThrow
await service.delete(doc.id, TEST_USER_ID);
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw NotFoundException for non-existent document', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.delete('non-existent', TEST_USER_ID)).rejects.toThrow(NotFoundException);
});
});
describe('createVersion', () => {
it('should create a version of an existing document', async () => {
const original = createMockDocument({ title: 'Original' });
const version = createMockDocument({
title: 'Zusammenfassung: Original',
metadata: {
parent_document: original.id,
generation_type: 'summary',
model_used: 'gpt-4.1',
},
});
mockDb.where.mockResolvedValueOnce([original]); // findByIdOrThrow
mockDb.returning.mockResolvedValueOnce([version]);
const result = await service.createVersion(original.id, TEST_USER_ID, {
content: 'Summary content',
generationType: 'summary',
model: 'gpt-4.1',
prompt: 'Summarize this',
});
expect(result.metadata?.parent_document).toBe(original.id);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should throw NotFoundException when original not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(
service.createVersion('non-existent', TEST_USER_ID, {
content: 'Summary',
generationType: 'summary',
model: 'gpt-4.1',
prompt: 'Summarize',
})
).rejects.toThrow(NotFoundException);
});
it('should use correct title prefix for each generation type', async () => {
const original = createMockDocument({ title: 'My Doc' });
const types = ['summary', 'continuation', 'rewrite', 'ideas'] as const;
const expectedPrefixes = ['Zusammenfassung', 'Fortsetzung', 'Umformulierung', 'Ideen zu'];
for (let i = 0; i < types.length; i++) {
mockDb.where.mockResolvedValueOnce([original]);
const version = createMockDocument({
title: `${expectedPrefixes[i]}: My Doc`,
});
mockDb.returning.mockResolvedValueOnce([version]);
const result = await service.createVersion(original.id, TEST_USER_ID, {
content: 'Generated content',
generationType: types[i],
model: 'gpt-4.1',
prompt: 'Generate',
});
expect(result.title).toContain(expectedPrefixes[i]);
}
});
});
});

View file

@ -1,294 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc, or, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { documents } from '../db/schema/documents.schema';
import type { Document, NewDocument, DocumentMetadata } from '../db/schema/documents.schema';
import { SpaceService } from '../space/space.service';
function countWords(text: string): number {
if (!text) return 0;
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
}
function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
function extractTitleFromMarkdown(content: string): string {
if (!content) return 'Neues Dokument';
const lines = content.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('# ')) {
return trimmed.slice(2).trim();
}
if (trimmed.length > 0) {
return trimmed.length > 100 ? trimmed.slice(0, 100) + '...' : trimmed;
}
}
return 'Neues Dokument';
}
@Injectable()
export class DocumentService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private spaceService: SpaceService
) {}
async findAll(userId: string, spaceId?: string): Promise<Document[]> {
const conditions = [eq(documents.userId, userId)];
if (spaceId) {
conditions.push(eq(documents.spaceId, spaceId));
}
return this.db
.select()
.from(documents)
.where(and(...conditions))
.orderBy(desc(documents.pinned), desc(documents.updatedAt));
}
async findAllWithPreview(userId: string, spaceId?: string, limit = 50): Promise<Document[]> {
const docs = await this.findAll(userId, spaceId);
return docs.slice(0, limit).map((doc) => ({
...doc,
content:
doc.content && doc.content.length > 200
? `${doc.content.substring(0, 200)}...`
: doc.content,
}));
}
async findRecent(userId: string, limit = 5): Promise<Document[]> {
return this.db
.select()
.from(documents)
.where(eq(documents.userId, userId))
.orderBy(desc(documents.updatedAt))
.limit(limit);
}
async findById(id: string, userId: string): Promise<Document | null> {
const result = await this.db
.select()
.from(documents)
.where(and(eq(documents.id, id), eq(documents.userId, userId)));
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Document> {
const doc = await this.findById(id, userId);
if (!doc) {
throw new NotFoundException(`Document with id ${id} not found`);
}
return doc;
}
async create(
userId: string,
data: {
content: string;
type: 'text' | 'context' | 'prompt';
spaceId?: string;
title?: string;
metadata?: Record<string, unknown>;
}
): Promise<Document> {
const title = data.title || extractTitleFromMarkdown(data.content);
const wordCount = countWords(data.content);
const tokenCount = estimateTokens(data.content);
const metadata: DocumentMetadata = {
...(data.metadata as DocumentMetadata),
word_count: wordCount,
token_count: tokenCount,
};
// Generate short_id
let shortId = `DOC-${Math.random().toString(36).substring(2, 8)}`;
if (data.spaceId) {
const { counter, prefix } = await this.spaceService.incrementDocCounter(
data.spaceId,
data.type
);
if (prefix && counter > 0) {
const typeChar = data.type === 'text' ? 'D' : data.type === 'context' ? 'C' : 'P';
shortId = `${prefix}${typeChar}${counter}`;
}
}
const newDoc: NewDocument = {
userId,
spaceId: data.spaceId || null,
title,
content: data.content,
type: data.type,
shortId,
metadata,
};
const [created] = await this.db.insert(documents).values(newDoc).returning();
return created;
}
async update(id: string, userId: string, data: Record<string, unknown>): Promise<Document> {
const existing = await this.findByIdOrThrow(id, userId);
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (data.title !== undefined) updateData.title = data.title;
if (data.content !== undefined) {
updateData.content = data.content;
const wordCount = countWords(data.content as string);
const tokenCount = estimateTokens(data.content as string);
updateData.metadata = {
...(existing.metadata || {}),
...((data.metadata as object) || {}),
word_count: wordCount,
token_count: tokenCount,
};
} else if (data.metadata !== undefined) {
updateData.metadata = { ...(existing.metadata || {}), ...(data.metadata as object) };
}
if (data.type !== undefined) {
updateData.type = data.type;
// Update short_id type char if type changes
if (existing.shortId && existing.spaceId && /^[A-Z][CDP]\d+$/.test(existing.shortId)) {
const spacePrefix = existing.shortId.charAt(0);
const number = existing.shortId.substring(2);
const newTypeChar = data.type === 'text' ? 'D' : data.type === 'context' ? 'C' : 'P';
updateData.shortId = `${spacePrefix}${newTypeChar}${number}`;
}
}
if (data.pinned !== undefined) updateData.pinned = data.pinned;
const [updated] = await this.db
.update(documents)
.set(updateData)
.where(and(eq(documents.id, id), eq(documents.userId, userId)))
.returning();
return updated;
}
async updateTags(id: string, userId: string, tags: string[]): Promise<Document> {
const existing = await this.findByIdOrThrow(id, userId);
const [updated] = await this.db
.update(documents)
.set({
metadata: { ...(existing.metadata || {}), tags },
updatedAt: new Date(),
})
.where(and(eq(documents.id, id), eq(documents.userId, userId)))
.returning();
return updated;
}
async togglePinned(id: string, userId: string, pinned: boolean): Promise<Document> {
await this.findByIdOrThrow(id, userId);
const [updated] = await this.db
.update(documents)
.set({ pinned, updatedAt: new Date() })
.where(and(eq(documents.id, id), eq(documents.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(documents).where(and(eq(documents.id, id), eq(documents.userId, userId)));
}
async getVersions(id: string, userId: string): Promise<Document[]> {
const original = await this.findByIdOrThrow(id, userId);
const isVersion = original.metadata?.parent_document && original.metadata?.version;
const rootDocumentId = isVersion ? (original.metadata!.parent_document as string) : id;
const versions = await this.db
.select()
.from(documents)
.where(
and(
eq(documents.userId, userId),
or(
eq(documents.id, rootDocumentId),
sql`${documents.metadata}->>'parent_document' = ${rootDocumentId}`
)
)
);
return versions.sort((a, b) => {
if (a.id === rootDocumentId) return -1;
if (b.id === rootDocumentId) return 1;
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
}
async createVersion(
originalDocumentId: string,
userId: string,
data: {
content: string;
generationType: 'summary' | 'continuation' | 'rewrite' | 'ideas';
model: string;
prompt: string;
}
): Promise<Document> {
const original = await this.findByIdOrThrow(originalDocumentId, userId);
const titlePrefixes: Record<string, string> = {
summary: 'Zusammenfassung',
continuation: 'Fortsetzung',
rewrite: 'Umformulierung',
ideas: 'Ideen zu',
};
const title = `${titlePrefixes[data.generationType] || 'KI-Version'}: ${original.title}`;
const wordCount = countWords(data.content);
const metadata: DocumentMetadata = {
parent_document: originalDocumentId,
original_title: original.title,
generation_type: data.generationType,
model_used: data.model,
prompt_used: data.prompt,
version: 1,
version_history: [
{
id: originalDocumentId,
title: original.title,
type: original.type,
created_at: original.createdAt.toISOString(),
is_original: true,
},
],
word_count: wordCount,
};
const newDoc: NewDocument = {
userId,
spaceId: original.spaceId,
title,
content: data.content,
type: 'text',
metadata,
};
const [created] = await this.db.insert(documents).values(newDoc).returning();
return created;
}
}

View file

@ -1,8 +0,0 @@
import { initErrorTracking } from '@manacore/shared-error-tracking';
initErrorTracking({
serviceName: 'context-backend',
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
debug: process.env.NODE_ENV === 'development',
});

View file

@ -1,9 +0,0 @@
import './instrument';
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
import { AppModule } from './app.module';
bootstrapApp(AppModule, {
defaultPort: 3020,
serviceName: 'Context',
additionalCorsOrigins: ['http://localhost:5192'],
});

View file

@ -1,53 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { SpaceService } from './space.service';
@Controller('spaces')
@UseGuards(JwtAuthGuard)
export class SpaceController {
constructor(private readonly spaceService: SpaceService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const spaces = await this.spaceService.findAll(user.userId);
return { spaces };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const space = await this.spaceService.findByIdOrThrow(id, user.userId);
return { space };
}
@Post()
async create(
@CurrentUser() user: CurrentUserData,
@Body() body: { name: string; description?: string; pinned?: boolean; prefix?: string }
) {
const space = await this.spaceService.create(user.userId, body);
return { space };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body()
body: Partial<{
name: string;
description: string;
pinned: boolean;
prefix: string;
settings: Record<string, unknown>;
}>
) {
const space = await this.spaceService.update(id, user.userId, body);
return { space };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.spaceService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { SpaceController } from './space.controller';
import { SpaceService } from './space.service';
@Module({
controllers: [SpaceController],
providers: [SpaceService],
exports: [SpaceService],
})
export class SpaceModule {}

View file

@ -1,218 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { SpaceService } from './space.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { createMockSpace, TEST_USER_ID } from '../__tests__/utils/mock-factories';
import { createMockDb } from '../__tests__/utils/mock-db';
describe('SpaceService', () => {
let service: SpaceService;
let mockDb: any;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [
SpaceService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<SpaceService>(SpaceService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return all spaces for a user', async () => {
const spaces = [createMockSpace({ name: 'Space 1' }), createMockSpace({ name: 'Space 2' })];
mockDb.orderBy.mockResolvedValueOnce(spaces);
const result = await service.findAll(TEST_USER_ID);
expect(result).toEqual(spaces);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
});
it('should return empty array when user has no spaces', async () => {
mockDb.orderBy.mockResolvedValueOnce([]);
const result = await service.findAll(TEST_USER_ID);
expect(result).toEqual([]);
});
});
describe('findById', () => {
it('should return space when found', async () => {
const space = createMockSpace();
mockDb.where.mockResolvedValueOnce([space]);
const result = await service.findById(space.id, TEST_USER_ID);
expect(result).toEqual(space);
});
it('should return null when space not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
const result = await service.findById('non-existent', TEST_USER_ID);
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should return space when found', async () => {
const space = createMockSpace();
mockDb.where.mockResolvedValueOnce([space]);
const result = await service.findByIdOrThrow(space.id, TEST_USER_ID);
expect(result).toEqual(space);
});
it('should throw NotFoundException when space not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.findByIdOrThrow('non-existent', TEST_USER_ID)).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('should create a new space', async () => {
const newSpace = createMockSpace({ name: 'New Space' });
mockDb.returning.mockResolvedValueOnce([newSpace]);
const result = await service.create(TEST_USER_ID, {
name: 'New Space',
description: 'Test description',
});
expect(result).toEqual(newSpace);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalled();
});
it('should create space with default pinned=true', async () => {
const newSpace = createMockSpace({ pinned: true });
mockDb.returning.mockResolvedValueOnce([newSpace]);
const result = await service.create(TEST_USER_ID, { name: 'Pinned Space' });
expect(result.pinned).toBe(true);
});
it('should create space with pinned=false', async () => {
const newSpace = createMockSpace({ pinned: false });
mockDb.returning.mockResolvedValueOnce([newSpace]);
const result = await service.create(TEST_USER_ID, {
name: 'Unpinned',
pinned: false,
});
expect(result.pinned).toBe(false);
});
it('should create space with prefix', async () => {
const newSpace = createMockSpace({ prefix: 'P' });
mockDb.returning.mockResolvedValueOnce([newSpace]);
const result = await service.create(TEST_USER_ID, {
name: 'Project',
prefix: 'P',
});
expect(result.prefix).toBe('P');
});
});
describe('update', () => {
it('should update space', async () => {
const space = createMockSpace();
const updated = { ...space, name: 'Updated Name' };
mockDb.where.mockResolvedValueOnce([space]); // findByIdOrThrow
mockDb.returning.mockResolvedValueOnce([updated]);
const result = await service.update(space.id, TEST_USER_ID, { name: 'Updated Name' });
expect(result.name).toBe('Updated Name');
});
it('should throw NotFoundException when updating non-existent space', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.update('non-existent', TEST_USER_ID, { name: 'New' })).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete space', async () => {
const space = createMockSpace();
mockDb.where.mockResolvedValueOnce([space]); // findByIdOrThrow
await service.delete(space.id, TEST_USER_ID);
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw NotFoundException when deleting non-existent space', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.delete('non-existent', TEST_USER_ID)).rejects.toThrow(NotFoundException);
});
});
describe('incrementDocCounter', () => {
it('should increment text doc counter', async () => {
const space = createMockSpace({ prefix: 'A', textDocCounter: 3 });
mockDb.where.mockResolvedValueOnce([space]);
const result = await service.incrementDocCounter(space.id, 'text');
expect(result.counter).toBe(4);
expect(result.prefix).toBe('A');
expect(mockDb.update).toHaveBeenCalled();
});
it('should increment context doc counter', async () => {
const space = createMockSpace({ prefix: 'B', contextDocCounter: 1 });
mockDb.where.mockResolvedValueOnce([space]);
const result = await service.incrementDocCounter(space.id, 'context');
expect(result.counter).toBe(2);
expect(result.prefix).toBe('B');
});
it('should increment prompt doc counter', async () => {
const space = createMockSpace({ prefix: 'C', promptDocCounter: 0 });
mockDb.where.mockResolvedValueOnce([space]);
const result = await service.incrementDocCounter(space.id, 'prompt');
expect(result.counter).toBe(1);
});
it('should return 0 and null prefix when space not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
const result = await service.incrementDocCounter('non-existent', 'text');
expect(result.counter).toBe(0);
expect(result.prefix).toBeNull();
});
});
});

View file

@ -1,94 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { spaces } from '../db/schema/spaces.schema';
import type { Space, NewSpace } from '../db/schema/spaces.schema';
@Injectable()
export class SpaceService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Space[]> {
return this.db.select().from(spaces).where(eq(spaces.userId, userId)).orderBy(spaces.createdAt);
}
async findById(id: string, userId: string): Promise<Space | null> {
const result = await this.db
.select()
.from(spaces)
.where(and(eq(spaces.id, id), eq(spaces.userId, userId)));
return result[0] || null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Space> {
const space = await this.findById(id, userId);
if (!space) {
throw new NotFoundException(`Space with id ${id} not found`);
}
return space;
}
async create(
userId: string,
data: { name: string; description?: string; pinned?: boolean; prefix?: string }
): Promise<Space> {
const newSpace: NewSpace = {
userId,
name: data.name,
description: data.description || null,
pinned: data.pinned ?? true,
prefix: data.prefix,
};
const [created] = await this.db.insert(spaces).values(newSpace).returning();
return created;
}
async update(id: string, userId: string, data: Partial<Space>): Promise<Space> {
await this.findByIdOrThrow(id, userId);
const [updated] = await this.db
.update(spaces)
.set({
...data,
updatedAt: new Date(),
})
.where(and(eq(spaces.id, id), eq(spaces.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(spaces).where(and(eq(spaces.id, id), eq(spaces.userId, userId)));
}
async incrementDocCounter(
spaceId: string,
type: 'text' | 'context' | 'prompt'
): Promise<{ counter: number; prefix: string | null }> {
const space = await this.db.select().from(spaces).where(eq(spaces.id, spaceId));
if (!space[0]) return { counter: 0, prefix: null };
const s = space[0];
let counter = 0;
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (type === 'text') {
counter = (s.textDocCounter || 0) + 1;
updateData.textDocCounter = counter;
} else if (type === 'context') {
counter = (s.contextDocCounter || 0) + 1;
updateData.contextDocCounter = counter;
} else if (type === 'prompt') {
counter = (s.promptDocCounter || 0) + 1;
updateData.promptDocCounter = counter;
}
await this.db.update(spaces).set(updateData).where(eq(spaces.id, spaceId));
return { counter, prefix: s.prefix };
}
}

View file

@ -1,44 +0,0 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { TokenService } from './token.service';
@Controller('tokens')
@UseGuards(JwtAuthGuard)
export class TokenController {
constructor(private readonly tokenService: TokenService) {}
@Get('balance')
async getBalance(@CurrentUser() user: CurrentUserData) {
const balance = await this.tokenService.getBalance(user.userId);
return { balance };
}
@Get('stats')
async getStats(@CurrentUser() user: CurrentUserData, @Query('timeframe') timeframe?: string) {
const stats = await this.tokenService.getUsageStats(
user.userId,
(timeframe as 'day' | 'week' | 'month' | 'year') || 'month'
);
return { stats };
}
@Get('transactions')
async getTransactions(
@CurrentUser() user: CurrentUserData,
@Query('limit') limit?: string,
@Query('offset') offset?: string
) {
const transactions = await this.tokenService.getTransactions(
user.userId,
limit ? parseInt(limit, 10) : 20,
offset ? parseInt(offset, 10) : 0
);
return { transactions };
}
@Get('models')
async getModelPrices() {
const models = await this.tokenService.getModelPrices();
return { models };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { TokenController } from './token.controller';
import { TokenService } from './token.service';
@Module({
controllers: [TokenController],
providers: [TokenService],
exports: [TokenService],
})
export class TokenModule {}

View file

@ -1,220 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from './token.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import {
createMockUserToken,
createMockModelPrice,
createMockTokenTransaction,
TEST_USER_ID,
} from '../__tests__/utils/mock-factories';
import { createMockDb } from '../__tests__/utils/mock-db';
describe('TokenService', () => {
let service: TokenService;
let mockDb: any;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<TokenService>(TokenService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getBalance', () => {
it('should return token balance for existing user', async () => {
const userToken = createMockUserToken({ tokenBalance: 500 });
mockDb.where.mockResolvedValueOnce([userToken]);
const result = await service.getBalance(TEST_USER_ID);
expect(result).toBe(500);
});
it('should create user with default balance when not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
mockDb.returning.mockResolvedValueOnce([]); // insert
const result = await service.getBalance(TEST_USER_ID);
expect(result).toBe(1000);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should return 0 when balance is null', async () => {
const userToken = createMockUserToken({ tokenBalance: null as any });
mockDb.where.mockResolvedValueOnce([userToken]);
const result = await service.getBalance(TEST_USER_ID);
expect(result).toBe(0);
});
});
describe('hasEnoughTokens', () => {
it('should return true when balance is sufficient', async () => {
const userToken = createMockUserToken({ tokenBalance: 100 });
mockDb.where.mockResolvedValueOnce([userToken]);
const result = await service.hasEnoughTokens(TEST_USER_ID, 50);
expect(result).toBe(true);
});
it('should return false when balance is insufficient', async () => {
const userToken = createMockUserToken({ tokenBalance: 10 });
mockDb.where.mockResolvedValueOnce([userToken]);
const result = await service.hasEnoughTokens(TEST_USER_ID, 50);
expect(result).toBe(false);
});
});
describe('getModelPrice', () => {
it('should return model price when found', async () => {
const price = createMockModelPrice({ modelName: 'gpt-4.1' });
mockDb.where.mockResolvedValueOnce([price]);
const result = await service.getModelPrice('gpt-4.1');
expect(result).toEqual(price);
});
it('should return null when model not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
const result = await service.getModelPrice('unknown-model');
expect(result).toBeNull();
});
});
describe('getModelPrices', () => {
it('should return all model prices', async () => {
const prices = [
createMockModelPrice({ modelName: 'gpt-4.1' }),
createMockModelPrice({ modelName: 'gemini-pro' }),
];
mockDb.from.mockResolvedValueOnce(prices);
const result = await service.getModelPrices();
expect(result).toEqual(prices);
});
});
describe('calculateCost', () => {
it('should calculate cost with model prices from DB', async () => {
const price = createMockModelPrice({
modelName: 'gpt-4.1',
inputPricePer1kTokens: '0.010000',
outputPricePer1kTokens: '0.030000',
tokensPerDollar: 50000,
});
mockDb.where.mockResolvedValueOnce([price]);
const result = await service.calculateCost('gpt-4.1', 1000, 500);
expect(result.inputTokens).toBe(1000);
expect(result.outputTokens).toBe(500);
expect(result.totalTokens).toBe(1500);
expect(result.costUsd).toBeCloseTo(0.025); // (1000/1000)*0.01 + (500/1000)*0.03
expect(result.appTokens).toBeGreaterThan(0);
});
it('should use fallback prices when model not found', async () => {
mockDb.where.mockResolvedValueOnce([]);
const result = await service.calculateCost('unknown-model', 1000, 500);
expect(result.costUsd).toBeCloseTo(0.025); // fallback: same prices
expect(result.appTokens).toBeGreaterThan(0);
});
it('should return minimum 1 app token', async () => {
mockDb.where.mockResolvedValueOnce([]);
const result = await service.calculateCost('model', 1, 1);
expect(result.appTokens).toBeGreaterThanOrEqual(1);
});
});
describe('logUsage', () => {
it('should deduct tokens and create transaction', async () => {
// getBalance
const userToken = createMockUserToken({ tokenBalance: 100 });
mockDb.where.mockResolvedValueOnce([]); // getModelPrice → fallback
mockDb.where.mockResolvedValueOnce([userToken]); // getBalance
const result = await service.logUsage(TEST_USER_ID, 'gpt-4.1', 100, 200);
expect(result.tokensUsed).toBeGreaterThan(0);
expect(result.remainingBalance).toBeLessThan(100);
expect(mockDb.update).toHaveBeenCalled(); // deduct tokens
expect(mockDb.insert).toHaveBeenCalled(); // create transaction
});
it('should not go below 0 balance', async () => {
mockDb.where.mockResolvedValueOnce([]); // getModelPrice
const userToken = createMockUserToken({ tokenBalance: 1 });
mockDb.where.mockResolvedValueOnce([userToken]); // getBalance
const result = await service.logUsage(TEST_USER_ID, 'gpt-4.1', 10000, 10000);
expect(result.remainingBalance).toBe(0);
});
});
describe('getUsageStats', () => {
it('should aggregate usage stats by model and date', async () => {
const transactions = [
createMockTokenTransaction({ amount: -10, modelUsed: 'gpt-4.1' }),
createMockTokenTransaction({ amount: -5, modelUsed: 'gpt-4.1' }),
createMockTokenTransaction({ amount: -3, modelUsed: 'gemini-pro' }),
];
mockDb.orderBy.mockResolvedValueOnce(transactions);
const result = await service.getUsageStats(TEST_USER_ID, 'month');
expect(result.totalUsed).toBe(18);
expect(result.byModel['gpt-4.1']).toBe(15);
expect(result.byModel['gemini-pro']).toBe(3);
});
it('should return empty stats when no transactions', async () => {
mockDb.orderBy.mockResolvedValueOnce([]);
const result = await service.getUsageStats(TEST_USER_ID, 'week');
expect(result.totalUsed).toBe(0);
expect(result.byModel).toEqual({});
expect(result.byDate).toEqual({});
});
});
describe('getTransactions', () => {
it('should return paginated transactions', async () => {
const transactions = [createMockTokenTransaction(), createMockTokenTransaction()];
mockDb.offset.mockResolvedValueOnce(transactions);
const result = await service.getTransactions(TEST_USER_ID, 20, 0);
expect(result).toEqual(transactions);
expect(mockDb.limit).toHaveBeenCalled();
});
});
});

View file

@ -1,174 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, gte, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { userTokens } from '../db/schema/user-tokens.schema';
import { tokenTransactions } from '../db/schema/token-transactions.schema';
import { modelPrices } from '../db/schema/model-prices.schema';
import type { TokenTransaction } from '../db/schema/token-transactions.schema';
import type { ModelPrice } from '../db/schema/model-prices.schema';
export interface TokenCostEstimate {
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
appTokens: number;
}
export interface TokenUsageStats {
totalUsed: number;
byModel: Record<string, number>;
byDate: Record<string, number>;
}
@Injectable()
export class TokenService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async getBalance(userId: string): Promise<number> {
const result = await this.db.select().from(userTokens).where(eq(userTokens.userId, userId));
if (result.length === 0) {
// Create user token record with default balance
await this.db.insert(userTokens).values({
userId,
tokenBalance: 1000,
monthlyFreeTokens: 1000,
});
return 1000;
}
return result[0].tokenBalance || 0;
}
async hasEnoughTokens(userId: string, required: number): Promise<boolean> {
const balance = await this.getBalance(userId);
return balance >= required;
}
async getModelPrice(modelName: string): Promise<ModelPrice | null> {
const result = await this.db
.select()
.from(modelPrices)
.where(eq(modelPrices.modelName, modelName));
return result[0] || null;
}
async getModelPrices(): Promise<ModelPrice[]> {
return this.db.select().from(modelPrices);
}
async calculateCost(
model: string,
promptTokens: number,
completionTokens: number
): Promise<TokenCostEstimate> {
let inputPricePer1k = 0.01;
let outputPricePer1k = 0.03;
let tokensPerDollar = 50000;
const price = await this.getModelPrice(model);
if (price) {
inputPricePer1k = parseFloat(price.inputPricePer1kTokens);
outputPricePer1k = parseFloat(price.outputPricePer1kTokens);
tokensPerDollar = price.tokensPerDollar;
}
const inputCost = (promptTokens / 1000) * inputPricePer1k;
const outputCost = (completionTokens / 1000) * outputPricePer1k;
const totalCostUsd = inputCost + outputCost;
const appTokens = Math.max(1, Math.ceil(totalCostUsd * tokensPerDollar));
return {
inputTokens: promptTokens,
outputTokens: completionTokens,
totalTokens: promptTokens + completionTokens,
costUsd: totalCostUsd,
appTokens,
};
}
async logUsage(
userId: string,
model: string,
promptTokens: number,
completionTokens: number,
documentId?: string
): Promise<{ tokensUsed: number; remainingBalance: number }> {
const cost = await this.calculateCost(model, promptTokens, completionTokens);
// Deduct tokens
const currentBalance = await this.getBalance(userId);
const newBalance = Math.max(0, currentBalance - cost.appTokens);
await this.db
.update(userTokens)
.set({
tokenBalance: newBalance,
updatedAt: new Date(),
})
.where(eq(userTokens.userId, userId));
// Log transaction
await this.db.insert(tokenTransactions).values({
userId,
amount: -cost.appTokens,
transactionType: 'usage',
modelUsed: model,
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
costUsd: cost.costUsd.toFixed(6),
documentId: documentId || null,
});
return { tokensUsed: cost.appTokens, remainingBalance: newBalance };
}
async getUsageStats(
userId: string,
timeframe: 'day' | 'week' | 'month' | 'year'
): Promise<TokenUsageStats> {
const daysMap = { day: 1, week: 7, month: 30, year: 365 };
const days = daysMap[timeframe];
const since = new Date();
since.setDate(since.getDate() - days);
const transactions = await this.db
.select()
.from(tokenTransactions)
.where(
and(
eq(tokenTransactions.userId, userId),
eq(tokenTransactions.transactionType, 'usage'),
gte(tokenTransactions.createdAt, since)
)
)
.orderBy(desc(tokenTransactions.createdAt));
const stats: TokenUsageStats = { totalUsed: 0, byModel: {}, byDate: {} };
transactions.forEach((t) => {
stats.totalUsed += Math.abs(t.amount);
if (t.modelUsed) {
stats.byModel[t.modelUsed] = (stats.byModel[t.modelUsed] || 0) + Math.abs(t.amount);
}
const date = new Date(t.createdAt).toLocaleDateString('de-DE');
stats.byDate[date] = (stats.byDate[date] || 0) + Math.abs(t.amount);
});
return stats;
}
async getTransactions(userId: string, limit = 20, offset = 0): Promise<TokenTransaction[]> {
return this.db
.select()
.from(tokenTransactions)
.where(eq(tokenTransactions.userId, userId))
.orderBy(desc(tokenTransactions.createdAt))
.limit(limit)
.offset(offset);
}
}

View file

@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -41,8 +41,7 @@
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/feedback": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-ui": "workspace:*",
"@manacore/help": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",

View file

@ -2,8 +2,8 @@
* Help content for Context app
*/
import type { HelpContent } from '@manacore/shared-help-types';
import { getPrivacyFAQs } from '@manacore/shared-help-types';
import type { HelpContent } from '@manacore/help';
import { getPrivacyFAQs } from '@manacore/help';
export function getContextHelpContent(locale: string): HelpContent {
const isDE = locale === 'de';

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { HelpPage, getHelpTranslations } from '@manacore/shared-help-ui';
import { HelpPage, getHelpTranslations } from '@manacore/help';
import { getContextHelpContent } from '$lib/content/help/index.js';
const content = $derived(getContextHelpContent($locale ?? 'de'));