feat(context): add NestJS backend, PostgreSQL database, and migrate web app from Supabase to API

- Create NestJS backend on port 3020 with 4 modules (space, document, ai, token)
- Add Drizzle schema with 5 tables (spaces, documents, token_transactions, model_prices, user_tokens)
- Rewrite web services (spaces, documents, tokens, ai) to use shared API client instead of Supabase
- Move AI API keys server-side (Azure OpenAI, Google Gemini)
- Add seed script for model prices (gpt-4.1, gemini-pro, gemini-flash)
- Add 70 unit tests across 4 test suites (space, document, token, ai services)
- Add monorepo integration (setup-databases.sh, generate-env.mjs, docker init-db, root scripts)
- Remove @supabase/supabase-js dependency and delete supabase.ts from web app
- Update CLAUDE.md with full API documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 09:28:01 +01:00
parent 7f4edb3dfb
commit ea4b585f37
50 changed files with 4041 additions and 361 deletions

View file

@ -258,6 +258,18 @@ CALENDAR_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
# Local dev: http://localhost:3020
STT_URL=https://stt-api.mana.how
# ============================================
# CONTEXT PROJECT
# ============================================
CONTEXT_BACKEND_PORT=3020
CONTEXT_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/context
# AI API Keys (server-side only)
CONTEXT_AZURE_OPENAI_API_KEY=YOUR_KEY
CONTEXT_AZURE_OPENAI_ENDPOINT=https://memoroseopenai.openai.azure.com/
CONTEXT_GOOGLE_API_KEY=YOUR_KEY
# ============================================
# STORAGE PROJECT (Cloud Drive)
# ============================================
@ -359,6 +371,13 @@ TRACES_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/traces
SKILLTREE_BACKEND_PORT=3024
SKILLTREE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/skilltree
# ============================================
# MUKKE PROJECT
# ============================================
MUKKE_BACKEND_PORT=3010
MUKKE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mukke
# ============================================
# WORLDREAM GAME
# ============================================

View file

@ -2,95 +2,209 @@
AI-powered document management and context system for knowledge organization.
| App | Port | URL |
|-----|------|-----|
| Backend | 3020 | http://localhost:3020 |
| Web App | 5192 | http://localhost:5192 |
| Mobile | 8081 | Expo Go |
## Structure
```
apps/context/
├── apps/
│ ├── mobile/ # Expo React Native app
│ ├── web/ # (Planned) SvelteKit Web-App
│ ├── backend/ # (Planned) NestJS Backend
│ ├── backend/ # NestJS API server (@context/backend)
│ │ └── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── db/ # Drizzle schemas + migrations
│ │ │ ├── schema/
│ │ │ │ ├── spaces.schema.ts
│ │ │ │ ├── documents.schema.ts
│ │ │ │ ├── token-transactions.schema.ts
│ │ │ │ ├── model-prices.schema.ts
│ │ │ │ └── user-tokens.schema.ts
│ │ │ ├── connection.ts
│ │ │ ├── database.module.ts
│ │ │ ├── migrate.ts
│ │ │ └── seed.ts
│ │ ├── space/ # Space CRUD
│ │ ├── document/ # Document CRUD + versions + tags
│ │ ├── ai/ # AI generation (Azure + Google)
│ │ ├── token/ # Token balance + stats
│ │ └── common/
│ ├── web/ # SvelteKit web application (@context/web)
│ ├── mobile/ # Expo React Native app (@context/mobile)
│ └── landing/ # (Planned) Astro Landing Page
├── packages/ # Project-specific shared code
├── package.json # Workspace root
└── pnpm-workspace.yaml
└── package.json # Workspace root
```
## Development Commands
```bash
# From monorepo root
pnpm dev:context:mobile # Start mobile app
pnpm dev:context:full # Start auth + backend + web (with DB setup)
pnpm dev:context:backend # Start backend only (port 3020)
pnpm dev:context:web # Start web only (port 5192)
pnpm dev:context:app # Start web + backend together
pnpm dev:context:mobile # Start mobile app
# From apps/context/apps/mobile
pnpm dev # Start Expo dev client
pnpm ios # Run on iOS simulator
pnpm android # Run on Android emulator
pnpm build:dev # EAS development build
pnpm build:preview # EAS preview build
pnpm build:prod # EAS production build
pnpm type-check # TypeScript check
pnpm lint # ESLint + Prettier check
pnpm format # Fix linting issues
# Database
pnpm context:db:push # Push schema to database
pnpm context:db:studio # Open Drizzle Studio
pnpm context:db:seed # Seed model prices
pnpm setup:db:context # Create DB + push schema
```
## Tech Stack
- **Mobile**: Expo 52 + React Native 0.76
- **Styling**: NativeWind (TailwindCSS for React Native)
- **Database**: Supabase (PostgreSQL + Auth)
- **AI**: OpenAI (GPT-4), Azure OpenAI, Google Gemini
- **Monetization**: RevenueCat (subscriptions + token economy)
- **i18n**: i18next + react-i18next
- **Navigation**: Expo Router (file-based routing)
| Layer | Technology |
|-------|------------|
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
| **Web** | SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS 4 |
| **Mobile** | React Native 0.76 + Expo SDK 52, NativeWind |
| **Auth** | Mana Core Auth (JWT) |
| **AI** | Azure OpenAI (GPT-4.1), Google Gemini (Pro, Flash) |
| **i18n** | svelte-i18n (DE, EN) |
## Core Features
- **Spaces**: Organize documents into collections
- **Documents**: Text, context references, and AI prompts
- **AI Generation**: Multi-model support with streaming
- **Token Economy**: Track and manage AI usage credits
- **Spaces**: Organize documents into collections with prefix-based short IDs
- **Documents**: Text, context references, and AI prompts with versioning
- **AI Generation**: Multi-model support (Azure OpenAI, Google Gemini)
- **Token Economy**: Track and manage AI usage credits per user
- **Document Versioning**: AI-generated summaries, continuations, rewrites
## Architecture
## Backend API Endpoints
### Services (`apps/mobile/services/`)
### Health
| Service | Purpose |
|---------|---------|
| `supabaseService.ts` | Database CRUD operations |
| `aiService.ts` | AI model integrations |
| `revenueCatService.ts` | Subscription management |
| `tokenCountingService.ts` | Token usage calculation |
| `spaceService.ts` | Space management logic |
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
### State Management
### Spaces
- **AuthContext**: User authentication
- **ThemeContext**: Dark/light theme
- **DebugContext**: Development tools
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/spaces` | GET | List user's spaces |
| `/api/v1/spaces` | POST | Create space |
| `/api/v1/spaces/:id` | GET | Get space details |
| `/api/v1/spaces/:id` | PUT | Update space |
| `/api/v1/spaces/:id` | DELETE | Delete space (cascades documents) |
### Database Schema
### Documents
- **users**: User accounts
- **spaces**: Document containers (name, description, settings)
- **documents**: Core content (title, content, type, metadata)
- **token_transactions**: AI usage audit trail
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/documents` | GET | List documents (?spaceId=&preview=true&limit=) |
| `/api/v1/documents/recent` | GET | Recent documents (?limit=) |
| `/api/v1/documents` | POST | Create document |
| `/api/v1/documents/:id` | GET | Get document |
| `/api/v1/documents/:id` | PUT | Update document |
| `/api/v1/documents/:id` | DELETE | Delete document |
| `/api/v1/documents/:id/tags` | PUT | Update document tags |
| `/api/v1/documents/:id/pinned` | PUT | Toggle pinned |
| `/api/v1/documents/:id/versions` | GET | Get document versions |
| `/api/v1/documents/:id/versions` | POST | Create AI version |
### AI
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/ai/generate` | POST | Generate text (server-side AI) |
| `/api/v1/ai/estimate` | POST | Estimate token cost |
### Tokens
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/tokens/balance` | GET | Get user token balance |
| `/api/v1/tokens/stats` | GET | Usage stats (?timeframe=day\|week\|month\|year) |
| `/api/v1/tokens/transactions` | GET | Transaction history (?limit=&offset=) |
| `/api/v1/tokens/models` | GET | Available model prices |
## Database Schema
### spaces
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | Owner |
| `name` | VARCHAR(255) | Space name |
| `description` | TEXT | Optional description |
| `settings` | JSONB | Space settings |
| `pinned` | BOOLEAN | Pinned in sidebar |
| `prefix` | VARCHAR(10) | Short ID prefix (e.g. "A") |
| `text_doc_counter` | INTEGER | Counter for text docs |
| `context_doc_counter` | INTEGER | Counter for context docs |
| `prompt_doc_counter` | INTEGER | Counter for prompt docs |
### documents
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | Owner |
| `space_id` | UUID | FK to spaces (cascade delete) |
| `title` | VARCHAR(500) | Document title |
| `content` | TEXT | Document content |
| `type` | VARCHAR(20) | text / context / prompt |
| `short_id` | VARCHAR(20) | Short ID (e.g. "AD1") |
| `pinned` | BOOLEAN | Pinned flag |
| `metadata` | JSONB | Tags, word count, version info |
### token_transactions
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | TEXT | User |
| `amount` | INTEGER | Tokens used (negative for usage) |
| `transaction_type` | VARCHAR(50) | usage / bonus / purchase |
| `model_used` | VARCHAR(100) | AI model name |
| `prompt_tokens` | INTEGER | Input tokens |
| `completion_tokens` | INTEGER | Output tokens |
| `cost_usd` | NUMERIC(10,6) | Actual USD cost |
### model_prices
| Column | Type | Description |
|--------|------|-------------|
| `model_name` | VARCHAR(100) | Unique model name |
| `input_price_per_1k_tokens` | NUMERIC(10,6) | Input price |
| `output_price_per_1k_tokens` | NUMERIC(10,6) | Output price |
| `tokens_per_dollar` | INTEGER | App tokens per USD |
### user_tokens
| Column | Type | Description |
|--------|------|-------------|
| `user_id` | TEXT | Primary key |
| `token_balance` | INTEGER | Current balance |
| `monthly_free_tokens` | INTEGER | Free monthly allocation |
## Environment Variables
Required in `.env`:
### Backend (.env)
```env
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_OPENAI_API_KEY=
EXPO_PUBLIC_GOOGLE_API_KEY=
EXPO_PUBLIC_REVENUECAT_API_KEY=
NODE_ENV=development
PORT=3020
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/context
MANA_CORE_AUTH_URL=http://localhost:3001
AZURE_OPENAI_API_KEY=your-key
AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com/
GOOGLE_API_KEY=your-key
```
### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3020
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Important Patterns
1. **Absolute imports** with `~` alias (configured in tsconfig.json)
2. **NativeWind for styling** - use Tailwind classes
3. **Service layer pattern** - business logic in services
4. **Auto-save** - 3-second debounce after typing
5. **Optimistic updates** - immediate UI feedback
1. **API Client pattern** - All web services use `@manacore/shared-api-client` (Go-style `{ data, error }`)
2. **Svelte 5 runes** - `$state`, `$derived`, `$effect` throughout
3. **Server-side AI keys** - API keys only on backend, never in frontend
4. **Auto word/token count** - Backend calculates on create/update
5. **Optimistic updates** - Immediate UI feedback in stores
6. **Document versioning** - AI generations linked via `parent_document` in metadata

View file

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

View file

@ -0,0 +1,21 @@
/** @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

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

View file

@ -0,0 +1,66 @@
{
"name": "@context/backend",
"version": "1.0.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-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

@ -0,0 +1,28 @@
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

@ -0,0 +1,88 @@
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

@ -0,0 +1,45 @@
import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AiService } from './ai.service';
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('generate')
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;
}
@Post('estimate')
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;
}
}

View file

@ -0,0 +1,12 @@
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

@ -0,0 +1,231 @@
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

@ -0,0 +1,184 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TokenService } from '../token/token.service';
type AIProvider = 'azure' | 'google';
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);
}
function getProvider(model: string): AIProvider {
if (model.startsWith('gpt')) return 'azure';
return 'google';
}
@Injectable()
export class AiService {
constructor(
private configService: ConfigService,
private tokenService: TokenService
) {}
async generate(userId: string, options: GenerateOptions) {
const model = options.model || 'gpt-4.1';
const provider = getProvider(model);
// 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
let completionText: string;
if (provider === 'azure') {
completionText = await this.generateWithAzure(fullPrompt, options);
} else {
completionText = await this.generateWithGoogle(fullPrompt, { ...options, model });
}
// Calculate actual cost and log
const actualPromptTokens = estimateTokens(fullPrompt);
const completionTokens = estimateTokens(completionText);
const { tokensUsed, remainingBalance } = await this.tokenService.logUsage(
userId,
model,
actualPromptTokens,
completionTokens,
options.documentId
);
return {
text: completionText,
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 || 'gpt-4.1';
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,
};
}
private async generateWithAzure(prompt: string, options: GenerateOptions): Promise<string> {
const apiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY', '');
const endpoint = this.configService.get<string>(
'AZURE_OPENAI_ENDPOINT',
'https://memoroseopenai.openai.azure.com/'
);
const deployment = 'gpt-4.1';
const apiVersion = '2025-01-01-preview';
const response = await fetch(
`${endpoint}openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: prompt },
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
}),
}
);
if (!response.ok) {
throw new BadRequestException(`Azure OpenAI error: ${response.statusText}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '';
}
private async generateWithGoogle(prompt: string, options: GenerateOptions): Promise<string> {
const apiKey = this.configService.get<string>('GOOGLE_API_KEY', '');
const model = options.model || 'gemini-pro';
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: options.temperature || 0.7,
maxOutputTokens: options.maxTokens || 2000,
},
}),
}
);
if (!response.ok) {
throw new BadRequestException(`Google AI error: ${response.statusText}`);
}
const data = await response.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
}
}

View file

@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
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,
},
]),
DatabaseModule,
HealthModule.forRoot({ serviceName: 'context-backend' }),
SpaceModule,
DocumentModule,
AiModule,
TokenModule,
],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}

View file

@ -0,0 +1,60 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,177 @@
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,5 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,69 @@
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

@ -0,0 +1,117 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,359 @@
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

@ -0,0 +1,294 @@
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

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

View file

@ -0,0 +1,53 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,218 @@
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

@ -0,0 +1,94 @@
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

@ -0,0 +1,44 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,220 @@
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

@ -0,0 +1,174 @@
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

@ -0,0 +1,27 @@
{
"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

@ -0,0 +1,53 @@
{
"name": "@context/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"@vite-pwa/sveltekit": "^1.1.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,20 @@
/**
* API Client for Context backend
* Uses @manacore/shared-api-client for consistent error handling
*/
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
import { authStore } from '$lib/stores/auth.svelte';
const API_URL =
import.meta.env.VITE_BACKEND_URL || import.meta.env.PUBLIC_BACKEND_URL || 'http://localhost:3020';
export const api = createApiClient({
baseUrl: API_URL,
apiPrefix: '/api/v1',
getAuthToken: () => authStore.getValidToken(),
timeout: 30000,
debug: import.meta.env.DEV,
});
export type { ApiResult };

