From ea4b585f37c5fc6ca43a362efa9d219e0e58a725 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 19 Mar 2026 09:28:01 +0100 Subject: [PATCH] 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) --- .env.development | 19 + apps/context/CLAUDE.md | 226 ++++-- apps/context/apps/backend/drizzle.config.ts | 3 + apps/context/apps/backend/jest.config.js | 21 + apps/context/apps/backend/nest-cli.json | 10 + apps/context/apps/backend/package.json | 66 ++ .../backend/src/__tests__/utils/mock-db.ts | 28 + .../src/__tests__/utils/mock-factories.ts | 88 +++ .../apps/backend/src/ai/ai.controller.ts | 45 ++ apps/context/apps/backend/src/ai/ai.module.ts | 12 + .../apps/backend/src/ai/ai.service.spec.ts | 231 +++++++ .../context/apps/backend/src/ai/ai.service.ts | 184 +++++ apps/context/apps/backend/src/app.module.ts | 39 ++ .../src/common/http-exception.filter.ts | 60 ++ .../context/apps/backend/src/db/connection.ts | 38 + .../apps/backend/src/db/database.module.ts | 29 + apps/context/apps/backend/src/db/migrate.ts | 177 +++++ .../backend/src/db/schema/documents.schema.ts | 56 ++ .../apps/backend/src/db/schema/index.ts | 5 + .../src/db/schema/model-prices.schema.ts | 20 + .../backend/src/db/schema/spaces.schema.ts | 38 + .../db/schema/token-transactions.schema.ts | 34 + .../src/db/schema/user-tokens.schema.ts | 13 + apps/context/apps/backend/src/db/seed.ts | 69 ++ .../src/document/document.controller.ts | 117 ++++ .../backend/src/document/document.module.ts | 12 + .../src/document/document.service.spec.ts | 359 ++++++++++ .../backend/src/document/document.service.ts | 294 ++++++++ apps/context/apps/backend/src/main.ts | 8 + .../backend/src/space/space.controller.ts | 53 ++ .../apps/backend/src/space/space.module.ts | 10 + .../backend/src/space/space.service.spec.ts | 218 ++++++ .../apps/backend/src/space/space.service.ts | 94 +++ .../backend/src/token/token.controller.ts | 44 ++ .../apps/backend/src/token/token.module.ts | 10 + .../backend/src/token/token.service.spec.ts | 220 ++++++ .../apps/backend/src/token/token.service.ts | 174 +++++ apps/context/apps/backend/tsconfig.json | 27 + apps/context/apps/web/package.json | 53 ++ apps/context/apps/web/src/lib/api/client.ts | 20 + apps/context/apps/web/src/lib/services/ai.ts | 131 ++++ .../apps/web/src/lib/services/documents.ts | 125 ++++ .../apps/web/src/lib/services/spaces.ts | 53 ++ .../apps/web/src/lib/services/tokens.ts | 124 ++++ apps/context/package.json | 5 +- docker/init-db/01-create-databases.sql | 2 + package.json | 16 +- pnpm-lock.yaml | 651 ++++++++++-------- scripts/generate-env.mjs | 54 ++ scripts/setup-databases.sh | 17 +- 50 files changed, 4041 insertions(+), 361 deletions(-) create mode 100644 apps/context/apps/backend/drizzle.config.ts create mode 100644 apps/context/apps/backend/jest.config.js create mode 100644 apps/context/apps/backend/nest-cli.json create mode 100644 apps/context/apps/backend/package.json create mode 100644 apps/context/apps/backend/src/__tests__/utils/mock-db.ts create mode 100644 apps/context/apps/backend/src/__tests__/utils/mock-factories.ts create mode 100644 apps/context/apps/backend/src/ai/ai.controller.ts create mode 100644 apps/context/apps/backend/src/ai/ai.module.ts create mode 100644 apps/context/apps/backend/src/ai/ai.service.spec.ts create mode 100644 apps/context/apps/backend/src/ai/ai.service.ts create mode 100644 apps/context/apps/backend/src/app.module.ts create mode 100644 apps/context/apps/backend/src/common/http-exception.filter.ts create mode 100644 apps/context/apps/backend/src/db/connection.ts create mode 100644 apps/context/apps/backend/src/db/database.module.ts create mode 100644 apps/context/apps/backend/src/db/migrate.ts create mode 100644 apps/context/apps/backend/src/db/schema/documents.schema.ts create mode 100644 apps/context/apps/backend/src/db/schema/index.ts create mode 100644 apps/context/apps/backend/src/db/schema/model-prices.schema.ts create mode 100644 apps/context/apps/backend/src/db/schema/spaces.schema.ts create mode 100644 apps/context/apps/backend/src/db/schema/token-transactions.schema.ts create mode 100644 apps/context/apps/backend/src/db/schema/user-tokens.schema.ts create mode 100644 apps/context/apps/backend/src/db/seed.ts create mode 100644 apps/context/apps/backend/src/document/document.controller.ts create mode 100644 apps/context/apps/backend/src/document/document.module.ts create mode 100644 apps/context/apps/backend/src/document/document.service.spec.ts create mode 100644 apps/context/apps/backend/src/document/document.service.ts create mode 100644 apps/context/apps/backend/src/main.ts create mode 100644 apps/context/apps/backend/src/space/space.controller.ts create mode 100644 apps/context/apps/backend/src/space/space.module.ts create mode 100644 apps/context/apps/backend/src/space/space.service.spec.ts create mode 100644 apps/context/apps/backend/src/space/space.service.ts create mode 100644 apps/context/apps/backend/src/token/token.controller.ts create mode 100644 apps/context/apps/backend/src/token/token.module.ts create mode 100644 apps/context/apps/backend/src/token/token.service.spec.ts create mode 100644 apps/context/apps/backend/src/token/token.service.ts create mode 100644 apps/context/apps/backend/tsconfig.json create mode 100644 apps/context/apps/web/package.json create mode 100644 apps/context/apps/web/src/lib/api/client.ts create mode 100644 apps/context/apps/web/src/lib/services/ai.ts create mode 100644 apps/context/apps/web/src/lib/services/documents.ts create mode 100644 apps/context/apps/web/src/lib/services/spaces.ts create mode 100644 apps/context/apps/web/src/lib/services/tokens.ts diff --git a/.env.development b/.env.development index ffbaab667..830081325 100644 --- a/.env.development +++ b/.env.development @@ -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 # ============================================ diff --git a/apps/context/CLAUDE.md b/apps/context/CLAUDE.md index 4ec7b31d9..c20f89bc3 100644 --- a/apps/context/CLAUDE.md +++ b/apps/context/CLAUDE.md @@ -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 diff --git a/apps/context/apps/backend/drizzle.config.ts b/apps/context/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..a01903eec --- /dev/null +++ b/apps/context/apps/backend/drizzle.config.ts @@ -0,0 +1,3 @@ +import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; + +export default createDrizzleConfig({ dbName: 'context' }); diff --git a/apps/context/apps/backend/jest.config.js b/apps/context/apps/backend/jest.config.js new file mode 100644 index 000000000..a3c9e7291 --- /dev/null +++ b/apps/context/apps/backend/jest.config.js @@ -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)/)'], +}; diff --git a/apps/context/apps/backend/nest-cli.json b/apps/context/apps/backend/nest-cli.json new file mode 100644 index 000000000..b4a4fa09c --- /dev/null +++ b/apps/context/apps/backend/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false, + "assets": [], + "watchAssets": false + } +} diff --git a/apps/context/apps/backend/package.json b/apps/context/apps/backend/package.json new file mode 100644 index 000000000..f54ecfa61 --- /dev/null +++ b/apps/context/apps/backend/package.json @@ -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" + } +} diff --git a/apps/context/apps/backend/src/__tests__/utils/mock-db.ts b/apps/context/apps/backend/src/__tests__/utils/mock-db.ts new file mode 100644 index 000000000..11141faa8 --- /dev/null +++ b/apps/context/apps/backend/src/__tests__/utils/mock-db.ts @@ -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; +} diff --git a/apps/context/apps/backend/src/__tests__/utils/mock-factories.ts b/apps/context/apps/backend/src/__tests__/utils/mock-factories.ts new file mode 100644 index 000000000..1206d81c0 --- /dev/null +++ b/apps/context/apps/backend/src/__tests__/utils/mock-factories.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return { + userId: TEST_USER_ID, + tokenBalance: 1000, + monthlyFreeTokens: 1000, + lastTokenReset: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} diff --git a/apps/context/apps/backend/src/ai/ai.controller.ts b/apps/context/apps/backend/src/ai/ai.controller.ts new file mode 100644 index 000000000..627564350 --- /dev/null +++ b/apps/context/apps/backend/src/ai/ai.controller.ts @@ -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; + } +} diff --git a/apps/context/apps/backend/src/ai/ai.module.ts b/apps/context/apps/backend/src/ai/ai.module.ts new file mode 100644 index 000000000..88892cf85 --- /dev/null +++ b/apps/context/apps/backend/src/ai/ai.module.ts @@ -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 {} diff --git a/apps/context/apps/backend/src/ai/ai.service.spec.ts b/apps/context/apps/backend/src/ai/ai.service.spec.ts new file mode 100644 index 000000000..b39dbafe4 --- /dev/null +++ b/apps/context/apps/backend/src/ai/ai.service.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/apps/context/apps/backend/src/ai/ai.service.ts b/apps/context/apps/backend/src/ai/ai.service.ts new file mode 100644 index 000000000..8ce40db8f --- /dev/null +++ b/apps/context/apps/backend/src/ai/ai.service.ts @@ -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 { + const apiKey = this.configService.get('AZURE_OPENAI_API_KEY', ''); + const endpoint = this.configService.get( + '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 { + const apiKey = this.configService.get('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 || ''; + } +} diff --git a/apps/context/apps/backend/src/app.module.ts b/apps/context/apps/backend/src/app.module.ts new file mode 100644 index 000000000..bc52e4d92 --- /dev/null +++ b/apps/context/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/context/apps/backend/src/common/http-exception.filter.ts b/apps/context/apps/backend/src/common/http-exception.filter.ts new file mode 100644 index 000000000..e73f40d46 --- /dev/null +++ b/apps/context/apps/backend/src/common/http-exception.filter.ts @@ -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(); + const request = ctx.getRequest(); + + 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; + 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, + }); + } +} diff --git a/apps/context/apps/backend/src/db/connection.ts b/apps/context/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..fccc63f4a --- /dev/null +++ b/apps/context/apps/backend/src/db/connection.ts @@ -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 | null = null; +let db: ReturnType | 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; diff --git a/apps/context/apps/backend/src/db/database.module.ts b/apps/context/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..5a0a033b3 --- /dev/null +++ b/apps/context/apps/backend/src/db/database.module.ts @@ -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('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(); + } +} diff --git a/apps/context/apps/backend/src/db/migrate.ts b/apps/context/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..7bcc2c029 --- /dev/null +++ b/apps/context/apps/backend/src/db/migrate.ts @@ -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( + operation: () => Promise, + operationName: string, + maxRetries = MAX_RETRIES +): Promise { + 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): Promise { + 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): Promise { + await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`); +} + +async function waitForLock(db: ReturnType): Promise { + 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 { + 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); + }); diff --git a/apps/context/apps/backend/src/db/schema/documents.schema.ts b/apps/context/apps/backend/src/db/schema/documents.schema.ts new file mode 100644 index 000000000..bf0733aa0 --- /dev/null +++ b/apps/context/apps/backend/src/db/schema/documents.schema.ts @@ -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(), + 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; diff --git a/apps/context/apps/backend/src/db/schema/index.ts b/apps/context/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..79a93fa2f --- /dev/null +++ b/apps/context/apps/backend/src/db/schema/index.ts @@ -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'; diff --git a/apps/context/apps/backend/src/db/schema/model-prices.schema.ts b/apps/context/apps/backend/src/db/schema/model-prices.schema.ts new file mode 100644 index 000000000..f6c2c679a --- /dev/null +++ b/apps/context/apps/backend/src/db/schema/model-prices.schema.ts @@ -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; diff --git a/apps/context/apps/backend/src/db/schema/spaces.schema.ts b/apps/context/apps/backend/src/db/schema/spaces.schema.ts new file mode 100644 index 000000000..60d9ae466 --- /dev/null +++ b/apps/context/apps/backend/src/db/schema/spaces.schema.ts @@ -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(), + 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; diff --git a/apps/context/apps/backend/src/db/schema/token-transactions.schema.ts b/apps/context/apps/backend/src/db/schema/token-transactions.schema.ts new file mode 100644 index 000000000..55ec7d491 --- /dev/null +++ b/apps/context/apps/backend/src/db/schema/token-transactions.schema.ts @@ -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; diff --git a/apps/context/apps/backend/src/db/schema/user-tokens.schema.ts b/apps/context/apps/backend/src/db/schema/user-tokens.schema.ts new file mode 100644 index 000000000..05c2e029e --- /dev/null +++ b/apps/context/apps/backend/src/db/schema/user-tokens.schema.ts @@ -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; diff --git a/apps/context/apps/backend/src/db/seed.ts b/apps/context/apps/backend/src/db/seed.ts new file mode 100644 index 000000000..4fe618fce --- /dev/null +++ b/apps/context/apps/backend/src/db/seed.ts @@ -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)); diff --git a/apps/context/apps/backend/src/document/document.controller.ts b/apps/context/apps/backend/src/document/document.controller.ts new file mode 100644 index 000000000..630bfa2e2 --- /dev/null +++ b/apps/context/apps/backend/src/document/document.controller.ts @@ -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; + } + ) { + 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 + ) { + 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 }; + } +} diff --git a/apps/context/apps/backend/src/document/document.module.ts b/apps/context/apps/backend/src/document/document.module.ts new file mode 100644 index 000000000..c3187b889 --- /dev/null +++ b/apps/context/apps/backend/src/document/document.module.ts @@ -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 {} diff --git a/apps/context/apps/backend/src/document/document.service.spec.ts b/apps/context/apps/backend/src/document/document.service.spec.ts new file mode 100644 index 000000000..971fc4bce --- /dev/null +++ b/apps/context/apps/backend/src/document/document.service.spec.ts @@ -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); + spaceService = module.get(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]); + } + }); + }); +}); diff --git a/apps/context/apps/backend/src/document/document.service.ts b/apps/context/apps/backend/src/document/document.service.ts new file mode 100644 index 000000000..593d93475 --- /dev/null +++ b/apps/context/apps/backend/src/document/document.service.ts @@ -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 { + 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 { + 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 { + return this.db + .select() + .from(documents) + .where(eq(documents.userId, userId)) + .orderBy(desc(documents.updatedAt)) + .limit(limit); + } + + async findById(id: string, userId: string): Promise { + 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 { + 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; + } + ): Promise { + 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): Promise { + const existing = await this.findByIdOrThrow(id, userId); + + const updateData: Record = { 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 { + 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 { + 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 { + 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 { + 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 { + const original = await this.findByIdOrThrow(originalDocumentId, userId); + + const titlePrefixes: Record = { + 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; + } +} diff --git a/apps/context/apps/backend/src/main.ts b/apps/context/apps/backend/src/main.ts new file mode 100644 index 000000000..c5b7d5aff --- /dev/null +++ b/apps/context/apps/backend/src/main.ts @@ -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'], +}); diff --git a/apps/context/apps/backend/src/space/space.controller.ts b/apps/context/apps/backend/src/space/space.controller.ts new file mode 100644 index 000000000..cde701190 --- /dev/null +++ b/apps/context/apps/backend/src/space/space.controller.ts @@ -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; + }> + ) { + 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 }; + } +} diff --git a/apps/context/apps/backend/src/space/space.module.ts b/apps/context/apps/backend/src/space/space.module.ts new file mode 100644 index 000000000..d344375cc --- /dev/null +++ b/apps/context/apps/backend/src/space/space.module.ts @@ -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 {} diff --git a/apps/context/apps/backend/src/space/space.service.spec.ts b/apps/context/apps/backend/src/space/space.service.spec.ts new file mode 100644 index 000000000..64ca88cd3 --- /dev/null +++ b/apps/context/apps/backend/src/space/space.service.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/apps/context/apps/backend/src/space/space.service.ts b/apps/context/apps/backend/src/space/space.service.ts new file mode 100644 index 000000000..5802fb41f --- /dev/null +++ b/apps/context/apps/backend/src/space/space.service.ts @@ -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 { + return this.db.select().from(spaces).where(eq(spaces.userId, userId)).orderBy(spaces.createdAt); + } + + async findById(id: string, userId: string): Promise { + 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 { + 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 { + 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): Promise { + 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 { + 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 = { 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 }; + } +} diff --git a/apps/context/apps/backend/src/token/token.controller.ts b/apps/context/apps/backend/src/token/token.controller.ts new file mode 100644 index 000000000..95a2bbb87 --- /dev/null +++ b/apps/context/apps/backend/src/token/token.controller.ts @@ -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 }; + } +} diff --git a/apps/context/apps/backend/src/token/token.module.ts b/apps/context/apps/backend/src/token/token.module.ts new file mode 100644 index 000000000..160983835 --- /dev/null +++ b/apps/context/apps/backend/src/token/token.module.ts @@ -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 {} diff --git a/apps/context/apps/backend/src/token/token.service.spec.ts b/apps/context/apps/backend/src/token/token.service.spec.ts new file mode 100644 index 000000000..8f3d25e0f --- /dev/null +++ b/apps/context/apps/backend/src/token/token.service.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/apps/context/apps/backend/src/token/token.service.ts b/apps/context/apps/backend/src/token/token.service.ts new file mode 100644 index 000000000..6475c6b29 --- /dev/null +++ b/apps/context/apps/backend/src/token/token.service.ts @@ -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; + byDate: Record; +} + +@Injectable() +export class TokenService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async getBalance(userId: string): Promise { + 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 { + const balance = await this.getBalance(userId); + return balance >= required; + } + + async getModelPrice(modelName: string): Promise { + const result = await this.db + .select() + .from(modelPrices) + .where(eq(modelPrices.modelName, modelName)); + return result[0] || null; + } + + async getModelPrices(): Promise { + return this.db.select().from(modelPrices); + } + + async calculateCost( + model: string, + promptTokens: number, + completionTokens: number + ): Promise { + 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 { + 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 { + return this.db + .select() + .from(tokenTransactions) + .where(eq(tokenTransactions.userId, userId)) + .orderBy(desc(tokenTransactions.createdAt)) + .limit(limit) + .offset(offset); + } +} diff --git a/apps/context/apps/backend/tsconfig.json b/apps/context/apps/backend/tsconfig.json new file mode 100644 index 000000000..27971033a --- /dev/null +++ b/apps/context/apps/backend/tsconfig.json @@ -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"] +} diff --git a/apps/context/apps/web/package.json b/apps/context/apps/web/package.json new file mode 100644 index 000000000..35cc330ed --- /dev/null +++ b/apps/context/apps/web/package.json @@ -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" +} diff --git a/apps/context/apps/web/src/lib/api/client.ts b/apps/context/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..172a9f72c --- /dev/null +++ b/apps/context/apps/web/src/lib/api/client.ts @@ -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 }; diff --git a/apps/context/apps/web/src/lib/services/ai.ts b/apps/context/apps/web/src/lib/services/ai.ts new file mode 100644 index 000000000..be8b70989 --- /dev/null +++ b/apps/context/apps/web/src/lib/services/ai.ts @@ -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 { + 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'; +} diff --git a/apps/context/apps/web/src/lib/services/documents.ts b/apps/context/apps/web/src/lib/services/documents.ts new file mode 100644 index 000000000..a223c949b --- /dev/null +++ b/apps/context/apps/web/src/lib/services/documents.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + 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 +): 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 }; +} diff --git a/apps/context/apps/web/src/lib/services/spaces.ts b/apps/context/apps/web/src/lib/services/spaces.ts new file mode 100644 index 000000000..68240501d --- /dev/null +++ b/apps/context/apps/web/src/lib/services/spaces.ts @@ -0,0 +1,53 @@ +import { api } from '$lib/api/client'; +import type { Space } from '$lib/types'; + +export async function getSpaces(): Promise { + const { data, error } = await api.get<{ spaces: Space[] }>('/spaces'); + if (error || !data) return []; + return data.spaces; +} + +export async function getSpaceById(id: string): Promise { + 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 +): 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 }; +} diff --git a/apps/context/apps/web/src/lib/services/tokens.ts b/apps/context/apps/web/src/lib/services/tokens.ts new file mode 100644 index 000000000..8f0aa7305 --- /dev/null +++ b/apps/context/apps/web/src/lib/services/tokens.ts @@ -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; + byDate: Record; +} + +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 { + 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 { + const balance = await getCurrentTokenBalance(userId); + return balance >= requiredTokens; +} + +export async function getModelPrice(modelName: string): Promise { + 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 { + 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 { + const promptTokens = estimateTokens(prompt); + return calculateCost(model, promptTokens, estimatedCompletionLength); +} + +export async function logTokenUsage( + userId: string, + model: string, + prompt: string, + completion: string, + documentId?: string +): Promise { + // 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 { + 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 { + const { data, error } = await api.get<{ transactions: TokenTransaction[] }>( + `/tokens/transactions?limit=${limit}&offset=${offset}` + ); + + if (error || !data) return []; + return data.transactions; +} diff --git a/apps/context/package.json b/apps/context/package.json index 8b7a18894..b250e20a4 100644 --- a/apps/context/package.json +++ b/apps/context/package.json @@ -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" } } diff --git a/docker/init-db/01-create-databases.sql b/docker/init-db/01-create-databases.sql index b22fe3cfb..35463a98b 100644 --- a/docker/init-db/01-create-databases.sql +++ b/docker/init-db/01-create-databases.sql @@ -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; diff --git a/package.json b/package.json index 9adf8c47b..818955980 100644 --- a/package.json +++ b/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e073d72b..5c6630ec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,7 +159,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.27.0) + version: 10.4.9(esbuild@0.19.12) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -198,7 +198,7 @@ importers: version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) prettier: specifier: ^3.4.2 version: 3.6.2 @@ -207,10 +207,10 @@ importers: version: 0.5.21 ts-jest: specifier: ^29.4.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) + version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -234,14 +234,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1)) @@ -250,13 +250,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) + version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -661,19 +661,19 @@ importers: version: 18.3.27 '@typescript-eslint/eslint-plugin': specifier: ^7.7.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.7.0 - version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) dotenv: specifier: ^16.4.7 version: 16.6.1 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-universe: specifier: ^12.0.1 - version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3) + version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3) prettier: specifier: ^3.2.5 version: 3.6.2 @@ -1404,7 +1404,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9 '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -1449,10 +1449,10 @@ importers: version: 0.5.21 ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -2143,7 +2143,7 @@ importers: version: 7.1.4 ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3) ts-loader: specifier: ^9.5.2 version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) @@ -2696,6 +2696,9 @@ importers: apps/mukke/apps/backend: dependencies: + '@manacore/shared-drizzle-config': + specifier: workspace:* + version: link:../../../../packages/shared-drizzle-config '@manacore/shared-nestjs-auth': specifier: workspace:* version: link:../../../../packages/shared-nestjs-auth @@ -3031,7 +3034,7 @@ importers: version: 0.5.21 ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) @@ -4781,7 +4784,7 @@ importers: version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -5186,7 +5189,7 @@ importers: version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -6861,7 +6864,7 @@ importers: version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -7045,7 +7048,7 @@ importers: version: 7.1.4 ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) @@ -7157,7 +7160,7 @@ importers: version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -7347,7 +7350,7 @@ importers: version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -7420,7 +7423,7 @@ importers: version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -26793,6 +26796,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': + dependencies: + astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + autoprefixer: 10.4.22(postcss@8.5.6) + postcss: 8.5.6 + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.1) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) @@ -30108,7 +30121,7 @@ snapshots: ws: 8.18.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.5(5a4iqhoia26ysb7y6a7pgcamiq) + expo-router: 55.0.5(apnkrhypuo4jtg23v6qzhb7sxe) react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) transitivePeerDependencies: - '@expo/dom-webview' @@ -31231,7 +31244,7 @@ snapshots: react: 19.2.4 optionalDependencies: '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - expo-router: 55.0.5(5a4iqhoia26ysb7y6a7pgcamiq) + expo-router: 55.0.5(apnkrhypuo4jtg23v6qzhb7sxe) react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: - supports-color @@ -31932,41 +31945,6 @@ snapshots: jest-util: 30.3.0 slash: 3.0.0 - '@jest/core@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.1) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 @@ -32038,7 +32016,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))': + '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -32053,7 +32031,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -32072,7 +32050,6 @@ snapshots: - esbuild-register - supports-color - ts-node - optional: true '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: @@ -38038,19 +38015,6 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)': - dependencies: - jest-matcher-utils: 30.3.0 - picocolors: 1.1.1 - pretty-format: 30.3.0 - react: 19.2.4 - react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - react-test-renderer: 19.1.0(react@19.2.4) - redent: 3.0.0 - optionalDependencies: - jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - optional: true - '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.3.0 @@ -38064,6 +38028,19 @@ snapshots: jest: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true + '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)': + dependencies: + jest-matcher-utils: 30.3.0 + picocolors: 1.1.1 + pretty-format: 30.3.0 + react: 19.2.4 + react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + react-test-renderer: 19.1.0(react@19.2.4) + redent: 3.0.0 + optionalDependencies: + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + optional: true + '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.3.0 @@ -38074,7 +38051,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': @@ -38087,7 +38064,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1)': @@ -38100,7 +38077,7 @@ snapshots: react-test-renderer: 19.1.0(react@18.3.1) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': @@ -38113,7 +38090,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)': @@ -38126,7 +38103,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.2.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true '@testing-library/svelte-core@1.0.0(svelte@5.44.0)': @@ -38718,16 +38695,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -38776,15 +38753,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -38876,14 +38853,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -38915,14 +38892,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -39048,12 +39025,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -39084,12 +39061,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -39271,15 +39248,15 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -39310,13 +39287,13 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript @@ -40345,6 +40322,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.9 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 3.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.3.1 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.0 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.5.0 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.3.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.5.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.3 + shiki: 3.15.0 + smol-toml: 1.5.2 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.6.0 + unist-util-visit: 5.0.0 + unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.9.2) + vfile: 6.0.3 + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -42868,6 +42947,11 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -42880,7 +42964,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -42897,7 +42981,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -42914,7 +42998,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -42932,14 +43016,14 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -42964,17 +43048,17 @@ snapshots: - supports-color - typescript - eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3): + eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3): dependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1)) optionalDependencies: prettier: 3.6.2 transitivePeerDependencies: @@ -43010,7 +43094,7 @@ snapshots: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-config-prettier: 9.1.2(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-n: 17.24.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) @@ -43045,7 +43129,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -43059,12 +43143,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -43101,6 +43185,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.48.0 + astro-eslint-parser: 1.2.2 + eslint: 9.39.1(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -43128,12 +43226,6 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-utils: 2.1.0 - regexpp: 3.2.0 - eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -43187,7 +43279,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -43196,9 +43288,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -43210,7 +43302,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -43245,7 +43337,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -43274,7 +43366,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -43328,16 +43420,6 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 - eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7)) - eslint-utils: 2.1.0 - ignore: 5.3.2 - minimatch: 3.1.2 - resolve: 1.22.11 - semver: 6.3.1 - eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -43368,16 +43450,6 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 8.10.2(eslint@8.57.1) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - prettier: 3.6.2 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.11 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -43402,10 +43474,6 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -43436,28 +43504,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@1.21.7) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -45039,7 +45085,7 @@ snapshots: - expo-font - supports-color - expo-router@55.0.5(5a4iqhoia26ysb7y6a7pgcamiq): + expo-router@55.0.5(apnkrhypuo4jtg23v6qzhb7sxe): dependencies: '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -45077,7 +45123,7 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-gesture-handler@2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4) + '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4) react-dom: 19.2.4(react@19.2.4) react-native-gesture-handler: 2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -48047,7 +48093,7 @@ snapshots: jest-cli@29.7.0(@types/node@24.10.1): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 @@ -48085,7 +48131,7 @@ snapshots: jest-cli@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 @@ -48103,6 +48149,45 @@ snapshots: - ts-node optional: true + jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -48122,56 +48207,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - optional: true - - jest-config@29.7.0(@types/node@22.19.1): - dependencies: - '@babel/core': 7.28.5 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -48300,7 +48335,7 @@ snapshots: - supports-color optional: true - jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): + jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -48327,11 +48362,11 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.1 - esbuild-register: 3.6.0(esbuild@0.27.0) + esbuild-register: 3.6.0(esbuild@0.19.12) + ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - optional: true jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: @@ -48366,38 +48401,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@babel/core': 7.28.5 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - esbuild-register: 3.6.0(esbuild@0.27.0) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - optional: true - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -49057,7 +49060,7 @@ snapshots: jest@29.7.0(@types/node@24.10.1): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 jest-cli: 29.7.0(@types/node@24.10.1) @@ -49082,7 +49085,7 @@ snapshots: jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@jest/types': 30.3.0 import-local: 3.2.0 jest-cli: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) @@ -49094,6 +49097,33 @@ snapshots: - ts-node optional: true + jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -49107,20 +49137,6 @@ snapshots: - supports-color - ts-node - jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.0)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - optional: true - jimp-compact@0.16.1: {} jiti@1.21.7: {} @@ -56132,12 +56148,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -56153,7 +56169,7 @@ snapshots: esbuild: 0.19.12 jest-util: 30.3.0 - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -56195,6 +56211,26 @@ snapshots: esbuild: 0.27.0 jest-util: 30.3.0 + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.5 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.28.5) + jest-util: 30.3.0 + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)): dependencies: chalk: 4.1.2 @@ -57119,6 +57155,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 + vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.1 + vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -57222,6 +57275,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.1 + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): + optionalDependencies: + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 3d9380a95..f43ba916d 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -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', diff --git a/scripts/setup-databases.sh b/scripts/setup-databases.sh index 643e7015a..ac77fa780 100755 --- a/scripts/setup-databases.sh +++ b/scripts/setup-databases.sh @@ -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