make auth working

This commit is contained in:
Wuesteon 2025-11-26 01:31:12 +01:00
parent 7a1f1e9aef
commit 25824ed0ac
73 changed files with 9093 additions and 3877 deletions

File diff suppressed because it is too large Load diff

37
.dockerignore Normal file
View file

@ -0,0 +1,37 @@
# TypeScript build cache - must be excluded to prevent incremental build issues
**/*.tsbuildinfo
# Dependencies
**/node_modules
**/.pnpm-store
# Build outputs are excluded to force fresh builds
**/dist
# Development files
**/.env
**/.env.local
**/.env.*.local
# IDE
**/.idea
**/.vscode
**/*.swp
**/*.swo
# OS files
**/.DS_Store
**/Thumbs.db
# Logs
**/*.log
**/npm-debug.log*
**/pnpm-debug.log*
# Test files
**/coverage
**/.nyc_output
# Git
.git
.gitignore

681
chat/TESTING_GUIDE.md Normal file
View file

@ -0,0 +1,681 @@
# Testing Guide - Mana Core Auth Integration
This guide walks you through testing the Chat project with Mana Core Auth.
---
## Prerequisites
Before testing, make sure you have:
- ✅ Node.js 20+
- ✅ pnpm installed
- ✅ All dependencies installed (`pnpm install` from monorepo root)
- ✅ PostgreSQL running (or Docker for Mana Core Auth)
---
## Step 1: Generate JWT Keys for Mana Core Auth
Mana Core Auth requires RS256 JWT keys. Generate them first:
```bash
cd mana-core-auth
chmod +x scripts/generate-keys.sh
./scripts/generate-keys.sh
```
**You'll see output like:**
```
Generating RS256 key pair...
Keys generated successfully!
Private key: private.pem
Public key: public.pem
Add these to your .env file:
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKC...
-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBg...
-----END PUBLIC KEY-----"
```
**Copy these keys - you'll need them in the next step!**
---
## Step 2: Configure Environment Variables
### 2.1 Mana Core Auth
```bash
cd mana-core-auth
cp .env.example .env
```
Edit `mana-core-auth/.env` and add:
```env
# Database
DATABASE_URL=postgresql://manacore:password@localhost:5432/manacore
# Paste the keys from Step 1
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
YOUR_PRIVATE_KEY_HERE
-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
YOUR_PUBLIC_KEY_HERE
-----END PUBLIC KEY-----"
# Other settings (use defaults for now)
REDIS_PASSWORD=
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
PORT=3001
```
### 2.2 Chat Backend
```bash
cd ../chat/backend
cp .env.example .env
```
Edit `chat/backend/.env`:
```env
# Azure OpenAI (your existing keys)
AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth (NEW)
MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase (for database, not auth)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key
# Server
PORT=3002
```
### 2.3 Chat Web App
```bash
cd ../apps/web
cp .env.example .env
```
Edit `chat/apps/web/.env`:
```env
# Mana Core Auth (NEW)
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Backend API (NEW PORT)
PUBLIC_BACKEND_URL=http://localhost:3002
# Supabase (for database)
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
### 2.4 Chat Mobile App
```bash
cd ../mobile
cp .env.example .env
```
Edit `chat/apps/mobile/.env`:
```env
# Mana Core Auth (NEW)
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Backend API (NEW PORT)
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
# Supabase (for database)
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
---
## Step 3: Start Services (4 Terminals)
### Terminal 1: Mana Core Auth
```bash
cd mana-core-auth
# Start PostgreSQL (if using Docker)
docker-compose up postgres -d
# Run migrations
pnpm migration:run
# Start auth service
pnpm start:dev
```
**Expected output:**
```
🚀 Mana Core Auth running on: http://localhost:3001
📚 Environment: development
```
**Leave this running!**
### Terminal 2: Chat Backend
```bash
cd chat/backend
pnpm start:dev
```
**Expected output:**
```
[Nest] LOG [NestApplication] Nest application successfully started
[Nest] LOG Listening on port 3002
```
**Leave this running!**
### Terminal 3: Chat Web App
```bash
cd chat/apps/web
pnpm dev
```
**Expected output:**
```
VITE v5.x.x ready in xxx ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
```
**Leave this running!**
### Terminal 4: Chat Mobile App (Optional)
```bash
cd chat/apps/mobile
pnpm dev
```
**Expected output:**
```
Metro waiting on exp://localhost:8081
Scan the QR code above with Expo Go (Android) or Camera (iOS)
```
---
## Step 4: Test Web App Authentication
### 4.1 Open Web App
Open browser: http://localhost:5173
### 4.2 Register New User
1. Click **"Register"** or go to `/register`
2. Enter test credentials:
- Email: `test@example.com`
- Password: `Test1234!`
3. Click **"Register"**
**Expected:**
- Registration succeeds
- Automatically redirects to login
- Login happens automatically
- You're redirected to the main app
### 4.3 Check Authentication
Open browser console (F12) and run:
```javascript
// Check if user is authenticated
console.log('Authenticated:', window.localStorage.getItem('@auth/appToken'));
```
**Expected:** You should see a JWT token
### 4.4 Check Credits
In browser console:
```javascript
// Get credit balance
const authStore = window.authStore; // If exported globally
// Or navigate to a page that displays credits
```
**Expected:**
- 150 initial credits
- API call to `/api/v1/credits/balance` succeeds
### 4.5 Test Logout
1. Click **"Logout"** button
2. Check you're redirected to login page
3. Try accessing protected route → Should redirect to login
---
## Step 5: Test Backend API
### 5.1 Get Access Token
First, login via web app, then get the token from localStorage:
**In browser console:**
```javascript
const token = localStorage.getItem('@auth/appToken');
console.log(token);
// Copy this token!
```
### 5.2 Test Protected Endpoints
Use `curl` or Postman/Insomnia:
#### Test 1: Get AI Models (Protected)
```bash
# Without token - Should fail with 401
curl http://localhost:3002/api/chat/models
# With token - Should succeed
curl http://localhost:3002/api/chat/models \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
**Expected Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "GPT-O3-Mini",
"description": "Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.",
...
},
...
]
```
#### Test 2: List Conversations (Protected)
```bash
curl http://localhost:3002/api/conversations \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
**Expected:** Array of conversations (may be empty for new user)
#### Test 3: Create Conversation (Protected)
```bash
curl -X POST http://localhost:3002/api/conversations \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"modelId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Test Conversation"
}'
```
**Expected:** New conversation object
#### Test 4: Chat Completion (Protected)
```bash
curl -X POST http://localhost:3002/api/chat/completions \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"modelId": "550e8400-e29b-41d4-a716-446655440000",
"messages": [
{"role": "user", "content": "Say hello!"}
]
}'
```
**Expected:** AI response with content and usage stats
---
## Step 6: Test Mobile App (Optional)
### 6.1 Install Expo Go
- **iOS:** Install from App Store
- **Android:** Install from Google Play Store
### 6.2 Scan QR Code
1. Look at Terminal 4 (mobile app terminal)
2. Scan the QR code with:
- iOS: Camera app
- Android: Expo Go app
### 6.3 Register/Login
1. App opens to login screen
2. Tap **"Register"**
3. Enter credentials:
- Email: `mobile@example.com`
- Password: `Mobile1234!`
4. Tap **"Register"**
**Expected:**
- Registration succeeds
- Auto-login
- Redirected to chat interface
### 6.4 Test Chat
1. Try sending a message
2. Should get AI response
3. Check conversation is saved
---
## Step 7: Test Token Validation
### 7.1 Test Invalid Token
```bash
curl http://localhost:3002/api/chat/models \
-H "Authorization: Bearer invalid-token-here"
```
**Expected:**
```json
{
"statusCode": 401,
"message": "Invalid token"
}
```
### 7.2 Test Expired Token
After 15 minutes, the access token expires. Try using it:
```bash
curl http://localhost:3002/api/chat/models \
-H "Authorization: Bearer EXPIRED_TOKEN"
```
**Expected:** 401 Unauthorized
### 7.3 Test Token Refresh
The `@manacore/shared-auth` package automatically refreshes tokens. To test:
1. Wait 15+ minutes (or change `JWT_ACCESS_TOKEN_EXPIRY=1m` for testing)
2. Make an API call from web/mobile app
3. Check Network tab - should see automatic refresh
4. Request succeeds with new token
---
## Step 8: Test Credit System
### 8.1 Check Initial Balance
```bash
curl http://localhost:3001/api/v1/credits/balance \
-H "Authorization: Bearer YOUR_TOKEN"
```
**Expected:**
```json
{
"balance": 0,
"freeCreditsRemaining": 150,
"totalEarned": 0,
"totalSpent": 0,
"dailyFreeCredits": 5
}
```
### 8.2 Use Credits
```bash
curl -X POST http://localhost:3001/api/v1/credits/use \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amount": 10,
"appId": "chat",
"description": "Test chat completion",
"idempotencyKey": "test-123"
}'
```
**Expected:**
```json
{
"success": true,
"transaction": { ... },
"newBalance": {
"balance": 0,
"freeCreditsRemaining": 140,
"totalSpent": 10
}
}
```
### 8.3 Check Transaction History
```bash
curl http://localhost:3001/api/v1/credits/transactions \
-H "Authorization: Bearer YOUR_TOKEN"
```
**Expected:** Array with signup bonus and usage transactions
---
## Common Issues & Solutions
### Issue 1: "Connection refused" to port 3001
**Problem:** Mana Core Auth not running
**Solution:**
```bash
cd mana-core-auth
pnpm start:dev
```
### Issue 2: "Invalid token" errors
**Problem:** JWT keys mismatch or token expired
**Solution:**
1. Clear tokens: `localStorage.clear()` in browser
2. Login again
3. Verify JWT keys are identical in Mana Core Auth .env
### Issue 3: CORS errors in browser
**Problem:** Web app URL not in CORS whitelist
**Solution:**
Edit `mana-core-auth/.env`:
```env
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
```
Restart Mana Core Auth
### Issue 4: "Database connection failed"
**Problem:** PostgreSQL not running
**Solution:**
```bash
# If using Docker
cd mana-core-auth
docker-compose up postgres -d
# Check it's running
docker-compose ps
```
### Issue 5: "Port 3001 already in use"
**Problem:** Another service using port 3001
**Solution:**
```bash
# Find what's using the port
lsof -ti:3001
# Kill it
kill -9 $(lsof -ti:3001)
```
### Issue 6: Mobile app can't connect
**Problem:** Using localhost on mobile device
**Solution:**
Edit `chat/apps/mobile/.env`:
```env
# Replace localhost with your computer's IP
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://192.168.1.XXX:3001
EXPO_PUBLIC_BACKEND_URL=http://192.168.1.XXX:3002
```
Get your IP:
```bash
# macOS
ipconfig getifaddr en0
# Linux
hostname -I
```
---
## Quick Test Script
Save this as `test-auth.sh`:
```bash
#!/bin/bash
echo "🧪 Testing Mana Core Auth Integration"
echo ""
# Test 1: Register user
echo "1⃣ Testing registration..."
REGISTER_RESPONSE=$(curl -s -X POST http://localhost:3001/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}')
echo "Response: $REGISTER_RESPONSE"
echo ""
# Test 2: Login
echo "2⃣ Testing login..."
LOGIN_RESPONSE=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}')
# Extract token
TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"accessToken":"[^"]*' | cut -d'"' -f4)
if [ -z "$TOKEN" ]; then
echo "❌ Login failed!"
echo "Response: $LOGIN_RESPONSE"
exit 1
fi
echo "✅ Login successful! Token: ${TOKEN:0:50}..."
echo ""
# Test 3: Get credits
echo "3⃣ Testing credit balance..."
CREDITS_RESPONSE=$(curl -s http://localhost:3001/api/v1/credits/balance \
-H "Authorization: Bearer $TOKEN")
echo "Response: $CREDITS_RESPONSE"
echo ""
# Test 4: Backend protected endpoint
echo "4⃣ Testing backend protected endpoint..."
MODELS_RESPONSE=$(curl -s http://localhost:3002/api/chat/models \
-H "Authorization: Bearer $TOKEN")
echo "Response: $MODELS_RESPONSE"
echo ""
echo "✅ All tests complete!"
```
Make it executable and run:
```bash
chmod +x test-auth.sh
./test-auth.sh
```
---
## Testing Checklist
Use this checklist to verify everything works:
### Mana Core Auth ✅
- [ ] Service starts on port 3001
- [ ] Can register new user
- [ ] Can login with credentials
- [ ] Can refresh access token
- [ ] Can logout
- [ ] Can check credit balance
- [ ] Can use credits
### Chat Backend ✅
- [ ] Service starts on port 3002
- [ ] Protected endpoints return 401 without token
- [ ] Protected endpoints work with valid token
- [ ] Can list AI models
- [ ] Can create conversation
- [ ] Can list conversations
- [ ] Can send messages
### Web App ✅
- [ ] App loads on port 5173
- [ ] Can register new user
- [ ] Can login
- [ ] Can logout
- [ ] Can access protected routes
- [ ] Can send chat messages
- [ ] Can see conversations
### Mobile App ✅
- [ ] App loads in Expo Go
- [ ] Can register new user
- [ ] Can login
- [ ] Can logout
- [ ] Can send chat messages
- [ ] Can see conversations
- [ ] Tokens persist after app restart
---
**Status:** Ready for Testing! 🚀
Follow these steps and check off items as you test. If you encounter issues, check the "Common Issues" section above.

View file

@ -0,0 +1,633 @@
/**
* API Client for Chat Mobile App
* Handles all communication with the NestJS backend
*/
import * as SecureStore from 'expo-secure-store';
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001';
// Token storage key (must match what @manacore/shared-auth uses)
const APP_TOKEN_KEY = '@manacore/app_token';
// ============================================================================
// Types
// ============================================================================
export type Conversation = {
id: string;
userId: string;
modelId: string;
templateId?: string;
spaceId?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
title?: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
updatedAt: string;
};
export type Template = {
id: string;
userId: string;
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color: string;
isDefault: boolean;
documentMode: boolean;
createdAt: string;
updatedAt: string;
};
export type Space = {
id: string;
name: string;
description?: string;
ownerId: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
};
export type SpaceMember = {
id: string;
spaceId: string;
userId: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
invitationStatus: 'pending' | 'accepted' | 'declined';
invitedBy?: string;
invitedAt: string;
joinedAt?: string;
createdAt: string;
updatedAt: string;
};
export type Document = {
id: string;
conversationId: string;
version: number;
content: string;
createdAt: string;
updatedAt: string;
};
export type AIModel = {
id: string;
name: string;
description?: string;
parameters: {
temperature?: number;
max_tokens?: number;
provider?: string;
deployment?: string;
endpoint?: string;
api_version?: string;
};
costSettings?: {
prompt_per_1k_tokens?: number;
completion_per_1k_tokens?: number;
};
isActive: boolean;
createdAt: string;
updatedAt: string;
};
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
export type TokenUsage = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
export type ChatCompletionResponse = {
content: string;
usage: TokenUsage;
};
// ============================================================================
// Base API Functions
// ============================================================================
async function getAuthToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(APP_TOKEN_KEY);
} catch {
return null;
}
}
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ data: T | null; error: string | null }> {
try {
const token = await getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const errorText = await response.text();
console.error(`API Error [${response.status}]: ${errorText}`);
return { data: null, error: `API Error: ${response.status}` };
}
// Handle empty responses
const text = await response.text();
if (!text) {
return { data: null, error: null };
}
const data = JSON.parse(text);
return { data, error: null };
} catch (error) {
console.error('API Request failed:', error);
return { data: null, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
// ============================================================================
// Conversation API
// ============================================================================
export const conversationApi = {
async getConversations(spaceId?: string): Promise<Conversation[]> {
const params = spaceId ? `?spaceId=${spaceId}` : '';
const { data, error } = await apiRequest<Conversation[]>(`/api/conversations${params}`);
if (error) {
console.error('Failed to fetch conversations:', error);
return [];
}
return data || [];
},
async getArchivedConversations(): Promise<Conversation[]> {
const { data, error } = await apiRequest<Conversation[]>('/api/conversations/archived');
if (error) {
console.error('Failed to fetch archived conversations:', error);
return [];
}
return data || [];
},
async getConversation(id: string): Promise<Conversation | null> {
const { data, error } = await apiRequest<Conversation>(`/api/conversations/${id}`);
if (error) {
console.error('Failed to fetch conversation:', error);
return null;
}
return data;
},
async getMessages(conversationId: string): Promise<Message[]> {
const { data, error } = await apiRequest<Message[]>(`/api/conversations/${conversationId}/messages`);
if (error) {
console.error('Failed to fetch messages:', error);
return [];
}
return data || [];
},
async createConversation(params: {
modelId: string;
conversationMode?: 'free' | 'guided' | 'template';
templateId?: string;
documentMode?: boolean;
spaceId?: string;
}): Promise<Conversation | null> {
const { data, error } = await apiRequest<Conversation>('/api/conversations', {
method: 'POST',
body: JSON.stringify(params),
});
if (error) {
console.error('Failed to create conversation:', error);
return null;
}
return data;
},
async addMessage(
conversationId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string
): Promise<Message | null> {
const { data, error } = await apiRequest<Message>(`/api/conversations/${conversationId}/messages`, {
method: 'POST',
body: JSON.stringify({ sender, messageText }),
});
if (error) {
console.error('Failed to add message:', error);
return null;
}
return data;
},
async updateTitle(conversationId: string, title: string): Promise<boolean> {
const { error } = await apiRequest(`/api/conversations/${conversationId}/title`, {
method: 'PATCH',
body: JSON.stringify({ title }),
});
return !error;
},
async archiveConversation(conversationId: string): Promise<boolean> {
const { error } = await apiRequest(`/api/conversations/${conversationId}/archive`, {
method: 'POST',
});
return !error;
},
async unarchiveConversation(conversationId: string): Promise<boolean> {
const { error } = await apiRequest(`/api/conversations/${conversationId}/unarchive`, {
method: 'POST',
});
return !error;
},
async deleteConversation(conversationId: string): Promise<boolean> {
const { error } = await apiRequest(`/api/conversations/${conversationId}`, {
method: 'DELETE',
});
return !error;
},
};
// ============================================================================
// Template API
// ============================================================================
export const templateApi = {
async getTemplates(): Promise<Template[]> {
const { data, error } = await apiRequest<Template[]>('/api/templates');
if (error) {
console.error('Failed to fetch templates:', error);
return [];
}
return data || [];
},
async getTemplate(id: string): Promise<Template | null> {
const { data, error } = await apiRequest<Template>(`/api/templates/${id}`);
if (error) {
console.error('Failed to fetch template:', error);
return null;
}
return data;
},
async getDefaultTemplate(): Promise<Template | null> {
const { data, error } = await apiRequest<Template>('/api/templates/default');
if (error) {
// Not finding a default template is not an error
return null;
}
return data;
},
async createTemplate(template: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
}): Promise<Template | null> {
const { data, error } = await apiRequest<Template>('/api/templates', {
method: 'POST',
body: JSON.stringify(template),
});
if (error) {
console.error('Failed to create template:', error);
return null;
}
return data;
},
async updateTemplate(
id: string,
updates: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>
): Promise<boolean> {
const { error } = await apiRequest(`/api/templates/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
return !error;
},
async setDefaultTemplate(id: string): Promise<boolean> {
const { error } = await apiRequest(`/api/templates/${id}/default`, {
method: 'POST',
});
return !error;
},
async deleteTemplate(id: string): Promise<boolean> {
const { error } = await apiRequest(`/api/templates/${id}`, {
method: 'DELETE',
});
return !error;
},
};
// ============================================================================
// Space API
// ============================================================================
export const spaceApi = {
async getUserSpaces(): Promise<Space[]> {
const { data, error } = await apiRequest<Space[]>('/api/spaces');
if (error) {
console.error('Failed to fetch spaces:', error);
return [];
}
return data || [];
},
async getOwnedSpaces(): Promise<Space[]> {
const { data, error } = await apiRequest<Space[]>('/api/spaces/owned');
if (error) {
console.error('Failed to fetch owned spaces:', error);
return [];
}
return data || [];
},
async getSpace(id: string): Promise<Space | null> {
const { data, error } = await apiRequest<Space>(`/api/spaces/${id}`);
if (error) {
console.error('Failed to fetch space:', error);
return null;
}
return data;
},
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const { data, error } = await apiRequest<SpaceMember[]>(`/api/spaces/${spaceId}/members`);
if (error) {
console.error('Failed to fetch space members:', error);
return [];
}
return data || [];
},
async getUserRoleInSpace(spaceId: string): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
const { data, error } = await apiRequest<{ role: 'owner' | 'admin' | 'member' | 'viewer' }>(`/api/spaces/${spaceId}/role`);
if (error) {
return null;
}
return data?.role || null;
},
async getPendingInvitations(): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
const { data, error } = await apiRequest<Array<{ invitation: SpaceMember; space: Space }>>('/api/spaces/invitations/pending');
if (error) {
console.error('Failed to fetch pending invitations:', error);
return [];
}
return data || [];
},
async createSpace(name: string, description?: string): Promise<Space | null> {
const { data, error } = await apiRequest<Space>('/api/spaces', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
if (error) {
console.error('Failed to create space:', error);
return null;
}
return data;
},
async updateSpace(
id: string,
updates: { name?: string; description?: string; isArchived?: boolean }
): Promise<boolean> {
const { error } = await apiRequest(`/api/spaces/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
return !error;
},
async deleteSpace(id: string): Promise<boolean> {
const { error } = await apiRequest(`/api/spaces/${id}`, {
method: 'DELETE',
});
return !error;
},
async inviteUser(
spaceId: string,
userId: string,
role: 'admin' | 'member' | 'viewer' = 'member'
): Promise<boolean> {
const { error } = await apiRequest(`/api/spaces/${spaceId}/members`, {
method: 'POST',
body: JSON.stringify({ userId, role }),
});
return !error;
},
async respondToInvitation(
spaceId: string,
status: 'accepted' | 'declined'
): Promise<boolean> {
const { error } = await apiRequest(`/api/spaces/${spaceId}/invitation`, {
method: 'POST',
body: JSON.stringify({ status }),
});
return !error;
},
async removeMember(spaceId: string, userId: string): Promise<boolean> {
const { error } = await apiRequest(`/api/spaces/${spaceId}/members/${userId}`, {
method: 'DELETE',
});
return !error;
},
async changeMemberRole(
spaceId: string,
userId: string,
newRole: 'admin' | 'member' | 'viewer'
): Promise<boolean> {
const { error } = await apiRequest(`/api/spaces/${spaceId}/members/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role: newRole }),
});
return !error;
},
};
// ============================================================================
// Document API
// ============================================================================
export const documentApi = {
async getLatestDocument(conversationId: string): Promise<Document | null> {
const { data, error } = await apiRequest<Document>(`/api/documents/conversation/${conversationId}/latest`);
if (error) {
return null;
}
return data;
},
async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
const { data, error } = await apiRequest<Document[]>(`/api/documents/conversation/${conversationId}`);
if (error) {
console.error('Failed to fetch document versions:', error);
return [];
}
return data || [];
},
async hasDocument(conversationId: string): Promise<boolean> {
const { data, error } = await apiRequest<{ exists: boolean }>(`/api/documents/conversation/${conversationId}/exists`);
if (error) {
return false;
}
return data?.exists || false;
},
async createDocument(conversationId: string, content: string): Promise<Document | null> {
const { data, error } = await apiRequest<Document>('/api/documents', {
method: 'POST',
body: JSON.stringify({ conversationId, content }),
});
if (error) {
console.error('Failed to create document:', error);
return null;
}
return data;
},
async createDocumentVersion(conversationId: string, content: string): Promise<Document | null> {
const { data, error } = await apiRequest<Document>(`/api/documents/conversation/${conversationId}/version`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (error) {
console.error('Failed to create document version:', error);
return null;
}
return data;
},
async deleteDocumentVersion(documentId: string): Promise<boolean> {
const { error } = await apiRequest(`/api/documents/${documentId}`, {
method: 'DELETE',
});
return !error;
},
};
// ============================================================================
// Model API
// ============================================================================
export const modelApi = {
async getModels(): Promise<AIModel[]> {
const { data, error } = await apiRequest<AIModel[]>('/api/chat/models');
if (error) {
console.error('Failed to fetch models:', error);
return [];
}
return data || [];
},
async getModel(id: string): Promise<AIModel | null> {
const { data, error } = await apiRequest<AIModel>(`/api/models/${id}`);
if (error) {
console.error('Failed to fetch model:', error);
return null;
}
return data;
},
};
// ============================================================================
// Chat API
// ============================================================================
export const chatApi = {
async createCompletion(params: {
messages: ChatMessage[];
modelId: string;
temperature?: number;
maxTokens?: number;
}): Promise<ChatCompletionResponse | null> {
const { data, error } = await apiRequest<ChatCompletionResponse>('/api/chat/completions', {
method: 'POST',
body: JSON.stringify({
messages: params.messages,
modelId: params.modelId,
temperature: params.temperature ?? 0.7,
maxTokens: params.maxTokens ?? 1000,
}),
});
if (error) {
console.error('Failed to create completion:', error);
return null;
}
return data;
},
};
// ============================================================================
// Usage Log API
// ============================================================================
export const usageApi = {
async logTokenUsage(params: {
conversationId: string;
messageId: string;
modelId: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
estimatedCost: number;
}): Promise<boolean> {
const { error } = await apiRequest('/api/usage-logs', {
method: 'POST',
body: JSON.stringify(params),
});
return !error;
},
};