View file

@ -0,0 +1,131 @@
import { api } from '$lib/api/client';
import { estimateTokens } from '$lib/utils/text';
import { getCurrentTokenBalance } from './tokens';
import type {
AIProvider,
AIModelOption,
AIGenerationOptions,
AIGenerationResult,
TokenCostEstimate,
} from '$lib/types';
export const availableModels: AIModelOption[] = [
{ label: 'GPT-4.1', value: 'gpt-4.1', provider: 'azure' },
{ label: 'Gemini Pro', value: 'gemini-pro', provider: 'google' },
{ label: 'Gemini Flash', value: 'gemini-flash', provider: 'google' },
];
export const predefinedPrompts = [
{
title: 'Text fortsetzen',
prompt: 'Setze den folgenden Text fort, behalte dabei den Stil und Ton bei:\n\n',
icon: 'pencil',
type: 'continuation' as const,
},
{
title: 'Zusammenfassen',
prompt: 'Fasse den folgenden Text prägnant zusammen:\n\n',
icon: 'list',
type: 'summary' as const,
},
{
title: 'Umformulieren',
prompt: 'Formuliere den folgenden Text um, behalte dabei den Inhalt bei:\n\n',
icon: 'arrows-clockwise',
type: 'rewrite' as const,
},
{
title: 'Ideen generieren',
prompt: 'Generiere Ideen zum folgenden Thema:\n\n',
icon: 'lightbulb',
type: 'ideas' as const,
},
];
export type InsertionMode = 'append' | 'prepend' | 'replace' | 'new_version';
export async function checkTokenBalance(
userId: string,
prompt: string,
model: string,
estimatedCompletionLength: number = 500,
referencedDocuments?: { title: string; content: string }[]
): Promise<{ hasEnough: boolean; estimate: TokenCostEstimate; balance: number }> {
const { data, error } = await api.post<{
hasEnough: boolean;
estimate: TokenCostEstimate;
balance: number;
}>('/ai/estimate', {
prompt,
model,
estimatedCompletionLength,
referencedDocuments,
});
if (error || !data) {
// Fallback: estimate locally
let totalInputTokens = estimateTokens(prompt);
if (referencedDocuments?.length) {
const formattingOverhead = 20 + referencedDocuments.length * 10;
totalInputTokens += formattingOverhead;
referencedDocuments.forEach((doc) => {
totalInputTokens += estimateTokens(doc.content || '');
});
}
const balance = await getCurrentTokenBalance(userId);
return {
hasEnough: balance > 0,
estimate: {
inputTokens: totalInputTokens,
outputTokens: estimatedCompletionLength,
totalTokens: totalInputTokens + estimatedCompletionLength,
costUsd: 0,
appTokens: 1,
},
balance,
};
}
return data;
}
export async function generateText(
userId: string,
prompt: string,
provider: AIProvider = 'azure',
options: AIGenerationOptions = {}
): Promise<AIGenerationResult> {
const model = options.model || (provider === 'azure' ? 'gpt-4.1' : 'gemini-pro');
const { data, error } = await api.post<{
text: string;
tokenInfo: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
tokensUsed: number;
remainingTokens: number;
};
}>('/ai/generate', {
prompt,
model,
temperature: options.temperature,
maxTokens: options.maxTokens,
documentId: options.documentId,
referencedDocuments: options.referencedDocuments,
});
if (error || !data) {
throw new Error(error?.message || 'AI-Generierung fehlgeschlagen');
}
return {
text: data.text,
tokenInfo: data.tokenInfo,
};
}
export function getProviderForModel(modelValue: string): AIProvider {
const model = availableModels.find((m) => m.value === modelValue);
return model?.provider || 'azure';
}

View file

@ -0,0 +1,125 @@
import { api } from '$lib/api/client';
import type { Document, DocumentMetadata, DocumentType } from '$lib/types';
export async function getDocuments(spaceId?: string): Promise<Document[]> {
const params = new URLSearchParams();
if (spaceId) params.set('spaceId', spaceId);
const { data, error } = await api.get<{ documents: Document[] }>(`/documents?${params}`);
if (error || !data) return [];
return data.documents;
}
export async function getDocumentsWithPreview(
spaceId?: string,
limit: number = 50
): Promise<Document[]> {
const params = new URLSearchParams({ preview: 'true', limit: String(limit) });
if (spaceId) params.set('spaceId', spaceId);
const { data, error } = await api.get<{ documents: Document[] }>(`/documents?${params}`);
if (error || !data) return [];
return data.documents;
}
export async function getRecentDocuments(userId: string, limit: number = 5): Promise<Document[]> {
const params = new URLSearchParams({ limit: String(limit) });
const { data, error } = await api.get<{ documents: Document[] }>(`/documents/recent?${params}`);
if (error || !data) return [];
return data.documents;
}
export async function getDocumentById(id: string): Promise<Document | null> {
const { data, error } = await api.get<{ document: Document }>(`/documents/${id}`);
if (error || !data) return null;
return data.document;
}
export async function createDocument(
userId: string,
content: string,
type: DocumentType,
spaceId?: string,
metadata?: Partial<DocumentMetadata>,
title?: string
): Promise<{ data: Document | null; error: string | null }> {
const { data, error } = await api.post<{ document: Document }>('/documents', {
content,
type,
spaceId: spaceId || undefined,
title,
metadata,
});
if (error || !data) {
return { data: null, error: error?.message || 'Fehler beim Erstellen' };
}
return { data: data.document, error: null };
}
export async function updateDocument(
id: string,
updates: Partial<Document>
): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.put(`/documents/${id}`, updates);
return { success: !error, error: error?.message || null };
}
export async function deleteDocument(
id: string
): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.delete(`/documents/${id}`);
return { success: !error, error: error?.message || null };
}
export async function toggleDocumentPinned(
id: string,
pinned: boolean
): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.put(`/documents/${id}/pinned`, { pinned });
return { success: !error, error: error?.message || null };
}
export async function saveDocumentTags(
id: string,
tags: string[]
): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.put(`/documents/${id}/tags`, { tags });
return { success: !error, error: error?.message || null };
}
export async function getDocumentVersions(
documentId: string
): Promise<{ data: Document[]; error: string | null }> {
const { data, error } = await api.get<{ documents: Document[] }>(
`/documents/${documentId}/versions`
);
if (error || !data) {
return { data: [], error: error?.message || 'Fehler beim Laden der Versionen' };
}
return { data: data.documents, error: null };
}
export async function createDocumentVersion(
originalDocumentId: string,
userId: string,
newContent: string,
generationType: 'summary' | 'continuation' | 'rewrite' | 'ideas',
aiModel: string,
prompt: string
): Promise<{ data: Document | null; error: string | null }> {
const { data, error } = await api.post<{ document: Document }>(
`/documents/${originalDocumentId}/versions`,
{
content: newContent,
generationType,
model: aiModel,
prompt,
}
);
if (error || !data) {
return { data: null, error: error?.message || 'Fehler beim Erstellen der Version' };
}
return { data: data.document, error: null };
}

