From 9363063cd728229495fd473636bc4d15dcbb1f8d Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 21:34:08 +0200 Subject: [PATCH] feat(api): port remaining 12 modules to unified API server Complete consolidation of all 15 app servers into one Hono/Bun process. Modules added: chat, context, picture, storage, todo, planta, nutriphi, guides, moodlit, news, traces, presi Total: 15 modules, one server, one port (3050), ~2400 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/package.json | 6 + apps/api/src/index.ts | 24 ++ apps/api/src/modules/chat/routes.ts | 128 ++++++++++ apps/api/src/modules/context/routes.ts | 85 +++++++ apps/api/src/modules/guides/routes.ts | 217 +++++++++++++++++ apps/api/src/modules/moodlit/routes.ts | 41 ++++ apps/api/src/modules/news/routes.ts | 186 +++++++++++++++ apps/api/src/modules/nutriphi/routes.ts | 140 +++++++++++ apps/api/src/modules/picture/routes.ts | 136 +++++++++++ apps/api/src/modules/planta/routes.ts | 89 +++++++ apps/api/src/modules/presi/routes.ts | 247 +++++++++++++++++++ apps/api/src/modules/storage/routes.ts | 107 +++++++++ apps/api/src/modules/todo/routes.ts | 304 ++++++++++++++++++++++++ apps/api/src/modules/traces/routes.ts | 304 ++++++++++++++++++++++++ 14 files changed, 2014 insertions(+) create mode 100644 apps/api/src/modules/chat/routes.ts create mode 100644 apps/api/src/modules/context/routes.ts create mode 100644 apps/api/src/modules/guides/routes.ts create mode 100644 apps/api/src/modules/moodlit/routes.ts create mode 100644 apps/api/src/modules/news/routes.ts create mode 100644 apps/api/src/modules/nutriphi/routes.ts create mode 100644 apps/api/src/modules/picture/routes.ts create mode 100644 apps/api/src/modules/planta/routes.ts create mode 100644 apps/api/src/modules/presi/routes.ts create mode 100644 apps/api/src/modules/storage/routes.ts create mode 100644 apps/api/src/modules/todo/routes.ts create mode 100644 apps/api/src/modules/traces/routes.ts diff --git a/apps/api/package.json b/apps/api/package.json index 68db80757..4aef6cfe5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,11 +12,17 @@ "dependencies": { "@manacore/shared-hono": "workspace:*", "@manacore/shared-storage": "workspace:*", + "@mozilla/readability": "^0.5.0", + "drizzle-orm": "^0.38.0", "hono": "^4.7.0", + "jsdom": "^25.0.0", + "postgres": "^3.4.0", + "rrule": "^2.8.1", "zod": "^3.23.0" }, "devDependencies": { "@types/bun": "latest", + "@types/jsdom": "^21.1.0", "typescript": "^5.8.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c5982b34e..f15ed23ec 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,6 +19,18 @@ import { import { calendarRoutes } from './modules/calendar/routes'; import { contactsRoutes } from './modules/contacts/routes'; import { mukkeRoutes } from './modules/mukke/routes'; +import { chatRoutes } from './modules/chat/routes'; +import { contextRoutes } from './modules/context/routes'; +import { pictureRoutes } from './modules/picture/routes'; +import { storageRoutes } from './modules/storage/routes'; +import { todoRoutes } from './modules/todo/routes'; +import { plantaRoutes } from './modules/planta/routes'; +import { nutriphiRoutes } from './modules/nutriphi/routes'; +import { guidesRoutes } from './modules/guides/routes'; +import { moodlitRoutes } from './modules/moodlit/routes'; +import { newsRoutes } from './modules/news/routes'; +import { tracesRoutes } from './modules/traces/routes'; +import { presiRoutes } from './modules/presi/routes'; const PORT = parseInt(process.env.PORT || '3050', 10); const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); @@ -37,6 +49,18 @@ app.use('/api/*', authMiddleware()); app.route('/api/v1/calendar', calendarRoutes); app.route('/api/v1/contacts', contactsRoutes); app.route('/api/v1/mukke', mukkeRoutes); +app.route('/api/v1/chat', chatRoutes); +app.route('/api/v1/context', contextRoutes); +app.route('/api/v1/picture', pictureRoutes); +app.route('/api/v1/storage', storageRoutes); +app.route('/api/v1/todo', todoRoutes); +app.route('/api/v1/planta', plantaRoutes); +app.route('/api/v1/nutriphi', nutriphiRoutes); +app.route('/api/v1/guides', guidesRoutes); +app.route('/api/v1/moodlit', moodlitRoutes); +app.route('/api/v1/news', newsRoutes); +app.route('/api/v1/traces', tracesRoutes); +app.route('/api/v1/presi', presiRoutes); // ─── Server Info ──────────────────────────────────────────── console.log(`mana-api starting on port ${PORT}...`); diff --git a/apps/api/src/modules/chat/routes.ts b/apps/api/src/modules/chat/routes.ts new file mode 100644 index 000000000..4393d10ab --- /dev/null +++ b/apps/api/src/modules/chat/routes.ts @@ -0,0 +1,128 @@ +/** + * Chat module — LLM completions (sync + streaming SSE) + * Ported from apps/chat/apps/server + * + * CRUD for conversations/messages handled by mana-sync. + * This module handles AI completions via mana-llm or OpenRouter. + */ + +import { Hono } from 'hono'; +import { streamSSE } from 'hono/streaming'; +import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits'; + +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; + +const routes = new Hono(); + +// ─── Chat Completion (sync) ────────────────────────────────── + +routes.post('/completions', async (c) => { + const userId = c.get('userId'); + const { messages, model, temperature, maxTokens } = await c.req.json(); + + if (!messages?.length) return c.json({ error: 'messages required' }, 400); + + const isLocal = !model || model.startsWith('ollama/') || model.startsWith('local/'); + const cost = isLocal ? 0.1 : 5; + + const validation = await validateCredits(userId, 'AI_CHAT', cost); + if (!validation.hasCredits) { + return c.json({ error: 'Insufficient credits', required: cost }, 402); + } + + try { + const llmRes = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages, + model: model || 'gemma3:4b', + temperature: temperature || 0.7, + max_tokens: maxTokens || 2000, + }), + }); + + if (!llmRes.ok) return c.json({ error: 'LLM request failed' }, 502); + + const data = await llmRes.json(); + await consumeCredits(userId, 'AI_CHAT', cost, `Chat: ${model || 'gemma3:4b'}`); + + return c.json(data); + } catch (_err) { + return c.json({ error: 'Chat completion failed' }, 500); + } +}); + +// ─── Chat Completion (streaming SSE) ───────────────────────── + +routes.post('/completions/stream', async (c) => { + const userId = c.get('userId'); + const { messages, model, temperature, maxTokens } = await c.req.json(); + + if (!messages?.length) return c.json({ error: 'messages required' }, 400); + + const isLocal = !model || model.startsWith('ollama/') || model.startsWith('local/'); + const cost = isLocal ? 0.1 : 5; + + const validation = await validateCredits(userId, 'AI_CHAT', cost); + if (!validation.hasCredits) { + return c.json({ error: 'Insufficient credits' }, 402); + } + + return streamSSE(c, async (stream) => { + try { + const llmRes = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages, + model: model || 'gemma3:4b', + temperature: temperature || 0.7, + max_tokens: maxTokens || 2000, + stream: true, + }), + }); + + if (!llmRes.ok || !llmRes.body) { + await stream.writeSSE({ data: JSON.stringify({ error: 'LLM failed' }) }); + return; + } + + const reader = llmRes.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + // Forward SSE chunks directly + for (const line of chunk.split('\n')) { + if (line.startsWith('data: ')) { + await stream.writeSSE({ data: line.slice(6) }); + } + } + } + + await stream.writeSSE({ data: '[DONE]' }); + consumeCredits(userId, 'AI_CHAT', cost, `Chat stream: ${model || 'gemma3:4b'}`).catch( + () => {} + ); + } catch (_err) { + await stream.writeSSE({ data: JSON.stringify({ error: 'Stream failed' }) }); + } + }); +}); + +// ─── Models List ───────────────────────────────────────────── + +routes.get('/models', async (c) => { + try { + const res = await fetch(`${LLM_URL}/api/v1/models`); + if (res.ok) return c.json(await res.json()); + } catch { + // Fallback + } + return c.json({ models: [] }); +}); + +export { routes as chatRoutes }; diff --git a/apps/api/src/modules/context/routes.ts b/apps/api/src/modules/context/routes.ts new file mode 100644 index 000000000..4bb02eec5 --- /dev/null +++ b/apps/api/src/modules/context/routes.ts @@ -0,0 +1,85 @@ +/** + * Context module — AI text generation + token estimation + * Ported from apps/context/apps/server + * + * CRUD for spaces/documents handled by mana-sync. + */ + +import { Hono } from 'hono'; +import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits'; + +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; + +const routes = new Hono(); + +// ─── AI Generation (server-only: mana-llm) ────────────────── + +routes.post('/ai/generate', async (c) => { + const userId = c.get('userId'); + const { prompt, documents, model, maxTokens } = await c.req.json(); + + if (!prompt) return c.json({ error: 'prompt required' }, 400); + + // Validate credits + const validation = await validateCredits(userId, 'AI_CONTEXT_GENERATE', 5); + if (!validation.hasCredits) { + return c.json( + { error: 'Insufficient credits', required: 5, available: validation.availableCredits }, + 402 + ); + } + + try { + // Build messages with document context + const messages: Array<{ role: string; content: string }> = []; + + if (documents?.length) { + const contextText = documents + .map((d: { title: string; content: string }) => `--- ${d.title} ---\n${d.content}`) + .join('\n\n'); + messages.push({ + role: 'system', + content: `Verwende diese Dokumente als Kontext:\n\n${contextText}`, + }); + } + + messages.push({ role: 'user', content: prompt }); + + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages, + model: model || 'gemma3:4b', + max_tokens: maxTokens || 2000, + }), + }); + + if (!res.ok) return c.json({ error: 'AI generation failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content || ''; + const tokensUsed = data.usage?.total_tokens || 0; + + // Consume credits + await consumeCredits(userId, 'AI_CONTEXT_GENERATE', 5, `AI generation (${tokensUsed} tokens)`); + + return c.json({ content, tokensUsed, model: model || 'gemma3:4b' }); + } catch (_err) { + return c.json({ error: 'Generation failed' }, 500); + } +}); + +routes.post('/ai/estimate', async (c) => { + const { prompt, documents } = await c.req.json(); + const charCount = + (prompt?.length || 0) + + (documents || []).reduce( + (sum: number, d: { content: string }) => sum + (d.content?.length || 0), + 0 + ); + const estimatedTokens = Math.ceil(charCount / 4); + return c.json({ estimatedTokens, estimatedCost: 5 }); +}); + +export { routes as contextRoutes }; diff --git a/apps/api/src/modules/guides/routes.ts b/apps/api/src/modules/guides/routes.ts new file mode 100644 index 000000000..365c0f635 --- /dev/null +++ b/apps/api/src/modules/guides/routes.ts @@ -0,0 +1,217 @@ +/** + * Guides module — Content import (URL/text/AI) + shareable links + * Ported from apps/guides/apps/server + * + * All CRUD is handled client-side via local-first + mana-sync. + * This module handles web import via mana-search + mana-llm, and share links. + */ + +import { Hono } from 'hono'; + +const MANA_SEARCH_URL = process.env.MANA_SEARCH_URL ?? 'http://localhost:3021'; +const MANA_LLM_URL = process.env.MANA_LLM_URL ?? 'http://localhost:3030'; + +const routes = new Hono(); + +// ─── Import: URL ──────────────────────────────────────────── + +routes.post('/import/url', async (c) => { + const body = await c.req.json<{ url: string }>(); + const { url } = body; + + if (!url || !URL.canParse(url)) { + return c.json({ error: 'Ungültige URL' }, 400); + } + + // Extract content via mana-search + let extracted: { title?: string; content?: string; markdown?: string } = {}; + try { + const res = await fetch(`${MANA_SEARCH_URL}/api/v1/extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, options: { includeMarkdown: true } }), + }); + if (res.ok) { + extracted = await res.json(); + } + } catch (e) { + console.error('mana-search extract failed:', e); + } + + const content = extracted.markdown ?? extracted.content ?? ''; + if (!content) { + return c.json({ error: 'Inhalt konnte nicht extrahiert werden' }, 422); + } + + return await generateGuideFromText(c, { + title: extracted.title, + text: content, + sourceUrl: url, + }); +}); + +// ─── Import: Text/Markdown ────────────────────────────────── + +routes.post('/import/text', async (c) => { + const body = await c.req.json<{ text: string; title?: string }>(); + const { text, title } = body; + + if (!text?.trim()) { + return c.json({ error: 'Kein Text angegeben' }, 400); + } + + return await generateGuideFromText(c, { text, title }); +}); + +// ─── Import: AI Generation ────────────────────────────────── + +routes.post('/import/ai', async (c) => { + const body = await c.req.json<{ prompt: string; title?: string }>(); + const { prompt, title } = body; + + if (!prompt?.trim()) { + return c.json({ error: 'Kein Prompt angegeben' }, 400); + } + + return await generateGuideFromText(c, { + text: prompt, + title, + isAiPrompt: true, + }); +}); + +// ─── Share: Create + Retrieve ─────────────────────────────── + +// In-memory store for shared guides (replace with DB later) +const sharedGuides = new Map< + string, + { guide: unknown; sections: unknown[]; createdAt: string; expiresAt: string } +>(); + +routes.post('/share', async (c) => { + const body = await c.req.json<{ guide: unknown; sections: unknown[] }>(); + + if (!body.guide) { + return c.json({ error: 'Kein Guide-Inhalt angegeben' }, 400); + } + + const token = crypto.randomUUID().replace(/-/g, '').slice(0, 12); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days + + sharedGuides.set(token, { + guide: body.guide, + sections: body.sections ?? [], + createdAt: new Date().toISOString(), + expiresAt, + }); + + const baseUrl = process.env.PUBLIC_BASE_URL ?? 'http://localhost:5200'; + return c.json({ token, url: `${baseUrl}/shared/${token}`, expiresAt }); +}); + +routes.get('/share/:token', (c) => { + const { token } = c.req.param(); + const shared = sharedGuides.get(token); + + if (!shared) { + return c.json({ error: 'Guide nicht gefunden oder Link abgelaufen' }, 404); + } + + if (new Date(shared.expiresAt) < new Date()) { + sharedGuides.delete(token); + return c.json({ error: 'Dieser Link ist abgelaufen' }, 410); + } + + return c.json(shared); +}); + +// ─── Shared: LLM guide generation ────────────────────────── + +async function generateGuideFromText( + c: Parameters[1]>[0], + opts: { text: string; title?: string; sourceUrl?: string; isAiPrompt?: boolean } +) { + const systemPrompt = `Du bist ein Experte für das Erstellen strukturierter Schritt-für-Schritt-Anleitungen. +Analysiere den folgenden Text und erstelle daraus eine strukturierte Anleitung im JSON-Format. + +Antworte NUR mit einem validen JSON-Objekt in diesem exakten Format: +{ + "title": "Titel der Anleitung", + "description": "Kurze Beschreibung (1-2 Sätze)", + "category": "Technik|Kochen|Sport|Lernen|Arbeit|Haushalt|Hobby|Allgemein", + "difficulty": "easy|medium|hard", + "estimatedMinutes": Zahl, + "tags": ["tag1", "tag2"], + "sections": [ + { + "title": "Abschnitt-Titel (optional, leer lassen wenn keine Sections nötig)", + "steps": [ + { + "title": "Schritt-Titel", + "content": "Optionale Details oder Code", + "type": "instruction|warning|tip|checkpoint|code" + } + ] + } + ] +} + +Regeln: +- Maximal 3-4 Abschnitte, maximal 8-10 Schritte pro Abschnitt +- type "warning" nur bei wirklichen Warnungen/Gefahren +- type "tip" für hilfreiche Hinweise +- type "code" wenn der Inhalt Kommandos oder Code enthält +- type "checkpoint" für Überprüfungsschritte +- Wenn kein sinnvolles Abschnitt-System, eine leere Section mit title "" +- Schritt-Titel: prägnant, maximal 80 Zeichen +- Auf Deutsch antworten`; + + const userMessage = opts.isAiPrompt + ? `Erstelle eine Anleitung für: ${opts.text}` + : `Hier ist der Inhalt, den du in eine Anleitung umwandeln sollst:\n\n${opts.text.slice(0, 8000)}`; + + try { + const llmRes = await fetch(`${MANA_LLM_URL}/api/v1/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + model: 'claude-haiku-4-5-20251001', + temperature: 0.3, + maxTokens: 4096, + }), + }); + + if (!llmRes.ok) { + throw new Error(`LLM error: ${llmRes.status}`); + } + + const llmData = await llmRes.json<{ content: string }>(); + const rawJson = llmData.content.trim(); + + // Extract JSON from potential markdown code fences + const jsonMatch = rawJson.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ?? [null, rawJson]; + const parsed = JSON.parse(jsonMatch[1] ?? rawJson); + + return c.json({ + guide: { + title: opts.title ?? parsed.title, + description: parsed.description, + category: parsed.category ?? 'Allgemein', + difficulty: parsed.difficulty ?? 'medium', + estimatedMinutes: parsed.estimatedMinutes, + tags: parsed.tags ?? [], + sourceUrl: opts.sourceUrl, + }, + sections: parsed.sections ?? [], + }); + } catch (e) { + console.error('Guide generation failed:', e); + return c.json({ error: 'Guide-Generierung fehlgeschlagen', details: String(e) }, 500); + } +} + +export { routes as guidesRoutes }; diff --git a/apps/api/src/modules/moodlit/routes.ts b/apps/api/src/modules/moodlit/routes.ts new file mode 100644 index 000000000..8e2e12e58 --- /dev/null +++ b/apps/api/src/modules/moodlit/routes.ts @@ -0,0 +1,41 @@ +/** + * Moodlit module — Preset moods library + * Ported from apps/moodlit/apps/server + * + * Local-first for user moods/sequences. + * This module serves the default preset library. + */ + +import { Hono } from 'hono'; + +const DEFAULT_MOODS = [ + { id: 'fire', name: 'Fire', colors: ['#ff6b35', '#f72585', '#ff006e'], animation: 'flicker' }, + { id: 'breath', name: 'Breath', colors: ['#4361ee', '#3a0ca3', '#7209b7'], animation: 'pulse' }, + { + id: 'northern-lights', + name: 'Northern Lights', + colors: ['#06d6a0', '#118ab2', '#073b4c'], + animation: 'aurora', + }, + { id: 'thunder', name: 'Thunder', colors: ['#14213d', '#fca311', '#e5e5e5'], animation: 'flash' }, + { + id: 'sunset', + name: 'Sunset', + colors: ['#ff6b6b', '#feca57', '#ff9ff3'], + animation: 'gradient', + }, + { id: 'ocean', name: 'Ocean', colors: ['#0077b6', '#00b4d8', '#90e0ef'], animation: 'wave' }, + { id: 'forest', name: 'Forest', colors: ['#2d6a4f', '#40916c', '#52b788'], animation: 'sway' }, + { + id: 'lavender', + name: 'Lavender', + colors: ['#7b2cbf', '#9d4edd', '#c77dff'], + animation: 'pulse', + }, +]; + +const routes = new Hono(); + +routes.get('/presets', (c) => c.json(DEFAULT_MOODS)); + +export { routes as moodlitRoutes }; diff --git a/apps/api/src/modules/news/routes.ts b/apps/api/src/modules/news/routes.ts new file mode 100644 index 000000000..df51e64d4 --- /dev/null +++ b/apps/api/src/modules/news/routes.ts @@ -0,0 +1,186 @@ +/** + * News module — Article extraction + AI feed + * Ported from apps/news/apps/server + * + * Saved articles handled by local-first + mana-sync. + * This module handles content extraction (Mozilla Readability) and feed from sync_changes. + */ + +import { Hono } from 'hono'; +import { Readability } from '@mozilla/readability'; +import { JSDOM } from 'jsdom'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { sql } from 'drizzle-orm'; + +// ─── DB Connection (reads from sync_changes for feed) ─────── + +const DATABASE_URL = + process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_sync'; + +const connection = postgres(DATABASE_URL, { max: 10 }); +const db = drizzle(connection); + +// ─── Extract Service ──────────────────────────────────────── + +interface ExtractedArticle { + title: string; + content: string; + htmlContent: string; + excerpt: string; + byline: string | null; + siteName: string | null; + wordCount: number; + readingTimeMinutes: number; +} + +async function extractFromUrl(url: string): Promise { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; ManaNews/1.0; +https://mana.how)', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch URL: ${response.status}`); + } + + const html = await response.text(); + const dom = new JSDOM(html, { url }); + const reader = new Readability(dom.window.document); + const article = reader.parse(); + + if (!article) { + throw new Error('Could not extract article content'); + } + + const wordCount = article.textContent.split(/\s+/).filter(Boolean).length; + const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 200)); + + return { + title: article.title, + content: article.textContent, + htmlContent: article.content, + excerpt: article.excerpt || article.textContent.slice(0, 200), + byline: article.byline || null, + siteName: article.siteName || null, + wordCount, + readingTimeMinutes, + }; +} + +// ─── Routes ───────────────────────────────────────────────── + +const routes = new Hono(); + +// ─── Feed (public, reads from sync_changes) ───────────────── + +routes.get('/feed', async (c) => { + const type = c.req.query('type'); + const categoryId = c.req.query('categoryId'); + const limit = parseInt(c.req.query('limit') || '20', 10); + const offset = parseInt(c.req.query('offset') || '0', 10); + + let whereClause = sql`app_id = 'news' AND table_name = 'articles' AND op != 'delete'`; + + if (type) { + whereClause = sql`${whereClause} AND data->>'type' = ${type}`; + } + if (categoryId) { + whereClause = sql`${whereClause} AND data->>'categoryId' = ${categoryId}`; + } + + const result = await db.execute(sql` + SELECT DISTINCT ON (record_id) + record_id as id, + data->>'title' as title, + data->>'excerpt' as excerpt, + data->>'author' as author, + data->>'imageUrl' as "imageUrl", + data->>'type' as type, + data->>'categoryId' as "categoryId", + (data->>'wordCount')::int as "wordCount", + (data->>'readingTimeMinutes')::int as "readingTimeMinutes", + data->>'publishedAt' as "publishedAt", + created_at as "createdAt" + FROM sync_changes + WHERE ${whereClause} + ORDER BY record_id, created_at DESC + LIMIT ${limit} OFFSET ${offset} + `); + + return c.json(result as unknown as Record[]); +}); + +routes.get('/feed/:id', async (c) => { + const id = c.req.param('id'); + + const result = await db.execute(sql` + SELECT DISTINCT ON (record_id) + record_id as id, + data->>'title' as title, + data->>'content' as content, + data->>'htmlContent' as "htmlContent", + data->>'excerpt' as excerpt, + data->>'author' as author, + data->>'imageUrl' as "imageUrl", + data->>'originalUrl' as "originalUrl", + data->>'type' as type, + (data->>'wordCount')::int as "wordCount", + (data->>'readingTimeMinutes')::int as "readingTimeMinutes", + data->>'publishedAt' as "publishedAt", + created_at as "createdAt" + FROM sync_changes + WHERE app_id = 'news' AND table_name = 'articles' AND record_id = ${id} AND op != 'delete' + ORDER BY record_id, created_at DESC + LIMIT 1 + `); + + const rows = result as unknown as Record[]; + if (!rows[0]) return c.json({ error: 'Article not found' }, 404); + return c.json(rows[0]); +}); + +// ─── Extract (content extraction) ─────────────────────────── + +routes.post('/extract/preview', async (c) => { + const { url } = await c.req.json<{ url: string }>(); + if (!url) return c.json({ error: 'URL is required' }, 400); + + try { + const article = await extractFromUrl(url); + return c.json(article); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : 'Extraction failed' }, 500); + } +}); + +routes.post('/extract/save', async (c) => { + const { url } = await c.req.json<{ url: string }>(); + if (!url) return c.json({ error: 'URL is required' }, 400); + + try { + const extracted = await extractFromUrl(url); + + // Return extracted data -- client saves to local-first store + return c.json({ + id: crypto.randomUUID(), + type: 'saved', + sourceOrigin: 'user_saved', + originalUrl: url, + title: extracted.title, + content: extracted.content, + htmlContent: extracted.htmlContent, + excerpt: extracted.excerpt, + author: extracted.byline, + siteName: extracted.siteName, + wordCount: extracted.wordCount, + readingTimeMinutes: extracted.readingTimeMinutes, + isArchived: false, + }); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : 'Extraction failed' }, 500); + } +}); + +export { routes as newsRoutes }; diff --git a/apps/api/src/modules/nutriphi/routes.ts b/apps/api/src/modules/nutriphi/routes.ts new file mode 100644 index 000000000..a52fccb40 --- /dev/null +++ b/apps/api/src/modules/nutriphi/routes.ts @@ -0,0 +1,140 @@ +/** + * NutriPhi module — Meal analysis (Gemini) + recommendations + * Ported from apps/nutriphi/apps/server + * + * CRUD for meals, goals, favorites handled by mana-sync. + * This module handles AI analysis and rule-based recommendations. + */ + +import { Hono } from 'hono'; + +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; + +const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die Mahlzeit und gib ein JSON zurück mit: +{ + "foods": [{"name": "...", "quantity": "...", "calories": 0}], + "totalNutrition": {"calories": 0, "protein": 0, "carbohydrates": 0, "fat": 0, "fiber": 0, "sugar": 0}, + "description": "Kurze Beschreibung der Mahlzeit", + "confidence": 0.0-1.0, + "warnings": [], + "suggestions": [] +}`; + +const routes = new Hono(); + +// ─── Photo Analysis (server-only: Gemini Vision) ──────────── + +routes.post('/analysis/photo', async (c) => { + const { imageBase64, mimeType } = await c.req.json(); + if (!imageBase64) return c.json({ error: 'imageBase64 required' }, 400); + + try { + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { role: 'system', content: ANALYSIS_PROMPT }, + { + role: 'user', + content: [ + { type: 'text', text: 'Analysiere diese Mahlzeit.' }, + { + type: 'image_url', + image_url: { url: `data:${mimeType || 'image/jpeg'};base64,${imageBase64}` }, + }, + ], + }, + ], + model: process.env.GEMINI_MODEL || 'gemini-2.0-flash', + response_format: { type: 'json_object' }, + temperature: 0.3, + }), + }); + + if (!res.ok) return c.json({ error: 'AI analysis failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content; + const analysis = typeof content === 'string' ? JSON.parse(content) : content; + + return c.json(analysis); + } catch (err) { + console.error('Photo analysis failed:', err); + return c.json({ error: 'Analysis failed' }, 500); + } +}); + +// ─── Text Analysis (server-only: Gemini) ───────────────────── + +routes.post('/analysis/text', async (c) => { + const { description } = await c.req.json(); + if (!description) return c.json({ error: 'description required' }, 400); + + try { + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { role: 'system', content: ANALYSIS_PROMPT }, + { role: 'user', content: `Analysiere diese Mahlzeit: ${description}` }, + ], + model: process.env.GEMINI_MODEL || 'gemini-2.0-flash', + response_format: { type: 'json_object' }, + temperature: 0.3, + }), + }); + + if (!res.ok) return c.json({ error: 'AI analysis failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content; + const analysis = typeof content === 'string' ? JSON.parse(content) : content; + + return c.json(analysis); + } catch (err) { + console.error('Text analysis failed:', err); + return c.json({ error: 'Analysis failed' }, 500); + } +}); + +// ─── Recommendations (server-only: rule engine) ────────────── + +routes.post('/recommendations/generate', async (c) => { + const { dailyNutrition } = await c.req.json(); + const hints: Array<{ type: string; priority: string; message: string; nutrient?: string }> = []; + + if (dailyNutrition) { + if (dailyNutrition.protein < 25) { + hints.push({ + type: 'hint', + priority: 'medium', + message: + 'Deine Proteinzufuhr ist niedrig. Versuche Hülsenfrüchte, Eier oder Joghurt einzubauen.', + nutrient: 'protein', + }); + } + if (dailyNutrition.fiber < 10) { + hints.push({ + type: 'hint', + priority: 'medium', + message: 'Mehr Ballaststoffe! Vollkornprodukte, Gemüse und Obst helfen.', + nutrient: 'fiber', + }); + } + if (dailyNutrition.sugar > 50) { + hints.push({ + type: 'hint', + priority: 'high', + message: + 'Dein Zuckerkonsum ist hoch. Achte auf versteckten Zucker in Getränken und Fertigprodukten.', + nutrient: 'sugar', + }); + } + } + + return c.json({ recommendations: hints }); +}); + +export { routes as nutriphiRoutes }; diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts new file mode 100644 index 000000000..7fe515988 --- /dev/null +++ b/apps/api/src/modules/picture/routes.ts @@ -0,0 +1,136 @@ +/** + * Picture module — AI image generation + upload + * Ported from apps/picture/apps/server + * + * CRUD for images/boards/boardItems handled by mana-sync. + * This module handles Replicate API, S3 uploads, and explore. + */ + +import { Hono } from 'hono'; +import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits'; + +const REPLICATE_TOKEN = process.env.REPLICATE_API_TOKEN || ''; +const IMAGE_GEN_URL = process.env.MANA_IMAGE_GEN_URL || ''; + +const routes = new Hono(); + +// ─── AI Image Generation (server-only: Replicate/local) ───── + +routes.post('/generate', async (c) => { + const userId = c.get('userId'); + const { prompt, model, width, height, negativePrompt, steps, guidanceScale } = await c.req.json(); + + if (!prompt) return c.json({ error: 'prompt required' }, 400); + + const cost = 10; + const validation = await validateCredits(userId, 'AI_IMAGE_GENERATION', cost); + if (!validation.hasCredits) { + return c.json({ error: 'Insufficient credits', required: cost }, 402); + } + + try { + let imageUrl: string; + + if (model?.startsWith('local/') && IMAGE_GEN_URL) { + // Local generation via mana-image-gen + const res = await fetch(`${IMAGE_GEN_URL}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + negative_prompt: negativePrompt, + width: width || 1024, + height: height || 1024, + steps: steps || 20, + guidance_scale: guidanceScale || 7.5, + }), + }); + if (!res.ok) return c.json({ error: 'Local generation failed' }, 502); + const data = await res.json(); + imageUrl = data.image_url || data.url; + } else if (REPLICATE_TOKEN) { + // Cloud generation via Replicate + const res = await fetch('https://api.replicate.com/v1/predictions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${REPLICATE_TOKEN}`, + }, + body: JSON.stringify({ + model: model || 'black-forest-labs/flux-schnell', + input: { + prompt, + negative_prompt: negativePrompt, + width: width || 1024, + height: height || 1024, + num_inference_steps: steps || 4, + guidance_scale: guidanceScale || 0, + }, + }), + }); + if (!res.ok) return c.json({ error: 'Replicate API failed' }, 502); + + const prediction = await res.json(); + + // Poll for completion + let output = prediction.output; + if (!output && prediction.urls?.get) { + for (let i = 0; i < 60; i++) { + await new Promise((r) => setTimeout(r, 2000)); + const pollRes = await fetch(prediction.urls.get, { + headers: { Authorization: `Bearer ${REPLICATE_TOKEN}` }, + }); + const pollData = await pollRes.json(); + if (pollData.status === 'succeeded') { + output = pollData.output; + break; + } + if (pollData.status === 'failed') { + return c.json({ error: 'Generation failed' }, 500); + } + } + } + + imageUrl = Array.isArray(output) ? output[0] : output; + } else { + return c.json({ error: 'No image generation service configured' }, 503); + } + + await consumeCredits(userId, 'AI_IMAGE_GENERATION', cost, `Image: ${prompt.slice(0, 50)}`); + + return c.json({ imageUrl, prompt, model: model || 'flux-schnell' }); + } catch (_err) { + return c.json({ error: 'Generation failed' }, 500); + } +}); + +// ─── Image Upload (server-only: S3) ───────────────────────── + +routes.post('/upload', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > 10 * 1024 * 1024) return c.json({ error: 'Max 10MB' }, 400); + + try { + const { createPictureStorage, generateUserFileKey, getContentType } = await import( + '@manacore/shared-storage' + ); + const storage = createPictureStorage(); + const key = generateUserFileKey(userId, file.name); + const buffer = Buffer.from(await file.arrayBuffer()); + + const result = await storage.upload(key, buffer, { + contentType: getContentType(file.name), + public: true, + }); + + return c.json({ storagePath: key, publicUrl: result.url }, 201); + } catch (_err) { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +export { routes as pictureRoutes }; diff --git a/apps/api/src/modules/planta/routes.ts b/apps/api/src/modules/planta/routes.ts new file mode 100644 index 000000000..14852dea8 --- /dev/null +++ b/apps/api/src/modules/planta/routes.ts @@ -0,0 +1,89 @@ +/** + * Planta module — Photo upload + AI plant analysis + * Ported from apps/planta/apps/server + * + * CRUD for plants, photos, watering handled by mana-sync. + * This module handles S3 uploads and Gemini Vision analysis. + */ + +import { Hono } from 'hono'; + +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; + +const routes = new Hono(); + +// ─── Photo Upload (server-only: S3 storage) ───────────────── + +routes.post('/photos/upload', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + const plantId = formData.get('plantId') as string | null; + + if (!file) return c.json({ error: 'No file provided' }, 400); + if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400); + + try { + const { createPlantaStorage, generateUserFileKey, getContentType } = await import( + '@manacore/shared-storage' + ); + const storage = createPlantaStorage(); + const key = generateUserFileKey(userId, file.name); + const buffer = Buffer.from(await file.arrayBuffer()); + + const result = await storage.upload(key, buffer, { + contentType: getContentType(file.name), + public: true, + }); + + return c.json({ storagePath: key, publicUrl: result.url, plantId }, 201); + } catch (err) { + console.error('Upload failed:', err); + return c.json({ error: 'Upload failed' }, 500); + } +}); + +// ─── AI Analysis (server-only: Gemini Vision) ─────────────── + +routes.post('/analysis/identify', async (c) => { + const { photoUrl } = await c.req.json(); + if (!photoUrl) return c.json({ error: 'photoUrl required' }, 400); + + try { + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: + 'Du bist ein Pflanzenexperte. Analysiere das Bild und gib JSON zurück: {scientificName, commonNames[], confidence, healthAssessment, wateringAdvice, lightAdvice, generalTips[]}', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Analysiere diese Pflanze.' }, + { type: 'image_url', image_url: { url: photoUrl } }, + ], + }, + ], + model: process.env.VISION_MODEL || 'gemini-2.0-flash', + response_format: { type: 'json_object' }, + }), + }); + + if (!res.ok) return c.json({ error: 'AI analysis failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content; + const analysis = typeof content === 'string' ? JSON.parse(content) : content; + + return c.json(analysis); + } catch (err) { + console.error('Analysis failed:', err); + return c.json({ error: 'Analysis failed' }, 500); + } +}); + +export { routes as plantaRoutes }; diff --git a/apps/api/src/modules/presi/routes.ts b/apps/api/src/modules/presi/routes.ts new file mode 100644 index 000000000..c66ffee4e --- /dev/null +++ b/apps/api/src/modules/presi/routes.ts @@ -0,0 +1,247 @@ +/** + * Presi module — Share link lookups + * Ported from apps/presi/apps/server + * + * All CRUD (decks, slides, themes) is handled client-side via local-first + sync. + * This module handles public share links and share management. + */ + +import { Hono } from 'hono'; +import { eq, and, gt, or, isNull, asc } from 'drizzle-orm'; +import { HTTPException } from 'hono/http-exception'; +import { authMiddleware } from '@manacore/shared-hono/auth'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { + pgSchema, + uuid, + text, + boolean, + timestamp, + integer, + jsonb, + index, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// ─── DB Schema (read-only for share lookups) ──────────────── + +const DATABASE_URL = + process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform'; + +const presiSchema = pgSchema('presi'); + +const decks = presiSchema.table('decks', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + title: text('title').notNull(), + description: text('description'), + themeId: uuid('theme_id'), + isPublic: boolean('is_public').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const slides = presiSchema.table( + 'slides', + { + id: uuid('id').primaryKey().defaultRandom(), + deckId: uuid('deck_id').notNull(), + order: integer('order').default(0).notNull(), + content: jsonb('content'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('slides_deck_order_api_idx').on(table.deckId, table.order)] +); + +const themes = presiSchema.table('themes', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + colors: jsonb('colors'), + fonts: jsonb('fonts'), + isDefault: boolean('is_default').default(false), +}); + +const sharedDecks = presiSchema.table( + 'shared_decks', + { + id: uuid('id').primaryKey().defaultRandom(), + deckId: uuid('deck_id').notNull(), + shareCode: text('share_code').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('shared_decks_deck_id_api_idx').on(table.deckId)] +); + +const decksRelations = relations(decks, ({ many }) => ({ + slides: many(slides), + sharedDecks: many(sharedDecks), +})); + +const slidesRelations = relations(slides, ({ one }) => ({ + deck: one(decks, { fields: [slides.deckId], references: [decks.id] }), +})); + +const sharedDecksRelations = relations(sharedDecks, ({ one }) => ({ + deck: one(decks, { fields: [sharedDecks.deckId], references: [decks.id] }), +})); + +const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 }); +const db = drizzle(connection, { + schema: { + decks, + slides, + themes, + sharedDecks, + decksRelations, + slidesRelations, + sharedDecksRelations, + }, +}); + +// ─── Helpers ──────────────────────────────────────────────── + +function generateShareCode(): string { + const bytes = new Uint8Array(6); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +// ─── Routes ───────────────────────────────────────────────── + +const routes = new Hono(); + +// ─── Public endpoint (no auth) ────────────────────────────── + +routes.get('/share/:code', async (c) => { + const code = c.req.param('code'); + + const share = await db.query.sharedDecks.findFirst({ + where: and( + eq(sharedDecks.shareCode, code), + or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date())) + ), + }); + + if (!share) { + throw new HTTPException(404, { message: 'Shared deck not found or link has expired' }); + } + + // Load deck with slides and theme + const deck = await db.query.decks.findFirst({ + where: eq(decks.id, share.deckId), + }); + + if (!deck) { + throw new HTTPException(404, { message: 'Deck not found' }); + } + + const deckSlides = await db.query.slides.findMany({ + where: eq(slides.deckId, deck.id), + orderBy: [asc(slides.order)], + }); + + let theme = null; + if (deck.themeId) { + theme = await db.query.themes.findFirst({ + where: eq(themes.id, deck.themeId), + }); + } + + return c.json({ + ...deck, + slides: deckSlides, + theme, + }); +}); + +// ─── Authenticated endpoints ──────────────────────────────── + +routes.use('/share/deck/*', authMiddleware()); + +routes.post('/share/deck/:deckId', async (c) => { + const userId = c.get('userId'); + const deckId = c.req.param('deckId'); + + // Verify ownership + const deck = await db.query.decks.findFirst({ + where: and(eq(decks.id, deckId), eq(decks.userId, userId)), + }); + if (!deck) { + throw new HTTPException(403, { message: 'You do not own this deck' }); + } + + // Check for existing valid share + const existing = await db.query.sharedDecks.findFirst({ + where: and( + eq(sharedDecks.deckId, deckId), + or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date())) + ), + }); + + if (existing) { + return c.json(existing); + } + + // Parse optional expiry + const body = await c.req.json<{ expiresAt?: string }>().catch(() => ({})); + + const [share] = await db + .insert(sharedDecks) + .values({ + deckId, + shareCode: generateShareCode(), + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, + }) + .returning(); + + return c.json(share, 201); +}); + +routes.get('/share/deck/:deckId/links', async (c) => { + const userId = c.get('userId'); + const deckId = c.req.param('deckId'); + + // Verify ownership + const deck = await db.query.decks.findFirst({ + where: and(eq(decks.id, deckId), eq(decks.userId, userId)), + }); + if (!deck) { + throw new HTTPException(403, { message: 'You do not own this deck' }); + } + + const links = await db.query.sharedDecks.findMany({ + where: eq(sharedDecks.deckId, deckId), + }); + + return c.json(links); +}); + +routes.delete('/share/:shareId', authMiddleware(), async (c) => { + const userId = c.get('userId'); + const shareId = c.req.param('shareId'); + + const share = await db.query.sharedDecks.findFirst({ + where: eq(sharedDecks.id, shareId), + }); + + if (!share) { + throw new HTTPException(404, { message: 'Share not found' }); + } + + // Verify ownership of the deck + const deck = await db.query.decks.findFirst({ + where: eq(decks.id, share.deckId), + }); + if (!deck || deck.userId !== userId) { + throw new HTTPException(403, { message: 'You do not own this deck' }); + } + + await db.delete(sharedDecks).where(eq(sharedDecks.id, shareId)); + return c.json({ success: true }); +}); + +export { routes as presiRoutes }; diff --git a/apps/api/src/modules/storage/routes.ts b/apps/api/src/modules/storage/routes.ts new file mode 100644 index 000000000..0b6969df9 --- /dev/null +++ b/apps/api/src/modules/storage/routes.ts @@ -0,0 +1,107 @@ +/** + * Storage module — File upload/download via S3 + * Ported from apps/storage/apps/server + * + * Metadata CRUD for files/folders handled by mana-sync. + * This module handles S3 operations (upload, download, presigned URLs). + */ + +import { Hono } from 'hono'; + +const routes = new Hono(); + +// ─── File Upload (server-only: S3) ────────────────────────── + +routes.post('/files/upload', async (c) => { + const userId = c.get('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + const folderId = formData.get('folderId') as string | null; + + if (!file) return c.json({ error: 'No file' }, 400); + if (file.size > 100 * 1024 * 1024) return c.json({ error: 'Max 100MB' }, 400); + + try { + const { createStorageStorage, generateUserFileKey, getContentType } = await import( + '@manacore/shared-storage' + ); + const storage = createStorageStorage(); + const key = generateUserFileKey(userId, file.name); + const buffer = Buffer.from(await file.arrayBuffer()); + + await storage.upload(key, buffer, { + contentType: getContentType(file.name), + public: false, + }); + + return c.json( + { + id: crypto.randomUUID(), + name: file.name, + storagePath: key, + storageKey: key, + mimeType: file.type, + size: file.size, + parentFolderId: folderId, + }, + 201 + ); + } catch (_err) { + return c.json({ error: 'Upload failed' }, 500); + } +}); + +// ─── File Download (server-only: S3 presigned URL) ────────── + +routes.get('/files/:id/download', async (c) => { + const storagePath = c.req.query('storagePath'); + const urlOnly = c.req.query('url') === 'true'; + + if (!storagePath) return c.json({ error: 'storagePath required' }, 400); + + try { + const { createStorageStorage } = await import('@manacore/shared-storage'); + const storage = createStorageStorage(); + + if (urlOnly) { + const url = await storage.getDownloadUrl(storagePath, { expiresIn: 3600 }); + return c.json({ url }); + } + + const data = await storage.download(storagePath); + return new Response(data.body, { + headers: { + 'Content-Type': data.contentType || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${storagePath.split('/').pop()}"`, + }, + }); + } catch (_err) { + return c.json({ error: 'Download failed' }, 500); + } +}); + +// ─── Version Upload ───────────────────────────────────────── + +routes.post('/files/:id/versions', async (c) => { + const userId = c.get('userId'); + const fileId = c.req.param('id'); + const formData = await c.req.formData(); + const file = formData.get('file') as File | null; + + if (!file) return c.json({ error: 'No file' }, 400); + + try { + const { createStorageStorage, generateUserFileKey } = await import('@manacore/shared-storage'); + const storage = createStorageStorage(); + const key = generateUserFileKey(userId, `v-${Date.now()}-${file.name}`); + const buffer = Buffer.from(await file.arrayBuffer()); + + await storage.upload(key, buffer, { contentType: file.type }); + + return c.json({ fileId, storagePath: key, size: file.size }, 201); + } catch (_err) { + return c.json({ error: 'Version upload failed' }, 500); + } +}); + +export { routes as storageRoutes }; diff --git a/apps/api/src/modules/todo/routes.ts b/apps/api/src/modules/todo/routes.ts new file mode 100644 index 000000000..2a99cc541 --- /dev/null +++ b/apps/api/src/modules/todo/routes.ts @@ -0,0 +1,304 @@ +/** + * Todo module — RRULE compute + reminders + admin + * Ported from apps/todo/apps/server + * + * All CRUD is handled client-side via local-first + sync. + * This module provides compute-only endpoints. + * + * NOTE: The standalone server also runs a background reminder worker + * (startReminderWorker) that polls for due reminders and dispatches + * them via mana-notify. That worker needs to be started separately + * or integrated into the unified API's startup lifecycle. + * See: apps/todo/apps/server/src/lib/reminder-worker.ts + */ + +import { Hono } from 'hono'; +import { rrulestr } from 'rrule'; +import { z } from 'zod'; +import { eq, and, asc, sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { serviceAuthMiddleware } from '@manacore/shared-hono'; +import { + pgSchema, + uuid, + text, + timestamp, + varchar, + integer, + boolean, + jsonb, + index, +} from 'drizzle-orm/pg-core'; + +// ─── DB Schema (minimal, server-only) ────────────────────── + +const DATABASE_URL = + process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform'; + +const todoSchema = pgSchema('todo'); + +const tasks = todoSchema.table('tasks', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + projectId: uuid('project_id'), + title: varchar('title', { length: 500 }).notNull(), + description: text('description'), + dueDate: timestamp('due_date', { withTimezone: true }), + dueTime: varchar('due_time', { length: 5 }), + startDate: timestamp('start_date', { withTimezone: true }), + priority: varchar('priority', { length: 20 }).default('medium'), + status: varchar('status', { length: 20 }).default('pending'), + isCompleted: boolean('is_completed').default(false), + completedAt: timestamp('completed_at', { withTimezone: true }), + order: integer('order').default(0), + recurrenceRule: varchar('recurrence_rule', { length: 500 }), + recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }), + lastOccurrence: timestamp('last_occurrence', { withTimezone: true }), + parentTaskId: uuid('parent_task_id'), + subtasks: jsonb('subtasks'), + metadata: jsonb('metadata'), + columnId: uuid('column_id'), + columnOrder: integer('column_order'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const projects = todoSchema.table('projects', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), +}); + +const reminders = todoSchema.table( + 'reminders', + { + id: uuid('id').primaryKey().defaultRandom(), + taskId: uuid('task_id').notNull(), + userId: text('user_id').notNull(), + minutesBefore: integer('minutes_before').notNull(), + reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(), + type: varchar('type', { length: 20 }).default('push'), + status: varchar('status', { length: 20 }).default('pending'), + sentAt: timestamp('sent_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + taskIdx: index('reminders_task_idx_api').on(table.taskId), + userIdx: index('reminders_user_idx_api').on(table.userId), + }) +); + +const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 }); +const db = drizzle(connection, { schema: { tasks, projects, reminders } }); + +// ─── Routes ──────────────────────────────────────────────── + +const routes = new Hono(); + +// ─── RRULE Compute ───────────────────────────────────────── + +const NextOccurrenceSchema = z.object({ + rrule: z.string().min(1, 'Missing rrule parameter').max(500, 'RRULE too long (max 500 chars)'), + recurrenceEndDate: z.string().datetime({ offset: true }).optional(), + after: z.string().datetime({ offset: true }).optional(), +}); + +const ValidateSchema = z.object({ + rrule: z.string().min(1).max(500), +}); + +routes.post('/compute/next-occurrence', async (c) => { + const parsed = NextOccurrenceSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400); + } + + const { rrule: rruleString, recurrenceEndDate, after } = parsed.data; + + try { + const rule = rrulestr(rruleString); + const afterDate = after ? new Date(after) : new Date(); + + // Validate: not too many occurrences + const maxOccurrences = 5000; + const tenYearsFromNow = new Date(); + tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10); + + const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => { + return count < maxOccurrences; + }); + + if (occurrences.length >= maxOccurrences) { + return c.json({ error: 'RRULE generates too many occurrences (max 5000)' }, 400); + } + + // Get next occurrence + const nextDate = rule.after(afterDate, false); + + // Check recurrence end date + if (recurrenceEndDate) { + const endDate = new Date(recurrenceEndDate); + if (!nextDate || nextDate > endDate) { + return c.json({ nextDate: null, message: 'No more occurrences (past end date)' }); + } + } + + return c.json({ + nextDate: nextDate?.toISOString() ?? null, + valid: true, + totalOccurrences: occurrences.length, + }); + } catch (err) { + return c.json( + { error: 'Invalid RRULE: ' + (err instanceof Error ? err.message : 'unknown') }, + 400 + ); + } +}); + +routes.post('/compute/validate', async (c) => { + const parsed = ValidateSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ valid: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }); + } + + const { rrule: rruleString } = parsed.data; + + try { + const rule = rrulestr(rruleString); + const tenYearsFromNow = new Date(); + tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10); + + const count = rule.between(new Date(), tenYearsFromNow, true, (_, c) => c < 5000).length; + + return c.json({ + valid: count < 5000, + occurrences: count, + error: count >= 5000 ? 'Too many occurrences' : undefined, + }); + } catch (err) { + return c.json({ valid: false, error: err instanceof Error ? err.message : 'Invalid RRULE' }); + } +}); + +// ─── Reminders ───────────────────────────────────────────── + +routes.get('/tasks/:taskId/reminders', async (c) => { + const userId = c.get('userId'); + const taskId = c.req.param('taskId'); + + // Verify task belongs to user + const task = await db.query.tasks.findFirst({ + where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)), + }); + if (!task) { + return c.json({ error: 'Task not found' }, 404); + } + + const result = await db.query.reminders.findMany({ + where: and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)), + orderBy: [asc(reminders.minutesBefore)], + }); + + return c.json({ reminders: result }); +}); + +routes.post('/tasks/:taskId/reminders', async (c) => { + const userId = c.get('userId'); + const taskId = c.req.param('taskId'); + const body = await c.req.json<{ + minutesBefore: number; + type?: 'push' | 'email' | 'both'; + }>(); + + // Verify task + const task = await db.query.tasks.findFirst({ + where: and(eq(tasks.id, taskId), eq(tasks.userId, userId)), + }); + if (!task) { + return c.json({ error: 'Task not found' }, 404); + } + if (!task.dueDate) { + return c.json({ error: 'Cannot create reminder for task without due date' }, 400); + } + + const dueDate = new Date(task.dueDate); + const reminderTime = new Date(dueDate.getTime() - body.minutesBefore * 60 * 1000); + + const [created] = await db + .insert(reminders) + .values({ + taskId, + userId, + minutesBefore: body.minutesBefore, + reminderTime, + type: body.type ?? 'push', + }) + .returning(); + + return c.json({ reminder: created }, 201); +}); + +routes.delete('/reminders/:id', async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + + const existing = await db.query.reminders.findFirst({ + where: and(eq(reminders.id, id), eq(reminders.userId, userId)), + }); + if (!existing) { + return c.json({ error: 'Reminder not found' }, 404); + } + + await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId))); + return c.json({ success: true }); +}); + +// ─── Admin (GDPR) ────────────────────────────────────────── + +const adminSub = new Hono(); +adminSub.use('/*', serviceAuthMiddleware()); + +adminSub.get('/user-data/:userId', async (c) => { + const userId = c.req.param('userId'); + + const [taskCount] = await db + .select({ count: sql`count(*)` }) + .from(tasks) + .where(eq(tasks.userId, userId)); + const [projectCount] = await db + .select({ count: sql`count(*)` }) + .from(projects) + .where(eq(projects.userId, userId)); + const [reminderCount] = await db + .select({ count: sql`count(*)` }) + .from(reminders) + .where(eq(reminders.userId, userId)); + + return c.json({ + userId, + counts: { + tasks: Number(taskCount?.count ?? 0), + projects: Number(projectCount?.count ?? 0), + reminders: Number(reminderCount?.count ?? 0), + }, + }); +}); + +adminSub.delete('/user-data/:userId', async (c) => { + const userId = c.req.param('userId'); + + await db.delete(reminders).where(eq(reminders.userId, userId)); + await db.delete(tasks).where(eq(tasks.userId, userId)); + await db.delete(projects).where(eq(projects.userId, userId)); + + return c.json({ + userId, + deleted: true, + message: 'All user data deleted', + }); +}); + +routes.route('/admin', adminSub); + +export { routes as todoRoutes }; diff --git a/apps/api/src/modules/traces/routes.ts b/apps/api/src/modules/traces/routes.ts new file mode 100644 index 000000000..0923ca01b --- /dev/null +++ b/apps/api/src/modules/traces/routes.ts @@ -0,0 +1,304 @@ +/** + * Traces module — GPS sync + AI city guides + * Ported from apps/traces/apps/server + * + * CRUD for locations, cities, places, POIs handled by mana-sync. + * This module handles AI guide generation and location sync with city detection. + */ + +import { Hono } from 'hono'; +import { eq, and } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { + pgSchema, + uuid, + text, + doublePrecision, + timestamp, + integer, + pgEnum, +} from 'drizzle-orm/pg-core'; + +// ─── DB Schema ────────────────────────────────────────────── + +const DATABASE_URL = + process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform'; +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; + +const tracesSchema = pgSchema('traces'); + +const locationSourceEnum = pgEnum('location_source', [ + 'foreground', + 'background', + 'manual', + 'photo-import', +]); + +const guideStatusEnum = pgEnum('guide_status', ['generating', 'ready', 'error']); + +const poiCategoryEnum = pgEnum('poi_category', [ + 'building', + 'monument', + 'church', + 'museum', + 'palace', + 'bridge', + 'park', + 'square', + 'sculpture', + 'fountain', + 'historic_site', + 'other', +]); + +const locations = tracesSchema.table('locations', { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(), + accuracy: doublePrecision('accuracy'), + altitude: doublePrecision('altitude'), + speed: doublePrecision('speed'), + source: locationSourceEnum('source').default('foreground'), + addressFormatted: text('address_formatted'), + city: text('city'), + country: text('country'), + countryCode: text('country_code'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const cities = tracesSchema.table('cities', { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + country: text('country').notNull(), + countryCode: text('country_code').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const pois = tracesSchema.table('pois', { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + description: text('description'), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + category: poiCategoryEnum('category').default('other').notNull(), + cityId: uuid('city_id').notNull(), + aiSummary: text('ai_summary'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const guides = tracesSchema.table('guides', { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + cityId: uuid('city_id').notNull(), + title: text('title').notNull(), + description: text('description'), + status: guideStatusEnum('status').default('generating').notNull(), + estimatedDurationMin: integer('estimated_duration_min'), + language: text('language').default('de').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const guidePois = tracesSchema.table('guide_pois', { + id: uuid('id').defaultRandom().primaryKey(), + guideId: uuid('guide_id').notNull(), + poiId: uuid('poi_id').notNull(), + sortOrder: integer('sort_order').notNull(), + aiNarrative: text('ai_narrative'), + narrativeLanguage: text('narrative_language').default('de'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 }); +const db = drizzle(connection, { schema: { locations, cities, pois, guides, guidePois } }); + +// ─── Routes ───────────────────────────────────────────────── + +const routes = new Hono(); + +// ─── Guide Generation (server-only: AI + search) ──────────── + +routes.post('/guides/generate', async (c) => { + const userId = c.get('userId'); + const params = await c.req.json<{ + cityId: string; + title: string; + language?: string; + maxPois?: number; + }>(); + + // Get city + const [city] = await db.select().from(cities).where(eq(cities.id, params.cityId)).limit(1); + if (!city) return c.json({ error: 'City not found' }, 404); + + // Create guide in 'generating' state + const [guide] = await db + .insert(guides) + .values({ + userId, + cityId: params.cityId, + title: params.title || `Guide: ${city.name}`, + status: 'generating', + language: params.language || 'de', + }) + .returning(); + + // Fire-and-forget async pipeline + runGuidePipeline(guide.id, userId, city, params.language || 'de', params.maxPois || 10).catch( + (err) => { + console.error('Guide generation failed:', err); + db.update(guides) + .set({ status: 'error' }) + .where(eq(guides.id, guide.id)) + .catch(() => {}); + } + ); + + return c.json(guide, 201); +}); + +routes.get('/guides', async (c) => { + const userId = c.get('userId'); + return c.json(await db.select().from(guides).where(eq(guides.userId, userId))); +}); + +routes.get('/guides/:id', async (c) => { + const userId = c.get('userId'); + const guideId = c.req.param('id'); + + const [guide] = await db + .select() + .from(guides) + .where(and(eq(guides.id, guideId), eq(guides.userId, userId))) + .limit(1); + + if (!guide) return c.json({ error: 'Not found' }, 404); + + const waypoints = await db + .select() + .from(guidePois) + .innerJoin(pois, eq(guidePois.poiId, pois.id)) + .where(eq(guidePois.guideId, guideId)) + .orderBy(guidePois.sortOrder); + + return c.json({ ...guide, waypoints }); +}); + +routes.delete('/guides/:id', async (c) => { + const userId = c.get('userId'); + await db.delete(guides).where(and(eq(guides.id, c.req.param('id')), eq(guides.userId, userId))); + return c.json({ success: true }); +}); + +// ─── Location Sync (server-only: city detection) ──────────── + +routes.post('/locations/sync', async (c) => { + const userId = c.get('userId'); + const { items } = await c.req.json(); + + let synced = 0; + for (const item of items || []) { + try { + await db + .insert(locations) + .values({ + userId, + latitude: item.latitude, + longitude: item.longitude, + recordedAt: new Date(item.recordedAt), + accuracy: item.accuracy, + altitude: item.altitude, + speed: item.speed, + source: item.source || 'foreground', + addressFormatted: item.address, + city: item.city, + country: item.country, + countryCode: item.countryCode, + }) + .onConflictDoNothing(); + synced++; + } catch { + // Skip duplicates + } + } + + return c.json({ synced, total: items?.length || 0 }); +}); + +// ─── Internal: Guide Pipeline ─────────────────────────────── + +async function runGuidePipeline( + guideId: string, + userId: string, + city: { id: string; name: string }, + language: string, + maxPois: number +) { + // 1. Find nearby POIs + const nearbyPois = await db.select().from(pois).where(eq(pois.cityId, city.id)).limit(maxPois); + + if (nearbyPois.length === 0) { + await db.update(guides).set({ status: 'ready' }).where(eq(guides.id, guideId)); + return; + } + + // 2. Generate AI narratives for each POI + for (let i = 0; i < nearbyPois.length; i++) { + const poi = nearbyPois[i]; + let narrative = poi.aiSummary || ''; + + if (!narrative) { + try { + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: `Du bist ein Stadtführer in ${city.name}. Schreibe einen kurzen, informativen Text (max 200 Wörter) über die Sehenswürdigkeit. Sprache: ${language === 'de' ? 'Deutsch' : 'English'}.`, + }, + { role: 'user', content: `Erzähle mir über: ${poi.name}` }, + ], + model: 'gemma3:4b', + max_tokens: 300, + }), + }); + + if (res.ok) { + const data = await res.json(); + narrative = data.choices?.[0]?.message?.content?.trim() || poi.name; + } else { + narrative = poi.description || poi.name; + } + } catch { + narrative = poi.description || poi.name; + } + } + + await db.insert(guidePois).values({ + guideId, + poiId: poi.id, + sortOrder: i, + aiNarrative: narrative, + narrativeLanguage: language, + }); + } + + // 3. Mark as ready + await db + .update(guides) + .set({ + status: 'ready', + estimatedDurationMin: nearbyPois.length * 15, + }) + .where(eq(guides.id, guideId)); +} + +export { routes as tracesRoutes };