View file

@ -1,16 +1,29 @@
import { supabase } from '../utils/supabase'; /**
import { sendChatRequest, ChatMessage, logTokenUsage, ChatRequestResult } from './openai'; * Conversation Service - CRUD operations via Backend API
*/
import {
conversationApi,
chatApi,
modelApi,
templateApi,
usageApi,
type Conversation as ApiConversation,
type Message as ApiMessage,
type ChatMessage,
type TokenUsage,
} from './api';
// Typdefinitionen für Konversationen und Nachrichten // Re-export types with backwards-compatible naming (snake_case for mobile)
export type Conversation = { export type Conversation = {
id: string; // UUID id: string;
user_id: string; // UUID des Benutzers (auth.uid) user_id: string;
model_id: string; // UUID model_id: string;
template_id?: string; // UUID, optional template_id?: string;
space_id?: string;
conversation_mode: 'free' | 'guided' | 'template'; conversation_mode: 'free' | 'guided' | 'template';
document_mode: boolean; // Gibt an, ob der Dokumentmodus aktiviert ist document_mode: boolean;
title?: string; // Titel der Konversation title?: string;
is_archived: boolean; // Gibt an, ob die Konversation archiviert wurde is_archived: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@ -37,6 +50,34 @@ export type TokenUsageType = {
created_at: string; created_at: string;
}; };
// Helper to convert API response to local format
function toLocalConversation(conv: ApiConversation): Conversation {
return {
id: conv.id,
user_id: conv.userId,
model_id: conv.modelId,
template_id: conv.templateId,
space_id: conv.spaceId,
conversation_mode: conv.conversationMode,
document_mode: conv.documentMode,
title: conv.title,
is_archived: conv.isArchived,
created_at: conv.createdAt,
updated_at: conv.updatedAt,
};
}
function toLocalMessage(msg: ApiMessage): Message {
return {
id: msg.id,
conversation_id: msg.conversationId,
sender: msg.sender,
message_text: msg.messageText,
created_at: msg.createdAt,
updated_at: msg.updatedAt,
};
}
/** /**
* Erstellt eine neue Konversation in der Datenbank * Erstellt eine neue Konversation in der Datenbank
*/ */
@ -49,28 +90,22 @@ export async function createConversation(
spaceId?: string spaceId?: string
): Promise<string | null> { ): Promise<string | null> {
try { try {
console.log("🔵 Erstelle Konversation mit Space ID:", spaceId || "keine"); console.log('🔵 Erstelle Konversation mit Space ID:', spaceId || 'keine');
// Erstelle einen neuen Eintrag in der Conversations-Tabelle
const { data, error } = await supabase
.from('conversations')
.insert({
user_id: userId,
model_id: modelId,
template_id: templateId,
conversation_mode: mode,
document_mode: documentMode,
space_id: spaceId,
})
.select('id')
.single();
if (error) { const conversation = await conversationApi.createConversation({
console.error('Fehler beim Erstellen der Konversation:', error); modelId,
conversationMode: mode,
templateId,
documentMode,
spaceId,
});
if (!conversation) {
console.error('Fehler beim Erstellen der Konversation');
return null; return null;
} }
return data.id; return conversation.id;
} catch (error) { } catch (error) {
console.error('Fehler beim Erstellen der Konversation:', error); console.error('Fehler beim Erstellen der Konversation:', error);
return null; return null;
@ -86,35 +121,23 @@ export async function addMessage(
messageText: string messageText: string
): Promise<string | null> { ): Promise<string | null> {
try { try {
// Führe eine Prüfung und Validierung des Senders durch // Validate sender
let validSender = sender; let validSender = sender;
// Stelle sicher, dass der Sender den zulässigen Werten entspricht
// Das scheint das Problem zu sein - die Datenbank akzeptiert nur bestimmte Werte
if (!['user', 'assistant', 'system'].includes(validSender)) { if (!['user', 'assistant', 'system'].includes(validSender)) {
console.error('Ungültiger Sender-Wert:', sender); console.error('Ungültiger Sender-Wert:', sender);
validSender = 'user'; // Fallback auf 'user' validSender = 'user';
} }
console.log('Füge Nachricht hinzu mit Sender:', validSender);
// Füge eine neue Nachricht in die Messages-Tabelle ein
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender: validSender,
message_text: messageText,
})
.select('id')
.single();
if (error) { console.log('Füge Nachricht hinzu mit Sender:', validSender);
console.error('Fehler beim Hinzufügen der Nachricht:', error);
const message = await conversationApi.addMessage(conversationId, validSender, messageText);
if (!message) {
console.error('Fehler beim Hinzufügen der Nachricht');
return null; return null;
} }
return data.id; return message.id;
} catch (error) { } catch (error) {
console.error('Fehler beim Hinzufügen der Nachricht:', error); console.error('Fehler beim Hinzufügen der Nachricht:', error);
return null; return null;
@ -126,18 +149,8 @@ export async function addMessage(
*/ */
export async function getMessages(conversationId: string): Promise<Message[]> { export async function getMessages(conversationId: string): Promise<Message[]> {
try { try {
const { data, error } = await supabase const messages = await conversationApi.getMessages(conversationId);
.from('messages') return messages.map(toLocalMessage);
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
if (error) {
console.error('Fehler beim Laden der Nachrichten:', error);
return [];
}
return data as Message[];
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Nachrichten:', error); console.error('Fehler beim Laden der Nachrichten:', error);
return []; return [];
@ -146,52 +159,41 @@ export async function getMessages(conversationId: string): Promise<Message[]> {
/** /**
* Generiert einen Titel für die Konversation basierend auf der ersten Benutzeranfrage * Generiert einen Titel für die Konversation basierend auf der ersten Benutzeranfrage
* @param userQuestion Die erste Frage des Benutzers
* @returns Generierter Titel
*/ */
export async function generateConversationTitle(userQuestion: string): Promise<string> { export async function generateConversationTitle(userQuestion: string): Promise<string> {
try { try {
console.log('Generiere Titel für Konversation basierend auf:', console.log(
userQuestion.substring(0, 50) + (userQuestion.length > 50 ? '...' : '')); 'Generiere Titel für Konversation basierend auf:',
userQuestion.substring(0, 50) + (userQuestion.length > 50 ? '...' : '')
// Verwende speziell GPT-4o-Mini für die Titelerstellung );
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für unseren Chat mit dieser Frage: "${userQuestion}"`; const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für unseren Chat mit dieser Frage: "${userQuestion}"`;
// Manuell 4o-mini-Modell festlegen
const chatMessages: ChatMessage[] = [
{
role: 'system',
content: 'MODEL:gpt-4o-mini-se'
},
{
role: 'user',
content: titlePrompt
}
];
// Sende die Anfrage mit niedrigerer Temperatur für zuverlässigere Ergebnisse
const titleResponse = await sendChatRequest(chatMessages, 0.3, 50);
// Extrahiere den Text aus der Antwort const response = await chatApi.createCompletion({
const responseText = typeof titleResponse === 'string' messages: [{ role: 'user', content: titlePrompt }],
? titleResponse modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini
: titleResponse.content; temperature: 0.3,
maxTokens: 50,
});
if (!response) {
return 'Neue Konversation';
}
// Clean up title
let cleanTitle = response.content
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\.$/g, '');
// Entferne Anführungszeichen und Punkt am Ende, falls vorhanden
let cleanTitle = responseText.trim()
.replace(/^["']|["']$/g, '') // Entferne Anführungszeichen am Anfang und Ende
.replace(/\.$/g, ''); // Entferne Punkt am Ende
// Begrenze die Länge auf 100 Zeichen (für Datenbank)
if (cleanTitle.length > 100) { if (cleanTitle.length > 100) {
cleanTitle = cleanTitle.substring(0, 97) + '...'; cleanTitle = cleanTitle.substring(0, 97) + '...';
} }
console.log('Generierter Titel:', cleanTitle); console.log('Generierter Titel:', cleanTitle);
return cleanTitle; return cleanTitle;
} catch (error) { } catch (error) {
console.error('Fehler bei der Titelgenerierung:', error); console.error('Fehler bei der Titelgenerierung:', error);
// Fallback-Titel bei Fehler
return 'Neue Konversation'; return 'Neue Konversation';
} }
} }
@ -199,18 +201,18 @@ export async function generateConversationTitle(userQuestion: string): Promise<s
/** /**
* Aktualisiert den Titel einer Konversation * Aktualisiert den Titel einer Konversation
*/ */
export async function updateConversationTitle(conversationId: string, title: string): Promise<boolean> { export async function updateConversationTitle(
conversationId: string,
title: string
): Promise<boolean> {
try { try {
const { error } = await supabase const success = await conversationApi.updateTitle(conversationId, title);
.from('conversations')
.update({ title }) if (!success) {
.eq('id', conversationId); console.error('Fehler beim Aktualisieren des Konversationstitels');
if (error) {
console.error('Fehler beim Aktualisieren des Konversationstitels:', error);
return false; return false;
} }
return true; return true;
} catch (error) { } catch (error) {
console.error('Fehler beim Aktualisieren des Konversationstitels:', error); console.error('Fehler beim Aktualisieren des Konversationstitels:', error);
@ -220,23 +222,17 @@ export async function updateConversationTitle(conversationId: string, title: str
/** /**
* Lädt einen System-Prompt aus einer Vorlage * Lädt einen System-Prompt aus einer Vorlage
* @param templateId Die ID der Vorlage
* @returns Der System-Prompt der Vorlage oder null
*/ */
export async function getSystemPromptFromTemplate(templateId: string): Promise<string | null> { export async function getSystemPromptFromTemplate(templateId: string): Promise<string | null> {
try { try {
const { data, error } = await supabase const template = await templateApi.getTemplate(templateId);
.from('templates')
.select('system_prompt') if (!template) {
.eq('id', templateId) console.error('Fehler beim Laden der Vorlage');
.single();
if (error) {
console.error('Fehler beim Laden der Vorlage:', error);
return null; return null;
} }
return data.system_prompt; return template.systemPrompt;
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Vorlage:', error); console.error('Fehler beim Laden der Vorlage:', error);
return null; return null;
@ -252,70 +248,52 @@ export async function sendMessageAndGetResponse(
modelId: string, modelId: string,
templateId?: string, templateId?: string,
documentMode: boolean = false documentMode: boolean = false
): Promise<{ userMessageId: string | null; assistantMessageId: string | null; assistantResponse: string; title?: string; documentContent?: string }> { ): Promise<{
userMessageId: string | null;
assistantMessageId: string | null;
assistantResponse: string;
title?: string;
documentContent?: string;
}> {
try { try {
console.log('Starte sendMessageAndGetResponse mit:', { console.log('Starte sendMessageAndGetResponse mit:', {
conversationId, conversationId,
userMessage: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''), userMessage: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''),
modelId modelId,
}); });
// Lade das Modell aus der Datenbank oder verwende Fallback // Load the model from API
const { data: modelData, error: modelError } = await supabase const modelData = await modelApi.getModel(modelId);
.from('models')
.select('*') if (!modelData) {
.eq('id', modelId)
.single();
if (modelError) {
console.error('Fehler beim Laden des Modells:', modelError);
console.log('Verwende Standard-Parameter, da Modell nicht geladen werden konnte'); console.log('Verwende Standard-Parameter, da Modell nicht geladen werden konnte');
} else { } else {
console.log('Modell geladen:', { console.log('Modell geladen:', {
id: modelData.id, id: modelData.id,
name: modelData.name, name: modelData.name,
parameters: modelData.parameters, parameters: modelData.parameters,
deployment: modelData.parameters?.deployment deployment: modelData.parameters?.deployment,
}); });
} }
// Variable für die Benutzer-Nachricht-ID // Save the user message
let userMessageId: string | null = null; const userMessageId = await addMessage(conversationId, 'user', userMessage);
console.log('Benutzernachricht gespeichert mit ID:', userMessageId);
// Überprüfe, ob die Nachricht bereits in der Datenbank existiert
const { data: existingMessages } = await supabase
.from('messages')
.select('id')
.eq('conversation_id', conversationId)
.eq('sender', 'user')
.eq('message_text', userMessage)
.limit(1);
if (existingMessages && existingMessages.length > 0) {
// Wenn die Nachricht bereits existiert, verwende diese ID
userMessageId = existingMessages[0].id;
console.log('Bestehende Benutzernachricht gefunden mit ID:', userMessageId);
} else {
// Speichere die Benutzernachricht nur, wenn sie nicht bereits existiert
userMessageId = await addMessage(conversationId, 'user', userMessage);
console.log('Neue Benutzernachricht gespeichert mit ID:', userMessageId);
}
// Lade alle bisherigen Nachrichten für Kontext // Load all messages for context
const messages = await getMessages(conversationId); const messages = await getMessages(conversationId);
console.log(`${messages.length} Nachrichten für Kontext geladen`); console.log(`${messages.length} Nachrichten für Kontext geladen`);
// Konvertiere die Nachrichten in das Format für die OpenAI-API // Build chat messages for API
const chatMessages: ChatMessage[] = []; const chatMessages: ChatMessage[] = [];
// Lade den System-Prompt aus der Vorlage, falls template_id vorhanden // Load system prompt from template if available
let systemPrompt: string | null = null; let systemPrompt: string | null = null;
if (templateId) { if (templateId) {
systemPrompt = await getSystemPromptFromTemplate(templateId); systemPrompt = await getSystemPromptFromTemplate(templateId);
if (systemPrompt) { if (systemPrompt) {
console.log('System-Prompt aus Vorlage geladen'); console.log('System-Prompt aus Vorlage geladen');
// Wenn Dokumentmodus aktiv ist, füge spezielle Anweisungen hinzu
if (documentMode) { if (documentMode) {
const documentModePrompt = ` const documentModePrompt = `
${systemPrompt} ${systemPrompt}
@ -333,23 +311,13 @@ CHAT: Hier antwortest du auf die Frage oder das Feedback des Nutzers.
DOKUMENT: DOKUMENT:
Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-Markierungen. Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-Markierungen.
`; `;
// Ersetze den Original-Prompt durch den Dokumentmodus-Prompt chatMessages.push({ role: 'system', content: documentModePrompt });
chatMessages.push({
role: 'system',
content: documentModePrompt
});
console.log('Dokumentmodus-Prompt hinzugefügt'); console.log('Dokumentmodus-Prompt hinzugefügt');
} else { } else {
// Standard-Prompt ohne Dokumentmodus chatMessages.push({ role: 'system', content: systemPrompt });
chatMessages.push({
role: 'system',
content: systemPrompt
});
} }
} }
} else if (documentMode) { } else if (documentMode) {
// Wenn kein Template, aber Dokumentmodus aktiv ist
const documentModePrompt = ` const documentModePrompt = `
Du befindest dich im Dokumentmodus. Deine Aufgabe ist es, dem Benutzer zu helfen, ein Dokument zu erstellen und zu verbessern. Du befindest dich im Dokumentmodus. Deine Aufgabe ist es, dem Benutzer zu helfen, ein Dokument zu erstellen und zu verbessern.
@ -364,225 +332,168 @@ CHAT: Hier antwortest du auf die Frage oder das Feedback des Nutzers.
DOKUMENT: DOKUMENT:
Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-Markierungen. Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-Markierungen.
`; `;
chatMessages.push({ chatMessages.push({ role: 'system', content: documentModePrompt });
role: 'system',
content: documentModePrompt
});
console.log('Standard-Dokumentmodus-Prompt hinzugefügt'); console.log('Standard-Dokumentmodus-Prompt hinzugefügt');
} }
// Füge eine System-Nachricht mit der Modell-ID hinzu, falls ein Modell geladen wurde // Add all conversation messages
if (modelData && modelData.parameters && modelData.parameters.deployment) { chatMessages.push(
console.log(`Nutze deployment '${modelData.parameters.deployment}' für Modell ${modelData.name}`); ...messages.map((msg) => ({
// Stelle die Modell-Identifikation ganz am Anfang ein role: msg.sender === 'user' ? 'user' : msg.sender === 'assistant' ? 'assistant' : 'system',
chatMessages.unshift({ content: msg.message_text,
role: 'system', })) as ChatMessage[]
content: `MODEL:${modelData.parameters.deployment}` );
});
} else {
// Versuche, das Deployment über die Modell-ID zu finden
console.warn('Kein Modell-Deployment in Modell-Daten gefunden, suche in verfügbaren Modellen');
// Lade dynamisch die Modelle
try {
const { data: availableModels } = await supabase
.from('models')
.select('id, parameters, name');
const matchingModel = availableModels?.find(m => m.id === modelId);
if (matchingModel && matchingModel.parameters && matchingModel.parameters.deployment) {
console.log(`Nutze deployment '${matchingModel.parameters.deployment}' für Modell ${matchingModel.name}`);
chatMessages.unshift({
role: 'system',
content: `MODEL:${matchingModel.parameters.deployment}`
});
} else {
console.warn('Kein passendes Modell-Deployment gefunden, verwende Standard-Deployment');
}
} catch (error) {
console.error('Fehler beim Laden der verfügbaren Modelle:', error);
}
}
// Füge alle normalen Nachrichten hinzu
chatMessages.push(...messages.map(msg => ({
role: msg.sender === 'user' ? 'user' : msg.sender === 'assistant' ? 'assistant' : 'system',
content: msg.message_text
})));
console.log('Nachrichten für OpenAI konvertiert:', chatMessages.length, 'Nachrichten'); console.log('Nachrichten für OpenAI konvertiert:', chatMessages.length, 'Nachrichten');
// Sende die Anfrage an das LLM-Modell // Send request to AI
console.log('Sende Anfrage an LLM-Modell mit Parametern:', { console.log('Sende Anfrage an LLM-Modell mit Parametern:', {
temperature: modelData?.parameters?.temperature || 0.7, temperature: modelData?.parameters?.temperature || 0.7,
maxTokens: modelData?.parameters?.max_tokens || 800 maxTokens: modelData?.parameters?.max_tokens || 800,
}); });
const result = await sendChatRequest( const result = await chatApi.createCompletion({
chatMessages, messages: chatMessages,
modelData?.parameters?.temperature || 0.7, modelId,
modelData?.parameters?.max_tokens || 800 temperature: modelData?.parameters?.temperature || 0.7,
); maxTokens: modelData?.parameters?.max_tokens || 800,
});
// Extrahiere die Antwort und Token-Nutzung aus dem Ergebnis
let assistantResponse: string; let assistantResponse: string;
let tokenUsage; let tokenUsage: TokenUsage | undefined;
if (typeof result === 'string') { if (!result) {
// Falls nur ein String zurückgegeben wurde (Fehlerfall) assistantResponse =
assistantResponse = result; 'Es konnte keine Antwort generiert werden. Bitte stelle sicher, dass das Backend läuft.';
console.log('Einfache String-Antwort vom LLM-Modell erhalten (kein Tokennutzungs-Tracking):', {
length: assistantResponse.length,
preview: assistantResponse.substring(0, 50) + (assistantResponse.length > 50 ? '...' : '')
});
} else { } else {
// Bei vollständigem Ergebnis mit Token-Nutzung
assistantResponse = result.content; assistantResponse = result.content;
tokenUsage = result.usage; tokenUsage = result.usage;
console.log('Antwort vom LLM-Modell erhalten:', { console.log('Antwort vom LLM-Modell erhalten:', {
length: assistantResponse.length, length: assistantResponse.length,
preview: assistantResponse.substring(0, 50) + (assistantResponse.length > 50 ? '...' : ''), preview: assistantResponse.substring(0, 50) + (assistantResponse.length > 50 ? '...' : ''),
tokenUsage tokenUsage,
}); });
} }
// Dokumentinhalt extrahieren, wenn im Dokumentmodus // Extract document content if in document mode
let documentContent: string | undefined; let documentContent: string | undefined;
let chatResponse = assistantResponse; let chatResponse = assistantResponse;
if (documentMode) { if (documentMode) {
// Nach dem Format "CHAT: ... DOKUMENT: ```markdown ... ```" suchen console.log(
console.log("Analysiere LLM-Antwort für Dokumentextraktion:", assistantResponse.substring(0, 200) + "..."); 'Analysiere LLM-Antwort für Dokumentextraktion:',
assistantResponse.substring(0, 200) + '...'
);
const chatMatch = assistantResponse.match(/CHAT:(.*?)(?=DOKUMENT:|$)/s); const chatMatch = assistantResponse.match(/CHAT:(.*?)(?=DOKUMENT:|$)/s);
const documentMatch = assistantResponse.match(/DOKUMENT:[\s\n]*(```markdown|```|`markdown)?([^`].*?)(?:```|`+)?$/s); const documentMatch = assistantResponse.match(
/DOKUMENT:[\s\n]*(```markdown|```|`markdown)?([^`].*?)(?:```|`+)?$/s
console.log("Dokument-Regex Match:", documentMatch ? "Ja" : "Nein"); );
console.log('Dokument-Regex Match:', documentMatch ? 'Ja' : 'Nein');
if (chatMatch && chatMatch[1]) { if (chatMatch && chatMatch[1]) {
chatResponse = chatMatch[1].trim(); chatResponse = chatMatch[1].trim();
console.log('Chat-Antwort extrahiert:', chatResponse.substring(0, 50) + '...'); console.log('Chat-Antwort extrahiert:', chatResponse.substring(0, 50) + '...');
} }
if (documentMatch && documentMatch[2]) { if (documentMatch && documentMatch[2]) {
documentContent = documentMatch[2].trim(); documentContent = documentMatch[2].trim();
console.log('Dokument-Inhalt extrahiert:', (documentContent?.substring(0, 50) || '') + '...'); console.log(
} else if (documentMatch) { 'Dokument-Inhalt extrahiert:',
console.log('Dokument-Match gefunden, aber kein Inhalt in Gruppe 2'); (documentContent?.substring(0, 50) || '') + '...'
console.log('Dokument-Match-Gruppen:', documentMatch.length); );
for (let i = 0; i < documentMatch.length; i++) {
console.log(`Gruppe ${i}:`, documentMatch[i]?.substring(0, 30));
}
} }
} }
// Speichere die Antwort des Assistenten // Save assistant message
const assistantMessageId = await addMessage(conversationId, 'assistant', chatResponse); const assistantMessageId = await addMessage(conversationId, 'assistant', chatResponse);
console.log('Assistentenantwort gespeichert mit ID:', assistantMessageId); console.log('Assistentenantwort gespeichert mit ID:', assistantMessageId);
// Token-Nutzung loggen, falls verfügbar // Log token usage if available
if (tokenUsage && assistantMessageId && userMessageId && modelData) { if (tokenUsage && assistantMessageId && userMessageId) {
try { try {
// Lade die Konversation, um die user_id zu erhalten const estimatedCost = calculateTokenCost(
const { data: conversationData, error: convError } = await supabase tokenUsage.prompt_tokens,
.from('conversations') tokenUsage.completion_tokens,
.select('user_id') modelData?.costSettings
.eq('id', conversationId) );
.single();
await usageApi.logTokenUsage({
if (convError || !conversationData) { conversationId,
console.error('Fehler beim Laden der Konversation für Token-Logging:', convError); messageId: assistantMessageId,
} else { modelId,
const userId = conversationData.user_id; promptTokens: tokenUsage.prompt_tokens,
completionTokens: tokenUsage.completion_tokens,
// Logge die Token-Nutzung totalTokens: tokenUsage.total_tokens,
await logTokenUsage( estimatedCost,
tokenUsage, });
conversationId, console.log('Token-Nutzung erfolgreich geloggt');
assistantMessageId,
userId,
modelId
);
console.log('Token-Nutzung erfolgreich geloggt');
}
} catch (error) { } catch (error) {
console.error('Fehler beim Loggen der Token-Nutzung:', error); console.error('Fehler beim Loggen der Token-Nutzung:', error);
// Wir werfen keinen Fehler, da das Token-Logging nicht kritisch ist
} }
} }
// Prüfe, ob dies die erste Nachricht in der Konversation ist // Generate title for new conversations
// Wenn ja, generiere einen Titel und aktualisiere die Konversation const allMessages = await getMessages(conversationId);
const { count } = await supabase let title: string | undefined;
.from('messages')
.select('id', { count: 'exact', head: true }) if (allMessages.length <= 2) {
.eq('conversation_id', conversationId);
let title;
// Nur für die erste oder zweite Nachricht (die erste könnte eine System-Nachricht sein)
if (count === 1 || count === 2) {
// Generiere einen Titel basierend auf der Benutzernachricht
title = await generateConversationTitle(userMessage); title = await generateConversationTitle(userMessage);
// Aktualisiere den Titel in der Datenbank if (title) {
const success = await updateConversationTitle(conversationId, title); const success = await updateConversationTitle(conversationId, title);
console.log('Konversationstitel aktualisiert:', success ? 'erfolgreich' : 'fehlgeschlagen'); console.log('Konversationstitel aktualisiert:', success ? 'erfolgreich' : 'fehlgeschlagen');
}
} }
return { return {
userMessageId, userMessageId,
assistantMessageId, assistantMessageId,
assistantResponse: chatResponse, assistantResponse: chatResponse,
title, title,
documentContent documentContent,
}; };
} catch (error) { } catch (error) {
console.error('Fehler beim Senden der Nachricht:', error); console.error('Fehler beim Senden der Nachricht:', error);
// Detaillierte Fehlerinformationen ausgeben
if (error instanceof Error) { if (error instanceof Error) {
console.error('Fehlerdetails:', { console.error('Fehlerdetails:', {
name: error.name, name: error.name,
message: error.message, message: error.message,
stack: error.stack stack: error.stack,
}); });
} }
return { return {
userMessageId: null, userMessageId: null,
assistantMessageId: null, assistantMessageId: null,
assistantResponse: `Es ist ein Fehler aufgetreten: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}. Bitte versuche es später erneut.` assistantResponse: `Es ist ein Fehler aufgetreten: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}. Bitte versuche es später erneut.`,
}; };
} }
} }
// Helper function to calculate token cost
function calculateTokenCost(
promptTokens: number,
completionTokens: number,
costSettings?: { prompt_per_1k_tokens?: number; completion_per_1k_tokens?: number }
): number {
const promptCost = costSettings?.prompt_per_1k_tokens || 0.0001;
const completionCost = costSettings?.completion_per_1k_tokens || 0.0002;
const cost = (promptTokens * promptCost + completionTokens * completionCost) / 1000;
return Number(cost.toFixed(6));
}
/** /**
* Lädt alle aktiven (nicht archivierten) Konversationen eines Benutzers * Lädt alle aktiven (nicht archivierten) Konversationen eines Benutzers
* Optional: Mit spaceId werden nur Konversationen aus diesem Space geladen
*/ */
export async function getConversations(userId: string, spaceId?: string): Promise<Conversation[]> { export async function getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
try { try {
let query = supabase const conversations = await conversationApi.getConversations(spaceId);
.from('conversations') return conversations.map(toLocalConversation);
.select('*')
.eq('user_id', userId)
.eq('is_archived', false);
// Wenn eine Space-ID angegeben wurde, filtere nach diesem Space
if (spaceId) {
query = query.eq('space_id', spaceId);
}
const { data, error } = await query.order('updated_at', { ascending: false });
if (error) {
console.error('Fehler beim Laden der Konversationen:', error);
return [];
}
return data as Conversation[];
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Konversationen:', error); console.error('Fehler beim Laden der Konversationen:', error);
return []; return [];
@ -594,19 +505,8 @@ export async function getConversations(userId: string, spaceId?: string): Promis
*/ */
export async function getArchivedConversations(userId: string): Promise<Conversation[]> { export async function getArchivedConversations(userId: string): Promise<Conversation[]> {
try { try {
const { data, error } = await supabase const conversations = await conversationApi.getArchivedConversations();
.from('conversations') return conversations.map(toLocalConversation);
.select('*')
.eq('user_id', userId)
.eq('is_archived', true)
.order('updated_at', { ascending: false });
if (error) {
console.error('Fehler beim Laden der archivierten Konversationen:', error);
return [];
}
return data as Conversation[];
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der archivierten Konversationen:', error); console.error('Fehler beim Laden der archivierten Konversationen:', error);
return []; return [];
@ -618,17 +518,7 @@ export async function getArchivedConversations(userId: string): Promise<Conversa
*/ */
export async function archiveConversation(conversationId: string): Promise<boolean> { export async function archiveConversation(conversationId: string): Promise<boolean> {
try { try {
const { error } = await supabase return await conversationApi.archiveConversation(conversationId);
.from('conversations')
.update({ is_archived: true })
.eq('id', conversationId);
if (error) {
console.error('Fehler beim Archivieren der Konversation:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Fehler beim Archivieren der Konversation:', error); console.error('Fehler beim Archivieren der Konversation:', error);
return false; return false;
@ -640,17 +530,7 @@ export async function archiveConversation(conversationId: string): Promise<boole
*/ */
export async function unarchiveConversation(conversationId: string): Promise<boolean> { export async function unarchiveConversation(conversationId: string): Promise<boolean> {
try { try {
const { error } = await supabase return await conversationApi.unarchiveConversation(conversationId);
.from('conversations')
.update({ is_archived: false })
.eq('id', conversationId);
if (error) {
console.error('Fehler beim Wiederherstellen der Konversation:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Fehler beim Wiederherstellen der Konversation:', error); console.error('Fehler beim Wiederherstellen der Konversation:', error);
return false; return false;
@ -662,29 +542,7 @@ export async function unarchiveConversation(conversationId: string): Promise<boo
*/ */
export async function deleteConversation(conversationId: string): Promise<boolean> { export async function deleteConversation(conversationId: string): Promise<boolean> {
try { try {
// Lösche zuerst alle zugehörigen Nachrichten return await conversationApi.deleteConversation(conversationId);
const { error: messagesError } = await supabase
.from('messages')
.delete()
.eq('conversation_id', conversationId);
if (messagesError) {
console.error('Fehler beim Löschen der Nachrichten:', messagesError);
return false;
}
// Lösche dann die Konversation selbst
const { error: conversationError } = await supabase
.from('conversations')
.delete()
.eq('id', conversationId);
if (conversationError) {
console.error('Fehler beim Löschen der Konversation:', conversationError);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Fehler beim Löschen der Konversation:', error); console.error('Fehler beim Löschen der Konversation:', error);
return false; return false;

View file

@ -1,9 +1,9 @@
/** /**
* Document-Service für die Verwaltung von Dokumenten im Dokumentmodus * Document Service - CRUD operations via Backend API
*/ */
import { supabase } from '../utils/supabase'; import { documentApi, type Document as ApiDocument } from './api';
// Typdefinition für ein Dokument // Re-export type with backwards-compatible naming (snake_case for mobile)
export interface Document { export interface Document {
id: string; id: string;
conversation_id: string; conversation_id: string;
@ -13,37 +13,39 @@ export interface Document {
updated_at: string; updated_at: string;
} }
// Helper to convert API response to local format
function toLocalDocument(doc: ApiDocument): Document {
return {
id: doc.id,
conversation_id: doc.conversationId,
version: doc.version,
content: doc.content,
created_at: doc.createdAt,
updated_at: doc.updatedAt,
};
}
/** /**
* Erstellt ein neues Dokument in einer Konversation * Erstellt ein neues Dokument in einer Konversation
* @param conversationId Die ID der Konversation
* @param content Der Inhalt des Dokuments
* @returns Das erstellte Dokument oder null bei Fehler
*/ */
export async function createDocument( export async function createDocument(
conversationId: string, conversationId: string,
content: string content: string
): Promise<Document | null> { ): Promise<Document | null> {
try { try {
console.log(`Erstelle Dokument für Konversation ${conversationId} mit Inhalt: ${content.substring(0, 50)}...`); console.log(
`Erstelle Dokument für Konversation ${conversationId} mit Inhalt: ${content.substring(0, 50)}...`
const { data, error } = await supabase );
.from('documents')
.insert({
conversation_id: conversationId,
version: 1, // Initiale Version ist immer 1
content,
})
.select()
.single();
if (error) { const document = await documentApi.createDocument(conversationId, content);
console.error('Fehler beim Erstellen des Dokuments:', error);
console.error('Vollständiger Fehler:', JSON.stringify(error)); if (!document) {
console.error('Fehler beim Erstellen des Dokuments');
return null; return null;
} }
console.log('Dokument erfolgreich erstellt:', data); console.log('Dokument erfolgreich erstellt:', document);
return data as Document; return toLocalDocument(document);
} catch (error) { } catch (error) {
console.error('Fehler beim Erstellen des Dokuments:', error); console.error('Fehler beim Erstellen des Dokuments:', error);
if (error instanceof Error) { if (error instanceof Error) {
@ -55,48 +57,20 @@ export async function createDocument(
/** /**
* Erstellt eine neue Version eines Dokuments * Erstellt eine neue Version eines Dokuments
* @param conversationId Die ID der Konversation
* @param content Der neue Inhalt des Dokuments
* @returns Das erstellte Dokument oder null bei Fehler
*/ */
export async function createDocumentVersion( export async function createDocumentVersion(
conversationId: string, conversationId: string,
content: string content: string
): Promise<Document | null> { ): Promise<Document | null> {
try { try {
// Hole die aktuelle höchste Version const document = await documentApi.createDocumentVersion(conversationId, content);
const { data: latestVersionData, error: versionError } = await supabase
.from('documents')
.select('version')
.eq('conversation_id', conversationId)
.order('version', { ascending: false })
.limit(1)
.single();
if (versionError) { if (!document) {
console.error('Fehler beim Laden der letzten Dokumentversion:', versionError); console.error('Fehler beim Erstellen der neuen Dokumentversion');
return null; return null;
} }
const newVersion = (latestVersionData?.version || 0) + 1; return toLocalDocument(document);
// Erstelle eine neue Dokumentversion
const { data, error } = await supabase
.from('documents')
.insert({
conversation_id: conversationId,
version: newVersion,
content,
})
.select()
.single();
if (error) {
console.error('Fehler beim Erstellen der neuen Dokumentversion:', error);
return null;
}
return data as Document;
} catch (error) { } catch (error) {
console.error('Fehler beim Erstellen der neuen Dokumentversion:', error); console.error('Fehler beim Erstellen der neuen Dokumentversion:', error);
return null; return null;
@ -105,32 +79,20 @@ export async function createDocumentVersion(
/** /**
* Holt die aktuellste Version eines Dokuments für eine Konversation * Holt die aktuellste Version eines Dokuments für eine Konversation
* @param conversationId Die ID der Konversation
* @returns Das aktuellste Dokument oder null, wenn nicht gefunden
*/ */
export async function getLatestDocument(conversationId: string): Promise<Document | null> { export async function getLatestDocument(conversationId: string): Promise<Document | null> {
try { try {
console.log(`Lade neuestes Dokument für Konversation ${conversationId}`); console.log(`Lade neuestes Dokument für Konversation ${conversationId}`);
// Einfache Abfrage ohne Cache-Busting
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('conversation_id', conversationId)
.order('version', { ascending: false })
.limit(1)
.single();
if (error) { const document = await documentApi.getLatestDocument(conversationId);
console.error('Fehler beim Laden des aktuellen Dokuments:', error);
if (error.code === 'PGRST116') { if (!document) {
console.log('Kein Dokument gefunden (PGRST116)'); console.log('Kein Dokument gefunden');
}
return null; return null;
} }
console.log(`Neuestes Dokument gefunden: Version ${data.version}, ID ${data.id}`); console.log(`Neuestes Dokument gefunden: Version ${document.version}, ID ${document.id}`);
return data as Document; return toLocalDocument(document);
} catch (error) { } catch (error) {
console.error('Fehler beim Laden des aktuellen Dokuments:', error); console.error('Fehler beim Laden des aktuellen Dokuments:', error);
return null; return null;
@ -139,35 +101,22 @@ export async function getLatestDocument(conversationId: string): Promise<Documen
/** /**
* Lädt alle Versionen eines Dokuments für eine Konversation * Lädt alle Versionen eines Dokuments für eine Konversation
* @param conversationId Die ID der Konversation
* @returns Eine Liste aller Dokumentversionen
*/ */
export async function getAllDocumentVersions(conversationId: string): Promise<Document[]> { export async function getAllDocumentVersions(conversationId: string): Promise<Document[]> {
try { try {
console.log(`Lade alle Dokumentversionen für Konversation ${conversationId}`); console.log(`Lade alle Dokumentversionen für Konversation ${conversationId}`);
// Einfache Abfrage ohne Cache-Busting (das verursacht Probleme)
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('conversation_id', conversationId)
.order('version', { ascending: false });
if (error) { const documents = await documentApi.getAllDocumentVersions(conversationId);
console.error('Fehler beim Laden der Dokumentversionen:', error);
return [];
}
console.log(`${data?.length || 0} Dokumentversionen geladen`); console.log(`${documents.length} Dokumentversionen geladen`);
// Detaillierte Infos zur Fehlersuche if (documents.length > 0) {
if (data && data.length > 0) { console.log(`Erstes Dokument: ID=${documents[0].id}, Version=${documents[0].version}`);
console.log(`Erstes Dokument: ID=${data[0].id}, Version=${data[0].version}`);
} else { } else {
console.log('Keine Dokumente gefunden'); console.log('Keine Dokumente gefunden');
} }
return data as Document[]; return documents.map(toLocalDocument);
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Dokumentversionen:', error); console.error('Fehler beim Laden der Dokumentversionen:', error);
return []; return [];
@ -176,22 +125,10 @@ export async function getAllDocumentVersions(conversationId: string): Promise<Do
/** /**
* Prüft, ob für eine Konversation ein Dokument existiert * Prüft, ob für eine Konversation ein Dokument existiert
* @param conversationId Die ID der Konversation
* @returns true, wenn ein Dokument existiert, sonst false
*/ */
export async function hasDocument(conversationId: string): Promise<boolean> { export async function hasDocument(conversationId: string): Promise<boolean> {
try { try {
const { count, error } = await supabase return await documentApi.hasDocument(conversationId);
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('conversation_id', conversationId);
if (error) {
console.error('Fehler beim Prüfen auf Dokument:', error);
return false;
}
return (count || 0) > 0;
} catch (error) { } catch (error) {
console.error('Fehler beim Prüfen auf Dokument:', error); console.error('Fehler beim Prüfen auf Dokument:', error);
return false; return false;
@ -200,47 +137,20 @@ export async function hasDocument(conversationId: string): Promise<boolean> {
/** /**
* Löscht eine spezifische Dokumentversion * Löscht eine spezifische Dokumentversion
* @param documentId Die ID des zu löschenden Dokuments
* @returns true, wenn erfolgreich gelöscht, sonst false
*/ */
export async function deleteDocumentVersion(documentId: string): Promise<boolean> { export async function deleteDocumentVersion(documentId: string): Promise<boolean> {
try { try {
console.log(`=== LÖSCH-OPERATION GESTARTET FÜR DOKUMENT ID ${documentId} ===`); console.log(`=== LÖSCH-OPERATION GESTARTET FÜR DOKUMENT ID ${documentId} ===`);
// Prüfe zuerst, ob das Dokument existiert const success = await documentApi.deleteDocumentVersion(documentId);
const { data: existingData, error: checkError } = await supabase
.from('documents') if (success) {
.select('id') console.log(`=== DOKUMENT ${documentId} ERFOLGREICH GELÖSCHT ===`);
.eq('id', documentId) } else {
.single(); console.error('Fehler beim Löschen der Dokumentversion');
if (checkError) {
console.error('Fehler beim Prüfen, ob das Dokument existiert:', checkError);
console.error('Details:', JSON.stringify(checkError));
return false;
} }
if (!existingData) { return success;
console.error('Dokument nicht gefunden:', documentId);
return false;
}
console.log('Dokument gefunden, führe Löschung durch...');
// Führe die eigentliche Löschung durch
const { error } = await supabase
.from('documents')
.delete()
.eq('id', documentId);
if (error) {
console.error('Fehler beim Löschen der Dokumentversion:', error);
console.error('Vollständige Fehlermeldung:', JSON.stringify(error));
return false;
}
console.log(`=== DOKUMENT ${documentId} ERFOLGREICH GELÖSCHT ===`);
return true;
} catch (error) { } catch (error) {
console.error('Unerwarteter Fehler beim Löschen der Dokumentversion:', error); console.error('Unerwarteter Fehler beim Löschen der Dokumentversion:', error);
if (error instanceof Error) { if (error instanceof Error) {
@ -248,4 +158,4 @@ export async function deleteDocumentVersion(documentId: string): Promise<boolean
} }
return false; return false;
} }
} }

View file

@ -1,19 +1,20 @@
// Chat Service - AI API Aufrufe werden über das Backend gehandhabt /**
* Chat Service - AI API calls via Backend
* This service wraps the backend API for AI completions
*/
import { availableModels } from '../config/azure'; import { availableModels } from '../config/azure';
import { sendChatRequest as sendChatRequestApi } from '../utils/api'; import { chatApi, modelApi, usageApi, type ChatMessage, type TokenUsage } from './api';
import { supabase } from '../utils/supabase';
// Typdefinition für eine Nachricht // Re-export types for backward compatibility
export type ChatMessage = { export type { ChatMessage };
role: 'system' | 'user' | 'assistant';
content: string;
};
// Typdefinition für die Chat-Antwort vom o3-mini-Modell // Re-export TokenUsage
export type { TokenUsage };
// Chat response type (kept for compatibility)
export type ChatResponse = { export type ChatResponse = {
id: string; id: string;
choices: { choices: {
// Für o3-mini-Modell
content_filter_results?: any; content_filter_results?: any;
finish_reason: string; finish_reason: string;
index: number; index: number;
@ -38,34 +39,20 @@ export type ChatResponse = {
}; };
}; };
// Token-Nutzungsinformationen // Return type for chat request
export type TokenUsage = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
// Rückgabetyp für die Chat-Anfrage
export type ChatRequestResult = { export type ChatRequestResult = {
content: string; content: string;
usage: TokenUsage; usage: TokenUsage;
}; };
// Backend-URL für sichere API-Aufrufe // Logging configuration
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001';
// Logging der Konfiguration
console.log('Chat Service Konfiguration:', { console.log('Chat Service Konfiguration:', {
backendUrl: BACKEND_URL, backendUrl: process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001',
availableModels: availableModels.length, availableModels: availableModels.length,
}); });
/** /**
* Berechnet die geschätzten Kosten einer LLM-Anfrage * Calculates estimated cost for an LLM request
* @param promptTokens Anzahl der Eingabe-Tokens
* @param completionTokens Anzahl der Ausgabe-Tokens
* @param modelId ID des verwendeten Modells
* @returns Geschätzte Kosten in der kleinsten Währungseinheit (z.B. Cent)
*/ */
export async function calculateTokenCost( export async function calculateTokenCost(
promptTokens: number, promptTokens: number,
@ -73,45 +60,31 @@ export async function calculateTokenCost(
modelId: string modelId: string
): Promise<number> { ): Promise<number> {
try { try {
// Hole die Kosteninformationen aus dem Modell // Get cost settings from model
const { data: modelData, error } = await supabase const modelData = await modelApi.getModel(modelId);
.from('models')
.select('cost_settings') if (!modelData || !modelData.costSettings) {
.eq('id', modelId) console.warn('Fehler beim Laden der Kosteninformationen, verwende Standardwerte');
.single(); const promptCost = 0.0001;
const completionCost = 0.0002;
if (error || !modelData || !modelData.cost_settings) {
console.warn('Fehler beim Laden der Kosteninformationen, verwende Standardwerte:', error);
// Standardwerte verwenden
const promptCost = 0.0001; // pro 1K Tokens
const completionCost = 0.0002; // pro 1K Tokens
// Berechne die Kosten
const cost = (promptTokens * promptCost + completionTokens * completionCost) / 1000; const cost = (promptTokens * promptCost + completionTokens * completionCost) / 1000;
return Number(cost.toFixed(6)); return Number(cost.toFixed(6));
} }
// Extrahiere die Kostensätze const promptCost = modelData.costSettings.prompt_per_1k_tokens || 0.0001;
const promptCost = parseFloat(modelData.cost_settings.prompt_per_1k_tokens) || 0.0001; const completionCost = modelData.costSettings.completion_per_1k_tokens || 0.0002;
const completionCost = parseFloat(modelData.cost_settings.completion_per_1k_tokens) || 0.0002;
// Berechne die Kosten
const cost = (promptTokens * promptCost + completionTokens * completionCost) / 1000; const cost = (promptTokens * promptCost + completionTokens * completionCost) / 1000;
return Number(cost.toFixed(6)); return Number(cost.toFixed(6));
} catch (error) { } catch (error) {
console.error('Fehler bei der Kostenberechnung:', error); console.error('Fehler bei der Kostenberechnung:', error);
// Fallback: vereinfachte Berechnung
return Number(((promptTokens * 0.0001 + completionTokens * 0.0002) / 1000).toFixed(6)); return Number(((promptTokens * 0.0001 + completionTokens * 0.0002) / 1000).toFixed(6));
} }
} }
/** /**
* Speichert die Token-Nutzung in der Datenbank * Logs token usage to the database
* @param usage Token-Nutzungsinformationen
* @param conversationId ID der Konversation
* @param messageId ID der Nachricht
* @param userId ID des Benutzers
* @param modelId ID des verwendeten Modells
*/ */
export async function logTokenUsage( export async function logTokenUsage(
usage: TokenUsage, usage: TokenUsage,
@ -121,36 +94,31 @@ export async function logTokenUsage(
modelId: string modelId: string
): Promise<void> { ): Promise<void> {
try { try {
// Berechne die geschätzten Kosten
const estimatedCost = await calculateTokenCost( const estimatedCost = await calculateTokenCost(
usage.prompt_tokens, usage.prompt_tokens,
usage.completion_tokens, usage.completion_tokens,
modelId modelId
); );
// Speichere die Nutzungsinformationen const success = await usageApi.logTokenUsage({
const { error } = await supabase conversationId,
.from('usage_logs') messageId,
.insert({ modelId,
conversation_id: conversationId, promptTokens: usage.prompt_tokens,
message_id: messageId, completionTokens: usage.completion_tokens,
user_id: userId, totalTokens: usage.total_tokens,
model_id: modelId, estimatedCost,
prompt_tokens: usage.prompt_tokens, });
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens, if (success) {
estimated_cost: estimatedCost
});
if (error) {
console.error('Fehler beim Speichern der Token-Nutzung:', error);
} else {
console.log('Token-Nutzung erfolgreich gespeichert:', { console.log('Token-Nutzung erfolgreich gespeichert:', {
conversationId, conversationId,
messageId, messageId,
totalTokens: usage.total_tokens, totalTokens: usage.total_tokens,
estimatedCost estimatedCost,
}); });
} else {
console.error('Fehler beim Speichern der Token-Nutzung');
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Loggen der Token-Nutzung:', error); console.error('Fehler beim Loggen der Token-Nutzung:', error);
@ -158,12 +126,7 @@ export async function logTokenUsage(
} }
/** /**
* Sendet eine Chat-Anfrage über das Backend * Sends a chat request via the backend
* Das Backend handhabt die Azure OpenAI API Aufrufe sicher
* @param messages Array von Nachrichten im Chat
* @param temperature Kreativität der Antwort (0.0 - 1.0)
* @param maxTokens Maximale Anzahl der Tokens in der Antwort
* @returns Die Antwort des LLM-Modells und Tokeninformationen
*/ */
export async function sendChatRequest( export async function sendChatRequest(
messages: ChatMessage[], messages: ChatMessage[],
@ -172,57 +135,66 @@ export async function sendChatRequest(
): Promise<string | ChatRequestResult> { ): Promise<string | ChatRequestResult> {
console.log('sendChatRequest gestartet mit:', { console.log('sendChatRequest gestartet mit:', {
messagesCount: messages.length, messagesCount: messages.length,
maxTokens maxTokens,
}); });
try { try {
// Hole aktuelle Modellparameter aus der Konversation (für Modellwechsel) // Find model deployment from system message
let deployment = ''; let modelId = '550e8400-e29b-41d4-a716-446655440000'; // Default to GPT-O3-Mini
// System-Nachricht mit Modell-Präfix suchen const systemMessage = messages.find(
const systemMessage = messages.find(msg => msg.role === 'system' && msg.content.startsWith('MODEL:')); (msg) => msg.role === 'system' && msg.content.startsWith('MODEL:')
);
if (systemMessage) { if (systemMessage) {
deployment = systemMessage.content.split(':')[1].trim(); const deployment = systemMessage.content.split(':')[1].trim();
console.log('Modell in system Nachricht erkannt:', deployment); console.log('Modell in system Nachricht erkannt:', deployment);
// Map deployment to model ID
const deploymentToModelId: Record<string, string> = {
'gpt-o3-mini-se': '550e8400-e29b-41d4-a716-446655440000',
'gpt-4o-mini-se': '550e8400-e29b-41d4-a716-446655440004',
'gpt-4o-se': '550e8400-e29b-41d4-a716-446655440005',
};
modelId = deploymentToModelId[deployment] || modelId;
} else { } else {
console.warn('Keine System-Nachricht mit MODEL-Präfix gefunden!'); console.warn('Keine System-Nachricht mit MODEL-Präfix gefunden!');
} }
// Falls kein Modell angegeben wurde, setze auf Fallback gpt-o3-mini-se console.log('Verwende Model ID:', modelId);
const deploymentToUse = deployment || 'gpt-o3-mini-se';
console.log('Verwende Deployment:', deploymentToUse);
// Konfiguration für den API-Wrapper (Backend benötigt nur deployment) // Filter out MODEL: system messages before sending to API
const config = { const filteredMessages = messages.filter(
deployment: deploymentToUse (msg) => !(msg.role === 'system' && msg.content.startsWith('MODEL:'))
}; );
// Verwende den zentralen API-Wrapper - dieser ruft das Backend auf // Send request to backend
const result = await sendChatRequestApi(messages, temperature, maxTokens, config); const result = await chatApi.createCompletion({
messages: filteredMessages,
modelId,
temperature,
maxTokens,
});
// Wenn es ein einfacher String ist (Fehlerfall), diesen zurückgeben if (!result) {
if (typeof result === 'string') { return 'Es tut mir leid, aber ich konnte keine Antwort generieren. Bitte stelle sicher, dass das Backend läuft.';
return result;
} }
// Ansonsten die vollständige Antwort mit Token-Nutzung zurückgeben
return { return {
content: result.content, content: result.content,
usage: result.usage usage: result.usage,
}; };
} catch (error) { } catch (error) {
console.error('Fehler bei der Chat-Anfrage:', error); console.error('Fehler bei der Chat-Anfrage:', error);
// Versuche, mehr Informationen über den Fehler zu erhalten
if (error instanceof Error) { if (error instanceof Error) {
console.error('Fehlerdetails:', { console.error('Fehlerdetails:', {
name: error.name, name: error.name,
message: error.message, message: error.message,
stack: error.stack stack: error.stack,
}); });
} }
// Gib eine benutzerfreundliche Fehlermeldung zurück, anstatt den Fehler zu werfen
return `Es tut mir leid, aber ich konnte keine Antwort generieren. Bitte stelle sicher, dass das Backend läuft. Fehlerdetails: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`; return `Es tut mir leid, aber ich konnte keine Antwort generieren. Bitte stelle sicher, dass das Backend läuft. Fehlerdetails: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`;
} }
} }

View file

@ -1,6 +1,9 @@
import { supabase } from '../utils/supabase'; /**
* Space Service - CRUD operations via Backend API
*/
import { spaceApi, type Space as ApiSpace, type SpaceMember as ApiSpaceMember } from './api';
// Type definitions for spaces and members // Re-export types with backwards-compatible naming (snake_case for mobile)
export type Space = { export type Space = {
id: string; id: string;
name: string; name: string;
@ -24,190 +27,140 @@ export type SpaceMember = {
updated_at: string; updated_at: string;
}; };
// Get all spaces for a user (both owned and member of) // Helper to convert API response to local format
function toLocalSpace(space: ApiSpace): Space {
return {
id: space.id,
name: space.name,
description: space.description,
owner_id: space.ownerId,
created_at: space.createdAt,
updated_at: space.updatedAt,
is_archived: space.isArchived,
};
}
function toLocalSpaceMember(member: ApiSpaceMember): SpaceMember {
return {
id: member.id,
space_id: member.spaceId,
user_id: member.userId,
role: member.role,
invitation_status: member.invitationStatus,
invited_by: member.invitedBy,
invited_at: member.invitedAt,
joined_at: member.joinedAt,
created_at: member.createdAt,
updated_at: member.updatedAt,
};
}
/**
* Get all spaces for a user (both owned and member of)
*/
export async function getUserSpaces(userId: string): Promise<Space[]> { export async function getUserSpaces(userId: string): Promise<Space[]> {
try { try {
const { data: memberData, error: memberError } = await supabase const spaces = await spaceApi.getUserSpaces();
.from('space_members') return spaces.map(toLocalSpace);
.select(`
space_id,
role,
invitation_status
`)
.eq('user_id', userId)
.eq('invitation_status', 'accepted');
if (memberError) {
console.error('Error fetching user space memberships:', memberError);
return [];
}
if (!memberData || memberData.length === 0) {
return [];
}
// Get space IDs the user is a member of
const spaceIds = memberData.map(m => m.space_id);
// Fetch the actual space data
const { data: spaces, error: spacesError } = await supabase
.from('spaces')
.select('*')
.in('id', spaceIds)
.eq('is_archived', false)
.order('created_at', { ascending: false });
if (spacesError) {
console.error('Error fetching spaces:', spacesError);
return [];
}
return spaces as Space[];
} catch (error) { } catch (error) {
console.error('Error in getUserSpaces:', error); console.error('Error in getUserSpaces:', error);
return []; return [];
} }
} }
// Get spaces the user owns /**
* Get spaces the user owns
*/
export async function getOwnedSpaces(userId: string): Promise<Space[]> { export async function getOwnedSpaces(userId: string): Promise<Space[]> {
try { try {
const { data, error } = await supabase const spaces = await spaceApi.getOwnedSpaces();
.from('spaces') return spaces.map(toLocalSpace);
.select('*')
.eq('owner_id', userId)
.eq('is_archived', false)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching owned spaces:', error);
return [];
}
return data as Space[];
} catch (error) { } catch (error) {
console.error('Error in getOwnedSpaces:', error); console.error('Error in getOwnedSpaces:', error);
return []; return [];
} }
} }
// Get a single space by ID /**
* Get a single space by ID
*/
export async function getSpace(spaceId: string): Promise<Space | null> { export async function getSpace(spaceId: string): Promise<Space | null> {
try { try {
const { data, error } = await supabase const space = await spaceApi.getSpace(spaceId);
.from('spaces') if (!space) {
.select('*')
.eq('id', spaceId)
.single();
if (error) {
console.error('Error fetching space:', error);
return null; return null;
} }
return toLocalSpace(space);
return data as Space;
} catch (error) { } catch (error) {
console.error('Error in getSpace:', error); console.error('Error in getSpace:', error);
return null; return null;
} }
} }
// Create a new space /**
* Create a new space
*/
export async function createSpace( export async function createSpace(
userId: string, userId: string,
name: string, name: string,
description?: string description?: string
): Promise<string | null> { ): Promise<string | null> {
try { try {
const { data, error } = await supabase const space = await spaceApi.createSpace(name, description);
.from('spaces') return space?.id || null;
.insert({
name,
description,
owner_id: userId
})
.select('id')
.single();
if (error) {
console.error('Error creating space:', error);
return null;
}
return data.id;
} catch (error) { } catch (error) {
console.error('Error in createSpace:', error); console.error('Error in createSpace:', error);
return null; return null;
} }
} }
// Update a space /**
* Update a space
*/
export async function updateSpace( export async function updateSpace(
spaceId: string, spaceId: string,
updates: { name?: string; description?: string; is_archived?: boolean } updates: { name?: string; description?: string; is_archived?: boolean }
): Promise<boolean> { ): Promise<boolean> {
try { try {
const { error } = await supabase return await spaceApi.updateSpace(spaceId, {
.from('spaces') name: updates.name,
.update(updates) description: updates.description,
.eq('id', spaceId); isArchived: updates.is_archived,
});
if (error) {
console.error('Error updating space:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Error in updateSpace:', error); console.error('Error in updateSpace:', error);
return false; return false;
} }
} }
// Delete a space /**
* Delete a space
*/
export async function deleteSpace(spaceId: string): Promise<boolean> { export async function deleteSpace(spaceId: string): Promise<boolean> {
try { try {
// Delete the space (members will be cascade deleted due to foreign key constraint) return await spaceApi.deleteSpace(spaceId);
const { error } = await supabase
.from('spaces')
.delete()
.eq('id', spaceId);
if (error) {
console.error('Error deleting space:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Error in deleteSpace:', error); console.error('Error in deleteSpace:', error);
return false; return false;
} }
} }
// Get members of a space /**
* Get members of a space
*/
export async function getSpaceMembers(spaceId: string): Promise<SpaceMember[]> { export async function getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
try { try {
const { data, error } = await supabase const members = await spaceApi.getSpaceMembers(spaceId);
.from('space_members') return members.map(toLocalSpaceMember);
.select('*')
.eq('space_id', spaceId)
.order('role', { ascending: true })
.order('joined_at', { ascending: false });
if (error) {
console.error('Error fetching space members:', error);
return [];
}
return data as SpaceMember[];
} catch (error) { } catch (error) {
console.error('Error in getSpaceMembers:', error); console.error('Error in getSpaceMembers:', error);
return []; return [];
} }
} }
// Add a member to a space /**
* Add a member to a space (invite)
*/
export async function inviteUserToSpace( export async function inviteUserToSpace(
spaceId: string, spaceId: string,
userId: string, userId: string,
@ -215,231 +168,86 @@ export async function inviteUserToSpace(
role: 'admin' | 'member' | 'viewer' = 'member' role: 'admin' | 'member' | 'viewer' = 'member'
): Promise<boolean> { ): Promise<boolean> {
try { try {
// Check if user is already a member return await spaceApi.inviteUser(spaceId, userId, role);
const { data: existingMember, error: checkError } = await supabase
.from('space_members')
.select('id, invitation_status')
.eq('space_id', spaceId)
.eq('user_id', userId)
.maybeSingle();
if (checkError) {
console.error('Error checking existing membership:', checkError);
return false;
}
// If already a member with accepted status, just return true
if (existingMember && existingMember.invitation_status === 'accepted') {
return true;
}
// If there's a pending or declined invitation, update it
if (existingMember) {
const { error: updateError } = await supabase
.from('space_members')
.update({
role,
invitation_status: 'pending',
invited_by: invitedByUserId,
invited_at: new Date().toISOString()
})
.eq('id', existingMember.id);
if (updateError) {
console.error('Error updating invitation:', updateError);
return false;
}
return true;
}
// Otherwise, create a new invitation
const { error: insertError } = await supabase
.from('space_members')
.insert({
space_id: spaceId,
user_id: userId,
role,
invited_by: invitedByUserId,
invitation_status: 'pending'
});
if (insertError) {
console.error('Error inviting user to space:', insertError);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Error in inviteUserToSpace:', error); console.error('Error in inviteUserToSpace:', error);
return false; return false;
} }
} }
// Accept or decline a space invitation /**
* Accept or decline a space invitation
*/
export async function respondToInvitation( export async function respondToInvitation(
spaceId: string, spaceId: string,
userId: string, userId: string,
status: 'accepted' | 'declined' status: 'accepted' | 'declined'
): Promise<boolean> { ): Promise<boolean> {
try { try {
const updates: any = { return await spaceApi.respondToInvitation(spaceId, status);
invitation_status: status
};
// If accepting, set the joined_at timestamp
if (status === 'accepted') {
updates.joined_at = new Date().toISOString();
}
const { error } = await supabase
.from('space_members')
.update(updates)
.eq('space_id', spaceId)
.eq('user_id', userId);
if (error) {
console.error('Error responding to invitation:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Error in respondToInvitation:', error); console.error('Error in respondToInvitation:', error);
return false; return false;
} }
} }
// Remove a member from a space /**
* Remove a member from a space
*/
export async function removeMember(spaceId: string, userId: string): Promise<boolean> { export async function removeMember(spaceId: string, userId: string): Promise<boolean> {
try { try {
const { error } = await supabase return await spaceApi.removeMember(spaceId, userId);
.from('space_members')
.delete()
.eq('space_id', spaceId)
.eq('user_id', userId);
if (error) {
console.error('Error removing member:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Error in removeMember:', error); console.error('Error in removeMember:', error);
return false; return false;
} }
} }
// Change a member's role /**
* Change a member's role
*/
export async function changeMemberRole( export async function changeMemberRole(
spaceId: string, spaceId: string,
userId: string, userId: string,
newRole: 'admin' | 'member' | 'viewer' newRole: 'admin' | 'member' | 'viewer'
): Promise<boolean> { ): Promise<boolean> {
try { try {
const { error } = await supabase return await spaceApi.changeMemberRole(spaceId, userId, newRole);
.from('space_members')
.update({ role: newRole })
.eq('space_id', spaceId)
.eq('user_id', userId);
if (error) {
console.error('Error changing member role:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Error in changeMemberRole:', error); console.error('Error in changeMemberRole:', error);
return false; return false;
} }
} }
// Get user's role in a space /**
* Get user's role in a space
*/
export async function getUserRoleInSpace( export async function getUserRoleInSpace(
spaceId: string, spaceId: string,
userId: string userId: string
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> { ): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
try { try {
// First check if they're the owner return await spaceApi.getUserRoleInSpace(spaceId);
const { data: space, error: spaceError } = await supabase
.from('spaces')
.select('owner_id')
.eq('id', spaceId)
.single();
if (spaceError) {
console.error('Error checking space ownership:', spaceError);
return null;
}
if (space.owner_id === userId) {
return 'owner';
}
// If not owner, check membership
const { data: member, error: memberError } = await supabase
.from('space_members')
.select('role, invitation_status')
.eq('space_id', spaceId)
.eq('user_id', userId)
.single();
if (memberError) {
// This could mean they're not a member, which is fine
return null;
}
// Only return role if invitation is accepted
if (member && member.invitation_status === 'accepted') {
return member.role as 'admin' | 'member' | 'viewer';
}
return null;
} catch (error) { } catch (error) {
console.error('Error in getUserRoleInSpace:', error); console.error('Error in getUserRoleInSpace:', error);
return null; return null;
} }
} }
// Get pending space invitations for a user /**
export async function getPendingInvitations(userId: string): Promise<Array<{ * Get pending space invitations for a user
invitation: SpaceMember; */
space: Space; export async function getPendingInvitations(
}>> { userId: string
): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
try { try {
const { data, error } = await supabase const invitations = await spaceApi.getPendingInvitations();
.from('space_members') return invitations.map((inv) => ({
.select(` invitation: toLocalSpaceMember(inv.invitation),
*, space: toLocalSpace(inv.space),
space:space_id (*)
`)
.eq('user_id', userId)
.eq('invitation_status', 'pending');
if (error) {
console.error('Error fetching pending invitations:', error);
return [];
}
return data.map(item => ({
invitation: {
id: item.id,
space_id: item.space_id,
user_id: item.user_id,
role: item.role,
invitation_status: item.invitation_status,
invited_by: item.invited_by,
invited_at: item.invited_at,
joined_at: item.joined_at,
created_at: item.created_at,
updated_at: item.updated_at
},
space: item.space
})); }));
} catch (error) { } catch (error) {
console.error('Error in getPendingInvitations:', error); console.error('Error in getPendingInvitations:', error);
return []; return [];
} }
} }

View file

@ -1,9 +1,9 @@
/** /**
* Template-Service für Chat-Vorlagen * Template Service - CRUD operations via Backend API
*/ */
import { supabase } from '../utils/supabase'; import { templateApi, type Template as ApiTemplate } from './api';
// Typdefinition für eine Vorlage // Re-export type with backwards-compatible naming (snake_case for mobile)
export interface Template { export interface Template {
id: string; id: string;
user_id: string; user_id: string;
@ -19,25 +19,31 @@ export interface Template {
updated_at: string; updated_at: string;
} }
// Helper to convert API response to local format
function toLocalTemplate(template: ApiTemplate): Template {
return {
id: template.id,
user_id: template.userId,
name: template.name,
description: template.description || null,
system_prompt: template.systemPrompt,
initial_question: template.initialQuestion || null,
model_id: template.modelId || null,
color: template.color,
is_default: template.isDefault,
document_mode: template.documentMode,
created_at: template.createdAt,
updated_at: template.updatedAt,
};
}
/** /**
* Lädt alle Vorlagen eines Benutzers * Lädt alle Vorlagen eines Benutzers
* @param userId Die ID des Benutzers
* @returns Liste der Vorlagen
*/ */
export async function getTemplates(userId: string): Promise<Template[]> { export async function getTemplates(userId: string): Promise<Template[]> {
try { try {
const { data, error } = await supabase const templates = await templateApi.getTemplates();
.from('templates') return templates.map(toLocalTemplate);
.select('*')
.eq('user_id', userId)
.order('name');
if (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
return [];
}
return data as Template[];
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error); console.error('Fehler beim Laden der Vorlagen:', error);
return []; return [];
@ -46,23 +52,14 @@ export async function getTemplates(userId: string): Promise<Template[]> {
/** /**
* Lädt eine bestimmte Vorlage anhand ihrer ID * Lädt eine bestimmte Vorlage anhand ihrer ID
* @param templateId Die ID der Vorlage
* @returns Die Vorlage oder null, wenn nicht gefunden
*/ */
export async function getTemplateById(templateId: string): Promise<Template | null> { export async function getTemplateById(templateId: string): Promise<Template | null> {
try { try {
const { data, error } = await supabase const template = await templateApi.getTemplate(templateId);
.from('templates') if (!template) {
.select('*')
.eq('id', templateId)
.single();
if (error) {
console.error('Fehler beim Laden der Vorlage:', error);
return null; return null;
} }
return toLocalTemplate(template);
return data as Template;
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Vorlage:', error); console.error('Fehler beim Laden der Vorlage:', error);
return null; return null;
@ -71,23 +68,27 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
/** /**
* Erstellt eine neue Vorlage * Erstellt eine neue Vorlage
* @param template Die zu erstellende Vorlage (ohne ID)
* @returns Die erstellte Vorlage mit ID oder null bei Fehler
*/ */
export async function createTemplate(template: Omit<Template, 'id' | 'created_at' | 'updated_at'>): Promise<Template | null> { export async function createTemplate(
template: Omit<Template, 'id' | 'created_at' | 'updated_at'>
): Promise<Template | null> {
try { try {
const { data, error } = await supabase const result = await templateApi.createTemplate({
.from('templates') name: template.name,
.insert(template) description: template.description || undefined,
.select() systemPrompt: template.system_prompt,
.single(); initialQuestion: template.initial_question || undefined,
modelId: template.model_id || undefined,
color: template.color,
documentMode: template.document_mode,
});
if (error) { if (!result) {
console.error('Fehler beim Erstellen der Vorlage:', error); console.error('Fehler beim Erstellen der Vorlage');
return null; return null;
} }
return data as Template; return toLocalTemplate(result);
} catch (error) { } catch (error) {
console.error('Fehler beim Erstellen der Vorlage:', error); console.error('Fehler beim Erstellen der Vorlage:', error);
return null; return null;
@ -96,26 +97,25 @@ export async function createTemplate(template: Omit<Template, 'id' | 'created_at
/** /**
* Aktualisiert eine bestehende Vorlage * Aktualisiert eine bestehende Vorlage
* @param templateId Die ID der zu aktualisierenden Vorlage
* @param updates Die zu aktualisierenden Felder
* @returns true bei Erfolg, false bei Fehler
*/ */
export async function updateTemplate( export async function updateTemplate(
templateId: string, templateId: string,
updates: Partial<Omit<Template, 'id' | 'user_id' | 'created_at' | 'updated_at'>> updates: Partial<Omit<Template, 'id' | 'user_id' | 'created_at' | 'updated_at'>>
): Promise<boolean> { ): Promise<boolean> {
try { try {
const { error } = await supabase const apiUpdates: Parameters<typeof templateApi.updateTemplate>[1] = {};
.from('templates')
.update(updates)
.eq('id', templateId);
if (error) { if (updates.name !== undefined) apiUpdates.name = updates.name;
console.error('Fehler beim Aktualisieren der Vorlage:', error); if (updates.description !== undefined)
return false; apiUpdates.description = updates.description || undefined;
} if (updates.system_prompt !== undefined) apiUpdates.systemPrompt = updates.system_prompt;
if (updates.initial_question !== undefined)
apiUpdates.initialQuestion = updates.initial_question || undefined;
if (updates.model_id !== undefined) apiUpdates.modelId = updates.model_id || undefined;
if (updates.color !== undefined) apiUpdates.color = updates.color;
if (updates.document_mode !== undefined) apiUpdates.documentMode = updates.document_mode;
return true; return await templateApi.updateTemplate(templateId, apiUpdates);
} catch (error) { } catch (error) {
console.error('Fehler beim Aktualisieren der Vorlage:', error); console.error('Fehler beim Aktualisieren der Vorlage:', error);
return false; return false;
@ -124,22 +124,10 @@ export async function updateTemplate(
/** /**
* Löscht eine Vorlage * Löscht eine Vorlage
* @param templateId Die ID der zu löschenden Vorlage
* @returns true bei Erfolg, false bei Fehler
*/ */
export async function deleteTemplate(templateId: string): Promise<boolean> { export async function deleteTemplate(templateId: string): Promise<boolean> {
try { try {
const { error } = await supabase return await templateApi.deleteTemplate(templateId);
.from('templates')
.delete()
.eq('id', templateId);
if (error) {
console.error('Fehler beim Löschen der Vorlage:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Fehler beim Löschen der Vorlage:', error); console.error('Fehler beim Löschen der Vorlage:', error);
return false; return false;
@ -148,31 +136,10 @@ export async function deleteTemplate(templateId: string): Promise<boolean> {
/** /**
* Setzt eine Vorlage als Standard * Setzt eine Vorlage als Standard
* @param templateId Die ID der Vorlage, die als Standard gesetzt werden soll
* @param userId Die ID des Benutzers
* @returns true bei Erfolg, false bei Fehler
*/ */
export async function setDefaultTemplate(templateId: string, userId: string): Promise<boolean> { export async function setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
try { try {
// Zuerst alle Vorlagen des Benutzers auf nicht-Standard setzen return await templateApi.setDefaultTemplate(templateId);
await supabase
.from('templates')
.update({ is_default: false })
.eq('user_id', userId);
// Dann die ausgewählte Vorlage als Standard setzen
const { error } = await supabase
.from('templates')
.update({ is_default: true })
.eq('id', templateId)
.eq('user_id', userId);
if (error) {
console.error('Fehler beim Setzen der Standard-Vorlage:', error);
return false;
}
return true;
} catch (error) { } catch (error) {
console.error('Fehler beim Setzen der Standard-Vorlage:', error); console.error('Fehler beim Setzen der Standard-Vorlage:', error);
return false; return false;
@ -181,26 +148,16 @@ export async function setDefaultTemplate(templateId: string, userId: string): Pr
/** /**
* Holt die Standard-Vorlage des Benutzers * Holt die Standard-Vorlage des Benutzers
* @param userId Die ID des Benutzers
* @returns Die Standard-Vorlage oder null, wenn keine gefunden wurde
*/ */
export async function getDefaultTemplate(userId: string): Promise<Template | null> { export async function getDefaultTemplate(userId: string): Promise<Template | null> {
try { try {
const { data, error } = await supabase const template = await templateApi.getDefaultTemplate();
.from('templates') if (!template) {
.select('*')
.eq('user_id', userId)
.eq('is_default', true)
.single();
if (error) {
console.error('Fehler beim Laden der Standard-Vorlage:', error);
return null; return null;
} }
return toLocalTemplate(template);
return data as Template;
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Standard-Vorlage:', error); console.error('Fehler beim Laden der Standard-Vorlage:', error);
return null; return null;
} }
} }

View file

@ -28,6 +28,7 @@
}, },
"dependencies": { "dependencies": {
"@chat/types": "workspace:*", "@chat/types": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*", "@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*", "@manacore/shared-i18n": "workspace:*",

View file

@ -1,17 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
supabase: SupabaseClient; // Auth is now handled client-side via Mana Core Auth
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
} }
interface PageData { interface PageData {
session: Session | null; pathname?: string;
user: User | null;
} }
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View file

@ -1,41 +1,10 @@
/** /**
* Server Hooks for SvelteKit * Server Hooks for SvelteKit
* Handles Supabase session management * Auth is now handled client-side via Mana Core Auth
*/ */
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { createSupabaseServerClient } from '$lib/services/supabase';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
// Create Supabase client for this request return resolve(event);
event.locals.supabase = createSupabaseServerClient(event.cookies);
// Get session
event.locals.safeGetSession = async () => {
const {
data: { session },
} = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
// Validate user (not just reading from cookies)
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
}
return { session, user };
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version';
},
});
}; };

View file

@ -1,52 +1,682 @@
/** /**
* Backend API Client for Chat * API Client for Chat Backend
*
* This replaces direct Supabase calls with backend API calls.
* All database operations now go through the NestJS backend.
*/ */
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3001'; const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
interface ApiResponse<T> { type FetchOptions = {
data?: T; method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
error?: string; body?: unknown;
} token?: string;
};
export async function apiRequest<T>( async function fetchApi<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: FetchOptions = {},
): Promise<ApiResponse<T>> { ): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token } = options;
// Get token from localStorage if not provided
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('mana_token') || undefined;
}
if (!authToken) {
return { data: null, error: new Error('No authentication token') };
}
try { try {
const response = await fetch(`${BACKEND_URL}${endpoint}`, { const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, Authorization: `Bearer ${authToken}`,
}, },
body: body ? JSON.stringify(body) : undefined,
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorData = await response.json().catch(() => ({}));
return { error: errorText || `HTTP ${response.status}` }; return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
} }
const data = await response.json(); const data = await response.json();
return { data }; return { data, error: null };
} catch (error) { } catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' }; return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
} }
} }
export const api = { // ============ Conversation API ============
get: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body: unknown) => export type Conversation = {
apiRequest<T>(endpoint, { id: string;
method: 'POST', userId: string;
body: JSON.stringify(body), modelId: string;
}), templateId?: string;
put: <T>(endpoint: string, body: unknown) => spaceId?: string;
apiRequest<T>(endpoint, { title?: string;
method: 'PUT', conversationMode: 'free' | 'guided' | 'template';
body: JSON.stringify(body), documentMode: boolean;
}), isArchived: boolean;
delete: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'DELETE' }), createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
updatedAt: string;
};
export const conversationApi = {
async getConversations(spaceId?: string): Promise<Conversation[]> {
const query = spaceId ? `?spaceId=${spaceId}` : '';
const { data, error } = await fetchApi<Conversation[]>(`/conversations${query}`);
if (error) {
console.error('Error loading conversations:', error);
return [];
}
return data || [];
},
async getArchivedConversations(): Promise<Conversation[]> {
const { data, error } = await fetchApi<Conversation[]>('/conversations/archived');
if (error) {
console.error('Error loading archived conversations:', error);
return [];
}
return data || [];
},
async getConversation(id: string): Promise<Conversation | null> {
const { data, error } = await fetchApi<Conversation>(`/conversations/${id}`);
if (error) {
console.error('Error loading conversation:', error);
return null;
}
return data;
},
async getMessages(conversationId: string): Promise<Message[]> {
const { data, error } = await fetchApi<Message[]>(
`/conversations/${conversationId}/messages`,
);
if (error) {
console.error('Error loading messages:', error);
return [];
}
return data || [];
},
async createConversation(options: {
modelId: string;
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
}): Promise<Conversation | null> {
const { data, error } = await fetchApi<Conversation>('/conversations', {
method: 'POST',
body: options,
});
if (error) {
console.error('Error creating conversation:', error);
return null;
}
return data;
},
async addMessage(
conversationId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string,
): Promise<Message | null> {
const { data, error } = await fetchApi<Message>(
`/conversations/${conversationId}/messages`,
{
method: 'POST',
body: { sender, messageText },
},
);
if (error) {
console.error('Error adding message:', error);
return null;
}
return data;
},
async updateTitle(conversationId: string, title: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(
`/conversations/${conversationId}/title`,
{
method: 'PATCH',
body: { title },
},
);
if (error) {
console.error('Error updating title:', error);
return false;
}
return true;
},
async archiveConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(
`/conversations/${conversationId}/archive`,
{ method: 'PATCH' },
);
if (error) {
console.error('Error archiving conversation:', error);
return false;
}
return true;
},
async unarchiveConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(
`/conversations/${conversationId}/unarchive`,
{ method: 'PATCH' },
);
if (error) {
console.error('Error unarchiving conversation:', error);
return false;
}
return true;
},
async deleteConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(
`/conversations/${conversationId}`,
{ method: 'DELETE' },
);
if (error) {
console.error('Error deleting conversation:', error);
return false;
}
return true;
},
};
// ============ Template API ============
export type Template = {
id: string;
userId: string;
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color: string;
isDefault: boolean;
documentMode: boolean;
createdAt: string;
updatedAt: string;
};
export const templateApi = {
async getTemplates(): Promise<Template[]> {
const { data, error } = await fetchApi<Template[]>('/templates');
if (error) {
console.error('Error loading templates:', error);
return [];
}
return data || [];
},
async getTemplate(id: string): Promise<Template | null> {
const { data, error } = await fetchApi<Template>(`/templates/${id}`);
if (error) {
console.error('Error loading template:', error);
return null;
}
return data;
},
async getDefaultTemplate(): Promise<Template | null> {
const { data, error } = await fetchApi<Template | null>('/templates/default');
if (error) {
console.error('Error loading default template:', error);
return null;
}
return data;
},
async createTemplate(template: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
}): Promise<Template | null> {
const { data, error } = await fetchApi<Template>('/templates', {
method: 'POST',
body: template,
});
if (error) {
console.error('Error creating template:', error);
return null;
}
return data;
},
async updateTemplate(
id: string,
updates: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
): Promise<boolean> {
const { error } = await fetchApi<Template>(`/templates/${id}`, {
method: 'PATCH',
body: updates,
});
if (error) {
console.error('Error updating template:', error);
return false;
}
return true;
},
async setDefaultTemplate(id: string): Promise<boolean> {
const { error } = await fetchApi<Template>(`/templates/${id}/default`, {
method: 'PATCH',
});
if (error) {
console.error('Error setting default template:', error);
return false;
}
return true;
},
async deleteTemplate(id: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(`/templates/${id}`, {
method: 'DELETE',
});
if (error) {
console.error('Error deleting template:', error);
return false;
}
return true;
},
};
// ============ Space API ============
export type Space = {
id: string;
ownerId: string;
name: string;
description?: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
};
export type SpaceMember = {
id: string;
spaceId: string;
userId: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
invitationStatus: 'pending' | 'accepted' | 'declined';
invitedBy?: string;
invitedAt: string;
joinedAt?: string;
createdAt: string;
updatedAt: string;
};
export const spaceApi = {
async getUserSpaces(): Promise<Space[]> {
const { data, error } = await fetchApi<Space[]>('/spaces');
if (error) {
console.error('Error loading spaces:', error);
return [];
}
return data || [];
},
async getOwnedSpaces(): Promise<Space[]> {
const { data, error } = await fetchApi<Space[]>('/spaces/owned');
if (error) {
console.error('Error loading owned spaces:', error);
return [];
}
return data || [];
},
async getSpace(id: string): Promise<Space | null> {
const { data, error } = await fetchApi<Space>(`/spaces/${id}`);
if (error) {
console.error('Error loading space:', error);
return null;
}
return data;
},
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const { data, error } = await fetchApi<SpaceMember[]>(`/spaces/${spaceId}/members`);
if (error) {
console.error('Error loading space members:', error);
return [];
}
return data || [];
},
async getUserRoleInSpace(
spaceId: string,
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
const { data, error } = await fetchApi<{
role: 'owner' | 'admin' | 'member' | 'viewer' | null;
}>(`/spaces/${spaceId}/role`);
if (error) {
console.error('Error getting user role:', error);
return null;
}
return data?.role || null;
},
async getPendingInvitations(): Promise<
Array<{ invitation: SpaceMember; space: Space }>
> {
const { data, error } = await fetchApi<
Array<{ invitation: SpaceMember; space: Space }>
>('/spaces/invitations');
if (error) {
console.error('Error loading invitations:', error);
return [];
}
return data || [];
},
async createSpace(name: string, description?: string): Promise<Space | null> {
const { data, error } = await fetchApi<Space>('/spaces', {
method: 'POST',
body: { name, description },
});
if (error) {
console.error('Error creating space:', error);
return null;
}
return data;
},
async updateSpace(
id: string,
updates: { name?: string; description?: string; isArchived?: boolean },
): Promise<boolean> {
const { error } = await fetchApi<Space>(`/spaces/${id}`, {
method: 'PATCH',
body: updates,
});
if (error) {
console.error('Error updating space:', error);
return false;
}
return true;
},
async deleteSpace(id: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(`/spaces/${id}`, {
method: 'DELETE',
});
if (error) {
console.error('Error deleting space:', error);
return false;
}
return true;
},
async inviteUser(
spaceId: string,
userId: string,
role?: 'admin' | 'member' | 'viewer',
): Promise<boolean> {
const { error } = await fetchApi<SpaceMember>(`/spaces/${spaceId}/invite`, {
method: 'POST',
body: { userId, role },
});
if (error) {
console.error('Error inviting user:', error);
return false;
}
return true;
},
async respondToInvitation(
spaceId: string,
status: 'accepted' | 'declined',
): Promise<boolean> {
const { error } = await fetchApi<SpaceMember>(`/spaces/${spaceId}/respond`, {
method: 'POST',
body: { status },
});
if (error) {
console.error('Error responding to invitation:', error);
return false;
}
return true;
},
async removeMember(spaceId: string, userId: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(
`/spaces/${spaceId}/members/${userId}`,
{ method: 'DELETE' },
);
if (error) {
console.error('Error removing member:', error);
return false;
}
return true;
},
async changeMemberRole(
spaceId: string,
userId: string,
role: 'admin' | 'member' | 'viewer',
): Promise<boolean> {
const { error } = await fetchApi<SpaceMember>(
`/spaces/${spaceId}/members/${userId}/role`,
{
method: 'PATCH',
body: { role },
},
);
if (error) {
console.error('Error changing member role:', error);
return false;
}
return true;
},
};
// ============ Document API ============
export type Document = {
id: string;
conversationId: string;
version: number;
content: string;
createdAt: string;
updatedAt: string;
};
export const documentApi = {
async getLatestDocument(conversationId: string): Promise<Document | null> {
const { data, error } = await fetchApi<Document | null>(
`/documents/conversation/${conversationId}`,
);
if (error) {
console.error('Error loading document:', error);
return null;
}
return data;
},
async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
const { data, error } = await fetchApi<Document[]>(
`/documents/conversation/${conversationId}/versions`,
);
if (error) {
console.error('Error loading document versions:', error);
return [];
}
return data || [];
},
async hasDocument(conversationId: string): Promise<boolean> {
const { data, error } = await fetchApi<{ exists: boolean }>(
`/documents/conversation/${conversationId}/exists`,
);
if (error) {
console.error('Error checking document:', error);
return false;
}
return data?.exists || false;
},
async createDocument(
conversationId: string,
content: string,
): Promise<Document | null> {
const { data, error } = await fetchApi<Document>(
`/documents/conversation/${conversationId}`,
{
method: 'POST',
body: { content },
},
);
if (error) {
console.error('Error creating document:', error);
return null;
}
return data;
},
async createDocumentVersion(
conversationId: string,
content: string,
): Promise<Document | null> {
const { data, error } = await fetchApi<Document>(
`/documents/conversation/${conversationId}/version`,
{
method: 'POST',
body: { content },
},
);
if (error) {
console.error('Error creating document version:', error);
return null;
}
return data;
},
async deleteDocumentVersion(documentId: string): Promise<boolean> {
const { error } = await fetchApi<{ success: boolean }>(`/documents/${documentId}`, {
method: 'DELETE',
});
if (error) {
console.error('Error deleting document:', error);
return false;
}
return true;
},
};
// ============ Model API ============
export type Model = {
id: string;
name: string;
description?: string;
provider: string;
parameters?: {
deployment?: string;
temperature?: number;
max_tokens?: number;
top_p?: number;
};
isActive: boolean;
createdAt: string;
updatedAt: string;
};
export const modelApi = {
async getModels(): Promise<Model[]> {
const { data, error } = await fetchApi<Model[]>('/models');
if (error) {
console.error('Error loading models:', error);
return [];
}
return data || [];
},
async getModel(id: string): Promise<Model | null> {
const { data, error } = await fetchApi<Model>(`/models/${id}`);
if (error) {
console.error('Error loading model:', error);
return null;
}
return data;
},
};
// ============ Chat API ============
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
export type ChatCompletionResponse = {
content: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export const chatApi = {
async createCompletion(options: {
messages: ChatMessage[];
modelId: string;
temperature?: number;
maxTokens?: number;
}): Promise<ChatCompletionResponse | null> {
const { data, error } = await fetchApi<ChatCompletionResponse>('/chat/completions', {
method: 'POST',
body: options,
});
if (error) {
console.error('Error creating completion:', error);
return null;
}
return data;
},
}; };

View file

@ -1,9 +1,10 @@
/** /**
* Chat Service - AI Completions via Backend * Chat Service - AI Completions via Backend API
*/ */
import { api } from './api'; import { chatApi, modelApi, type ChatMessage, type ChatCompletionResponse, type Model } from './api';
import type { ChatMessage, ChatCompletionResponse, AIModel } from '@chat/types';
export type { ChatMessage, ChatCompletionResponse };
export interface ChatCompletionRequest { export interface ChatCompletionRequest {
messages: ChatMessage[]; messages: ChatMessage[];
@ -16,31 +17,19 @@ export const chatService = {
/** /**
* Get available AI models * Get available AI models
*/ */
async getModels(): Promise<AIModel[]> { async getModels(): Promise<Model[]> {
const { data, error } = await api.get<AIModel[]>('/api/chat/models'); return modelApi.getModels();
if (error) {
console.error('Failed to fetch models:', error);
return [];
}
return data || [];
}, },
/** /**
* Send chat completion request * Send chat completion request
*/ */
async createCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse | null> { async createCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse | null> {
const { data, error } = await api.post<ChatCompletionResponse>('/api/chat/completions', { return chatApi.createCompletion({
messages: request.messages, messages: request.messages,
modelId: request.modelId, modelId: request.modelId,
temperature: request.temperature ?? 0.7, temperature: request.temperature ?? 0.7,
maxTokens: request.maxTokens ?? 1000, maxTokens: request.maxTokens ?? 1000,
}); });
if (error) {
console.error('Chat completion failed:', error);
return null;
}
return data || null;
}, },
}; };

View file

@ -1,19 +1,16 @@
/** /**
* Conversation Service - CRUD operations via Supabase * Conversation Service - CRUD operations via Backend API
*/ */
import { createSupabaseBrowserClient } from './supabase'; import {
import { chatService } from './chat'; conversationApi,
import type { Conversation, Message, ChatMessage } from '@chat/types'; chatApi,
type Conversation,
type Message,
type ChatMessage,
} from './api';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null; export type { Conversation, Message };
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export const conversationService = { export const conversationService = {
/** /**
@ -25,116 +22,45 @@ export const conversationService = {
mode: 'free' | 'guided' | 'template' = 'free', mode: 'free' | 'guided' | 'template' = 'free',
templateId?: string, templateId?: string,
documentMode: boolean = false, documentMode: boolean = false,
spaceId?: string spaceId?: string,
): Promise<string | null> { ): Promise<string | null> {
const sb = getSupabase(); const conversation = await conversationApi.createConversation({
modelId,
conversationMode: mode,
templateId,
documentMode,
spaceId,
});
const { data, error } = await sb return conversation?.id || null;
.from('conversations')
.insert({
user_id: userId,
model_id: modelId,
template_id: templateId,
conversation_mode: mode,
document_mode: documentMode,
space_id: spaceId,
})
.select('id')
.single();
if (error) {
console.error('Error creating conversation:', error);
return null;
}
return data.id;
}, },
/** /**
* Get all active conversations for a user * Get all active conversations for a user
*/ */
async getConversations(userId: string, spaceId?: string): Promise<Conversation[]> { async getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
const sb = getSupabase(); return conversationApi.getConversations(spaceId);
let query = sb
.from('conversations')
.select('*')
.eq('user_id', userId)
.eq('is_archived', false);
if (spaceId) {
query = query.eq('space_id', spaceId);
}
const { data, error } = await query.order('updated_at', { ascending: false });
if (error) {
console.error('Error loading conversations:', error);
return [];
}
return data as Conversation[];
}, },
/** /**
* Get archived conversations * Get archived conversations
*/ */
async getArchivedConversations(userId: string): Promise<Conversation[]> { async getArchivedConversations(userId: string): Promise<Conversation[]> {
const sb = getSupabase(); return conversationApi.getArchivedConversations();
const { data, error } = await sb
.from('conversations')
.select('*')
.eq('user_id', userId)
.eq('is_archived', true)
.order('updated_at', { ascending: false });
if (error) {
console.error('Error loading archived conversations:', error);
return [];
}
return data as Conversation[];
}, },
/** /**
* Get a single conversation * Get a single conversation
*/ */
async getConversation(conversationId: string): Promise<Conversation | null> { async getConversation(conversationId: string): Promise<Conversation | null> {
const sb = getSupabase(); return conversationApi.getConversation(conversationId);
const { data, error } = await sb
.from('conversations')
.select('*')
.eq('id', conversationId)
.single();
if (error) {
console.error('Error loading conversation:', error);
return null;
}
return data as Conversation;
}, },
/** /**
* Get messages for a conversation * Get messages for a conversation
*/ */
async getMessages(conversationId: string): Promise<Message[]> { async getMessages(conversationId: string): Promise<Message[]> {
const sb = getSupabase(); return conversationApi.getMessages(conversationId);
const { data, error } = await sb
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
if (error) {
console.error('Error loading messages:', error);
return [];
}
return data as Message[];
}, },
/** /**
@ -143,114 +69,38 @@ export const conversationService = {
async addMessage( async addMessage(
conversationId: string, conversationId: string,
sender: 'user' | 'assistant' | 'system', sender: 'user' | 'assistant' | 'system',
messageText: string messageText: string,
): Promise<string | null> { ): Promise<string | null> {
const sb = getSupabase(); const message = await conversationApi.addMessage(conversationId, sender, messageText);
return message?.id || null;
const { data, error } = await sb
.from('messages')
.insert({
conversation_id: conversationId,
sender,
message_text: messageText,
})
.select('id')
.single();
if (error) {
console.error('Error adding message:', error);
return null;
}
return data.id;
}, },
/** /**
* Update conversation title * Update conversation title
*/ */
async updateTitle(conversationId: string, title: string): Promise<boolean> { async updateTitle(conversationId: string, title: string): Promise<boolean> {
const sb = getSupabase(); return conversationApi.updateTitle(conversationId, title);
const { error } = await sb
.from('conversations')
.update({ title, updated_at: new Date().toISOString() })
.eq('id', conversationId);
if (error) {
console.error('Error updating title:', error);
return false;
}
return true;
}, },
/** /**
* Archive a conversation * Archive a conversation
*/ */
async archiveConversation(conversationId: string): Promise<boolean> { async archiveConversation(conversationId: string): Promise<boolean> {
const sb = getSupabase(); return conversationApi.archiveConversation(conversationId);
const { error } = await sb
.from('conversations')
.update({ is_archived: true })
.eq('id', conversationId);
if (error) {
console.error('Error archiving conversation:', error);
return false;
}
return true;
}, },
/** /**
* Unarchive a conversation * Unarchive a conversation
*/ */
async unarchiveConversation(conversationId: string): Promise<boolean> { async unarchiveConversation(conversationId: string): Promise<boolean> {
const sb = getSupabase(); return conversationApi.unarchiveConversation(conversationId);
const { error } = await sb
.from('conversations')
.update({ is_archived: false })
.eq('id', conversationId);
if (error) {
console.error('Error unarchiving conversation:', error);
return false;
}
return true;
}, },
/** /**
* Delete a conversation permanently * Delete a conversation permanently
*/ */
async deleteConversation(conversationId: string): Promise<boolean> { async deleteConversation(conversationId: string): Promise<boolean> {
const sb = getSupabase(); return conversationApi.deleteConversation(conversationId);
// Delete messages first
const { error: messagesError } = await sb
.from('messages')
.delete()
.eq('conversation_id', conversationId);
if (messagesError) {
console.error('Error deleting messages:', messagesError);
return false;
}
// Delete conversation
const { error: conversationError } = await sb
.from('conversations')
.delete()
.eq('id', conversationId);
if (conversationError) {
console.error('Error deleting conversation:', conversationError);
return false;
}
return true;
}, },
/** /**
@ -259,7 +109,7 @@ export const conversationService = {
async sendMessageAndGetResponse( async sendMessageAndGetResponse(
conversationId: string, conversationId: string,
userMessage: string, userMessage: string,
modelId: string modelId: string,
): Promise<{ ): Promise<{
userMessageId: string | null; userMessageId: string | null;
assistantMessageId: string | null; assistantMessageId: string | null;
@ -275,11 +125,11 @@ export const conversationService = {
// Build chat messages for API // Build chat messages for API
const chatMessages: ChatMessage[] = messages.map((m) => ({ const chatMessages: ChatMessage[] = messages.map((m) => ({
role: m.sender === 'user' ? 'user' : m.sender === 'assistant' ? 'assistant' : 'system', role: m.sender === 'user' ? 'user' : m.sender === 'assistant' ? 'assistant' : 'system',
content: m.message_text, content: m.messageText,
})); }));
// Get AI response // Get AI response
const response = await chatService.createCompletion({ const response = await chatApi.createCompletion({
messages: chatMessages, messages: chatMessages,
modelId, modelId,
}); });
@ -293,14 +143,11 @@ export const conversationService = {
} }
// Save assistant message // Save assistant message
const assistantMessageId = await this.addMessage(conversationId, 'assistant', response.content); const assistantMessageId = await this.addMessage(
conversationId,
// Update conversation timestamp 'assistant',
const sb = getSupabase(); response.content,
await sb );
.from('conversations')
.update({ updated_at: new Date().toISOString() })
.eq('id', conversationId);
// Generate title if this is a new conversation (first or second message) // Generate title if this is a new conversation (first or second message)
let title: string | undefined; let title: string | undefined;
@ -325,7 +172,7 @@ export const conversationService = {
async generateTitle(userMessage: string): Promise<string> { async generateTitle(userMessage: string): Promise<string> {
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`; const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
const response = await chatService.createCompletion({ const response = await chatApi.createCompletion({
messages: [{ role: 'user', content: titlePrompt }], messages: [{ role: 'user', content: titlePrompt }],
modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini
temperature: 0.3, temperature: 0.3,

View file

@ -1,62 +1,40 @@
/** /**
* Document Service - Manage documents in document mode conversations * Document Service - CRUD operations via Backend API
*/ */
import { createSupabaseBrowserClient } from './supabase'; import { documentApi, conversationApi, type Document } from './api';
import type { Document, DocumentWithConversation } from '@chat/types';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null; export type { Document };
function getSupabase() { export type DocumentWithConversation = Document & {
if (!supabase) { conversation_title: string;
supabase = createSupabaseBrowserClient(); };
}
return supabase;
}
export const documentService = { export const documentService = {
/** /**
* Get all documents for a user (latest version of each) * Get all documents for a user (latest version of each)
* This requires fetching conversations first, then documents
*/ */
async getUserDocuments(userId: string): Promise<DocumentWithConversation[]> { async getUserDocuments(userId: string): Promise<DocumentWithConversation[]> {
const sb = getSupabase(); // Get all conversations (the API will filter by user)
const conversations = await conversationApi.getConversations();
// Get all conversations with document_mode enabled // Filter to only document mode conversations
const { data: conversations, error: convError } = await sb const documentConversations = conversations.filter((c) => c.documentMode);
.from('conversations')
.select('id, title, document_mode')
.eq('user_id', userId)
.eq('document_mode', true);
if (convError) { if (documentConversations.length === 0) {
console.error('Error loading conversations:', convError);
return [];
}
if (!conversations || conversations.length === 0) {
return []; return [];
} }
// For each conversation, load the latest document version // For each conversation, load the latest document version
const documents: DocumentWithConversation[] = []; const documents: DocumentWithConversation[] = [];
for (const conv of conversations) { for (const conv of documentConversations) {
const { data: docData, error: docError } = await sb const doc = await documentApi.getLatestDocument(conv.id);
.from('documents')
.select('*')
.eq('conversation_id', conv.id)
.order('version', { ascending: false })
.limit(1)
.single();
if (docError && docError.code !== 'PGRST116') { if (doc) {
console.error(`Error loading document for conversation ${conv.id}:`, docError);
continue;
}
if (docData) {
documents.push({ documents.push({
...docData, ...doc,
conversation_title: conv.title || 'Unbenannte Konversation', conversation_title: conv.title || 'Unbenannte Konversation',
}); });
} }
@ -69,108 +47,44 @@ export const documentService = {
* Get the latest document for a conversation * Get the latest document for a conversation
*/ */
async getLatestDocument(conversationId: string): Promise<Document | null> { async getLatestDocument(conversationId: string): Promise<Document | null> {
const sb = getSupabase(); return documentApi.getLatestDocument(conversationId);
const { data, error } = await sb
.from('documents')
.select('*')
.eq('conversation_id', conversationId)
.order('version', { ascending: false })
.limit(1)
.single();
if (error) {
if (error.code !== 'PGRST116') {
console.error('Error loading document:', error);
}
return null;
}
return data as Document;
}, },
/** /**
* Create a new document * Create a new document
*/ */
async createDocument(conversationId: string, content: string): Promise<Document | null> { async createDocument(conversationId: string, content: string): Promise<Document | null> {
const sb = getSupabase(); return documentApi.createDocument(conversationId, content);
const { data, error } = await sb
.from('documents')
.insert({
conversation_id: conversationId,
version: 1,
content,
})
.select()
.single();
if (error) {
console.error('Error creating document:', error);
return null;
}
return data as Document;
}, },
/** /**
* Create a new version of a document * Create a new version of a document
*/ */
async createDocumentVersion(conversationId: string, content: string): Promise<Document | null> { async createDocumentVersion(
const sb = getSupabase(); conversationId: string,
content: string,
// Get the current highest version ): Promise<Document | null> {
const { data: latestVersionData, error: versionError } = await sb return documentApi.createDocumentVersion(conversationId, content);
.from('documents')
.select('version')
.eq('conversation_id', conversationId)
.order('version', { ascending: false })
.limit(1)
.single();
if (versionError && versionError.code !== 'PGRST116') {
console.error('Error loading latest document version:', versionError);
return null;
}
const newVersion = (latestVersionData?.version || 0) + 1;
// Create a new document version
const { data, error } = await sb
.from('documents')
.insert({
conversation_id: conversationId,
version: newVersion,
content,
})
.select()
.single();
if (error) {
console.error('Error creating document version:', error);
return null;
}
return data as Document;
}, },
/** /**
* Get all versions of a document * Get all versions of a document
*/ */
async getAllDocumentVersions(conversationId: string): Promise<Document[]> { async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
const sb = getSupabase(); return documentApi.getAllDocumentVersions(conversationId);
},
const { data, error } = await sb /**
.from('documents') * Check if a document exists for a conversation
.select('*') */
.eq('conversation_id', conversationId) async hasDocument(conversationId: string): Promise<boolean> {
.order('version', { ascending: false }); return documentApi.hasDocument(conversationId);
},
if (error) { /**
console.error('Error loading document versions:', error); * Delete a specific document version
return []; */
} async deleteDocumentVersion(documentId: string): Promise<boolean> {
return documentApi.deleteDocumentVersion(documentId);
return data as Document[];
}, },
}; };

View file

@ -1,151 +1,60 @@
/** /**
* Space Service - CRUD operations via Supabase * Space Service - CRUD operations via Backend API
*/ */
import { createSupabaseBrowserClient } from './supabase'; import { spaceApi, type Space, type SpaceMember } from './api';
import type { Space, SpaceMember, SpaceCreate, SpaceUpdate } from '@chat/types';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null; export type { Space, SpaceMember };
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export const spaceService = { export const spaceService = {
/** /**
* Get all spaces for a user (both owned and member of) * Get all spaces for the current user (both owned and member of)
*/ */
async getUserSpaces(userId: string): Promise<Space[]> { async getUserSpaces(userId: string): Promise<Space[]> {
const sb = getSupabase(); return spaceApi.getUserSpaces();
// Get space IDs the user is a member of (with accepted status)
const { data: memberData, error: memberError } = await sb
.from('space_members')
.select('space_id')
.eq('user_id', userId)
.eq('invitation_status', 'accepted');
if (memberError) {
console.error('Error fetching user space memberships:', memberError);
return [];
}
if (!memberData || memberData.length === 0) {
return [];
}
const spaceIds = memberData.map((m) => m.space_id);
// Fetch the actual space data
const { data: spaces, error: spacesError } = await sb
.from('spaces')
.select('*')
.in('id', spaceIds)
.eq('is_archived', false)
.order('created_at', { ascending: false });
if (spacesError) {
console.error('Error fetching spaces:', spacesError);
return [];
}
return spaces as Space[];
}, },
/** /**
* Get a single space by ID * Get a single space by ID
*/ */
async getSpace(spaceId: string): Promise<Space | null> { async getSpace(spaceId: string): Promise<Space | null> {
const sb = getSupabase(); return spaceApi.getSpace(spaceId);
const { data, error } = await sb.from('spaces').select('*').eq('id', spaceId).single();
if (error) {
console.error('Error fetching space:', error);
return null;
}
return data as Space;
}, },
/** /**
* Create a new space * Create a new space
*/ */
async createSpace(space: SpaceCreate): Promise<string | null> { async createSpace(space: {
const sb = getSupabase(); name: string;
description?: string;
const { data, error } = await sb owner_id: string;
.from('spaces') }): Promise<string | null> {
.insert({ const result = await spaceApi.createSpace(space.name, space.description);
name: space.name, return result?.id || null;
description: space.description,
owner_id: space.owner_id,
})
.select('id')
.single();
if (error) {
console.error('Error creating space:', error);
return null;
}
return data.id;
}, },
/** /**
* Update a space * Update a space
*/ */
async updateSpace(spaceId: string, updates: SpaceUpdate): Promise<boolean> { async updateSpace(
const sb = getSupabase(); spaceId: string,
updates: { name?: string; description?: string; isArchived?: boolean },
const { error } = await sb.from('spaces').update(updates).eq('id', spaceId); ): Promise<boolean> {
return spaceApi.updateSpace(spaceId, updates);
if (error) {
console.error('Error updating space:', error);
return false;
}
return true;
}, },
/** /**
* Delete a space * Delete a space
*/ */
async deleteSpace(spaceId: string): Promise<boolean> { async deleteSpace(spaceId: string): Promise<boolean> {
const sb = getSupabase(); return spaceApi.deleteSpace(spaceId);
const { error } = await sb.from('spaces').delete().eq('id', spaceId);
if (error) {
console.error('Error deleting space:', error);
return false;
}
return true;
}, },
/** /**
* Get members of a space * Get members of a space
*/ */
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> { async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const sb = getSupabase(); return spaceApi.getSpaceMembers(spaceId);
const { data, error } = await sb
.from('space_members')
.select('*')
.eq('space_id', spaceId)
.order('role', { ascending: true })
.order('joined_at', { ascending: false });
if (error) {
console.error('Error fetching space members:', error);
return [];
}
return data as SpaceMember[];
}, },
/** /**
@ -153,62 +62,63 @@ export const spaceService = {
*/ */
async getUserRoleInSpace( async getUserRoleInSpace(
spaceId: string, spaceId: string,
userId: string userId: string,
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> { ): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
const sb = getSupabase(); return spaceApi.getUserRoleInSpace(spaceId);
// First check if they're the owner
const { data: space, error: spaceError } = await sb
.from('spaces')
.select('owner_id')
.eq('id', spaceId)
.single();
if (spaceError) {
console.error('Error checking space ownership:', spaceError);
return null;
}
if (space.owner_id === userId) {
return 'owner';
}
// If not owner, check membership
const { data: member, error: memberError } = await sb
.from('space_members')
.select('role, invitation_status')
.eq('space_id', spaceId)
.eq('user_id', userId)
.single();
if (memberError) {
return null;
}
if (member && member.invitation_status === 'accepted') {
return member.role as 'admin' | 'member' | 'viewer';
}
return null;
}, },
/** /**
* Leave a space * Leave a space (remove self from members)
*/ */
async leaveSpace(spaceId: string, userId: string): Promise<boolean> { async leaveSpace(spaceId: string, userId: string): Promise<boolean> {
const sb = getSupabase(); return spaceApi.removeMember(spaceId, userId);
},
const { error } = await sb /**
.from('space_members') * Invite a user to a space
.delete() */
.eq('space_id', spaceId) async inviteUserToSpace(
.eq('user_id', userId); spaceId: string,
userId: string,
role: 'admin' | 'member' | 'viewer' = 'member',
): Promise<boolean> {
return spaceApi.inviteUser(spaceId, userId, role);
},
if (error) { /**
console.error('Error leaving space:', error); * Respond to a space invitation
return false; */
} async respondToInvitation(
spaceId: string,
status: 'accepted' | 'declined',
): Promise<boolean> {
return spaceApi.respondToInvitation(spaceId, status);
},
return true; /**
* Get pending invitations for the current user
*/
async getPendingInvitations(): Promise<
Array<{ invitation: SpaceMember; space: Space }>
> {
return spaceApi.getPendingInvitations();
},
/**
* Remove a member from a space
*/
async removeMember(spaceId: string, userId: string): Promise<boolean> {
return spaceApi.removeMember(spaceId, userId);
},
/**
* Change a member's role
*/
async changeMemberRole(
spaceId: string,
userId: string,
newRole: 'admin' | 'member' | 'viewer',
): Promise<boolean> {
return spaceApi.changeMemberRole(spaceId, userId, newRole);
}, },
}; };

View file

@ -1,157 +1,86 @@
/** /**
* Template Service - CRUD operations via Supabase * Template Service - CRUD operations via Backend API
*/ */
import { createSupabaseBrowserClient } from './supabase'; import { templateApi, type Template } from './api';
import type { Template, TemplateCreate, TemplateUpdate } from '@chat/types';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null; export type { Template };
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export const templateService = { export const templateService = {
/** /**
* Get all templates for a user * Get all templates for the current user
*/ */
async getTemplates(userId: string): Promise<Template[]> { async getTemplates(userId: string): Promise<Template[]> {
const sb = getSupabase(); return templateApi.getTemplates();
const { data, error } = await sb
.from('templates')
.select('*')
.eq('user_id', userId)
.order('name');
if (error) {
console.error('Error loading templates:', error);
return [];
}
return data as Template[];
}, },
/** /**
* Get a single template by ID * Get a single template by ID
*/ */
async getTemplate(templateId: string): Promise<Template | null> { async getTemplate(templateId: string): Promise<Template | null> {
const sb = getSupabase(); return templateApi.getTemplate(templateId);
const { data, error } = await sb
.from('templates')
.select('*')
.eq('id', templateId)
.single();
if (error) {
console.error('Error loading template:', error);
return null;
}
return data as Template;
}, },
/** /**
* Get the default template for a user * Get the default template for the current user
*/ */
async getDefaultTemplate(userId: string): Promise<Template | null> { async getDefaultTemplate(userId: string): Promise<Template | null> {
const sb = getSupabase(); return templateApi.getDefaultTemplate();
const { data, error } = await sb
.from('templates')
.select('*')
.eq('user_id', userId)
.eq('is_default', true)
.single();
if (error) {
console.error('Error loading default template:', error);
return null;
}
return data as Template;
}, },
/** /**
* Create a new template * Create a new template
*/ */
async createTemplate(template: TemplateCreate): Promise<Template | null> { async createTemplate(template: {
const sb = getSupabase(); userId: string;
name: string;
const { data, error } = await sb description?: string;
.from('templates') systemPrompt: string;
.insert(template) initialQuestion?: string;
.select() modelId?: string;
.single(); color?: string;
documentMode?: boolean;
if (error) { }): Promise<Template | null> {
console.error('Error creating template:', error); return templateApi.createTemplate({
return null; name: template.name,
} description: template.description,
systemPrompt: template.systemPrompt,
return data as Template; initialQuestion: template.initialQuestion,
modelId: template.modelId,
color: template.color,
documentMode: template.documentMode,
});
}, },
/** /**
* Update a template * Update an existing template
*/ */
async updateTemplate(templateId: string, updates: TemplateUpdate): Promise<boolean> { async updateTemplate(
const sb = getSupabase(); templateId: string,
updates: Partial<{
const { error } = await sb name: string;
.from('templates') description: string;
.update(updates) systemPrompt: string;
.eq('id', templateId); initialQuestion: string;
modelId: string;
if (error) { color: string;
console.error('Error updating template:', error); documentMode: boolean;
return false; }>,
} ): Promise<boolean> {
return templateApi.updateTemplate(templateId, updates);
return true;
}, },
/** /**
* Delete a template * Delete a template
*/ */
async deleteTemplate(templateId: string): Promise<boolean> { async deleteTemplate(templateId: string): Promise<boolean> {
const sb = getSupabase(); return templateApi.deleteTemplate(templateId);
const { error } = await sb.from('templates').delete().eq('id', templateId);
if (error) {
console.error('Error deleting template:', error);
return false;
}
return true;
}, },
/** /**
* Set a template as default * Set a template as default
*/ */
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> { async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
const sb = getSupabase(); return templateApi.setDefaultTemplate(templateId);
// First, unset all defaults for this user
await sb.from('templates').update({ is_default: false }).eq('user_id', userId);
// Then set the selected template as default
const { error } = await sb
.from('templates')
.update({ is_default: true })
.eq('id', templateId)
.eq('user_id', userId);
if (error) {
console.error('Error setting default template:', error);
return false;
}
return true;
}, },
}; };

View file

@ -3,14 +3,26 @@
* Now using Mana Core Auth instead of Supabase Auth * Now using Mana Core Auth instead of Supabase Auth
*/ */
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth'; import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
// Initialize Mana Core Auth // Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
const { authService, tokenManager } = initializeWebAuth({
baseUrl: MANA_AUTH_URL, // Lazy initialization to avoid SSR issues with localStorage
}); let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State // State
let user = $state<UserData | null>(null); let user = $state<UserData | null>(null);
@ -38,6 +50,13 @@ export const authStore = {
async initialize() { async initialize() {
if (initialized) return; if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true; loading = true;
try { try {
const authenticated = await authService.isAuthenticated(); const authenticated = await authService.isAuthenticated();
@ -58,6 +77,11 @@ export const authStore = {
* Sign in with email and password * Sign in with email and password
*/ */
async signIn(email: string, password: string) { async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try { try {
const result = await authService.signIn(email, password); const result = await authService.signIn(email, password);
@ -80,6 +104,11 @@ export const authStore = {
* Sign up with email and password * Sign up with email and password
*/ */
async signUp(email: string, password: string) { async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try { try {
const result = await authService.signUp(email, password); const result = await authService.signUp(email, password);
@ -105,6 +134,12 @@ export const authStore = {
* Sign out * Sign out
*/ */
async signOut() { async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try { try {
await authService.signOut(); await authService.signOut();
user = null; user = null;
@ -119,6 +154,11 @@ export const authStore = {
* Send password reset email * Send password reset email
*/ */
async resetPassword(email: string) { async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try { try {
const result = await authService.forgotPassword(email); const result = await authService.forgotPassword(email);
@ -137,6 +177,11 @@ export const authStore = {
* Get user credit balance * Get user credit balance
*/ */
async getCredits() { async getCredits() {
const authService = getAuthService();
if (!authService) {
return null;
}
try { try {
const credits = await authService.getUserCredits(); const credits = await authService.getUserCredits();
return credits; return credits;
@ -150,6 +195,10 @@ export const authStore = {
* Get access token for API calls * Get access token for API calls
*/ */
async getAccessToken() { async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken(); return await authService.getAppToken();
}, },
}; };

View file

@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui'; import { LoginPage } from '@manacore/shared-auth-ui';
import { ChatLogo } from '@manacore/shared-branding'; import { ChatLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/chat');
// German translations // German translations
const translations = { const translations = {
title: 'Anmelden', title: 'Anmelden',
@ -47,7 +51,7 @@
goto={goto} goto={goto}
enableGoogle={false} enableGoogle={false}
enableApple={false} enableApple={false}
successRedirect="/chat" successRedirect={redirectTo}
registerPath="/register" registerPath="/register"
forgotPasswordPath="/forgot-password" forgotPasswordPath="/forgot-password"
lightBackground="#e0f2fe" lightBackground="#e0f2fe"

View file

@ -1,22 +1,13 @@
/** /**
* Protected routes layout server * Protected routes layout server
* Validates session and redirects to login if not authenticated * Auth checking is now done client-side via Mana Core Auth
*/ */
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, url }) => { export const load: LayoutServerLoad = async ({ url }) => {
const { session, user } = await locals.safeGetSession(); // Return the current path for client-side redirect logic
if (!session) {
// Redirect to login with return URL
const redirectTo = encodeURIComponent(url.pathname);
redirect(303, `/login?redirectTo=${redirectTo}`);
}
return { return {
session, pathname: url.pathname,
user,
}; };
}; };

View file

@ -7,11 +7,19 @@
let { children, data }: { children: any; data: LayoutData } = $props(); let { children, data }: { children: any; data: LayoutData } = $props();
// Set session from server data let isChecking = $state(true);
onMount(() => {
if (data.session) { // Check auth on mount and redirect if not authenticated
authStore.setSession(data.session); onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const redirectTo = encodeURIComponent(data.pathname || '/chat');
goto(`/login?redirectTo=${redirectTo}`);
return;
} }
isChecking = false;
}); });
async function handleSignOut() { async function handleSignOut() {
@ -20,93 +28,100 @@
} }
</script> </script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900"> {#if isChecking}
<!-- Top Navigation --> <!-- Loading state while checking auth -->
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> <div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
<div class="flex justify-between h-16"> </div>
<div class="flex items-center"> {:else}
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white"> <div class="min-h-screen bg-gray-50 dark:bg-gray-900">
ManaChat <!-- Top Navigation -->
</a> <nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="hidden sm:ml-8 sm:flex sm:space-x-4"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<a <div class="flex justify-between h-16">
href="/" <div class="flex items-center">
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors <a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
{$page.url.pathname === '/' || $page.url.pathname.startsWith('/chat') ManaChat
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Chat
</a> </a>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<a
href="/"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname === '/' || $page.url.pathname.startsWith('/chat')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Chat
</a>
<a
href="/templates"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/templates')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Templates
</a>
<a
href="/spaces"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/spaces')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Spaces
</a>
<a
href="/documents"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/documents')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Dokumente
</a>
<a
href="/archive"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/archive')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Archiv
</a>
</div>
</div>
<div class="flex items-center gap-4">
{#if authStore.user}
<span class="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
{authStore.user.email}
</span>
{/if}
<a <a
href="/templates" href="/profile"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors class="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
{$page.url.pathname.startsWith('/templates') aria-label="Profil"
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
> >
Templates <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</a> </a>
<a <button
href="/spaces" onclick={handleSignOut}
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors class="px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300
{$page.url.pathname.startsWith('/spaces') hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
> >
Spaces Abmelden
</a> </button>
<a
href="/documents"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/documents')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Dokumente
</a>
<a
href="/archive"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$page.url.pathname.startsWith('/archive')
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
Archiv
</a>
</div> </div>
</div> </div>
<div class="flex items-center gap-4">
{#if data.user}
<span class="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
{data.user.email}
</span>
{/if}
<a
href="/profile"
class="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Profil"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</a>
<button
onclick={handleSignOut}
class="px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Abmelden
</button>
</div>
</div> </div>
</div> </nav>
</nav>
<!-- Main Content --> <!-- Main Content -->
<main> <main>
{@render children()} {@render children()}
</main> </main>
</div> </div>
{/if}

View file

@ -0,0 +1,37 @@
# Dependencies
node_modules
.pnpm-store
# Build output
dist
*.tsbuildinfo
# Development files
.env
.env.local
.env.*.local
# IDE
.idea
.vscode
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Test files
coverage
.nyc_output
# Misc
*.md
!README.md
.git
.gitignore

20
chat/backend/.env.docker Normal file
View file

@ -0,0 +1,20 @@
# Docker Environment Configuration
# Copy this file to .env and fill in the values
# Database Configuration
DB_USER=chat
DB_PASSWORD=chatpassword
DB_NAME=chat
DB_PORT=5432
# Azure OpenAI Configuration (required)
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key-here
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth URL
# Use host.docker.internal to connect to services running on host machine
MANA_CORE_AUTH_URL=http://host.docker.internal:3001
# Backend Port (exposed on host)
BACKEND_PORT=3002

View file

@ -6,9 +6,8 @@ AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth Configuration # Mana Core Auth Configuration
MANA_CORE_AUTH_URL=http://localhost:3001 MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Configuration (for database only, not auth) # PostgreSQL Database Configuration
SUPABASE_URL=https://your-project.supabase.co DATABASE_URL=postgresql://chat:password@localhost:5432/chat
SUPABASE_SERVICE_KEY=your-service-key-here
# Server Configuration # Server Configuration
PORT=3002 PORT=3002

63
chat/backend/Dockerfile Normal file
View file

@ -0,0 +1,63 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/shared-errors ./packages/shared-errors
# Copy chat backend
COPY chat/backend ./chat/backend
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/shared-errors
RUN pnpm build
# Build the backend
WORKDIR /app/chat/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/chat/backend ./chat/backend
# Copy entrypoint script
COPY chat/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/chat/backend
# Expose port
EXPOSE 3002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,67 @@
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: chat-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-chat}
POSTGRES_PASSWORD: ${DB_PASSWORD:-chatpassword}
POSTGRES_DB: ${DB_NAME:-chat}
ports:
- "${DB_PORT:-5433}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-chat} -d ${DB_NAME:-chat}"]
interval: 10s
timeout: 5s
retries: 5
# Chat Backend API
backend:
build:
context: ../..
dockerfile: chat/backend/Dockerfile
container_name: chat-backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
# Database
DATABASE_URL: postgresql://${DB_USER:-chat}:${DB_PASSWORD:-chatpassword}@postgres:5432/${DB_NAME:-chat}
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER:-chat}
DB_PASSWORD: ${DB_PASSWORD:-chatpassword}
DB_NAME: ${DB_NAME:-chat}
# Azure OpenAI
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY}
AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-12-01-preview}
# Mana Core Auth
MANA_CORE_AUTH_URL: ${MANA_CORE_AUTH_URL:-http://host.docker.internal:3001}
# Server
PORT: 3002
NODE_ENV: production
ports:
- "${BACKEND_PORT:-3002}:3002"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
volumes:
postgres_data:
driver: local
networks:
default:
name: chat-network

View file

@ -0,0 +1,34 @@
#!/bin/sh
set -e
echo "=== Chat Backend Entrypoint ==="
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL..."
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-chat} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/chat/backend
# Run schema push (for development) or migrations (for production)
if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then
echo "Running database migrations..."
npx tsx src/db/migrate.ts
echo "Migrations completed!"
else
echo "Pushing database schema (development mode)..."
npx drizzle-kit push --force
echo "Schema push completed!"
fi
# Run seed (only seeds if data doesn't exist)
echo "Running database seed..."
npx tsx src/db/seed.ts
echo "Seed completed!"
# Execute the main command
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat',
},
verbose: true,
strict: true,
});

View file

@ -3,6 +3,8 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"assets": [],
"watchAssets": false
} }
} }

View file

@ -10,7 +10,18 @@
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit",
"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",
"docker:build": "docker compose build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f",
"docker:restart": "docker compose restart",
"docker:clean": "docker compose down -v --rmi local"
}, },
"dependencies": { "dependencies": {
"@manacore/shared-errors": "workspace:*", "@manacore/shared-errors": "workspace:*",
@ -18,10 +29,13 @@
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "^2.47.10",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"openai": "^4.77.0", "openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -40,6 +54,7 @@
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }

View file

@ -1,7 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { ChatModule } from './chat/chat.module'; import { ChatModule } from './chat/chat.module';
import { ConversationModule } from './conversation/conversation.module'; import { ConversationModule } from './conversation/conversation.module';
import { TemplateModule } from './template/template.module';
import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { ModelModule } from './model/model.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@Module({ @Module({
@ -10,8 +15,13 @@ import { HealthModule } from './health/health.module';
isGlobal: true, isGlobal: true,
envFilePath: '.env', envFilePath: '.env',
}), }),
DatabaseModule,
ChatModule, ChatModule,
ConversationModule, ConversationModule,
TemplateModule,
SpaceModule,
DocumentModule,
ModelModule,
HealthModule, HealthModule,
], ],
}) })

View file

@ -17,7 +17,7 @@ export class ChatController {
constructor(private readonly chatService: ChatService) {} constructor(private readonly chatService: ChatService) {}
@Get('models') @Get('models')
getModels() { async getModels() {
return this.chatService.getAvailableModels(); return this.chatService.getAvailableModels();
} }

View file

@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { import {
type AsyncResult, type AsyncResult,
ok, ok,
@ -7,22 +8,11 @@ import {
ValidationError, ValidationError,
ServiceError, ServiceError,
} from '@manacore/shared-errors'; } from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { models, type Model } from '../db/schema/models.schema';
import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto'; import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto';
export interface AIModel {
id: string;
name: string;
description: string;
parameters: {
temperature: number;
max_tokens: number;
provider: string;
deployment: string;
endpoint: string;
api_version: string;
};
}
@Injectable() @Injectable()
export class ChatService { export class ChatService {
private readonly logger = new Logger(ChatService.name); private readonly logger = new Logger(ChatService.name);
@ -30,72 +20,55 @@ export class ChatService {
private readonly endpoint: string; private readonly endpoint: string;
private readonly apiVersion: string; private readonly apiVersion: string;
// Available models configuration constructor(
private readonly availableModels: AIModel[] = [ private configService: ConfigService,
{ @Inject(DATABASE_CONNECTION) private readonly db: Database,
id: '550e8400-e29b-41d4-a716-446655440000', ) {
name: 'GPT-O3-Mini',
description: 'Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.',
parameters: {
temperature: 0.7,
max_tokens: 800,
provider: 'azure',
deployment: 'gpt-o3-mini-se',
endpoint: 'https://memoroseopenai.openai.azure.com',
api_version: '2024-12-01-preview',
},
},
{
id: '550e8400-e29b-41d4-a716-446655440004',
name: 'GPT-4o-Mini',
description: 'Azure OpenAI GPT-4o-Mini: Kompaktes, leistungsstarkes KI-Modell.',
parameters: {
temperature: 0.7,
max_tokens: 1000,
provider: 'azure',
deployment: 'gpt-4o-mini-se',
endpoint: 'https://memoroseopenai.openai.azure.com',
api_version: '2024-12-01-preview',
},
},
{
id: '550e8400-e29b-41d4-a716-446655440005',
name: 'GPT-4o',
description: 'Azure OpenAI GPT-4o: Das fortschrittlichste multimodale KI-Modell.',
parameters: {
temperature: 0.7,
max_tokens: 1200,
provider: 'azure',
deployment: 'gpt-4o-se',
endpoint: 'https://memoroseopenai.openai.azure.com',
api_version: '2024-12-01-preview',
},
},
];
constructor(private configService: ConfigService) {
this.apiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY') || ''; this.apiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY') || '';
this.endpoint = this.configService.get<string>('AZURE_OPENAI_ENDPOINT') || 'https://memoroseopenai.openai.azure.com'; this.endpoint =
this.apiVersion = this.configService.get<string>('AZURE_OPENAI_API_VERSION') || '2024-12-01-preview'; this.configService.get<string>('AZURE_OPENAI_ENDPOINT') ||
'https://memoroseopenai.openai.azure.com';
this.apiVersion =
this.configService.get<string>('AZURE_OPENAI_API_VERSION') ||
'2024-12-01-preview';
if (!this.apiKey) { if (!this.apiKey) {
this.logger.warn('AZURE_OPENAI_API_KEY is not set!'); this.logger.warn('AZURE_OPENAI_API_KEY is not set!');
} }
} }
getAvailableModels(): AIModel[] { async getAvailableModels(): Promise<Model[]> {
return this.availableModels; try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true));
return result;
} catch (error) {
this.logger.error('Error fetching models from database', error);
return [];
}
} }
getModelById(modelId: string): AIModel | undefined { async getModelById(modelId: string): Promise<Model | undefined> {
return this.availableModels.find((m) => m.id === modelId); try {
const result = await this.db
.select()
.from(models)
.where(eq(models.id, modelId))
.limit(1);
return result[0];
} catch (error) {
this.logger.error('Error fetching model from database', error);
return undefined;
}
} }
async createCompletion( async createCompletion(
dto: ChatCompletionDto, dto: ChatCompletionDto,
userId?: string, userId?: string,
): AsyncResult<ChatCompletionResponseDto> { ): AsyncResult<ChatCompletionResponseDto> {
const model = this.getModelById(dto.modelId); const model = await this.getModelById(dto.modelId);
if (!model) { if (!model) {
return err( return err(
@ -110,9 +83,15 @@ export class ChatService {
); );
} }
const deployment = model.parameters.deployment; const params = model.parameters as {
const temperature = dto.temperature ?? model.parameters.temperature; deployment?: string;
const maxTokens = dto.maxTokens ?? model.parameters.max_tokens; temperature?: number;
max_tokens?: number;
} | null;
const deployment = params?.deployment || 'gpt-4o-mini-se';
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 1000;
// Prepare request body // Prepare request body
const requestBody: Record<string, unknown> = { const requestBody: Record<string, unknown> = {

View file

@ -1,10 +1,18 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { import {
ConversationService, Body,
type Conversation, Controller,
type Message, Delete,
} from './conversation.service'; Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { ConversationService } from './conversation.service';
import { type Conversation } from '../db/schema/conversations.schema';
import { type Message } from '../db/schema/messages.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { import {
CurrentUser, CurrentUser,
@ -19,8 +27,27 @@ export class ConversationController {
@Get() @Get()
async getConversations( async getConversations(
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
@Query('spaceId') spaceId?: string,
): Promise<Conversation[]> { ): Promise<Conversation[]> {
const result = await this.conversationService.getConversations(user.userId); const result = await this.conversationService.getConversations(
user.userId,
spaceId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('archived')
async getArchivedConversations(
@CurrentUser() user: CurrentUserData,
): Promise<Conversation[]> {
const result = await this.conversationService.getArchivedConversations(
user.userId,
);
if (!isOk(result)) { if (!isOk(result)) {
throw result.error; throw result.error;
@ -34,8 +61,10 @@ export class ConversationController {
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
): Promise<Conversation> { ): Promise<Conversation> {
// TODO: Add ownership check - ensure conversation belongs to user const result = await this.conversationService.getConversation(
const result = await this.conversationService.getConversation(id); id,
user.userId,
);
if (!isOk(result)) { if (!isOk(result)) {
throw result.error; throw result.error;
@ -49,8 +78,7 @@ export class ConversationController {
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
): Promise<Message[]> { ): Promise<Message[]> {
// TODO: Add ownership check - ensure conversation belongs to user const result = await this.conversationService.getMessages(id, user.userId);
const result = await this.conversationService.getMessages(id);
if (!isOk(result)) { if (!isOk(result)) {
throw result.error; throw result.error;
@ -61,13 +89,27 @@ export class ConversationController {
@Post() @Post()
async createConversation( async createConversation(
@Body() body: { modelId: string; title?: string }, @Body()
body: {
modelId: string;
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
},
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
): Promise<Conversation> { ): Promise<Conversation> {
const result = await this.conversationService.createConversation( const result = await this.conversationService.createConversation(
user.userId, user.userId,
body.modelId, body.modelId,
body.title, {
title: body.title,
templateId: body.templateId,
conversationMode: body.conversationMode,
documentMode: body.documentMode,
spaceId: body.spaceId,
},
); );
if (!isOk(result)) { if (!isOk(result)) {
@ -83,9 +125,9 @@ export class ConversationController {
@Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string }, @Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string },
@CurrentUser() user: CurrentUserData, @CurrentUser() user: CurrentUserData,
): Promise<Message> { ): Promise<Message> {
// TODO: Add ownership check - ensure conversation belongs to user
const result = await this.conversationService.addMessage( const result = await this.conversationService.addMessage(
id, id,
user.userId,
body.sender, body.sender,
body.messageText, body.messageText,
); );
@ -96,4 +138,74 @@ export class ConversationController {
return result.value; return result.value;
} }
@Patch(':id/title')
async updateTitle(
@Param('id') id: string,
@Body() body: { title: string },
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.updateTitle(
id,
user.userId,
body.title,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/archive')
async archiveConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.archiveConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/unarchive')
async unarchiveConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.unarchiveConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.conversationService.deleteConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
} }

View file

@ -1,168 +1,319 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { import {
type AsyncResult, type AsyncResult,
ok, ok,
err, err,
ServiceError,
DatabaseError, DatabaseError,
NotFoundError, NotFoundError,
} from '@manacore/shared-errors'; } from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
export interface Conversation { import { type Database } from '../db/connection';
id: string; import {
user_id: string; conversations,
model_id: string; type Conversation,
title?: string; type NewConversation,
is_archived: boolean; } from '../db/schema/conversations.schema';
created_at: string; import { messages, type Message, type NewMessage } from '../db/schema/messages.schema';
updated_at: string;
}
export interface Message {
id: string;
conversation_id: string;
sender: 'user' | 'assistant' | 'system';
message_text: string;
created_at: string;
}
@Injectable() @Injectable()
export class ConversationService { export class ConversationService {
private readonly logger = new Logger(ConversationService.name); private readonly logger = new Logger(ConversationService.name);
private supabase: SupabaseClient | null = null;
constructor(private configService: ConfigService) { constructor(
const supabaseUrl = this.configService.get<string>('SUPABASE_URL'); @Inject(DATABASE_CONNECTION) private readonly db: Database,
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_KEY'); ) {}
if (supabaseUrl && supabaseKey) { async getConversations(
this.supabase = createClient(supabaseUrl, supabaseKey); userId: string,
} else { spaceId?: string,
this.logger.warn('Supabase configuration missing'); ): AsyncResult<Conversation[]> {
} try {
} const conditions = [
eq(conversations.userId, userId),
eq(conversations.isArchived, false),
];
async getConversations(userId: string): AsyncResult<Conversation[]> { if (spaceId) {
if (!this.supabase) { conditions.push(eq(conversations.spaceId, spaceId));
return err(ServiceError.unavailable('Database')); }
}
const { data, error } = await this.supabase const result = await this.db
.from('conversations') .select()
.select('*') .from(conversations)
.eq('user_id', userId) .where(and(...conditions))
.eq('is_archived', false) .orderBy(desc(conversations.updatedAt));
.order('updated_at', { ascending: false });
if (error) { return ok(result);
} catch (error) {
this.logger.error('Error fetching conversations', error); this.logger.error('Error fetching conversations', error);
return err(DatabaseError.queryFailed('Failed to fetch conversations')); return err(DatabaseError.queryFailed('Failed to fetch conversations'));
} }
return ok(data || []);
} }
async getConversation(id: string): AsyncResult<Conversation> { async getArchivedConversations(userId: string): AsyncResult<Conversation[]> {
if (!this.supabase) { try {
return err(ServiceError.unavailable('Database')); const result = await this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.userId, userId),
eq(conversations.isArchived, true),
),
)
.orderBy(desc(conversations.updatedAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching archived conversations', error);
return err(DatabaseError.queryFailed('Failed to fetch archived conversations'));
} }
}
const { data, error } = await this.supabase async getConversation(id: string, userId: string): AsyncResult<Conversation> {
.from('conversations') try {
.select('*') const result = await this.db
.eq('id', id) .select()
.single(); .from(conversations)
.where(
and(eq(conversations.id, id), eq(conversations.userId, userId)),
)
.limit(1);
if (error) { if (result.length === 0) {
this.logger.error('Error fetching conversation', error);
if (error.code === 'PGRST116') {
return err(new NotFoundError('Conversation', id)); return err(new NotFoundError('Conversation', id));
} }
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching conversation', error);
return err(DatabaseError.queryFailed('Failed to fetch conversation')); return err(DatabaseError.queryFailed('Failed to fetch conversation'));
} }
return ok(data);
} }
async getMessages(conversationId: string): AsyncResult<Message[]> { async getMessages(
if (!this.supabase) { conversationId: string,
return err(ServiceError.unavailable('Database')); userId: string,
} ): AsyncResult<Message[]> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const { data, error } = await this.supabase const result = await this.db
.from('messages') .select()
.select('*') .from(messages)
.eq('conversation_id', conversationId) .where(eq(messages.conversationId, conversationId))
.order('created_at', { ascending: true }); .orderBy(asc(messages.createdAt));
if (error) { return ok(result);
} catch (error) {
this.logger.error('Error fetching messages', error); this.logger.error('Error fetching messages', error);
return err(DatabaseError.queryFailed('Failed to fetch messages')); return err(DatabaseError.queryFailed('Failed to fetch messages'));
} }
return ok(data || []);
} }
async createConversation( async createConversation(
userId: string, userId: string,
modelId: string, modelId: string,
title?: string, options?: {
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
},
): AsyncResult<Conversation> { ): AsyncResult<Conversation> {
if (!this.supabase) { try {
return err(ServiceError.unavailable('Database')); const newConversation: NewConversation = {
} userId,
modelId,
title: options?.title || 'Neue Unterhaltung',
templateId: options?.templateId,
conversationMode: options?.conversationMode || 'free',
documentMode: options?.documentMode || false,
spaceId: options?.spaceId,
isArchived: false,
};
const { data, error } = await this.supabase const result = await this.db
.from('conversations') .insert(conversations)
.insert({ .values(newConversation)
user_id: userId, .returning();
model_id: modelId,
title: title || 'Neue Unterhaltung',
is_archived: false,
})
.select()
.single();
if (error) { return ok(result[0]);
} catch (error) {
this.logger.error('Error creating conversation', error); this.logger.error('Error creating conversation', error);
return err(DatabaseError.queryFailed('Failed to create conversation')); return err(DatabaseError.queryFailed('Failed to create conversation'));
} }
return ok(data);
} }
async addMessage( async addMessage(
conversationId: string, conversationId: string,
userId: string,
sender: 'user' | 'assistant' | 'system', sender: 'user' | 'assistant' | 'system',
messageText: string, messageText: string,
): AsyncResult<Message> { ): AsyncResult<Message> {
if (!this.supabase) { try {
return err(ServiceError.unavailable('Database')); // First verify the conversation belongs to the user
} const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const { data, error } = await this.supabase const newMessage: NewMessage = {
.from('messages') conversationId,
.insert({
conversation_id: conversationId,
sender, sender,
message_text: messageText, messageText,
}) };
.select()
.single();
if (error) { const result = await this.db
.insert(messages)
.values(newMessage)
.returning();
// Update conversation updated_at
await this.db
.update(conversations)
.set({ updatedAt: new Date() })
.where(eq(conversations.id, conversationId));
return ok(result[0]);
} catch (error) {
this.logger.error('Error adding message', error); this.logger.error('Error adding message', error);
return err(DatabaseError.queryFailed('Failed to add message')); return err(DatabaseError.queryFailed('Failed to add message'));
} }
}
// Update conversation updated_at async updateTitle(
await this.supabase conversationId: string,
.from('conversations') userId: string,
.update({ updated_at: new Date().toISOString() }) title: string,
.eq('id', conversationId); ): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
return ok(data); const result = await this.db
.update(conversations)
.set({ title, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error updating title', error);
return err(DatabaseError.queryFailed('Failed to update title'));
}
}
async archiveConversation(
conversationId: string,
userId: string,
): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.update(conversations)
.set({ isArchived: true, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error archiving conversation', error);
return err(DatabaseError.queryFailed('Failed to archive conversation'));
}
}
async unarchiveConversation(
conversationId: string,
userId: string,
): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.id, conversationId),
eq(conversations.userId, userId),
),
)
.limit(1);
if (convResult.length === 0) {
return err(new NotFoundError('Conversation', conversationId));
}
const result = await this.db
.update(conversations)
.set({ isArchived: false, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error unarchiving conversation', error);
return err(DatabaseError.queryFailed('Failed to unarchive conversation'));
}
}
async deleteConversation(
conversationId: string,
userId: string,
): AsyncResult<void> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
// Messages will be cascade deleted due to foreign key constraint
await this.db
.delete(conversations)
.where(eq(conversations.id, conversationId));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting conversation', error);
return err(DatabaseError.queryFailed('Failed to delete conversation'));
}
}
async getMessageCount(
conversationId: string,
userId: string,
): AsyncResult<number> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(messages)
.where(eq(messages.conversationId, conversationId));
return ok(Number(result[0]?.count || 0));
} catch (error) {
this.logger.error('Error getting message count', error);
return err(DatabaseError.queryFailed('Failed to get message count'));
}
} }
} }

View file

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

View file

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

View file

@ -0,0 +1,29 @@
import { config } from 'dotenv';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { getDb, closeConnection } from './connection';
// Load environment variables
config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
try {
const db = getDb(databaseUrl);
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeConnection();
}
}
runMigrations();

View file

@ -0,0 +1,43 @@
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { messages } from './messages.schema';
import { documents } from './documents.schema';
import { spaces } from './spaces.schema';
import { models } from './models.schema';
import { templates } from './templates.schema';
export const conversationModeEnum = pgEnum('conversation_mode', ['free', 'guided', 'template']);
export const conversations = pgTable('conversations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id').references(() => models.id),
templateId: uuid('template_id').references(() => templates.id),
spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }),
title: text('title'),
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
documentMode: boolean('document_mode').default(false).notNull(),
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
model: one(models, {
fields: [conversations.modelId],
references: [models.id],
}),
template: one(templates, {
fields: [conversations.templateId],
references: [templates.id],
}),
space: one(spaces, {
fields: [conversations.spaceId],
references: [spaces.id],
}),
messages: many(messages),
documents: many(documents),
}));
export type Conversation = typeof conversations.$inferSelect;
export type NewConversation = typeof conversations.$inferInsert;

View file

@ -0,0 +1,24 @@
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { conversations } from './conversations.schema';
export const documents = pgTable('documents', {
id: uuid('id').primaryKey().defaultRandom(),
conversationId: uuid('conversation_id')
.references(() => conversations.id, { onDelete: 'cascade' })
.notNull(),
version: integer('version').default(1).notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const documentsRelations = relations(documents, ({ one }) => ({
conversation: one(conversations, {
fields: [documents.conversationId],
references: [conversations.id],
}),
}));
export type Document = typeof documents.$inferSelect;
export type NewDocument = typeof documents.$inferInsert;

View file

@ -0,0 +1,7 @@
export * from './conversations.schema';
export * from './messages.schema';
export * from './models.schema';
export * from './templates.schema';
export * from './spaces.schema';
export * from './documents.schema';
export * from './usage-logs.schema';

View file

@ -0,0 +1,26 @@
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { conversations } from './conversations.schema';
export const senderEnum = pgEnum('sender', ['user', 'assistant', 'system']);
export const messages = pgTable('messages', {
id: uuid('id').primaryKey().defaultRandom(),
conversationId: uuid('conversation_id')
.references(() => conversations.id, { onDelete: 'cascade' })
.notNull(),
sender: senderEnum('sender').notNull(),
messageText: text('message_text').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const messagesRelations = relations(messages, ({ one }) => ({
conversation: one(conversations, {
fields: [messages.conversationId],
references: [conversations.id],
}),
}));
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;

View file

@ -0,0 +1,20 @@
import { pgTable, uuid, text, timestamp, jsonb, boolean } from 'drizzle-orm/pg-core';
export const models = pgTable('models', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
provider: text('provider').notNull(), // 'azure', 'openai', 'anthropic', etc.
parameters: jsonb('parameters').$type<{
deployment?: string;
temperature?: number;
max_tokens?: number;
top_p?: number;
}>(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Model = typeof models.$inferSelect;
export type NewModel = typeof models.$inferInsert;

View file

@ -0,0 +1,46 @@
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member', 'viewer']);
export const invitationStatusEnum = pgEnum('invitation_status', ['pending', 'accepted', 'declined']);
export const spaces = pgTable('spaces', {
id: uuid('id').primaryKey().defaultRandom(),
ownerId: uuid('owner_id').notNull(),
name: text('name').notNull(),
description: text('description'),
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const spaceMembers = pgTable('space_members', {
id: uuid('id').primaryKey().defaultRandom(),
spaceId: uuid('space_id')
.references(() => spaces.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id').notNull(),
role: memberRoleEnum('role').default('member').notNull(),
invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(),
invitedBy: uuid('invited_by'),
invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const spacesRelations = relations(spaces, ({ many }) => ({
members: many(spaceMembers),
}));
export const spaceMembersRelations = relations(spaceMembers, ({ one }) => ({
space: one(spaces, {
fields: [spaceMembers.spaceId],
references: [spaces.id],
}),
}));
export type Space = typeof spaces.$inferSelect;
export type NewSpace = typeof spaces.$inferInsert;
export type SpaceMember = typeof spaceMembers.$inferSelect;
export type NewSpaceMember = typeof spaceMembers.$inferInsert;

View file

@ -0,0 +1,28 @@
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { models } from './models.schema';
export const templates = pgTable('templates', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
systemPrompt: text('system_prompt').notNull(),
initialQuestion: text('initial_question'),
modelId: uuid('model_id').references(() => models.id),
color: text('color').default('#3b82f6').notNull(),
isDefault: boolean('is_default').default(false).notNull(),
documentMode: boolean('document_mode').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const templatesRelations = relations(templates, ({ one }) => ({
model: one(models, {
fields: [templates.modelId],
references: [models.id],
}),
}));
export type Template = typeof templates.$inferSelect;
export type NewTemplate = typeof templates.$inferInsert;

View file

@ -0,0 +1,40 @@
import { pgTable, uuid, timestamp, integer, numeric } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { conversations } from './conversations.schema';
import { messages } from './messages.schema';
import { models } from './models.schema';
export const usageLogs = pgTable('usage_logs', {
id: uuid('id').primaryKey().defaultRandom(),
conversationId: uuid('conversation_id')
.references(() => conversations.id, { onDelete: 'cascade' })
.notNull(),
messageId: uuid('message_id')
.references(() => messages.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id').references(() => models.id),
promptTokens: integer('prompt_tokens').default(0).notNull(),
completionTokens: integer('completion_tokens').default(0).notNull(),
totalTokens: integer('total_tokens').default(0).notNull(),
estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const usageLogsRelations = relations(usageLogs, ({ one }) => ({
conversation: one(conversations, {
fields: [usageLogs.conversationId],
references: [conversations.id],
}),
message: one(messages, {
fields: [usageLogs.messageId],
references: [messages.id],
}),
model: one(models, {
fields: [usageLogs.modelId],
references: [models.id],
}),
}));
export type UsageLog = typeof usageLogs.$inferSelect;
export type NewUsageLog = typeof usageLogs.$inferInsert;

100
chat/backend/src/db/seed.ts Normal file
View file

@ -0,0 +1,100 @@
/**
* Database Seed Script
* Seeds initial data for the chat application
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { models } from './schema';
import * as dotenv from 'dotenv';
dotenv.config();
const connectionString = process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat';
async function seed() {
console.log('Starting database seed...');
const client = postgres(connectionString);
const db = drizzle(client);
try {
// Check if models already exist
const existingModels = await db.select().from(models);
if (existingModels.length > 0) {
console.log(`Found ${existingModels.length} existing models. Skipping seed.`);
await client.end();
return;
}
// Seed AI models
console.log('Seeding AI models...');
const modelData = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'GPT-O3-Mini',
description: 'Fast, efficient responses for everyday tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 800,
deployment: 'gpt-o3-mini-se',
},
isActive: true,
},
{
id: '550e8400-e29b-41d4-a716-446655440004',
name: 'GPT-4o-Mini',
description: 'Compact and powerful for complex tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 1000,
deployment: 'gpt-4o-mini-se',
},
isActive: true,
},
{
id: '550e8400-e29b-41d4-a716-446655440005',
name: 'GPT-4o',
description: 'Most advanced model for demanding tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 2000,
deployment: 'gpt-4o-se',
},
isActive: true,
},
];
await db.insert(models).values(modelData);
console.log(`Seeded ${modelData.length} AI models successfully!`);
// Log the seeded models
const seededModels = await db.select().from(models);
console.log('Seeded models:');
seededModels.forEach((model) => {
console.log(` - ${model.name} (${model.id})`);
});
} catch (error) {
console.error('Error seeding database:', error);
throw error;
} finally {
await client.end();
}
}
// Run seed
seed()
.then(() => {
console.log('Seed completed!');
process.exit(0);
})
.catch((error) => {
console.error('Seed failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,129 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { DocumentService } from './document.service';
import { type Document } from '../db/schema/documents.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('documents')
@UseGuards(JwtAuthGuard)
export class DocumentController {
constructor(private readonly documentService: DocumentService) {}
@Get('conversation/:conversationId')
async getLatestDocument(
@Param('conversationId') conversationId: string,
@CurrentUser() user: CurrentUserData,
): Promise<Document | null> {
const result = await this.documentService.getLatestDocument(
conversationId,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('conversation/:conversationId/versions')
async getAllDocumentVersions(
@Param('conversationId') conversationId: string,
@CurrentUser() user: CurrentUserData,
): Promise<Document[]> {
const result = await this.documentService.getAllDocumentVersions(
conversationId,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('conversation/:conversationId/exists')
async hasDocument(
@Param('conversationId') conversationId: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ exists: boolean }> {
const result = await this.documentService.hasDocument(
conversationId,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return { exists: result.value };
}
@Post('conversation/:conversationId')
async createDocument(
@Param('conversationId') conversationId: string,
@Body() body: { content: string },
@CurrentUser() user: CurrentUserData,
): Promise<Document> {
const result = await this.documentService.createDocument(
conversationId,
user.userId,
body.content,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post('conversation/:conversationId/version')
async createDocumentVersion(
@Param('conversationId') conversationId: string,
@Body() body: { content: string },
@CurrentUser() user: CurrentUserData,
): Promise<Document> {
const result = await this.documentService.createDocumentVersion(
conversationId,
user.userId,
body.content,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteDocumentVersion(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.documentService.deleteDocumentVersion(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
}

View file

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

View file

@ -0,0 +1,239 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
documents,
type Document,
type NewDocument,
} from '../db/schema/documents.schema';
import { conversations } from '../db/schema/conversations.schema';
@Injectable()
export class DocumentService {
private readonly logger = new Logger(DocumentService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
private async verifyConversationOwnership(
conversationId: string,
userId: string,
): AsyncResult<void> {
const result = await this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.id, conversationId),
eq(conversations.userId, userId),
),
)
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Conversation', conversationId));
}
return ok(undefined);
}
async createDocument(
conversationId: string,
userId: string,
content: string,
): AsyncResult<Document> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const newDocument: NewDocument = {
conversationId,
version: 1,
content,
};
const result = await this.db
.insert(documents)
.values(newDocument)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating document', error);
return err(DatabaseError.queryFailed('Failed to create document'));
}
}
async createDocumentVersion(
conversationId: string,
userId: string,
content: string,
): AsyncResult<Document> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
// Get the latest version number
const latestDoc = await this.db
.select({ version: documents.version })
.from(documents)
.where(eq(documents.conversationId, conversationId))
.orderBy(desc(documents.version))
.limit(1);
const newVersion = (latestDoc[0]?.version || 0) + 1;
const newDocument: NewDocument = {
conversationId,
version: newVersion,
content,
};
const result = await this.db
.insert(documents)
.values(newDocument)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating document version', error);
return err(DatabaseError.queryFailed('Failed to create document version'));
}
}
async getLatestDocument(
conversationId: string,
userId: string,
): AsyncResult<Document | null> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const result = await this.db
.select()
.from(documents)
.where(eq(documents.conversationId, conversationId))
.orderBy(desc(documents.version))
.limit(1);
return ok(result.length > 0 ? result[0] : null);
} catch (error) {
this.logger.error('Error fetching latest document', error);
return err(DatabaseError.queryFailed('Failed to fetch latest document'));
}
}
async getAllDocumentVersions(
conversationId: string,
userId: string,
): AsyncResult<Document[]> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const result = await this.db
.select()
.from(documents)
.where(eq(documents.conversationId, conversationId))
.orderBy(desc(documents.version));
return ok(result);
} catch (error) {
this.logger.error('Error fetching document versions', error);
return err(DatabaseError.queryFailed('Failed to fetch document versions'));
}
}
async hasDocument(
conversationId: string,
userId: string,
): AsyncResult<boolean> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(documents)
.where(eq(documents.conversationId, conversationId));
return ok(Number(result[0]?.count || 0) > 0);
} catch (error) {
this.logger.error('Error checking document existence', error);
return err(DatabaseError.queryFailed('Failed to check document existence'));
}
}
async deleteDocumentVersion(
documentId: string,
userId: string,
): AsyncResult<void> {
try {
// Get the document to verify ownership
const doc = await this.db
.select()
.from(documents)
.where(eq(documents.id, documentId))
.limit(1);
if (doc.length === 0) {
return err(new NotFoundError('Document', documentId));
}
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
doc[0].conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
await this.db.delete(documents).where(eq(documents.id, documentId));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting document version', error);
return err(DatabaseError.queryFailed('Failed to delete document version'));
}
}
}

View file

@ -1,6 +1,5 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
@ -13,13 +12,14 @@ async function bootstrap() {
'http://localhost:5173', 'http://localhost:5173',
'http://localhost:8081', 'http://localhost:8081',
'exp://localhost:8081', 'exp://localhost:8081',
'http://localhost:3001', // Mana Core Auth
], ],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true, credentials: true,
}); });
// Global exception filter for standardized error responses // Global exception filter will be added later via module
app.useGlobalFilters(new AppExceptionFilter()); // app.useGlobalFilters(new AppExceptionFilter());
// Enable validation // Enable validation
app.useGlobalPipes( app.useGlobalPipes(
@ -33,7 +33,7 @@ async function bootstrap() {
// Set global prefix for API routes // Set global prefix for API routes
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
const port = process.env.PORT || 3001; const port = process.env.PORT || 3002;
await app.listen(port); await app.listen(port);
console.log(`Chat backend running on http://localhost:${port}`); console.log(`Chat backend running on http://localhost:${port}`);
} }

View file

@ -0,0 +1,33 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { ModelService } from './model.service';
import { type Model } from '../db/schema/models.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Controller('models')
@UseGuards(JwtAuthGuard)
export class ModelController {
constructor(private readonly modelService: ModelService) {}
@Get()
async getModels(): Promise<Model[]> {
const result = await this.modelService.getModels();
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getModel(@Param('id') id: string): Promise<Model> {
const result = await this.modelService.getModel(id);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ModelController } from './model.controller';
import { ModelService } from './model.service';
@Module({
controllers: [ModelController],
providers: [ModelService],
exports: [ModelService],
})
export class ModelModule {}

View file

@ -0,0 +1,55 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, asc } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { models, type Model } from '../db/schema/models.schema';
@Injectable()
export class ModelService {
private readonly logger = new Logger(ModelService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getModels(): AsyncResult<Model[]> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true))
.orderBy(asc(models.name));
return ok(result);
} catch (error) {
this.logger.error('Error fetching models', error);
return err(DatabaseError.queryFailed('Failed to fetch models'));
}
}
async getModel(id: string): AsyncResult<Model> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.id, id))
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Model', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching model', error);
return err(DatabaseError.queryFailed('Failed to fetch model'));
}
}
}

View file

@ -0,0 +1,219 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { SpaceService } from './space.service';
import { type Space, type SpaceMember } from '../db/schema/spaces.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('spaces')
@UseGuards(JwtAuthGuard)
export class SpaceController {
constructor(private readonly spaceService: SpaceService) {}
@Get()
async getUserSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
const result = await this.spaceService.getUserSpaces(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('owned')
async getOwnedSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
const result = await this.spaceService.getOwnedSpaces(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('invitations')
async getPendingInvitations(
@CurrentUser() user: CurrentUserData,
): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
const result = await this.spaceService.getPendingInvitations(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getSpace(@Param('id') id: string): Promise<Space> {
const result = await this.spaceService.getSpace(id);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id/members')
async getSpaceMembers(
@Param('id') id: string,
): Promise<SpaceMember[]> {
const result = await this.spaceService.getSpaceMembers(id);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id/role')
async getUserRoleInSpace(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ role: 'owner' | 'admin' | 'member' | 'viewer' | null }> {
const result = await this.spaceService.getUserRoleInSpace(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { role: result.value };
}
@Post()
async createSpace(
@Body() body: { name: string; description?: string },
@CurrentUser() user: CurrentUserData,
): Promise<Space> {
const result = await this.spaceService.createSpace(
user.userId,
body.name,
body.description,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id')
async updateSpace(
@Param('id') id: string,
@Body() body: { name?: string; description?: string; isArchived?: boolean },
@CurrentUser() user: CurrentUserData,
): Promise<Space> {
const result = await this.spaceService.updateSpace(id, user.userId, body);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteSpace(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.spaceService.deleteSpace(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
@Post(':id/invite')
async inviteUser(
@Param('id') id: string,
@Body() body: { userId: string; role?: 'admin' | 'member' | 'viewer' },
@CurrentUser() user: CurrentUserData,
): Promise<SpaceMember> {
const result = await this.spaceService.inviteUserToSpace(
id,
body.userId,
user.userId,
body.role,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post(':id/respond')
async respondToInvitation(
@Param('id') id: string,
@Body() body: { status: 'accepted' | 'declined' },
@CurrentUser() user: CurrentUserData,
): Promise<SpaceMember> {
const result = await this.spaceService.respondToInvitation(
id,
user.userId,
body.status,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id/members/:userId')
async removeMember(
@Param('id') id: string,
@Param('userId') userId: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.spaceService.removeMember(id, userId, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
@Patch(':id/members/:userId/role')
async changeMemberRole(
@Param('id') id: string,
@Param('userId') userId: string,
@Body() body: { role: 'admin' | 'member' | 'viewer' },
@CurrentUser() user: CurrentUserData,
): Promise<SpaceMember> {
const result = await this.spaceService.changeMemberRole(
id,
userId,
body.role,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
}

View file

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

View file

@ -0,0 +1,449 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc, inArray } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
spaces,
spaceMembers,
type Space,
type NewSpace,
type SpaceMember,
type NewSpaceMember,
} from '../db/schema/spaces.schema';
@Injectable()
export class SpaceService {
private readonly logger = new Logger(SpaceService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getUserSpaces(userId: string): AsyncResult<Space[]> {
try {
// Get all space IDs where user is an accepted member
const memberData = await this.db
.select({ spaceId: spaceMembers.spaceId })
.from(spaceMembers)
.where(
and(
eq(spaceMembers.userId, userId),
eq(spaceMembers.invitationStatus, 'accepted'),
),
);
if (memberData.length === 0) {
return ok([]);
}
const spaceIds = memberData.map((m) => m.spaceId);
const result = await this.db
.select()
.from(spaces)
.where(and(inArray(spaces.id, spaceIds), eq(spaces.isArchived, false)))
.orderBy(desc(spaces.createdAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching user spaces', error);
return err(DatabaseError.queryFailed('Failed to fetch user spaces'));
}
}
async getOwnedSpaces(userId: string): AsyncResult<Space[]> {
try {
const result = await this.db
.select()
.from(spaces)
.where(and(eq(spaces.ownerId, userId), eq(spaces.isArchived, false)))
.orderBy(desc(spaces.createdAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching owned spaces', error);
return err(DatabaseError.queryFailed('Failed to fetch owned spaces'));
}
}
async getSpace(id: string): AsyncResult<Space> {
try {
const result = await this.db
.select()
.from(spaces)
.where(eq(spaces.id, id))
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Space', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching space', error);
return err(DatabaseError.queryFailed('Failed to fetch space'));
}
}
async createSpace(
userId: string,
name: string,
description?: string,
): AsyncResult<Space> {
try {
const newSpace: NewSpace = {
ownerId: userId,
name,
description,
isArchived: false,
};
const result = await this.db
.insert(spaces)
.values(newSpace)
.returning();
// Add owner as an accepted member
const memberData: NewSpaceMember = {
spaceId: result[0].id,
userId,
role: 'owner',
invitationStatus: 'accepted',
joinedAt: new Date(),
};
await this.db.insert(spaceMembers).values(memberData);
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating space', error);
return err(DatabaseError.queryFailed('Failed to create space'));
}
}
async updateSpace(
id: string,
userId: string,
data: { name?: string; description?: string; isArchived?: boolean },
): AsyncResult<Space> {
try {
// Verify ownership
const spaceResult = await this.getSpace(id);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId !== userId) {
return err(new NotFoundError('Space', id));
}
const result = await this.db
.update(spaces)
.set({ ...data, updatedAt: new Date() })
.where(eq(spaces.id, id))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error updating space', error);
return err(DatabaseError.queryFailed('Failed to update space'));
}
}
async deleteSpace(id: string, userId: string): AsyncResult<void> {
try {
// Verify ownership
const spaceResult = await this.getSpace(id);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId !== userId) {
return err(new NotFoundError('Space', id));
}
// Members will be cascade deleted
await this.db.delete(spaces).where(eq(spaces.id, id));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting space', error);
return err(DatabaseError.queryFailed('Failed to delete space'));
}
}
async getSpaceMembers(spaceId: string): AsyncResult<SpaceMember[]> {
try {
const result = await this.db
.select()
.from(spaceMembers)
.where(eq(spaceMembers.spaceId, spaceId))
.orderBy(spaceMembers.role, desc(spaceMembers.joinedAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching space members', error);
return err(DatabaseError.queryFailed('Failed to fetch space members'));
}
}
async inviteUserToSpace(
spaceId: string,
userId: string,
invitedByUserId: string,
role: 'admin' | 'member' | 'viewer' = 'member',
): AsyncResult<SpaceMember> {
try {
// Check if user is already a member
const existingMember = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
)
.limit(1);
if (existingMember.length > 0) {
if (existingMember[0].invitationStatus === 'accepted') {
return ok(existingMember[0]);
}
// Update existing invitation
const result = await this.db
.update(spaceMembers)
.set({
role,
invitationStatus: 'pending',
invitedBy: invitedByUserId,
invitedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(spaceMembers.id, existingMember[0].id))
.returning();
return ok(result[0]);
}
// Create new invitation
const memberData: NewSpaceMember = {
spaceId,
userId,
role,
invitationStatus: 'pending',
invitedBy: invitedByUserId,
};
const result = await this.db
.insert(spaceMembers)
.values(memberData)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error inviting user to space', error);
return err(DatabaseError.queryFailed('Failed to invite user to space'));
}
}
async respondToInvitation(
spaceId: string,
userId: string,
status: 'accepted' | 'declined',
): AsyncResult<SpaceMember> {
try {
const updates: Partial<SpaceMember> = {
invitationStatus: status,
updatedAt: new Date(),
};
if (status === 'accepted') {
updates.joinedAt = new Date();
}
const result = await this.db
.update(spaceMembers)
.set(updates)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
)
.returning();
if (result.length === 0) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error responding to invitation', error);
return err(DatabaseError.queryFailed('Failed to respond to invitation'));
}
}
async removeMember(
spaceId: string,
userId: string,
requestingUserId: string,
): AsyncResult<void> {
try {
// Verify the requesting user is the owner or an admin
const spaceResult = await this.getSpace(spaceId);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
const requestingMember = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, requestingUserId),
),
)
.limit(1);
const isOwner = spaceResult.value.ownerId === requestingUserId;
const isAdmin =
requestingMember.length > 0 && requestingMember[0].role === 'admin';
if (!isOwner && !isAdmin) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
await this.db
.delete(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
);
return ok(undefined);
} catch (error) {
this.logger.error('Error removing member', error);
return err(DatabaseError.queryFailed('Failed to remove member'));
}
}
async changeMemberRole(
spaceId: string,
userId: string,
newRole: 'admin' | 'member' | 'viewer',
requestingUserId: string,
): AsyncResult<SpaceMember> {
try {
// Verify the requesting user is the owner
const spaceResult = await this.getSpace(spaceId);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId !== requestingUserId) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
const result = await this.db
.update(spaceMembers)
.set({ role: newRole, updatedAt: new Date() })
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
)
.returning();
if (result.length === 0) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error changing member role', error);
return err(DatabaseError.queryFailed('Failed to change member role'));
}
}
async getUserRoleInSpace(
spaceId: string,
userId: string,
): AsyncResult<'owner' | 'admin' | 'member' | 'viewer' | null> {
try {
// Check if owner
const spaceResult = await this.getSpace(spaceId);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId === userId) {
return ok('owner');
}
// Check membership
const memberResult = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
eq(spaceMembers.invitationStatus, 'accepted'),
),
)
.limit(1);
if (memberResult.length === 0) {
return ok(null);
}
return ok(memberResult[0].role as 'admin' | 'member' | 'viewer');
} catch (error) {
this.logger.error('Error getting user role in space', error);
return err(DatabaseError.queryFailed('Failed to get user role in space'));
}
}
async getPendingInvitations(
userId: string,
): AsyncResult<Array<{ invitation: SpaceMember; space: Space }>> {
try {
const invitations = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.userId, userId),
eq(spaceMembers.invitationStatus, 'pending'),
),
);
const results: Array<{ invitation: SpaceMember; space: Space }> = [];
for (const invitation of invitations) {
const spaceResult = await this.getSpace(invitation.spaceId);
if (spaceResult.ok) {
results.push({ invitation, space: spaceResult.value });
}
}
return ok(results);
} catch (error) {
this.logger.error('Error fetching pending invitations', error);
return err(DatabaseError.queryFailed('Failed to fetch pending invitations'));
}
}
}

View file

@ -0,0 +1,141 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { TemplateService } from './template.service';
import { type Template } from '../db/schema/templates.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('templates')
@UseGuards(JwtAuthGuard)
export class TemplateController {
constructor(private readonly templateService: TemplateService) {}
@Get()
async getTemplates(@CurrentUser() user: CurrentUserData): Promise<Template[]> {
const result = await this.templateService.getTemplates(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('default')
async getDefaultTemplate(
@CurrentUser() user: CurrentUserData,
): Promise<Template | null> {
const result = await this.templateService.getDefaultTemplate(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getTemplate(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.getTemplate(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post()
async createTemplate(
@Body()
body: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
},
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.createTemplate(user.userId, body);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id')
async updateTemplate(
@Param('id') id: string,
@Body()
body: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.updateTemplate(
id,
user.userId,
body,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/default')
async setDefaultTemplate(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.setDefaultTemplate(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteTemplate(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.templateService.deleteTemplate(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TemplateController } from './template.controller';
import { TemplateService } from './template.service';
@Module({
controllers: [TemplateController],
providers: [TemplateService],
exports: [TemplateService],
})
export class TemplateModule {}

View file

@ -0,0 +1,191 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
templates,
type Template,
type NewTemplate,
} from '../db/schema/templates.schema';
@Injectable()
export class TemplateService {
private readonly logger = new Logger(TemplateService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getTemplates(userId: string): AsyncResult<Template[]> {
try {
const result = await this.db
.select()
.from(templates)
.where(eq(templates.userId, userId))
.orderBy(asc(templates.name));
return ok(result);
} catch (error) {
this.logger.error('Error fetching templates', error);
return err(DatabaseError.queryFailed('Failed to fetch templates'));
}
}
async getTemplate(id: string, userId: string): AsyncResult<Template> {
try {
const result = await this.db
.select()
.from(templates)
.where(and(eq(templates.id, id), eq(templates.userId, userId)))
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Template', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching template', error);
return err(DatabaseError.queryFailed('Failed to fetch template'));
}
}
async getDefaultTemplate(userId: string): AsyncResult<Template | null> {
try {
const result = await this.db
.select()
.from(templates)
.where(
and(eq(templates.userId, userId), eq(templates.isDefault, true)),
)
.limit(1);
return ok(result.length > 0 ? result[0] : null);
} catch (error) {
this.logger.error('Error fetching default template', error);
return err(DatabaseError.queryFailed('Failed to fetch default template'));
}
}
async createTemplate(
userId: string,
data: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
},
): AsyncResult<Template> {
try {
const newTemplate: NewTemplate = {
userId,
name: data.name,
description: data.description,
systemPrompt: data.systemPrompt,
initialQuestion: data.initialQuestion,
modelId: data.modelId,
color: data.color || '#3b82f6',
documentMode: data.documentMode || false,
isDefault: false,
};
const result = await this.db
.insert(templates)
.values(newTemplate)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating template', error);
return err(DatabaseError.queryFailed('Failed to create template'));
}
}
async updateTemplate(
id: string,
userId: string,
data: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
): AsyncResult<Template> {
try {
// First verify the template belongs to the user
const templateResult = await this.getTemplate(id, userId);
if (!templateResult.ok) {
return err(templateResult.error);
}
const result = await this.db
.update(templates)
.set({ ...data, updatedAt: new Date() })
.where(eq(templates.id, id))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error updating template', error);
return err(DatabaseError.queryFailed('Failed to update template'));
}
}
async setDefaultTemplate(id: string, userId: string): AsyncResult<Template> {
try {
// First verify the template belongs to the user
const templateResult = await this.getTemplate(id, userId);
if (!templateResult.ok) {
return err(templateResult.error);
}
// Clear all default flags for this user
await this.db
.update(templates)
.set({ isDefault: false, updatedAt: new Date() })
.where(eq(templates.userId, userId));
// Set the new default
const result = await this.db
.update(templates)
.set({ isDefault: true, updatedAt: new Date() })
.where(eq(templates.id, id))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error setting default template', error);
return err(DatabaseError.queryFailed('Failed to set default template'));
}
}
async deleteTemplate(id: string, userId: string): AsyncResult<void> {
try {
// First verify the template belongs to the user
const templateResult = await this.getTemplate(id, userId);
if (!templateResult.ok) {
return err(templateResult.error);
}
await this.db.delete(templates).where(eq(templates.id, id));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting template', error);
return err(DatabaseError.queryFailed('Failed to delete template'));
}
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View file

@ -1,6 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"moduleResolution": "node",
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
@ -10,6 +11,7 @@
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
"rootDir": "./src",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": true, "strictNullChecks": true,
@ -17,5 +19,7 @@
"strictBindCallApply": true, "strictBindCallApply": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
} },
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
} }

116
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,116 @@
# Development Docker Compose - Unified local development setup
# Start infrastructure: pnpm docker:up
# Start all services: pnpm docker:up:all
# Stop: pnpm docker:down
services:
# Shared PostgreSQL Database (multiple databases)
postgres:
image: postgres:16-alpine
container_name: manacore-postgres
restart: unless-stopped
environment:
POSTGRES_DB: manacore
POSTGRES_USER: ${POSTGRES_USER:-manacore}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devpassword}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/init-db:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
networks:
- manacore-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-manacore}"]
interval: 10s
timeout: 5s
retries: 5
# Redis cache
redis:
image: redis:7-alpine
container_name: manacore-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-devpassword} --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
ports:
- "6379:6379"
networks:
- manacore-network
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-devpassword}", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Mana Core Auth Service
mana-core-auth:
profiles: ["auth", "all"]
build:
context: .
dockerfile: ./mana-core-auth/Dockerfile
container_name: manacore-auth
restart: unless-stopped
environment:
NODE_ENV: development
PORT: 3001
DATABASE_URL: postgresql://${POSTGRES_USER:-manacore}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/manacore
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-devpassword}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m}
JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d}
JWT_ISSUER: ${JWT_ISSUER:-manacore}
JWT_AUDIENCE: ${JWT_AUDIENCE:-manacore}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:8081}
CREDITS_SIGNUP_BONUS: ${CREDITS_SIGNUP_BONUS:-150}
CREDITS_DAILY_FREE: ${CREDITS_DAILY_FREE:-5}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "3001:3001"
networks:
- manacore-network
# Chat Backend Service
chat-backend:
profiles: ["chat", "all"]
build:
context: .
dockerfile: chat/backend/Dockerfile
container_name: chat-backend
restart: unless-stopped
environment:
NODE_ENV: development
PORT: 3002
DATABASE_URL: postgresql://${POSTGRES_USER:-manacore}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/chat
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${POSTGRES_USER:-manacore}
DB_PASSWORD: ${POSTGRES_PASSWORD:-devpassword}
DB_NAME: chat
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY}
AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-12-01-preview}
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
depends_on:
postgres:
condition: service_healthy
ports:
- "3002:3002"
networks:
- manacore-network
networks:
manacore-network:
driver: bridge
volumes:
postgres-data:
redis-data:

View file

@ -0,0 +1,9 @@
-- Create databases for all services
-- This script runs on first container initialization
-- Create chat database
CREATE DATABASE chat;
-- Grant all privileges to the default user
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;

View file

@ -27,7 +27,6 @@
"@google-cloud/aiplatform": "^3.34.0", "@google-cloud/aiplatform": "^3.34.0",
"@google-cloud/storage": "^7.15.0", "@google-cloud/storage": "^7.15.0",
"@google/genai": "^1.14.0", "@google/genai": "^1.14.0",
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
"@nestjs/axios": "^4.0.0", "@nestjs/axios": "^4.0.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",

View file

@ -23,7 +23,6 @@
"dependencies": { "dependencies": {
"@manacore/shared-errors": "workspace:*", "@manacore/shared-errors": "workspace:*",
"@google/genai": "^1.14.0", "@google/genai": "^1.14.0",
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
"@manacore/manadeck-database": "workspace:*", "@manacore/manadeck-database": "workspace:*",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",

View file

@ -67,7 +67,18 @@
"dev:news:api": "pnpm --filter @news/api start:dev", "dev:news:api": "pnpm --filter @news/api start:dev",
"dev:news:app": "turbo run dev --filter=@news/web --filter=@news/api", "dev:news:app": "turbo run dev --filter=@news/web --filter=@news/api",
"news:db:push": "pnpm --filter @manacore/news-database db:push", "news:db:push": "pnpm --filter @manacore/news-database db:push",
"news:db:studio": "pnpm --filter @manacore/news-database db:studio" "news:db:studio": "pnpm --filter @manacore/news-database db:studio",
"docker:up": "docker compose -f docker-compose.dev.yml up -d postgres redis",
"docker:up:auth": "docker compose -f docker-compose.dev.yml --profile auth up -d",
"docker:up:chat": "docker compose -f docker-compose.dev.yml --profile chat up -d",
"docker:up:all": "docker compose -f docker-compose.dev.yml --profile all up -d",
"docker:down": "docker compose -f docker-compose.dev.yml --profile all down",
"docker:logs": "docker compose -f docker-compose.dev.yml logs -f",
"docker:logs:auth": "docker compose -f docker-compose.dev.yml logs -f mana-core-auth",
"docker:logs:chat": "docker compose -f docker-compose.dev.yml logs -f chat-backend",
"docker:ps": "docker compose -f docker-compose.dev.yml ps -a",
"docker:clean": "docker compose -f docker-compose.dev.yml --profile all down -v"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.3.3", "prettier": "^3.3.3",

View file

@ -9,9 +9,11 @@
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"isolatedModules": true, "isolatedModules": true,
"verbatimModuleSyntax": true, "declaration": true,
"noEmit": true "declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules"] "exclude": ["node_modules", "dist"]
} }

View file

@ -3,13 +3,20 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "Go-like error handling system for Manacore backends", "description": "Go-like error handling system for Manacore backends",
"main": "./src/index.ts", "main": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts", ".": {
"./nestjs": "./src/nestjs/index.ts" "types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./nestjs": {
"types": "./dist/nestjs/index.d.ts",
"default": "./dist/nestjs/index.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"clean": "rm -rf dist" "clean": "rm -rf dist"
}, },

View file

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

3297
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,6 @@
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
"@manacore/uload-database": "workspace:*", "@manacore/uload-database": "workspace:*",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",