View file

@ -0,0 +1,53 @@
import { api } from '$lib/api/client';
import type { Space } from '$lib/types';
export async function getSpaces(): Promise<Space[]> {
const { data, error } = await api.get<{ spaces: Space[] }>('/spaces');
if (error || !data) return [];
return data.spaces;
}
export async function getSpaceById(id: string): Promise<Space | null> {
const { data, error } = await api.get<{ space: Space }>(`/spaces/${id}`);
if (error || !data) return null;
return data.space;
}
export async function createSpace(
userId: string,
name: string,
description?: string,
pinned: boolean = true
): Promise<{ data: Space | null; error: string | null }> {
const { data, error } = await api.post<{ space: Space }>('/spaces', {
name,
description: description || null,
pinned,
});
if (error || !data) {
return { data: null, error: error?.message || 'Fehler beim Erstellen' };
}
return { data: data.space, error: null };
}
export async function updateSpace(
id: string,
updates: Partial<Space>
): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.put(`/spaces/${id}`, updates);
return { success: !error, error: error?.message || null };
}
export async function toggleSpacePinned(
id: string,
pinned: boolean
): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.put(`/spaces/${id}`, { pinned });
return { success: !error, error: error?.message || null };
}
export async function deleteSpace(id: string): Promise<{ success: boolean; error: string | null }> {
const { error } = await api.delete(`/spaces/${id}`);
return { success: !error, error: error?.message || null };
}

View file

