mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
eb97378438
commit
9363063cd7
14 changed files with 2014 additions and 0 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}...`);
|
||||
|
|
|
|||
128
apps/api/src/modules/chat/routes.ts
Normal file
128
apps/api/src/modules/chat/routes.ts
Normal 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 };
|
||||
85
apps/api/src/modules/context/routes.ts
Normal file
85
apps/api/src/modules/context/routes.ts
Normal 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 };
|
||||
217
apps/api/src/modules/guides/routes.ts
Normal file
217
apps/api/src/modules/guides/routes.ts
Normal 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 };
|
||||
41
apps/api/src/modules/moodlit/routes.ts
Normal file
41
apps/api/src/modules/moodlit/routes.ts
Normal 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 };
|
||||
186
apps/api/src/modules/news/routes.ts
Normal file
186
apps/api/src/modules/news/routes.ts
Normal 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 };
|
||||
140
apps/api/src/modules/nutriphi/routes.ts
Normal file
140
apps/api/src/modules/nutriphi/routes.ts
Normal 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 };
|
||||
136
apps/api/src/modules/picture/routes.ts
Normal file
136
apps/api/src/modules/picture/routes.ts
Normal 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 };
|
||||
89
apps/api/src/modules/planta/routes.ts
Normal file
89
apps/api/src/modules/planta/routes.ts
Normal 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 };
|
||||
247
apps/api/src/modules/presi/routes.ts
Normal file
247
apps/api/src/modules/presi/routes.ts
Normal 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 };
|
||||
107
apps/api/src/modules/storage/routes.ts
Normal file
107
apps/api/src/modules/storage/routes.ts
Normal 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 };
|
||||
304
apps/api/src/modules/todo/routes.ts
Normal file
304
apps/api/src/modules/todo/routes.ts
Normal 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 };
|
||||
304
apps/api/src/modules/traces/routes.ts
Normal file
304
apps/api/src/modules/traces/routes.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue