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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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