@ -0,0 +1,124 @@
import { api } from '$lib/api/client';
import { estimateTokens } from '$lib/utils/text';
import type { TokenCostEstimate } from '$lib/types';
export interface TokenTransaction {
id: string;
user_id: string;
amount: number;
transaction_type: string;
model_used?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
cost_usd?: number;
document_id?: string;
created_at: string;
}
export interface TokenUsageStats {
totalUsed: number;
byModel: Record<string, number>;
byDate: Record<string, number>;
}
export interface ModelPrice {
model_name: string;
input_price_per_1k_tokens: number;
output_price_per_1k_tokens: number;
tokens_per_dollar: number;
}
export async function getCurrentTokenBalance(userId: string): Promise<number> {
const { data, error } = await api.get<{ balance: number }>('/tokens/balance');
if (error || !data) return 0;
return data.balance;
}
export async function hasEnoughTokens(userId: string, requiredTokens: number): Promise<boolean> {
const balance = await getCurrentTokenBalance(userId);
return balance >= requiredTokens;
}
export async function getModelPrice(modelName: string): Promise<ModelPrice | null> {
const { data, error } = await api.get<{ models: ModelPrice[] }>('/tokens/models');
if (error || !data) return null;
return data.models.find((m) => m.model_name === modelName) || null;
}
export async function calculateCost(
model: string,
promptTokens: number,
completionTokens: number
): Promise<TokenCostEstimate> {
let inputPricePer1k = 0.01;
let outputPricePer1k = 0.03;
let tokensPerDollar = 50000;
const modelPrice = await getModelPrice(model);
if (modelPrice) {
inputPricePer1k = modelPrice.input_price_per_1k_tokens;
outputPricePer1k = modelPrice.output_price_per_1k_tokens;
tokensPerDollar = modelPrice.tokens_per_dollar;
}
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,
};
}
export async function estimateCostForPrompt(
prompt: string,
model: string,
estimatedCompletionLength: number = 500
): Promise<TokenCostEstimate> {
const promptTokens = estimateTokens(prompt);
return calculateCost(model, promptTokens, estimatedCompletionLength);
}
export async function logTokenUsage(
userId: string,
model: string,
prompt: string,
completion: string,
documentId?: string
): Promise<boolean> {
// Token logging is now handled server-side by the AI endpoint
return true;
}
export async function getTokenUsageStats(
userId: string,
timeframe: 'day' | 'week' | 'month' | 'year'
): Promise<TokenUsageStats> {
const { data, error } = await api.get<{ stats: TokenUsageStats }>(
`/tokens/stats?timeframe=${timeframe}`
);
if (error || !data) {
return { totalUsed: 0, byModel: {}, byDate: {} };
}
return data.stats;
}
export async function getTokenTransactions(
userId: string,
limit: number = 20,
offset: number = 0
): Promise<TokenTransaction[]> {
const { data, error } = await api.get<{ transactions: TokenTransaction[] }>(
`/tokens/transactions?limit=${limit}&offset=${offset}`
);
if (error || !data) return [];
return data.transactions;
}

View file

@ -3,6 +3,9 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "pnpm --filter @context/mobile dev"
"dev": "turbo run dev",
"dev:web": "pnpm --filter @context/web dev",
"dev:backend": "pnpm --filter @context/backend dev",
"dev:mobile": "pnpm --filter @context/mobile dev"
}
}

View file

@ -17,6 +17,7 @@ CREATE DATABASE IF NOT EXISTS inventory;
CREATE DATABASE IF NOT EXISTS techbase;
CREATE DATABASE IF NOT EXISTS voxel_lava;
CREATE DATABASE IF NOT EXISTS figgos;
CREATE DATABASE IF NOT EXISTS context;
-- Grant all privileges to the default user
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
@ -34,4 +35,5 @@ GRANT ALL PRIVILEGES ON DATABASE inventory TO manacore;
GRANT ALL PRIVILEGES ON DATABASE techbase TO manacore;
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
GRANT ALL PRIVILEGES ON DATABASE figgos TO manacore;
GRANT ALL PRIVILEGES ON DATABASE context TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;

View file

@ -157,6 +157,14 @@
"dev:worldream:web": "pnpm --filter @worldream/web dev",
"context:dev": "turbo run dev --filter=context...",
"dev:context:mobile": "pnpm --filter @context/mobile dev",
"dev:context:web": "pnpm --filter @context/web dev",
"dev:context:backend": "pnpm --filter @context/backend dev",
"dev:context:app": "turbo run dev --filter=@context/web --filter=@context/backend",
"dev:context:full": "./scripts/setup-databases.sh context && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:context:backend\" \"pnpm dev:context:web\"",
"context:db:push": "pnpm --filter @context/backend db:push",
"context:db:studio": "pnpm --filter @context/backend db:studio",
"context:db:seed": "pnpm --filter @context/backend db:seed",
"setup:db:context": "./scripts/setup-databases.sh context",
"planta:dev": "turbo run dev --filter=planta...",
"dev:planta:web": "pnpm --filter @planta/web dev",
"dev:planta:backend": "pnpm --filter @planta/backend dev",
@ -194,7 +202,13 @@
"storage:db:studio": "pnpm --filter @storage/backend db:studio",
"storage:db:seed": "pnpm --filter @storage/backend db:seed",
"mukke:dev": "turbo run dev --filter=mukke...",
"dev:mukke:mobile": "pnpm --filter @mukke/mobile dev",
"dev:mukke:web": "pnpm --filter @mukke/web dev",
"dev:mukke:landing": "pnpm --filter @mukke/landing dev",
"dev:mukke:backend": "pnpm --filter @mukke/backend dev",
"dev:mukke:app": "turbo run dev --filter=@mukke/web --filter=@mukke/backend",
"dev:mukke:full": "./scripts/setup-databases.sh mukke && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:mukke:backend\" \"pnpm dev:mukke:web\"",
"mukke:db:push": "pnpm --filter @mukke/backend db:push",
"mukke:db:studio": "pnpm --filter @mukke/backend db:studio",
"traces:dev": "turbo run dev --filter=traces...",
"dev:traces:mobile": "pnpm --filter @traces/mobile dev",
"dev:traces:backend": "pnpm --filter @traces/backend start:dev",

