mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
7f4edb3dfb
commit
ea4b585f37
50 changed files with 4041 additions and 361 deletions
|
|
@ -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
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
apps/context/apps/backend/drizzle.config.ts
Normal file
3
apps/context/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({ dbName: 'context' });
|
||||
21
apps/context/apps/backend/jest.config.js
Normal file
21
apps/context/apps/backend/jest.config.js
Normal 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)/)'],
|
||||
};
|
||||
10
apps/context/apps/backend/nest-cli.json
Normal file
10
apps/context/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
66
apps/context/apps/backend/package.json
Normal file
66
apps/context/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
apps/context/apps/backend/src/__tests__/utils/mock-db.ts
Normal file
28
apps/context/apps/backend/src/__tests__/utils/mock-db.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
45
apps/context/apps/backend/src/ai/ai.controller.ts
Normal file
45
apps/context/apps/backend/src/ai/ai.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
apps/context/apps/backend/src/ai/ai.module.ts
Normal file
12
apps/context/apps/backend/src/ai/ai.module.ts
Normal 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 {}
|
||||
231
apps/context/apps/backend/src/ai/ai.service.spec.ts
Normal file
231
apps/context/apps/backend/src/ai/ai.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
184
apps/context/apps/backend/src/ai/ai.service.ts
Normal file
184
apps/context/apps/backend/src/ai/ai.service.ts
Normal 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 || '';
|
||||
}
|
||||
}
|
||||
39
apps/context/apps/backend/src/app.module.ts
Normal file
39
apps/context/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
38
apps/context/apps/backend/src/db/connection.ts
Normal file
38
apps/context/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
29
apps/context/apps/backend/src/db/database.module.ts
Normal file
29
apps/context/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
177
apps/context/apps/backend/src/db/migrate.ts
Normal file
177
apps/context/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
56
apps/context/apps/backend/src/db/schema/documents.schema.ts
Normal file
56
apps/context/apps/backend/src/db/schema/documents.schema.ts
Normal 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;
|
||||
5
apps/context/apps/backend/src/db/schema/index.ts
Normal file
5
apps/context/apps/backend/src/db/schema/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
38
apps/context/apps/backend/src/db/schema/spaces.schema.ts
Normal file
38
apps/context/apps/backend/src/db/schema/spaces.schema.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
69
apps/context/apps/backend/src/db/seed.ts
Normal file
69
apps/context/apps/backend/src/db/seed.ts
Normal 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));
|
||||
117
apps/context/apps/backend/src/document/document.controller.ts
Normal file
117
apps/context/apps/backend/src/document/document.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
apps/context/apps/backend/src/document/document.module.ts
Normal file
12
apps/context/apps/backend/src/document/document.module.ts
Normal 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 {}
|
||||
359
apps/context/apps/backend/src/document/document.service.spec.ts
Normal file
359
apps/context/apps/backend/src/document/document.service.spec.ts
Normal 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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
294
apps/context/apps/backend/src/document/document.service.ts
Normal file
294
apps/context/apps/backend/src/document/document.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
apps/context/apps/backend/src/main.ts
Normal file
8
apps/context/apps/backend/src/main.ts
Normal 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'],
|
||||
});
|
||||
53
apps/context/apps/backend/src/space/space.controller.ts
Normal file
53
apps/context/apps/backend/src/space/space.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/context/apps/backend/src/space/space.module.ts
Normal file
10
apps/context/apps/backend/src/space/space.module.ts
Normal 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 {}
|
||||
218
apps/context/apps/backend/src/space/space.service.spec.ts
Normal file
218
apps/context/apps/backend/src/space/space.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
94
apps/context/apps/backend/src/space/space.service.ts
Normal file
94
apps/context/apps/backend/src/space/space.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
44
apps/context/apps/backend/src/token/token.controller.ts
Normal file
44
apps/context/apps/backend/src/token/token.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/context/apps/backend/src/token/token.module.ts
Normal file
10
apps/context/apps/backend/src/token/token.module.ts
Normal 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 {}
|
||||
220
apps/context/apps/backend/src/token/token.service.spec.ts
Normal file
220
apps/context/apps/backend/src/token/token.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
174
apps/context/apps/backend/src/token/token.service.ts
Normal file
174
apps/context/apps/backend/src/token/token.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
apps/context/apps/backend/tsconfig.json
Normal file
27
apps/context/apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
53
apps/context/apps/web/package.json
Normal file
53
apps/context/apps/web/package.json
Normal 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"
|
||||
}
|
||||
20
apps/context/apps/web/src/lib/api/client.ts
Normal file
20
apps/context/apps/web/src/lib/api/client.ts
Normal 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 };
|
||||
131
apps/context/apps/web/src/lib/services/ai.ts
Normal file
131
apps/context/apps/web/src/lib/services/ai.ts
Normal 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';
|
||||
}
|
||||
125
apps/context/apps/web/src/lib/services/documents.ts
Normal file
125
apps/context/apps/web/src/lib/services/documents.ts
Normal 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 };
|
||||
}
|
||||
53
apps/context/apps/web/src/lib/services/spaces.ts
Normal file
53
apps/context/apps/web/src/lib/services/spaces.ts
Normal 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 };
|
||||
}
|
||||
124
apps/context/apps/web/src/lib/services/tokens.ts
Normal file
124
apps/context/apps/web/src/lib/services/tokens.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -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
651
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue