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 = {
id: string; // UUID
user_id: string; // UUID des Benutzers (auth.uid)
model_id: string; // UUID
template_id?: string; // UUID, optional
id: string;
user_id: string;
model_id: string;
template_id?: string;
space_id?: string;
conversation_mode: 'free' | 'guided' | 'template';
document_mode: boolean; // Gibt an, ob der Dokumentmodus aktiviert ist
title?: string; // Titel der Konversation
is_archived: boolean; // Gibt an, ob die Konversation archiviert wurde
document_mode: boolean;
title?: string;
is_archived: boolean;
created_at: string;
updated_at: string;
};
@ -37,6 +50,34 @@ export type TokenUsageType = {
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
*/
@ -49,28 +90,22 @@ export async function createConversation(
spaceId?: string
): Promise<string | null> {
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();
const conversation = await conversationApi.createConversation({
modelId,
conversationMode: mode,
templateId,
documentMode,
spaceId,
});
if (error) {
console.error('Fehler beim Erstellen der Konversation:', error);
if (!conversation) {
console.error('Fehler beim Erstellen der Konversation');
return null;
}
return data.id;
return conversation.id;
} catch (error) {
console.error('Fehler beim Erstellen der Konversation:', error);
return null;
@ -86,35 +121,23 @@ export async function addMessage(
messageText: string
): Promise<string | null> {
try {
// Führe eine Prüfung und Validierung des Senders durch
// Validate 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)) {
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();
const message = await conversationApi.addMessage(conversationId, validSender, messageText);
if (error) {
console.error('Fehler beim Hinzufügen der Nachricht:', error);
if (!message) {
console.error('Fehler beim Hinzufügen der Nachricht');
return null;
}
return data.id;
return message.id;
} catch (error) {
console.error('Fehler beim Hinzufügen der Nachricht:', error);
return null;
@ -126,18 +149,8 @@ export async function addMessage(
*/
export async function getMessages(conversationId: string): Promise<Message[]> {
try {
const { data, error } = await supabase
.from('messages')
.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[];
const messages = await conversationApi.getMessages(conversationId);
return messages.map(toLocalMessage);
} catch (error) {
console.error('Fehler beim Laden der Nachrichten:', error);
return [];
@ -146,43 +159,33 @@ export async function getMessages(conversationId: string): Promise<Message[]> {
/**
* 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> {
try {
console.log('Generiere Titel für Konversation basierend auf:',
userQuestion.substring(0, 50) + (userQuestion.length > 50 ? '...' : ''));
console.log(
'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}"`;
// Manuell 4o-mini-Modell festlegen
const chatMessages: ChatMessage[] = [
{
role: 'system',
content: 'MODEL:gpt-4o-mini-se'
},
{
role: 'user',
content: titlePrompt
}
];
const response = await chatApi.createCompletion({
messages: [{ role: 'user', content: titlePrompt }],
modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini
temperature: 0.3,
maxTokens: 50,
});
// Sende die Anfrage mit niedrigerer Temperatur für zuverlässigere Ergebnisse
const titleResponse = await sendChatRequest(chatMessages, 0.3, 50);
if (!response) {
return 'Neue Konversation';
}
// Extrahiere den Text aus der Antwort
const responseText = typeof titleResponse === 'string'
? titleResponse
: titleResponse.content;
// 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) {
cleanTitle = cleanTitle.substring(0, 97) + '...';
}
@ -191,7 +194,6 @@ export async function generateConversationTitle(userQuestion: string): Promise<s
return cleanTitle;
} catch (error) {
console.error('Fehler bei der Titelgenerierung:', error);
// Fallback-Titel bei Fehler
return 'Neue Konversation';
}
}
@ -199,15 +201,15 @@ export async function generateConversationTitle(userQuestion: string): Promise<s
/**
* 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 {
const { error } = await supabase
.from('conversations')
.update({ title })
.eq('id', conversationId);
const success = await conversationApi.updateTitle(conversationId, title);
if (error) {
console.error('Fehler beim Aktualisieren des Konversationstitels:', error);
if (!success) {
console.error('Fehler beim Aktualisieren des Konversationstitels');
return false;
}
@ -220,23 +222,17 @@ export async function updateConversationTitle(conversationId: string, title: str
/**
* 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> {
try {
const { data, error } = await supabase
.from('templates')
.select('system_prompt')
.eq('id', templateId)
.single();
const template = await templateApi.getTemplate(templateId);
if (error) {
console.error('Fehler beim Laden der Vorlage:', error);
if (!template) {
console.error('Fehler beim Laden der Vorlage');
return null;
}
return data.system_prompt;
return template.systemPrompt;
} catch (error) {
console.error('Fehler beim Laden der Vorlage:', error);
return null;
@ -252,70 +248,52 @@ export async function sendMessageAndGetResponse(
modelId: string,
templateId?: string,
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 {
console.log('Starte sendMessageAndGetResponse mit:', {
conversationId,
userMessage: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''),
modelId
modelId,
});
// Lade das Modell aus der Datenbank oder verwende Fallback
const { data: modelData, error: modelError } = await supabase
.from('models')
.select('*')
.eq('id', modelId)
.single();
// Load the model from API
const modelData = await modelApi.getModel(modelId);
if (modelError) {
console.error('Fehler beim Laden des Modells:', modelError);
if (!modelData) {
console.log('Verwende Standard-Parameter, da Modell nicht geladen werden konnte');
} else {
console.log('Modell geladen:', {
id: modelData.id,
name: modelData.name,
parameters: modelData.parameters,
deployment: modelData.parameters?.deployment
deployment: modelData.parameters?.deployment,
});
}
// Variable für die Benutzer-Nachricht-ID
let userMessageId: string | null = null;
// Save the user message
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);
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[] = [];
// Lade den System-Prompt aus der Vorlage, falls template_id vorhanden
// Load system prompt from template if available
let systemPrompt: string | null = null;
if (templateId) {
systemPrompt = await getSystemPromptFromTemplate(templateId);
if (systemPrompt) {
console.log('System-Prompt aus Vorlage geladen');
// Wenn Dokumentmodus aktiv ist, füge spezielle Anweisungen hinzu
if (documentMode) {
const documentModePrompt = `
${systemPrompt}
@ -333,23 +311,13 @@ CHAT: Hier antwortest du auf die Frage oder das Feedback des Nutzers.
DOKUMENT:
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');
} else {
// Standard-Prompt ohne Dokumentmodus
chatMessages.push({
role: 'system',
content: systemPrompt
});
chatMessages.push({ role: 'system', content: systemPrompt });
}
}
} else if (documentMode) {
// Wenn kein Template, aber Dokumentmodus aktiv ist
const documentModePrompt = `
Du befindest dich im Dokumentmodus. Deine Aufgabe ist es, dem Benutzer zu helfen, ein Dokument zu erstellen und zu verbessern.
@ -364,102 +332,66 @@ CHAT: Hier antwortest du auf die Frage oder das Feedback des Nutzers.
DOKUMENT:
Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-Markierungen.
`;
chatMessages.push({
role: 'system',
content: documentModePrompt
});
chatMessages.push({ role: 'system', content: documentModePrompt });
console.log('Standard-Dokumentmodus-Prompt hinzugefügt');
}
// Füge eine System-Nachricht mit der Modell-ID hinzu, falls ein Modell geladen wurde
if (modelData && modelData.parameters && modelData.parameters.deployment) {
console.log(`Nutze deployment '${modelData.parameters.deployment}' für Modell ${modelData.name}`);
// Stelle die Modell-Identifikation ganz am Anfang ein
chatMessages.unshift({
role: 'system',
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
})));
// Add all conversation messages
chatMessages.push(
...messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : msg.sender === 'assistant' ? 'assistant' : 'system',
content: msg.message_text,
})) as ChatMessage[]
);
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:', {
temperature: modelData?.parameters?.temperature || 0.7,
maxTokens: modelData?.parameters?.max_tokens || 800
maxTokens: modelData?.parameters?.max_tokens || 800,
});
const result = await sendChatRequest(
chatMessages,
modelData?.parameters?.temperature || 0.7,
modelData?.parameters?.max_tokens || 800
);
const result = await chatApi.createCompletion({
messages: chatMessages,
modelId,
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 tokenUsage;
let tokenUsage: TokenUsage | undefined;
if (typeof result === 'string') {
// Falls nur ein String zurückgegeben wurde (Fehlerfall)
assistantResponse = result;
console.log('Einfache String-Antwort vom LLM-Modell erhalten (kein Tokennutzungs-Tracking):', {
length: assistantResponse.length,
preview: assistantResponse.substring(0, 50) + (assistantResponse.length > 50 ? '...' : '')
});
if (!result) {
assistantResponse =
'Es konnte keine Antwort generiert werden. Bitte stelle sicher, dass das Backend läuft.';
} else {
// Bei vollständigem Ergebnis mit Token-Nutzung
assistantResponse = result.content;
tokenUsage = result.usage;
console.log('Antwort vom LLM-Modell erhalten:', {
length: assistantResponse.length,
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 chatResponse = assistantResponse;
if (documentMode) {
// Nach dem Format "CHAT: ... DOKUMENT: ```markdown ... ```" suchen
console.log("Analysiere LLM-Antwort für Dokumentextraktion:", assistantResponse.substring(0, 200) + "...");
console.log(
'Analysiere LLM-Antwort für Dokumentextraktion:',
assistantResponse.substring(0, 200) + '...'
);
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]) {
chatResponse = chatMatch[1].trim();
@ -468,67 +400,52 @@ Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-M
if (documentMatch && documentMatch[2]) {
documentContent = documentMatch[2].trim();
console.log('Dokument-Inhalt extrahiert:', (documentContent?.substring(0, 50) || '') + '...');
} else if (documentMatch) {
console.log('Dokument-Match gefunden, aber kein Inhalt in Gruppe 2');
console.log('Dokument-Match-Gruppen:', documentMatch.length);
for (let i = 0; i < documentMatch.length; i++) {
console.log(`Gruppe ${i}:`, documentMatch[i]?.substring(0, 30));
}
console.log(
'Dokument-Inhalt extrahiert:',
(documentContent?.substring(0, 50) || '') + '...'
);
}
}
// Speichere die Antwort des Assistenten
// Save assistant message
const assistantMessageId = await addMessage(conversationId, 'assistant', chatResponse);
console.log('Assistentenantwort gespeichert mit ID:', assistantMessageId);
// Token-Nutzung loggen, falls verfügbar
if (tokenUsage && assistantMessageId && userMessageId && modelData) {
// Log token usage if available
if (tokenUsage && assistantMessageId && userMessageId) {
try {
// Lade die Konversation, um die user_id zu erhalten
const { data: conversationData, error: convError } = await supabase
.from('conversations')
.select('user_id')
.eq('id', conversationId)
.single();
const estimatedCost = calculateTokenCost(
tokenUsage.prompt_tokens,
tokenUsage.completion_tokens,
modelData?.costSettings
);
if (convError || !conversationData) {
console.error('Fehler beim Laden der Konversation für Token-Logging:', convError);
} else {
const userId = conversationData.user_id;
// Logge die Token-Nutzung
await logTokenUsage(
tokenUsage,
conversationId,
assistantMessageId,
userId,
modelId
);
console.log('Token-Nutzung erfolgreich geloggt');
}
await usageApi.logTokenUsage({
conversationId,
messageId: assistantMessageId,
modelId,
promptTokens: tokenUsage.prompt_tokens,
completionTokens: tokenUsage.completion_tokens,
totalTokens: tokenUsage.total_tokens,
estimatedCost,
});
console.log('Token-Nutzung erfolgreich geloggt');
} catch (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
// Wenn ja, generiere einen Titel und aktualisiere die Konversation
const { count } = await supabase
.from('messages')
.select('id', { count: 'exact', head: true })
.eq('conversation_id', conversationId);
// Generate title for new conversations
const allMessages = await getMessages(conversationId);
let title: string | undefined;
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
if (allMessages.length <= 2) {
title = await generateConversationTitle(userMessage);
// Aktualisiere den Titel in der Datenbank
const success = await updateConversationTitle(conversationId, title);
console.log('Konversationstitel aktualisiert:', success ? 'erfolgreich' : 'fehlgeschlagen');
if (title) {
const success = await updateConversationTitle(conversationId, title);
console.log('Konversationstitel aktualisiert:', success ? 'erfolgreich' : 'fehlgeschlagen');
}
}
return {
@ -536,53 +453,47 @@ Hier steht dein Vorschlag für das Dokument in Markdown-Format, ohne Codeblock-M
assistantMessageId,
assistantResponse: chatResponse,
title,
documentContent
documentContent,
};
} catch (error) {
console.error('Fehler beim Senden der Nachricht:', error);
// Detaillierte Fehlerinformationen ausgeben
if (error instanceof Error) {
console.error('Fehlerdetails:', {
name: error.name,
message: error.message,
stack: error.stack
stack: error.stack,
});
}
return {
userMessageId: 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
* Optional: Mit spaceId werden nur Konversationen aus diesem Space geladen
*/
export async function getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
try {
let query = supabase
.from('conversations')
.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[];
const conversations = await conversationApi.getConversations(spaceId);
return conversations.map(toLocalConversation);
} catch (error) {
console.error('Fehler beim Laden der Konversationen:', error);
return [];
@ -594,19 +505,8 @@ export async function getConversations(userId: string, spaceId?: string): Promis
*/
export async function getArchivedConversations(userId: string): Promise<Conversation[]> {
try {
const { data, error } = await supabase
.from('conversations')
.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[];
const conversations = await conversationApi.getArchivedConversations();
return conversations.map(toLocalConversation);
} catch (error) {
console.error('Fehler beim Laden der archivierten Konversationen:', error);
return [];
@ -618,17 +518,7 @@ export async function getArchivedConversations(userId: string): Promise<Conversa
*/
export async function archiveConversation(conversationId: string): Promise<boolean> {
try {
const { error } = await supabase
.from('conversations')
.update({ is_archived: true })
.eq('id', conversationId);
if (error) {
console.error('Fehler beim Archivieren der Konversation:', error);
return false;
}
return true;
return await conversationApi.archiveConversation(conversationId);
} catch (error) {
console.error('Fehler beim Archivieren der Konversation:', error);
return false;
@ -640,17 +530,7 @@ export async function archiveConversation(conversationId: string): Promise<boole
*/
export async function unarchiveConversation(conversationId: string): Promise<boolean> {
try {
const { error } = await supabase
.from('conversations')
.update({ is_archived: false })
.eq('id', conversationId);
if (error) {
console.error('Fehler beim Wiederherstellen der Konversation:', error);
return false;
}
return true;
return await conversationApi.unarchiveConversation(conversationId);
} catch (error) {
console.error('Fehler beim Wiederherstellen der Konversation:', error);
return false;
@ -662,29 +542,7 @@ export async function unarchiveConversation(conversationId: string): Promise<boo
*/
export async function deleteConversation(conversationId: string): Promise<boolean> {
try {
// Lösche zuerst alle zugehörigen Nachrichten
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;
return await conversationApi.deleteConversation(conversationId);
} catch (error) {
console.error('Fehler beim Löschen der Konversation:', error);
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 {
id: string;
conversation_id: string;
@ -13,37 +13,39 @@ export interface Document {
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
* @param conversationId Die ID der Konversation
* @param content Der Inhalt des Dokuments
* @returns Das erstellte Dokument oder null bei Fehler
*/
export async function createDocument(
conversationId: string,
content: string
): Promise<Document | null> {
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();
const document = await documentApi.createDocument(conversationId, content);
if (error) {
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;
}
console.log('Dokument erfolgreich erstellt:', data);
return data as Document;
console.log('Dokument erfolgreich erstellt:', document);
return toLocalDocument(document);
} catch (error) {
console.error('Fehler beim Erstellen des Dokuments:', error);
if (error instanceof Error) {
@ -55,48 +57,20 @@ export async function createDocument(
/**
* 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(
conversationId: string,
content: string
): Promise<Document | null> {
try {
// Hole die aktuelle höchste Version
const { data: latestVersionData, error: versionError } = await supabase
.from('documents')
.select('version')
.eq('conversation_id', conversationId)
.order('version', { ascending: false })
.limit(1)
.single();
const document = await documentApi.createDocumentVersion(conversationId, content);
if (versionError) {
console.error('Fehler beim Laden der letzten Dokumentversion:', versionError);
if (!document) {
console.error('Fehler beim Erstellen der neuen Dokumentversion');
return null;
}
const newVersion = (latestVersionData?.version || 0) + 1;
// 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;
return toLocalDocument(document);
} catch (error) {
console.error('Fehler beim Erstellen der neuen Dokumentversion:', error);
return null;
@ -105,32 +79,20 @@ export async function createDocumentVersion(
/**
* 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> {
try {
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();
const document = await documentApi.getLatestDocument(conversationId);
if (error) {
console.error('Fehler beim Laden des aktuellen Dokuments:', error);
if (error.code === 'PGRST116') {
console.log('Kein Dokument gefunden (PGRST116)');
}
if (!document) {
console.log('Kein Dokument gefunden');
return null;
}
console.log(`Neuestes Dokument gefunden: Version ${data.version}, ID ${data.id}`);
return data as Document;
console.log(`Neuestes Dokument gefunden: Version ${document.version}, ID ${document.id}`);
return toLocalDocument(document);
} catch (error) {
console.error('Fehler beim Laden des aktuellen Dokuments:', error);
return null;
@ -139,35 +101,22 @@ export async function getLatestDocument(conversationId: string): Promise<Documen
/**
* 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[]> {
try {
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 });
const documents = await documentApi.getAllDocumentVersions(conversationId);
if (error) {
console.error('Fehler beim Laden der Dokumentversionen:', error);
return [];
}
console.log(`${documents.length} Dokumentversionen geladen`);
console.log(`${data?.length || 0} Dokumentversionen geladen`);
// Detaillierte Infos zur Fehlersuche
if (data && data.length > 0) {
console.log(`Erstes Dokument: ID=${data[0].id}, Version=${data[0].version}`);
if (documents.length > 0) {
console.log(`Erstes Dokument: ID=${documents[0].id}, Version=${documents[0].version}`);
} else {
console.log('Keine Dokumente gefunden');
}
return data as Document[];
return documents.map(toLocalDocument);
} catch (error) {
console.error('Fehler beim Laden der Dokumentversionen:', error);
return [];
@ -176,22 +125,10 @@ export async function getAllDocumentVersions(conversationId: string): Promise<Do
/**
* 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> {
try {
const { count, error } = await supabase
.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;
return await documentApi.hasDocument(conversationId);
} catch (error) {
console.error('Fehler beim Prüfen auf Dokument:', error);
return false;
@ -200,47 +137,20 @@ export async function hasDocument(conversationId: string): Promise<boolean> {
/**
* 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> {
try {
console.log(`=== LÖSCH-OPERATION GESTARTET FÜR DOKUMENT ID ${documentId} ===`);
// Prüfe zuerst, ob das Dokument existiert
const { data: existingData, error: checkError } = await supabase
.from('documents')
.select('id')
.eq('id', documentId)
.single();
const success = await documentApi.deleteDocumentVersion(documentId);
if (checkError) {
console.error('Fehler beim Prüfen, ob das Dokument existiert:', checkError);
console.error('Details:', JSON.stringify(checkError));
return false;
if (success) {
console.log(`=== DOKUMENT ${documentId} ERFOLGREICH GELÖSCHT ===`);
} else {
console.error('Fehler beim Löschen der Dokumentversion');
}
if (!existingData) {
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;
return success;
} catch (error) {
console.error('Unerwarteter Fehler beim Löschen der Dokumentversion:', error);
if (error instanceof Error) {

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

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 = {
id: string;
name: string;
@ -24,190 +27,140 @@ export type SpaceMember = {
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[]> {
try {
const { data: memberData, error: memberError } = await supabase
.from('space_members')
.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[];
const spaces = await spaceApi.getUserSpaces();
return spaces.map(toLocalSpace);
} catch (error) {
console.error('Error in getUserSpaces:', error);
return [];
}
}
// Get spaces the user owns
/**
* Get spaces the user owns
*/
export async function getOwnedSpaces(userId: string): Promise<Space[]> {
try {
const { data, error } = await supabase
.from('spaces')
.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[];
const spaces = await spaceApi.getOwnedSpaces();
return spaces.map(toLocalSpace);
} catch (error) {
console.error('Error in getOwnedSpaces:', error);
return [];
}
}
// Get a single space by ID
/**
* Get a single space by ID
*/
export async function getSpace(spaceId: string): Promise<Space | null> {
try {
const { data, error } = await supabase
.from('spaces')
.select('*')
.eq('id', spaceId)
.single();
if (error) {
console.error('Error fetching space:', error);
const space = await spaceApi.getSpace(spaceId);
if (!space) {
return null;
}
return data as Space;
return toLocalSpace(space);
} catch (error) {
console.error('Error in getSpace:', error);
return null;
}
}
// Create a new space
/**
* Create a new space
*/
export async function createSpace(
userId: string,
name: string,
description?: string
): Promise<string | null> {
try {
const { data, error } = await supabase
.from('spaces')
.insert({
name,
description,
owner_id: userId
})
.select('id')
.single();
if (error) {
console.error('Error creating space:', error);
return null;
}
return data.id;
const space = await spaceApi.createSpace(name, description);
return space?.id || null;
} catch (error) {
console.error('Error in createSpace:', error);
return null;
}
}
// Update a space
/**
* Update a space
*/
export async function updateSpace(
spaceId: string,
updates: { name?: string; description?: string; is_archived?: boolean }
): Promise<boolean> {
try {
const { error } = await supabase
.from('spaces')
.update(updates)
.eq('id', spaceId);
if (error) {
console.error('Error updating space:', error);
return false;
}
return true;
return await spaceApi.updateSpace(spaceId, {
name: updates.name,
description: updates.description,
isArchived: updates.is_archived,
});
} catch (error) {
console.error('Error in updateSpace:', error);
return false;
}
}
// Delete a space
/**
* Delete a space
*/
export async function deleteSpace(spaceId: string): Promise<boolean> {
try {
// Delete the space (members will be cascade deleted due to foreign key constraint)
const { error } = await supabase
.from('spaces')
.delete()
.eq('id', spaceId);
if (error) {
console.error('Error deleting space:', error);
return false;
}
return true;
return await spaceApi.deleteSpace(spaceId);
} catch (error) {
console.error('Error in deleteSpace:', error);
return false;
}
}
// Get members of a space
/**
* Get members of a space
*/
export async function getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
try {
const { data, error } = await supabase
.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[];
const members = await spaceApi.getSpaceMembers(spaceId);
return members.map(toLocalSpaceMember);
} catch (error) {
console.error('Error in getSpaceMembers:', error);
return [];
}
}
// Add a member to a space
/**
* Add a member to a space (invite)
*/
export async function inviteUserToSpace(
spaceId: string,
userId: string,
@ -215,228 +168,83 @@ export async function inviteUserToSpace(
role: 'admin' | 'member' | 'viewer' = 'member'
): Promise<boolean> {
try {
// Check if user is already a member
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;
return await spaceApi.inviteUser(spaceId, userId, role);
} catch (error) {
console.error('Error in inviteUserToSpace:', error);
return false;
}
}
// Accept or decline a space invitation
/**
* Accept or decline a space invitation
*/
export async function respondToInvitation(
spaceId: string,
userId: string,
status: 'accepted' | 'declined'
): Promise<boolean> {
try {
const updates: any = {
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;
return await spaceApi.respondToInvitation(spaceId, status);
} catch (error) {
console.error('Error in respondToInvitation:', error);
return false;
}
}
// Remove a member from a space
/**
* Remove a member from a space
*/
export async function removeMember(spaceId: string, userId: string): Promise<boolean> {
try {
const { error } = await supabase
.from('space_members')
.delete()
.eq('space_id', spaceId)
.eq('user_id', userId);
if (error) {
console.error('Error removing member:', error);
return false;
}
return true;
return await spaceApi.removeMember(spaceId, userId);
} catch (error) {
console.error('Error in removeMember:', error);
return false;
}
}
// Change a member's role
/**
* Change a member's role
*/
export async function changeMemberRole(
spaceId: string,
userId: string,
newRole: 'admin' | 'member' | 'viewer'
): Promise<boolean> {
try {
const { error } = await supabase
.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;
return await spaceApi.changeMemberRole(spaceId, userId, newRole);
} catch (error) {
console.error('Error in changeMemberRole:', error);
return false;
}
}
// Get user's role in a space
/**
* Get user's role in a space
*/
export async function getUserRoleInSpace(
spaceId: string,
userId: string
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
try {
// First check if they're the owner
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;
return await spaceApi.getUserRoleInSpace(spaceId);
} catch (error) {
console.error('Error in getUserRoleInSpace:', error);
return null;
}
}
// Get pending space invitations for a user
export async function getPendingInvitations(userId: string): Promise<Array<{
invitation: SpaceMember;
space: Space;
}>> {
/**
* Get pending space invitations for a user
*/
export async function getPendingInvitations(
userId: string
): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
try {
const { data, error } = await supabase
.from('space_members')
.select(`
*,
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
const invitations = await spaceApi.getPendingInvitations();
return invitations.map((inv) => ({
invitation: toLocalSpaceMember(inv.invitation),
space: toLocalSpace(inv.space),
}));
} catch (error) {
console.error('Error in getPendingInvitations:', error);

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 {
id: string;
user_id: string;
@ -19,25 +19,31 @@ export interface Template {
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
* @param userId Die ID des Benutzers
* @returns Liste der Vorlagen
*/
export async function getTemplates(userId: string): Promise<Template[]> {
try {
const { data, error } = await supabase
.from('templates')
.select('*')
.eq('user_id', userId)
.order('name');
if (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
return [];
}
return data as Template[];
const templates = await templateApi.getTemplates();
return templates.map(toLocalTemplate);
} catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
return [];
@ -46,23 +52,14 @@ export async function getTemplates(userId: string): Promise<Template[]> {
/**
* 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> {
try {
const { data, error } = await supabase
.from('templates')
.select('*')
.eq('id', templateId)
.single();
if (error) {
console.error('Fehler beim Laden der Vorlage:', error);
const template = await templateApi.getTemplate(templateId);
if (!template) {
return null;
}
return data as Template;
return toLocalTemplate(template);
} catch (error) {
console.error('Fehler beim Laden der Vorlage:', error);
return null;
@ -71,23 +68,27 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
/**
* 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 {
const { data, error } = await supabase
.from('templates')
.insert(template)
.select()
.single();
const result = await templateApi.createTemplate({
name: template.name,
description: template.description || undefined,
systemPrompt: template.system_prompt,
initialQuestion: template.initial_question || undefined,
modelId: template.model_id || undefined,
color: template.color,
documentMode: template.document_mode,
});
if (error) {
console.error('Fehler beim Erstellen der Vorlage:', error);
if (!result) {
console.error('Fehler beim Erstellen der Vorlage');
return null;
}
return data as Template;
return toLocalTemplate(result);
} catch (error) {
console.error('Fehler beim Erstellen der Vorlage:', error);
return null;
@ -96,26 +97,25 @@ export async function createTemplate(template: Omit<Template, 'id' | 'created_at
/**
* 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(
templateId: string,
updates: Partial<Omit<Template, 'id' | 'user_id' | 'created_at' | 'updated_at'>>
): Promise<boolean> {
try {
const { error } = await supabase
.from('templates')
.update(updates)
.eq('id', templateId);
const apiUpdates: Parameters<typeof templateApi.updateTemplate>[1] = {};
if (error) {
console.error('Fehler beim Aktualisieren der Vorlage:', error);
return false;
}
if (updates.name !== undefined) apiUpdates.name = updates.name;
if (updates.description !== undefined)
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) {
console.error('Fehler beim Aktualisieren der Vorlage:', error);
return false;
@ -124,22 +124,10 @@ export async function updateTemplate(
/**
* 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> {
try {
const { error } = await supabase
.from('templates')
.delete()
.eq('id', templateId);
if (error) {
console.error('Fehler beim Löschen der Vorlage:', error);
return false;
}
return true;
return await templateApi.deleteTemplate(templateId);
} catch (error) {
console.error('Fehler beim Löschen der Vorlage:', error);
return false;
@ -148,31 +136,10 @@ export async function deleteTemplate(templateId: string): Promise<boolean> {
/**
* 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> {
try {
// Zuerst alle Vorlagen des Benutzers auf nicht-Standard setzen
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;
return await templateApi.setDefaultTemplate(templateId);
} catch (error) {
console.error('Fehler beim Setzen der Standard-Vorlage:', error);
return false;
@ -181,24 +148,14 @@ export async function setDefaultTemplate(templateId: string, userId: string): Pr
/**
* 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> {
try {
const { data, error } = await supabase
.from('templates')
.select('*')
.eq('user_id', userId)
.eq('is_default', true)
.single();
if (error) {
console.error('Fehler beim Laden der Standard-Vorlage:', error);
const template = await templateApi.getDefaultTemplate();
if (!template) {
return null;
}
return data as Template;
return toLocalTemplate(template);
} catch (error) {
console.error('Fehler beim Laden der Standard-Vorlage:', error);
return null;

View file

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

View file

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

View file

@ -1,41 +1,10 @@
/**
* 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 { createSupabaseServerClient } from '$lib/services/supabase';
export const handle: Handle = async ({ event, resolve }) => {
// Create Supabase client for this request
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';
},
});
return resolve(event);
};

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';
const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3001';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
interface ApiResponse<T> {
data?: T;
error?: string;
}
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
};
export async function apiRequest<T>(
async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
options: FetchOptions = {},
): 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 {
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
...options,
const response = await fetch(`${API_BASE}/api${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
...options.headers,
Authorization: `Bearer ${authToken}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
return { error: errorText || `HTTP ${response.status}` };
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
const data = await response.json();
return { data };
return { data, error: null };
} 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 = {
get: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body: unknown) =>
apiRequest<T>(endpoint, {
method: 'POST',
body: JSON.stringify(body),
}),
put: <T>(endpoint: string, body: unknown) =>
apiRequest<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(body),
}),
delete: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'DELETE' }),
// ============ Conversation API ============
export type Conversation = {
id: string;
userId: string;
modelId: string;
templateId?: string;
spaceId?: string;
title?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
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 type { ChatMessage, ChatCompletionResponse, AIModel } from '@chat/types';
import { chatApi, modelApi, type ChatMessage, type ChatCompletionResponse, type Model } from './api';
export type { ChatMessage, ChatCompletionResponse };
export interface ChatCompletionRequest {
messages: ChatMessage[];
@ -16,31 +17,19 @@ export const chatService = {
/**
* Get available AI models
*/
async getModels(): Promise<AIModel[]> {
const { data, error } = await api.get<AIModel[]>('/api/chat/models');
if (error) {
console.error('Failed to fetch models:', error);
return [];
}
return data || [];
async getModels(): Promise<Model[]> {
return modelApi.getModels();
},
/**
* Send chat completion request
*/
async createCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse | null> {
const { data, error } = await api.post<ChatCompletionResponse>('/api/chat/completions', {
return chatApi.createCompletion({
messages: request.messages,
modelId: request.modelId,
temperature: request.temperature ?? 0.7,
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 { chatService } from './chat';
import type { Conversation, Message, ChatMessage } from '@chat/types';
import {
conversationApi,
chatApi,
type Conversation,
type Message,
type ChatMessage,
} from './api';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export type { Conversation, Message };
export const conversationService = {
/**
@ -25,116 +22,45 @@ export const conversationService = {
mode: 'free' | 'guided' | 'template' = 'free',
templateId?: string,
documentMode: boolean = false,
spaceId?: string
spaceId?: string,
): Promise<string | null> {
const sb = getSupabase();
const conversation = await conversationApi.createConversation({
modelId,
conversationMode: mode,
templateId,
documentMode,
spaceId,
});
const { data, error } = await sb
.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;
return conversation?.id || null;
},
/**
* Get all active conversations for a user
*/
async getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
const sb = getSupabase();
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[];
return conversationApi.getConversations(spaceId);
},
/**
* Get archived conversations
*/
async getArchivedConversations(userId: string): Promise<Conversation[]> {
const sb = getSupabase();
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[];
return conversationApi.getArchivedConversations();
},
/**
* Get a single conversation
*/
async getConversation(conversationId: string): Promise<Conversation | null> {
const sb = getSupabase();
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;
return conversationApi.getConversation(conversationId);
},
/**
* Get messages for a conversation
*/
async getMessages(conversationId: string): Promise<Message[]> {
const sb = getSupabase();
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[];
return conversationApi.getMessages(conversationId);
},
/**
@ -143,114 +69,38 @@ export const conversationService = {
async addMessage(
conversationId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string
messageText: string,
): Promise<string | null> {
const sb = getSupabase();
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;
const message = await conversationApi.addMessage(conversationId, sender, messageText);
return message?.id || null;
},
/**
* Update conversation title
*/
async updateTitle(conversationId: string, title: string): Promise<boolean> {
const sb = getSupabase();
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;
return conversationApi.updateTitle(conversationId, title);
},
/**
* Archive a conversation
*/
async archiveConversation(conversationId: string): Promise<boolean> {
const sb = getSupabase();
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;
return conversationApi.archiveConversation(conversationId);
},
/**
* Unarchive a conversation
*/
async unarchiveConversation(conversationId: string): Promise<boolean> {
const sb = getSupabase();
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;
return conversationApi.unarchiveConversation(conversationId);
},
/**
* Delete a conversation permanently
*/
async deleteConversation(conversationId: string): Promise<boolean> {
const sb = getSupabase();
// 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;
return conversationApi.deleteConversation(conversationId);
},
/**
@ -259,7 +109,7 @@ export const conversationService = {
async sendMessageAndGetResponse(
conversationId: string,
userMessage: string,
modelId: string
modelId: string,
): Promise<{
userMessageId: string | null;
assistantMessageId: string | null;
@ -275,11 +125,11 @@ export const conversationService = {
// Build chat messages for API
const chatMessages: ChatMessage[] = messages.map((m) => ({
role: m.sender === 'user' ? 'user' : m.sender === 'assistant' ? 'assistant' : 'system',
content: m.message_text,
content: m.messageText,
}));
// Get AI response
const response = await chatService.createCompletion({
const response = await chatApi.createCompletion({
messages: chatMessages,
modelId,
});
@ -293,14 +143,11 @@ export const conversationService = {
}
// Save assistant message
const assistantMessageId = await this.addMessage(conversationId, 'assistant', response.content);
// Update conversation timestamp
const sb = getSupabase();
await sb
.from('conversations')
.update({ updated_at: new Date().toISOString() })
.eq('id', conversationId);
const assistantMessageId = await this.addMessage(
conversationId,
'assistant',
response.content,
);
// Generate title if this is a new conversation (first or second message)
let title: string | undefined;
@ -325,7 +172,7 @@ export const conversationService = {
async generateTitle(userMessage: string): Promise<string> {
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 }],
modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini
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 type { Document, DocumentWithConversation } from '@chat/types';
import { documentApi, conversationApi, type Document } from './api';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
export type { Document };
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export type DocumentWithConversation = Document & {
conversation_title: string;
};
export const documentService = {
/**
* Get all documents for a user (latest version of each)
* This requires fetching conversations first, then documents
*/
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
const { data: conversations, error: convError } = await sb
.from('conversations')
.select('id, title, document_mode')
.eq('user_id', userId)
.eq('document_mode', true);
// Filter to only document mode conversations
const documentConversations = conversations.filter((c) => c.documentMode);
if (convError) {
console.error('Error loading conversations:', convError);
return [];
}
if (!conversations || conversations.length === 0) {
if (documentConversations.length === 0) {
return [];
}
// For each conversation, load the latest document version
const documents: DocumentWithConversation[] = [];
for (const conv of conversations) {
const { data: docData, error: docError } = await sb
.from('documents')
.select('*')
.eq('conversation_id', conv.id)
.order('version', { ascending: false })
.limit(1)
.single();
for (const conv of documentConversations) {
const doc = await documentApi.getLatestDocument(conv.id);
if (docError && docError.code !== 'PGRST116') {
console.error(`Error loading document for conversation ${conv.id}:`, docError);
continue;
}
if (docData) {
if (doc) {
documents.push({
...docData,
...doc,
conversation_title: conv.title || 'Unbenannte Konversation',
});
}
@ -69,108 +47,44 @@ export const documentService = {
* Get the latest document for a conversation
*/
async getLatestDocument(conversationId: string): Promise<Document | null> {
const sb = getSupabase();
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;
return documentApi.getLatestDocument(conversationId);
},
/**
* Create a new document
*/
async createDocument(conversationId: string, content: string): Promise<Document | null> {
const sb = getSupabase();
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;
return documentApi.createDocument(conversationId, content);
},
/**
* Create a new version of a document
*/
async createDocumentVersion(conversationId: string, content: string): Promise<Document | null> {
const sb = getSupabase();
// Get the current highest version
const { data: latestVersionData, error: versionError } = await sb
.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;
async createDocumentVersion(
conversationId: string,
content: string,
): Promise<Document | null> {
return documentApi.createDocumentVersion(conversationId, content);
},
/**
* Get all versions of a document
*/
async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
const sb = getSupabase();
return documentApi.getAllDocumentVersions(conversationId);
},
const { data, error } = await sb
.from('documents')
.select('*')
.eq('conversation_id', conversationId)
.order('version', { ascending: false });
/**
* Check if a document exists for a conversation
*/
async hasDocument(conversationId: string): Promise<boolean> {
return documentApi.hasDocument(conversationId);
},
if (error) {
console.error('Error loading document versions:', error);
return [];
}
return data as Document[];
/**
* Delete a specific document version
*/
async deleteDocumentVersion(documentId: string): Promise<boolean> {
return documentApi.deleteDocumentVersion(documentId);
},
};

View file

@ -1,151 +1,60 @@
/**
* Space Service - CRUD operations via Supabase
* Space Service - CRUD operations via Backend API
*/
import { createSupabaseBrowserClient } from './supabase';
import type { Space, SpaceMember, SpaceCreate, SpaceUpdate } from '@chat/types';
import { spaceApi, type Space, type SpaceMember } from './api';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export type { Space, SpaceMember };
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[]> {
const sb = getSupabase();
// 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[];
return spaceApi.getUserSpaces();
},
/**
* Get a single space by ID
*/
async getSpace(spaceId: string): Promise<Space | null> {
const sb = getSupabase();
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;
return spaceApi.getSpace(spaceId);
},
/**
* Create a new space
*/
async createSpace(space: SpaceCreate): Promise<string | null> {
const sb = getSupabase();
const { data, error } = await sb
.from('spaces')
.insert({
name: space.name,
description: space.description,
owner_id: space.owner_id,
})
.select('id')
.single();
if (error) {
console.error('Error creating space:', error);
return null;
}
return data.id;
async createSpace(space: {
name: string;
description?: string;
owner_id: string;
}): Promise<string | null> {
const result = await spaceApi.createSpace(space.name, space.description);
return result?.id || null;
},
/**
* Update a space
*/
async updateSpace(spaceId: string, updates: SpaceUpdate): Promise<boolean> {
const sb = getSupabase();
const { error } = await sb.from('spaces').update(updates).eq('id', spaceId);
if (error) {
console.error('Error updating space:', error);
return false;
}
return true;
async updateSpace(
spaceId: string,
updates: { name?: string; description?: string; isArchived?: boolean },
): Promise<boolean> {
return spaceApi.updateSpace(spaceId, updates);
},
/**
* Delete a space
*/
async deleteSpace(spaceId: string): Promise<boolean> {
const sb = getSupabase();
const { error } = await sb.from('spaces').delete().eq('id', spaceId);
if (error) {
console.error('Error deleting space:', error);
return false;
}
return true;
return spaceApi.deleteSpace(spaceId);
},
/**
* Get members of a space
*/
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const sb = getSupabase();
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[];
return spaceApi.getSpaceMembers(spaceId);
},
/**
@ -153,62 +62,63 @@ export const spaceService = {
*/
async getUserRoleInSpace(
spaceId: string,
userId: string
userId: string,
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
const sb = getSupabase();
// 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;
return spaceApi.getUserRoleInSpace(spaceId);
},
/**
* Leave a space
* Leave a space (remove self from members)
*/
async leaveSpace(spaceId: string, userId: string): Promise<boolean> {
const sb = getSupabase();
return spaceApi.removeMember(spaceId, userId);
},
const { error } = await sb
.from('space_members')
.delete()
.eq('space_id', spaceId)
.eq('user_id', userId);
/**
* Invite a user to a space
*/
async inviteUserToSpace(
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);
return false;
}
/**
* Respond to a space invitation
*/
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 type { Template, TemplateCreate, TemplateUpdate } from '@chat/types';
import { templateApi, type Template } from './api';
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
function getSupabase() {
if (!supabase) {
supabase = createSupabaseBrowserClient();
}
return supabase;
}
export type { Template };
export const templateService = {
/**
* Get all templates for a user
* Get all templates for the current user
*/
async getTemplates(userId: string): Promise<Template[]> {
const sb = getSupabase();
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[];
return templateApi.getTemplates();
},
/**
* Get a single template by ID
*/
async getTemplate(templateId: string): Promise<Template | null> {
const sb = getSupabase();
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;
return templateApi.getTemplate(templateId);
},
/**
* Get the default template for a user
* Get the default template for the current user
*/
async getDefaultTemplate(userId: string): Promise<Template | null> {
const sb = getSupabase();
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;
return templateApi.getDefaultTemplate();
},
/**
* Create a new template
*/
async createTemplate(template: TemplateCreate): Promise<Template | null> {
const sb = getSupabase();
const { data, error } = await sb
.from('templates')
.insert(template)
.select()
.single();
if (error) {
console.error('Error creating template:', error);
return null;
}
return data as Template;
async createTemplate(template: {
userId: string;
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
}): Promise<Template | null> {
return templateApi.createTemplate({
name: template.name,
description: template.description,
systemPrompt: template.systemPrompt,
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> {
const sb = getSupabase();
const { error } = await sb
.from('templates')
.update(updates)
.eq('id', templateId);
if (error) {
console.error('Error updating template:', error);
return false;
}
return true;
async updateTemplate(
templateId: string,
updates: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
): Promise<boolean> {
return templateApi.updateTemplate(templateId, updates);
},
/**
* Delete a template
*/
async deleteTemplate(templateId: string): Promise<boolean> {
const sb = getSupabase();
const { error } = await sb.from('templates').delete().eq('id', templateId);
if (error) {
console.error('Error deleting template:', error);
return false;
}
return true;
return templateApi.deleteTemplate(templateId);
},
/**
* Set a template as default
*/
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
const sb = getSupabase();
// 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;
return templateApi.setDefaultTemplate(templateId);
},
};

View file

@ -3,14 +3,26 @@
* Now using Mana Core Auth instead of Supabase Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
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 { 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
let user = $state<UserData | null>(null);
@ -38,6 +50,13 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
@ -58,6 +77,11 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
@ -80,6 +104,11 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
@ -105,6 +134,12 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
@ -119,6 +154,11 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
@ -137,6 +177,11 @@ export const authStore = {
* Get user credit balance
*/
async getCredits() {
const authService = getAuthService();
if (!authService) {
return null;
}
try {
const credits = await authService.getUserCredits();
return credits;
@ -150,6 +195,10 @@ export const authStore = {
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

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

View file

@ -1,22 +1,13 @@
/**
* 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';
export const load: LayoutServerLoad = async ({ locals, url }) => {
const { session, user } = await locals.safeGetSession();
if (!session) {
// Redirect to login with return URL
const redirectTo = encodeURIComponent(url.pathname);
redirect(303, `/login?redirectTo=${redirectTo}`);
}
export const load: LayoutServerLoad = async ({ url }) => {
// Return the current path for client-side redirect logic
return {
session,
user,
pathname: url.pathname,
};
};

View file

@ -7,11 +7,19 @@
let { children, data }: { children: any; data: LayoutData } = $props();
// Set session from server data
onMount(() => {
if (data.session) {
authStore.setSession(data.session);
let isChecking = $state(true);
// Check auth on mount and redirect if not authenticated
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const redirectTo = encodeURIComponent(data.pathname || '/chat');
goto(`/login?redirectTo=${redirectTo}`);
return;
}
isChecking = false;
});
async function handleSignOut() {
@ -20,93 +28,100 @@
}
</script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Top Navigation -->
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
ManaChat
</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
{#if isChecking}
<!-- Loading state while checking auth -->
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
</div>
{:else}
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Top Navigation -->
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
ManaChat
</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
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'}"
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"
>
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
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'}"
<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"
>
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>
Abmelden
</button>
</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>
</nav>
</nav>
<!-- Main Content -->
<main>
{@render children()}
</main>
</div>
<!-- Main Content -->
<main>
{@render children()}
</main>
</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_URL=http://localhost:3001
# Supabase Configuration (for database only, not auth)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key-here
# PostgreSQL Database Configuration
DATABASE_URL=postgresql://chat:password@localhost:5432/chat
# Server Configuration
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",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [],
"watchAssets": false
}
}

View file

@ -10,7 +10,18 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"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": {
"@manacore/shared-errors": "workspace:*",
@ -18,10 +29,13 @@
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "^2.47.10",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@ -40,6 +54,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

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

View file

@ -17,7 +17,7 @@ export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get('models')
getModels() {
async getModels() {
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 { eq } from 'drizzle-orm';
import {
type AsyncResult,
ok,
@ -7,22 +8,11 @@ import {
ValidationError,
ServiceError,
} 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';
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()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
@ -30,72 +20,55 @@ export class ChatService {
private readonly endpoint: string;
private readonly apiVersion: string;
// Available models configuration
private readonly availableModels: AIModel[] = [
{
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) {
constructor(
private configService: ConfigService,
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {
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.apiVersion = this.configService.get<string>('AZURE_OPENAI_API_VERSION') || '2024-12-01-preview';
this.endpoint =
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) {
this.logger.warn('AZURE_OPENAI_API_KEY is not set!');
}
}
getAvailableModels(): AIModel[] {
return this.availableModels;
async getAvailableModels(): Promise<Model[]> {
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 {
return this.availableModels.find((m) => m.id === modelId);
async getModelById(modelId: string): Promise<Model | undefined> {
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(
dto: ChatCompletionDto,
userId?: string,
): AsyncResult<ChatCompletionResponseDto> {
const model = this.getModelById(dto.modelId);
const model = await this.getModelById(dto.modelId);
if (!model) {
return err(
@ -110,9 +83,15 @@ export class ChatService {
);
}
const deployment = model.parameters.deployment;
const temperature = dto.temperature ?? model.parameters.temperature;
const maxTokens = dto.maxTokens ?? model.parameters.max_tokens;
const params = model.parameters as {
deployment?: string;
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
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 {
ConversationService,
type Conversation,
type Message,
} from './conversation.service';
Body,
Controller,
Delete,
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 {
CurrentUser,
@ -19,8 +27,27 @@ export class ConversationController {
@Get()
async getConversations(
@CurrentUser() user: CurrentUserData,
@Query('spaceId') spaceId?: string,
): 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)) {
throw result.error;
@ -34,8 +61,10 @@ export class ConversationController {
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
// TODO: Add ownership check - ensure conversation belongs to user
const result = await this.conversationService.getConversation(id);
const result = await this.conversationService.getConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
@ -49,8 +78,7 @@ export class ConversationController {
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Message[]> {
// TODO: Add ownership check - ensure conversation belongs to user
const result = await this.conversationService.getMessages(id);
const result = await this.conversationService.getMessages(id, user.userId);
if (!isOk(result)) {
throw result.error;
@ -61,13 +89,27 @@ export class ConversationController {
@Post()
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,
): Promise<Conversation> {
const result = await this.conversationService.createConversation(
user.userId,
body.modelId,
body.title,
{
title: body.title,
templateId: body.templateId,
conversationMode: body.conversationMode,
documentMode: body.documentMode,
spaceId: body.spaceId,
},
);
if (!isOk(result)) {
@ -83,9 +125,9 @@ export class ConversationController {
@Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string },
@CurrentUser() user: CurrentUserData,
): Promise<Message> {
// TODO: Add ownership check - ensure conversation belongs to user
const result = await this.conversationService.addMessage(
id,
user.userId,
body.sender,
body.messageText,
);
@ -96,4 +138,74 @@ export class ConversationController {
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 { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
ServiceError,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
export interface Conversation {
id: string;
user_id: string;
model_id: string;
title?: string;
is_archived: boolean;
created_at: string;
updated_at: string;
}
export interface Message {
id: string;
conversation_id: string;
sender: 'user' | 'assistant' | 'system';
message_text: string;
created_at: string;
}
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
conversations,
type Conversation,
type NewConversation,
} from '../db/schema/conversations.schema';
import { messages, type Message, type NewMessage } from '../db/schema/messages.schema';
@Injectable()
export class ConversationService {
private readonly logger = new Logger(ConversationService.name);
private supabase: SupabaseClient | null = null;
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_KEY');
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
} else {
this.logger.warn('Supabase configuration missing');
}
}
async getConversations(
userId: string,
spaceId?: string,
): AsyncResult<Conversation[]> {
try {
const conditions = [
eq(conversations.userId, userId),
eq(conversations.isArchived, false),
];
async getConversations(userId: string): AsyncResult<Conversation[]> {
if (!this.supabase) {
return err(ServiceError.unavailable('Database'));
}
if (spaceId) {
conditions.push(eq(conversations.spaceId, spaceId));
}
const { data, error } = await this.supabase
.from('conversations')
.select('*')
.eq('user_id', userId)
.eq('is_archived', false)
.order('updated_at', { ascending: false });
const result = await this.db
.select()
.from(conversations)
.where(and(...conditions))
.orderBy(desc(conversations.updatedAt));
if (error) {
return ok(result);
} catch (error) {
this.logger.error('Error fetching conversations', error);
return err(DatabaseError.queryFailed('Failed to fetch conversations'));
}
return ok(data || []);
}
async getConversation(id: string): AsyncResult<Conversation> {
if (!this.supabase) {
return err(ServiceError.unavailable('Database'));
async getArchivedConversations(userId: string): AsyncResult<Conversation[]> {
try {
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
.from('conversations')
.select('*')
.eq('id', id)
.single();
async getConversation(id: string, userId: string): AsyncResult<Conversation> {
try {
const result = await this.db
.select()
.from(conversations)
.where(
and(eq(conversations.id, id), eq(conversations.userId, userId)),
)
.limit(1);
if (error) {
this.logger.error('Error fetching conversation', error);
if (error.code === 'PGRST116') {
if (result.length === 0) {
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 ok(data);
}
async getMessages(conversationId: string): AsyncResult<Message[]> {
if (!this.supabase) {
return err(ServiceError.unavailable('Database'));
}
async getMessages(
conversationId: string,
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
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
const result = await this.db
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(asc(messages.createdAt));
if (error) {
return ok(result);
} catch (error) {
this.logger.error('Error fetching messages', error);
return err(DatabaseError.queryFailed('Failed to fetch messages'));
}
return ok(data || []);
}
async createConversation(
userId: string,
modelId: string,
title?: string,
options?: {
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
},
): AsyncResult<Conversation> {
if (!this.supabase) {
return err(ServiceError.unavailable('Database'));
}
try {
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
.from('conversations')
.insert({
user_id: userId,
model_id: modelId,
title: title || 'Neue Unterhaltung',
is_archived: false,
})
.select()
.single();
const result = await this.db
.insert(conversations)
.values(newConversation)
.returning();
if (error) {
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating conversation', error);
return err(DatabaseError.queryFailed('Failed to create conversation'));
}
return ok(data);
}
async addMessage(
conversationId: string,
userId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string,
): AsyncResult<Message> {
if (!this.supabase) {
return err(ServiceError.unavailable('Database'));
}
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
.from('messages')
.insert({
conversation_id: conversationId,
const newMessage: NewMessage = {
conversationId,
sender,
message_text: messageText,
})
.select()
.single();
messageText,
};
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);
return err(DatabaseError.queryFailed('Failed to add message'));
}
}
// Update conversation updated_at
await this.supabase
.from('conversations')
.update({ updated_at: new Date().toISOString() })
.eq('id', conversationId);
async updateTitle(
conversationId: string,
userId: string,
title: 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);
}
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 { ValidationPipe } from '@nestjs/common';
import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
import { AppModule } from './app.module';
async function bootstrap() {
@ -13,13 +12,14 @@ async function bootstrap() {
'http://localhost:5173',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Global exception filter for standardized error responses
app.useGlobalFilters(new AppExceptionFilter());
// Global exception filter will be added later via module
// app.useGlobalFilters(new AppExceptionFilter());
// Enable validation
app.useGlobalPipes(
@ -33,7 +33,7 @@ async function bootstrap() {
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3001;
const port = process.env.PORT || 3002;
await app.listen(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": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
@ -10,6 +11,7 @@
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
@ -17,5 +19,7 @@
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": 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/storage": "^7.15.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/common": "^10.0.0",
"@nestjs/config": "^4.0.0",

View file

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

View file

@ -67,7 +67,18 @@
"dev:news:api": "pnpm --filter @news/api start:dev",
"dev:news:app": "turbo run dev --filter=@news/web --filter=@news/api",
"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": {
"prettier": "^3.3.3",

View file

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

View file

@ -3,13 +3,20 @@
"version": "0.1.0",
"private": true,
"description": "Go-like error handling system for Manacore backends",
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"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": {
"build": "tsc -p tsconfig.build.json",
"type-check": "tsc --noEmit",
"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"
},
"dependencies": {
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
"@manacore/uload-database": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",