651
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -381,6 +381,32 @@ const APP_CONFIGS = [
},
},
// Context Backend (NestJS)
{
path: 'apps/context/apps/backend/.env',
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.CONTEXT_BACKEND_PORT || '3020',
DATABASE_URL: (env) => env.CONTEXT_DATABASE_URL,
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
DEV_BYPASS_AUTH: () => 'true',
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
AZURE_OPENAI_API_KEY: (env) => env.CONTEXT_AZURE_OPENAI_API_KEY || '',
AZURE_OPENAI_ENDPOINT: (env) => env.CONTEXT_AZURE_OPENAI_ENDPOINT || '',
GOOGLE_API_KEY: (env) => env.CONTEXT_GOOGLE_API_KEY || '',
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
},
},
// Context Web (SvelteKit)
{
path: 'apps/context/apps/web/.env',
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CONTEXT_BACKEND_PORT || '3020'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
// Calendar Backend (NestJS)
{
path: 'apps/calendar/apps/backend/.env',
@ -662,6 +688,34 @@ const APP_CONFIGS = [
},
},
// Mukke Backend (NestJS)
{
path: 'apps/mukke/apps/backend/.env',
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.MUKKE_BACKEND_PORT || '3010',
DATABASE_URL: (env) => env.MUKKE_DATABASE_URL,
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
S3_ENDPOINT: (env) => env.S3_ENDPOINT || 'http://localhost:9000',
S3_REGION: (env) => env.S3_REGION || 'us-east-1',
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY || 'minioadmin',
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY || 'minioadmin',
S3_BUCKET: () => 'mukke-storage',
DEV_BYPASS_AUTH: () => 'true',
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
},
},
// Mukke Web (SvelteKit)
{
path: 'apps/mukke/apps/web/.env',
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.MUKKE_BACKEND_PORT || '3010'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
// LLM Playground (SvelteKit)
{
path: 'services/llm-playground/.env',

View file

@ -81,8 +81,9 @@ ALL_DATABASES=(
"nutriphi_bot"
"questions"
"skilltree"
"lightwrite"
"mukke"
"traces"
"context"
)
# Check if specific service requested
@ -192,17 +193,21 @@ setup_service() {
create_db_if_not_exists "skilltree"
push_schema "@skilltree/backend" "skilltree"
;;
lightwrite)
create_db_if_not_exists "lightwrite"
push_schema "@lightwrite/backend" "lightwrite"
mukke)
create_db_if_not_exists "mukke"
push_schema "@mukke/backend" "mukke"
;;
traces)
create_db_if_not_exists "traces"
push_schema "@traces/backend" "traces"
;;
context)
create_db_if_not_exists "context"
push_schema "@context/backend" "context"
;;
*)
echo -e "${RED}Unknown service: $service${NC}"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree, lightwrite, traces"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree, mukke, traces, context"
exit 1
;;
esac
@ -226,7 +231,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
echo "--------------------------------------"
# Push schemas for all known services
for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree lightwrite traces; do
for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree mukke traces context; do
setup_service "$service" 2>/dev/null || true
done