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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 21:34:08 +02:00
parent eb97378438
commit 9363063cd7
14 changed files with 2014 additions and 0 deletions

View file

@ -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"
}
}

View file

@ -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}...`);

View file

@ -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 };

View file

@ -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 };

View file

@ -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<Parameters<typeof Hono.prototype.post>[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 };

View file

@ -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 };

View file

@ -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<ExtractedArticle> {
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<string, unknown>[]);
});
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<string, unknown>[];
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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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<number>`count(*)` })
.from(tasks)
.where(eq(tasks.userId, userId));
const [projectCount] = await db
.select({ count: sql<number>`count(*)` })
.from(projects)
.where(eq(projects.userId, userId));
const [reminderCount] = await db
.select({ count: sql<number>`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 };

View file

@ -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 };