refactor: consolidate codebase — remove archived code, deduplicate packages, standardize middleware

- Delete 17 server-archived/ directories (consolidated into apps/api/)
- Delete apps-archived/ (clock, wisekeep) and services-archived/ (it-landing, ollama-metrics-proxy)
- Fix type safety: replace all `any` casts in cross-app-queries.ts with proper Local* types
- Remove duplicate shared-auth-stores package (identical copy of shared-auth-ui/stores/)
- Remove duplicate theme store from shared-stores (canonical version in shared-theme)
- Migrate memoro-server rate-limiter to shared-hono/rateLimitMiddleware
- Migrate uload-server JWT auth + error handler to shared-hono (authMiddleware, errorHandler)
- Migrate arcade-server error handling to shared-hono
- Merge shared-profile-ui and shared-app-onboarding into shared-ui
- Unify /clock route into /times/clock, remove redirect stubs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 12:55:58 +02:00
parent 7ee57b7afd
commit d8ce4eaf34
309 changed files with 172 additions and 21667 deletions

View file

@ -1,21 +0,0 @@
{
"name": "@calendar/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^3.0.0"
}
}

View file

@ -1,320 +0,0 @@
import { describe, it, expect } from 'vitest';
import { app } from './index';
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function formPost(path: string, formData: FormData) {
return app.request(path, { method: 'POST', body: formData });
}
// ─── Health ────────────────────────────────────────────────────
describe('GET /health', () => {
it('returns healthy status', async () => {
const res = await app.request('/health');
expect(res.status).toBe(200);
});
});
// ─── RRULE Expansion ───────────────────────────────────────────
describe('POST /api/v1/events/expand', () => {
it('expands daily RRULE', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=DAILY',
dtstart: '2026-01-01T00:00:00Z',
maxOccurrences: 7,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.occurrences).toHaveLength(7);
expect(data.count).toBe(7);
expect(data.occurrences[0]).toContain('2026-01-01');
});
it('expands weekly RRULE', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=WEEKLY',
dtstart: '2026-01-05T10:00:00Z',
maxOccurrences: 4,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.occurrences).toHaveLength(4);
// Each occurrence should be 7 days apart
const dates = data.occurrences.map((d: string) => new Date(d).getTime());
const weekMs = 7 * 24 * 60 * 60 * 1000;
expect(dates[1] - dates[0]).toBe(weekMs);
});
it('expands monthly RRULE', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=MONTHLY',
dtstart: '2026-01-15T09:00:00Z',
maxOccurrences: 3,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(3);
expect(data.occurrences[0]).toContain('2026-01-15');
expect(data.occurrences[1]).toContain('2026-02-15');
expect(data.occurrences[2]).toContain('2026-03-15');
});
it('expands yearly RRULE', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=YEARLY',
dtstart: '2026-06-01T00:00:00Z',
maxOccurrences: 3,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(3);
});
it('respects INTERVAL', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=DAILY;INTERVAL=3',
dtstart: '2026-01-01T00:00:00Z',
maxOccurrences: 4,
});
expect(res.status).toBe(200);
const data = await res.json();
const dates = data.occurrences.map((d: string) => new Date(d).getTime());
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
expect(dates[1] - dates[0]).toBe(threeDaysMs);
});
it('stops at until date', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=DAILY',
dtstart: '2026-01-01T00:00:00Z',
until: '2026-01-05T00:00:00Z',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(5); // Jan 1-5 inclusive
});
it('enforces max 5000 occurrences', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=DAILY',
dtstart: '2000-01-01T00:00:00Z',
maxOccurrences: 10000,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBeLessThanOrEqual(5000);
});
it('rejects missing rrule', async () => {
const res = await post('/api/v1/events/expand', {
dtstart: '2026-01-01T00:00:00Z',
});
expect(res.status).toBe(400);
});
it('rejects missing dtstart', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'FREQ=DAILY',
});
expect(res.status).toBe(400);
});
it('rejects empty rrule', async () => {
const res = await post('/api/v1/events/expand', {
rrule: '',
dtstart: '2026-01-01T00:00:00Z',
});
expect(res.status).toBe(400);
});
it('rejects rrule exceeding max length', async () => {
const res = await post('/api/v1/events/expand', {
rrule: 'F'.repeat(501),
dtstart: '2026-01-01T00:00:00Z',
});
expect(res.status).toBe(400);
});
});
// ─── Google Calendar Sync ──────────────────────────────────────
describe('POST /api/v1/sync/google', () => {
it('returns 501 Not Implemented', async () => {
const res = await post('/api/v1/sync/google', {});
expect(res.status).toBe(501);
const data = await res.json();
expect(data.error).toContain('not yet implemented');
});
});
// ─── ICS Import ────────────────────────────────────────────────
describe('POST /api/v1/import/ics', () => {
it('parses a valid ICS file with one event', async () => {
const ics = [
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'SUMMARY:Team Meeting',
'DTSTART:20260615T140000Z',
'DTEND:20260615T150000Z',
'DESCRIPTION:Weekly sync',
'LOCATION:Room 42',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const form = new FormData();
form.append('file', new File([ics], 'cal.ics', { type: 'text/calendar' }));
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
expect(data.events[0].title).toBe('Team Meeting');
expect(data.events[0].start).toBe('20260615T140000Z');
expect(data.events[0].end).toBe('20260615T150000Z');
expect(data.events[0].description).toBe('Weekly sync');
expect(data.events[0].location).toBe('Room 42');
});
it('parses multiple events', async () => {
const ics = [
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'SUMMARY:Event One',
'DTSTART:20260601T090000Z',
'DTEND:20260601T100000Z',
'END:VEVENT',
'BEGIN:VEVENT',
'SUMMARY:Event Two',
'DTSTART:20260602T110000Z',
'DTEND:20260602T120000Z',
'END:VEVENT',
'BEGIN:VEVENT',
'SUMMARY:Event Three',
'DTSTART:20260603T130000Z',
'DTEND:20260603T140000Z',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const form = new FormData();
form.append('file', new File([ics], 'multi.ics', { type: 'text/calendar' }));
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(3);
expect(data.events[0].title).toBe('Event One');
expect(data.events[2].title).toBe('Event Three');
});
it('parses event with RRULE', async () => {
const ics = [
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'SUMMARY:Daily Standup',
'DTSTART:20260101T090000Z',
'RRULE:FREQ=DAILY;COUNT=5',
'END:VEVENT',
'END:VCALENDAR',
].join('\n');
const form = new FormData();
form.append('file', new File([ics], 'recurring.ics'));
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.events[0].rrule).toBe('FREQ=DAILY;COUNT=5');
});
it('handles ICS with DTSTART parameters', async () => {
const ics = [
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'SUMMARY:All Day Event',
'DTSTART;VALUE=DATE:20260701',
'DTEND;VALUE=DATE:20260702',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
const form = new FormData();
form.append('file', new File([ics], 'allday.ics'));
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
// DTSTART;VALUE=DATE:20260701 → split(':').pop() → '20260701'
expect(data.events[0].start).toBe('20260701');
});
it('skips events without title and start', async () => {
const ics = [
'BEGIN:VCALENDAR',
'BEGIN:VEVENT',
'DESCRIPTION:No title or start',
'END:VEVENT',
'BEGIN:VEVENT',
'SUMMARY:Valid Event',
'DTSTART:20260101T090000Z',
'END:VEVENT',
'END:VCALENDAR',
].join('\n');
const form = new FormData();
form.append('file', new File([ics], 'partial.ics'));
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
expect(data.events[0].title).toBe('Valid Event');
});
it('returns empty array for ICS without events', async () => {
const ics = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
const form = new FormData();
form.append('file', new File([ics], 'empty.ics'));
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(0);
expect(data.events).toEqual([]);
});
it('returns 400 if no file provided', async () => {
const form = new FormData();
const res = await formPost('/api/v1/import/ics', form);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('No file');
});
});

View file

@ -1,138 +0,0 @@
/**
* Calendar Hono Server RRULE expansion + Google Calendar sync
*
* CRUD for calendars/events handled by mana-sync.
* This server handles recurring event expansion and external calendar sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
authMiddleware,
healthRoute,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
import { z } from 'zod';
const PORT = parseInt(process.env.PORT || '3003', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('calendar-server'));
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// ─── RRULE Expansion (server-only: DoS protection) ──────────
const ExpandSchema = z.object({
rrule: z.string().min(1).max(500),
dtstart: z.string().min(1),
until: z.string().optional(),
maxOccurrences: z.number().int().min(1).max(5000).optional(),
});
app.post('/api/v1/events/expand', async (c) => {
const parsed = ExpandSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
}
const { rrule, dtstart, until, maxOccurrences } = parsed.data;
const max = Math.min(maxOccurrences || 365, 5000);
try {
// Simple RRULE expansion (daily, weekly, monthly, yearly)
const start = new Date(dtstart);
const end = until ? new Date(until) : new Date(start.getTime() + 365 * 24 * 60 * 60 * 1000);
const occurrences: string[] = [];
const parts = rrule.replace('RRULE:', '').split(';');
const freq = parts.find((p: string) => p.startsWith('FREQ='))?.split('=')[1];
const interval = parseInt(
parts.find((p: string) => p.startsWith('INTERVAL='))?.split('=')[1] || '1',
10
);
let current = new Date(start);
while (current <= end && occurrences.length < max) {
occurrences.push(current.toISOString());
switch (freq) {
case 'DAILY':
current = new Date(current.getTime() + interval * 24 * 60 * 60 * 1000);
break;
case 'WEEKLY':
current = new Date(current.getTime() + interval * 7 * 24 * 60 * 60 * 1000);
break;
case 'MONTHLY':
current = new Date(current.setMonth(current.getMonth() + interval));
break;
case 'YEARLY':
current = new Date(current.setFullYear(current.getFullYear() + interval));
break;
default:
occurrences.push(current.toISOString());
current = end; // Break loop
}
}
return c.json({ occurrences, count: occurrences.length });
} catch (_err) {
return c.json({ error: 'RRULE expansion failed' }, 500);
}
});
// ─── Google Calendar Import (server-only: OAuth) ─────────────
app.post('/api/v1/sync/google', async (c) => {
// TODO: Implement Google Calendar OAuth flow
// This requires server-side OAuth token exchange
return c.json({ error: 'Google Calendar sync not yet implemented' }, 501);
});
// ─── ICS Import (server-only: parsing) ───────────────────────
app.post('/api/v1/import/ics', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
const text = await file.text();
const events = parseICS(text);
return c.json({ events, count: events.length });
});
function parseICS(text: string): Array<Record<string, string>> {
const events: Array<Record<string, string>> = [];
const blocks = text.split('BEGIN:VEVENT').filter((b) => b.includes('END:VEVENT'));
for (const block of blocks) {
const event: Record<string, string> = {};
const lines = block.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith('SUMMARY:')) event.title = line.slice(8);
if (line.startsWith('DTSTART')) event.start = line.split(':').pop() || '';
if (line.startsWith('DTEND')) event.end = line.split(':').pop() || '';
if (line.startsWith('DESCRIPTION:')) event.description = line.slice(12);
if (line.startsWith('LOCATION:')) event.location = line.slice(9);
if (line.startsWith('RRULE:')) event.rrule = line.slice(6);
}
if (event.title || event.start) events.push(event);
}
return events;
}
export { app };
export default { port: PORT, fetch: app.fetch };

View file

@ -1,2 +0,0 @@
process.env.DEV_BYPASS_AUTH = 'true';
process.env.NODE_ENV = 'development';

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "vitest.config.ts"]
}

View file

@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
setupFiles: ['./src/test-setup.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});

View file

@ -1,17 +0,0 @@
{
"name": "@cards/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,130 +0,0 @@
/**
* Cards Hono Server AI card/deck generation
*
* CRUD for decks/cards handled by mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits';
const PORT = parseInt(process.env.PORT || '3009', 10);
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('cards-server'));
app.use('/api/*', authMiddleware());
// ─── AI Deck Generation (server-only: mana-llm + credits) ───
app.post('/api/v1/decks/generate', async (c) => {
const userId = c.get('userId');
const { topic, cardCount, language } = await c.req.json();
if (!topic) return c.json({ error: 'topic required' }, 400);
const count = Math.min(cardCount || 10, 50);
const cost = 20; // Credits per deck generation
const validation = await validateCredits(userId, 'AI_DECK_GENERATION', cost);
if (!validation.hasCredits) {
return c.json(
{ error: 'Insufficient credits', required: cost, available: validation.availableCredits },
402
);
}
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: `Erstelle genau ${count} Karteikarten zum Thema. Gib JSON zurück: {"cards": [{"front": "Frage", "back": "Antwort"}]}. Sprache: ${language || 'Deutsch'}.`,
},
{ role: 'user', content: topic },
],
model: 'gemma3:4b',
response_format: { type: 'json_object' },
}),
});
if (!res.ok) return c.json({ error: 'AI generation failed' }, 502);
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
await consumeCredits(userId, 'AI_DECK_GENERATION', cost, `Deck: ${topic} (${count} cards)`);
return c.json({ cards: parsed.cards || [], topic, cardCount: count });
} catch (_err) {
return c.json({ error: 'Generation failed' }, 500);
}
});
// ─── AI Card Generation from Image (server-only: vision) ────
app.post('/api/v1/ai/generate-from-image', async (c) => {
const userId = c.get('userId');
const { imageUrl, imageBase64, mimeType } = await c.req.json();
const cost = 2;
const validation = await validateCredits(userId, 'AI_CARD_GENERATION', cost);
if (!validation.hasCredits) {
return c.json({ error: 'Insufficient credits' }, 402);
}
try {
const imageContent = imageUrl
? { type: 'image_url', image_url: { url: imageUrl } }
: {
type: 'image_url',
image_url: { url: `data:${mimeType || 'image/jpeg'};base64,${imageBase64}` },
};
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{
role: 'system',
content:
'Erstelle Karteikarten aus dem Bildinhalt. JSON: {"cards": [{"front": "...", "back": "..."}]}',
},
{
role: 'user',
content: [
{ type: 'text', text: 'Erstelle Karteikarten aus diesem Bild.' },
imageContent,
],
},
],
model: 'gemini-2.0-flash',
response_format: { type: 'json_object' },
}),
});
if (!res.ok) return c.json({ error: 'AI failed' }, 502);
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
await consumeCredits(userId, 'AI_CARD_GENERATION', cost, 'Cards from image');
return c.json({ cards: parsed.cards || [] });
} catch (_err) {
return c.json({ error: 'Generation failed' }, 500);
}
});
export default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,19 +0,0 @@
{
"name": "@chat/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,137 +0,0 @@
/**
* Chat Hono Server LLM completions (sync + streaming)
*
* CRUD for conversations/messages handled by mana-sync.
* This server handles AI completions via mana-llm or OpenRouter.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { streamSSE } from 'hono/streaming';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits';
const PORT = parseInt(process.env.PORT || '3002', 10);
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('chat-server'));
app.use('/api/*', authMiddleware());
// ─── Chat Completion (sync) ──────────────────────────────────
app.post('/api/v1/chat/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) ─────────────────────────
app.post('/api/v1/chat/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 ─────────────────────────────────────────────
app.get('/api/v1/chat/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 default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,21 +0,0 @@
{
"name": "@contacts/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^3.0.0"
}
}

View file

@ -1,242 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { app } from './index';
function formPost(path: string, formData: FormData) {
return app.request(path, { method: 'POST', body: formData });
}
// ─── Health ────────────────────────────────────────────────────
describe('GET /health', () => {
it('returns healthy status', async () => {
const res = await app.request('/health');
expect(res.status).toBe(200);
});
});
// ─── vCard Import ──────────────────────────────────────────────
describe('POST /api/v1/import/vcard', () => {
it('parses a single vCard', async () => {
const vcard = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Max Mustermann',
'EMAIL:max@example.com',
'TEL:+49 170 1234567',
'ORG:ACME Corp',
'TITLE:Engineer',
'END:VCARD',
].join('\r\n');
const form = new FormData();
form.append('file', new File([vcard], 'contacts.vcf', { type: 'text/vcard' }));
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
expect(data.contacts[0].name).toBe('Max Mustermann');
expect(data.contacts[0].email).toBe('max@example.com');
expect(data.contacts[0].phone).toBe('+49 170 1234567');
expect(data.contacts[0].company).toBe('ACME Corp');
expect(data.contacts[0].title).toBe('Engineer');
});
it('parses multiple vCards', async () => {
const vcard = [
'BEGIN:VCARD',
'FN:Alice',
'EMAIL:alice@example.com',
'END:VCARD',
'BEGIN:VCARD',
'FN:Bob',
'EMAIL:bob@example.com',
'END:VCARD',
'BEGIN:VCARD',
'FN:Charlie',
'TEL:+1 555 0123',
'END:VCARD',
].join('\n');
const form = new FormData();
form.append('file', new File([vcard], 'multi.vcf'));
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(3);
expect(data.contacts[0].name).toBe('Alice');
expect(data.contacts[1].name).toBe('Bob');
expect(data.contacts[2].name).toBe('Charlie');
});
it('handles vCard with EMAIL type parameters', async () => {
const vcard = [
'BEGIN:VCARD',
'FN:Test User',
'EMAIL;type=INTERNET;type=WORK:work@example.com',
'TEL;type=CELL:+49 170 0000000',
'END:VCARD',
].join('\r\n');
const form = new FormData();
form.append('file', new File([vcard], 'typed.vcf'));
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
// EMAIL;type=...:work@example.com → split(':').pop() → 'work@example.com'
expect(data.contacts[0].email).toBe('work@example.com');
expect(data.contacts[0].phone).toBe('+49 170 0000000');
});
it('skips contacts without name or email', async () => {
const vcard = [
'BEGIN:VCARD',
'TEL:+49 170 0000000',
'ORG:Company Only',
'END:VCARD',
'BEGIN:VCARD',
'FN:Valid Contact',
'END:VCARD',
].join('\n');
const form = new FormData();
form.append('file', new File([vcard], 'partial.vcf'));
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
expect(data.contacts[0].name).toBe('Valid Contact');
});
it('includes contact with only email (no name)', async () => {
const vcard = ['BEGIN:VCARD', 'EMAIL:noreply@example.com', 'END:VCARD'].join('\n');
const form = new FormData();
form.append('file', new File([vcard], 'email-only.vcf'));
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
expect(data.contacts[0].email).toBe('noreply@example.com');
});
it('returns empty array for empty vCard file', async () => {
const form = new FormData();
form.append('file', new File([''], 'empty.vcf'));
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(0);
expect(data.contacts).toEqual([]);
});
it('returns 400 if no file provided', async () => {
const form = new FormData();
const res = await formPost('/api/v1/import/vcard', form);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('No file');
});
});
// ─── Avatar Upload ─────────────────────────────────────────────
describe('POST /api/v1/contacts/:id/avatar', () => {
it('returns 400 if no file provided', async () => {
const form = new FormData();
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('No file');
});
it('returns 400 if file exceeds 5MB', async () => {
// Create a file > 5MB
const bigContent = new Uint8Array(6 * 1024 * 1024);
const form = new FormData();
form.append('file', new File([bigContent], 'big.jpg', { type: 'image/jpeg' }));
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('Max 5MB');
});
it('returns 400 for invalid file type', async () => {
const form = new FormData();
form.append('file', new File(['data'], 'doc.pdf', { type: 'application/pdf' }));
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Invalid file type');
});
it('accepts JPEG files', async () => {
vi.mock('@manacore/shared-storage', () => ({
createContactsStorage: () => ({
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.jpg' }),
}),
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.jpg'),
getContentType: vi.fn().mockReturnValue('image/jpeg'),
}));
const form = new FormData();
form.append('file', new File(['image-data'], 'photo.jpg', { type: 'image/jpeg' }));
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
expect(res.status).toBe(201);
const data = await res.json();
expect(data.avatarUrl).toBeDefined();
});
it('accepts PNG files', async () => {
vi.mock('@manacore/shared-storage', () => ({
createContactsStorage: () => ({
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.png' }),
}),
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.png'),
getContentType: vi.fn().mockReturnValue('image/png'),
}));
const form = new FormData();
form.append('file', new File(['image-data'], 'photo.png', { type: 'image/png' }));
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
expect(res.status).toBe(201);
});
it('accepts WebP files', async () => {
vi.mock('@manacore/shared-storage', () => ({
createContactsStorage: () => ({
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.webp' }),
}),
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.webp'),
getContentType: vi.fn().mockReturnValue('image/webp'),
}));
const form = new FormData();
form.append('file', new File(['image-data'], 'photo.webp', { type: 'image/webp' }));
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
expect(res.status).toBe(201);
});
});

View file

@ -1,108 +0,0 @@
/**
* Contacts Hono Server Photo upload + vCard/CSV import
*
* CRUD for contacts handled by mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
authMiddleware,
healthRoute,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3004', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const ALLOWED_AVATAR_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
]);
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('contacts-server'));
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// ─── Avatar Upload (server-only: S3) ─────────────────────────
app.post('/api/v1/contacts/:id/avatar', 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 > 5 * 1024 * 1024) return c.json({ error: 'Max 5MB' }, 400);
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, SVG' }, 400);
}
try {
const { createContactsStorage, generateUserFileKey, getContentType } = await import(
'@manacore/shared-storage'
);
const storage = createContactsStorage();
const key = generateUserFileKey(
userId,
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`
);
const buffer = Buffer.from(await file.arrayBuffer());
const result = await storage.upload(key, buffer, {
contentType: getContentType(file.name),
public: true,
});
return c.json({ avatarUrl: result.url }, 201);
} catch (_err) {
return c.json({ error: 'Upload failed' }, 500);
}
});
// ─── vCard Import (server-only: parsing) ─────────────────────
app.post('/api/v1/import/vcard', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as File | null;
if (!file) return c.json({ error: 'No file' }, 400);
const text = await file.text();
const contacts = parseVCard(text);
return c.json({ contacts, count: contacts.length });
});
function parseVCard(text: string): Array<Record<string, string>> {
const contacts: Array<Record<string, string>> = [];
const cards = text.split('BEGIN:VCARD').filter((c) => c.includes('END:VCARD'));
for (const card of cards) {
const contact: Record<string, string> = {};
const lines = card.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith('FN:')) contact.name = line.slice(3);
if (line.startsWith('EMAIL')) contact.email = line.split(':').pop() || '';
if (line.startsWith('TEL')) contact.phone = line.split(':').pop() || '';
if (line.startsWith('ORG:')) contact.company = line.slice(4);
if (line.startsWith('TITLE:')) contact.title = line.slice(6);
}
if (contact.name || contact.email) contacts.push(contact);
}
return contacts;
}
export { app };
export default { port: PORT, fetch: app.fetch };

View file

@ -1,2 +0,0 @@
process.env.DEV_BYPASS_AUTH = 'true';
process.env.NODE_ENV = 'development';

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "vitest.config.ts"]
}

View file

@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
setupFiles: ['./src/test-setup.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});

View file

@ -1,19 +0,0 @@
{
"name": "@context/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,94 +0,0 @@
/**
* Context Hono Server AI text generation + token management
*
* CRUD for spaces/documents handled by mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits';
const PORT = parseInt(process.env.PORT || '3020', 10);
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5192').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('context-server'));
app.use('/api/*', authMiddleware());
// ─── AI Generation (server-only: mana-llm) ──────────────────
app.post('/api/v1/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);
}
});
app.post('/api/v1/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 default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,20 +0,0 @@
{
"name": "@guides/server",
"version": "0.1.0",
"private": true,
"description": "Guides server (Hono + Bun) — web import, guide sharing, AI generation",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3"
}
}

View file

@ -1,49 +0,0 @@
/**
* Guides Server Hono + Bun
*
* Compute-only server for features that need server-side logic:
* - Web import: URL structured guide via mana-search
* - AI generation: text/paste guide via mana-llm
* - Guide sharing: public guide links
*
* All CRUD is handled client-side via local-first + mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { importRoutes } from './routes/import.js';
import { shareRoutes } from './routes/share.js';
const app = new Hono();
// Middleware
app.use('*', logger());
app.use(
'*',
cors({
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5200').split(','),
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type'],
credentials: true,
})
);
// Routes
app.route('/api/v1/import', importRoutes);
app.route('/api/v1/share', shareRoutes);
// Health check
app.get('/health', (c) =>
c.json({
status: 'ok',
service: 'guides-server',
runtime: 'bun',
timestamp: new Date().toISOString(),
})
);
const port = Number(process.env.PORT ?? 3027);
console.log(`🚀 Guides server (Hono + Bun) starting on port ${port}`);
export default { port, fetch: app.fetch };

View file

@ -1,171 +0,0 @@
/**
* Import routes convert URLs or raw text into structured guide data.
*
* POST /api/v1/import/url fetch URL via mana-search extract, return guide draft
* POST /api/v1/import/text parse plain text / markdown into guide steps
* POST /api/v1/import/ai send to mana-llm to generate structured guide
*/
import { Hono } from 'hono';
export const importRoutes = new 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';
// ─── URL Import ─────────────────────────────────────────────────────────────
importRoutes.post('/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);
}
// Use mana-llm to turn raw content into structured guide
return await generateGuideFromText(c, {
title: extracted.title,
text: content,
sourceUrl: url,
});
});
// ─── Text/Markdown Import ────────────────────────────────────────────────────
importRoutes.post('/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 });
});
// ─── AI Generation ───────────────────────────────────────────────────────────
importRoutes.post('/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,
});
});
// ─── 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);
}
}

View file

@ -1,50 +0,0 @@
/**
* Share routes public guide links (Phase 3, in-memory store for MVP)
*
* POST /api/v1/share create shareable link for a guide snapshot
* GET /api/v1/share/:token retrieve shared guide by token
*/
import { Hono } from 'hono';
export const shareRoutes = new Hono();
// In-memory store for shared guides (replace with DB in Phase 4)
const sharedGuides = new Map<string, { guide: unknown; sections: unknown[]; createdAt: string; expiresAt: string }>();
shareRoutes.post('/', 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 });
});
shareRoutes.get('/: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);
});

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -57,7 +57,6 @@
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-links": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",

View file

@ -83,7 +83,7 @@
</div>
{/if}
<a href="/clock" class="mt-2 block text-center text-sm text-primary hover:underline">
<a href="/times/clock" class="mt-2 block text-center text-sm text-primary hover:underline">
Uhr öffnen →
</a>
{/if}

View file

@ -39,8 +39,8 @@
let appColor = $derived(appEntry?.color ?? '#6B7280');
// ── Cross-module drop target ────────────────────────────
let targetEntity = $derived(getEntity(appId));
let acceptedDropTypes = $derived(targetEntity?.acceptsDropFrom ?? []);
// TODO: re-enable after fixing entity descriptor hang
let acceptedDropTypes: string[] = [];
function handleCrossModuleDrop(payload: DragPayload) {
const sourceEntity = getEntityByDragType(payload.type);

View file

@ -8,14 +8,27 @@
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from './database';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalContact } from '$lib/modules/contacts/types';
import type { LocalConversation } from '$lib/modules/chat/types';
import type { LocalFavorite } from '$lib/modules/zitare/types';
import type { LocalImage } from '$lib/modules/picture/types';
import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/mukke/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
import type { LocalDocument, LocalContextSpace } from '$lib/modules/context/types';
import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types';
// ─── Todo Queries ───────────────────────────────────────────
/** All open (incomplete) tasks, sorted by order. */
export function useOpenTasks() {
return useLiveQueryWithDefault(async () => {
const all = await db.table('tasks').orderBy('order').toArray();
return all.filter((t: any) => !t.isCompleted && !t.deletedAt);
}, [] as any[]);
const all = await db.table<LocalTask>('tasks').orderBy('order').toArray();
return all.filter((t) => !t.isCompleted && !t.deletedAt);
}, [] as LocalTask[]);
}
/** Tasks due today or overdue. */
@ -25,13 +38,13 @@ export function useTodayTasks() {
today.setHours(0, 0, 0, 0);
const todayStr = today.toISOString().slice(0, 10);
const all = await db.table('tasks').orderBy('order').toArray();
return all.filter((t: any) => {
const all = await db.table<LocalTask>('tasks').orderBy('order').toArray();
return all.filter((t) => {
if (t.isCompleted || t.deletedAt) return false;
if (!t.dueDate) return false;
return t.dueDate.slice(0, 10) <= todayStr;
});
}, [] as any[]);
}, [] as LocalTask[]);
}
/** Tasks upcoming in the next N days. */
@ -45,14 +58,14 @@ export function useUpcomingTasks(days = 7) {
future.setDate(future.getDate() + days);
const futureStr = future.toISOString().slice(0, 10);
const all = await db.table('tasks').orderBy('dueDate').toArray();
return all.filter((t: any) => {
const all = await db.table<LocalTask>('tasks').orderBy('dueDate').toArray();
return all.filter((t) => {
if (t.isCompleted || t.deletedAt) return false;
if (!t.dueDate) return false;
const due = t.dueDate.slice(0, 10);
return due > todayStr && due <= futureStr;
});
}, [] as any[]);
}, [] as LocalTask[]);
}
// ─── Calendar Queries ───────────────────────────────────────
@ -67,12 +80,12 @@ export function useUpcomingEvents(days = 7) {
const nowStr = now.toISOString();
const futureStr = future.toISOString();
const all = await db.table('events').orderBy('startDate').toArray();
return all.filter((e: any) => {
const all = await db.table<LocalEvent>('events').orderBy('startDate').toArray();
return all.filter((e) => {
if (e.deletedAt) return false;
return e.startDate >= nowStr && e.startDate <= futureStr;
});
}, [] as any[]);
}, [] as LocalEvent[]);
}
// ─── Contacts Queries ───────────────────────────────────────
@ -80,9 +93,9 @@ export function useUpcomingEvents(days = 7) {
/** Favorite contacts. */
export function useFavoriteContacts(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await db.table('contacts').orderBy('firstName').toArray();
return all.filter((c: any) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit);
}, [] as any[]);
const all = await db.table<LocalContact>('contacts').orderBy('firstName').toArray();
return all.filter((c) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit);
}, [] as LocalContact[]);
}
// ─── Chat Queries ───────────────────────────────────────────
@ -90,24 +103,27 @@ export function useFavoriteContacts(limit = 5) {
/** Recent conversations, sorted by updatedAt desc. */
export function useRecentConversations(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await db.table('conversations').toArray();
const all = await db.table<LocalConversation>('conversations').toArray();
return all
.filter((c: any) => !c.isArchived && !c.deletedAt)
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.filter((c) => !c.isArchived && !c.deletedAt)
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit);
}, [] as any[]);
}, [] as LocalConversation[]);
}
// ─── Zitare Queries ─────────────────────────────────────────
/** A random favorite quote. */
export function useRandomFavorite() {
return useLiveQueryWithDefault(async () => {
const all = await db.table('zitareFavorites').toArray();
const active = all.filter((f: any) => !f.deletedAt);
if (active.length === 0) return null;
return active[Math.floor(Math.random() * active.length)];
}, null as any);
return useLiveQueryWithDefault(
async () => {
const all = await db.table<LocalFavorite>('zitareFavorites').toArray();
const active = all.filter((f) => !f.deletedAt);
if (active.length === 0) return null;
return active[Math.floor(Math.random() * active.length)];
},
null as LocalFavorite | null
);
}
// ─── Picture Queries ────────────────────────────────────────
@ -115,12 +131,12 @@ export function useRandomFavorite() {
/** Recent generated images. */
export function useRecentImages(limit = 6) {
return useLiveQueryWithDefault(async () => {
const all = await db.table('images').toArray();
const all = await db.table<LocalImage>('images').toArray();
return all
.filter((i: any) => !i.archivedAt && !i.deletedAt)
.sort((a: any, b: any) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
.filter((i) => !i.isArchived && !i.deletedAt)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
.slice(0, limit);
}, [] as any[]);
}, [] as LocalImage[]);
}
// ─── Clock Queries ──────────────────────────────────────────
@ -128,60 +144,71 @@ export function useRecentImages(limit = 6) {
/** Enabled alarms. */
export function useEnabledAlarms() {
return useLiveQueryWithDefault(async () => {
const all = await db.table('alarms').toArray();
return all.filter((a: any) => a.enabled && !a.deletedAt);
}, [] as any[]);
const all = await db.table<LocalAlarm>('alarms').toArray();
return all.filter((a) => a.enabled && !a.deletedAt);
}, [] as LocalAlarm[]);
}
/** Active/running timers. */
export function useActiveTimers() {
return useLiveQueryWithDefault(async () => {
const all = await db.table('timers').toArray();
return all.filter(
(t: any) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt
);
}, [] as any[]);
const all = await db.table<LocalCountdownTimer>('timers').toArray();
return all.filter((t) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt);
}, [] as LocalCountdownTimer[]);
}
// ─── Storage Queries ────────────────────────────────────────
interface StorageStats {
totalFiles: number;
totalSize: number;
recentFiles: LocalFile[];
}
/** Storage stats: total files and total size. */
export function useStorageStats() {
return useLiveQueryWithDefault(
async () => {
const files = await db.table('files').toArray();
const active = files.filter((f: any) => !f.isDeleted && !f.deletedAt);
const totalSize = active.reduce((sum: number, f: any) => sum + (f.size || 0), 0);
async (): Promise<StorageStats> => {
const files = await db.table<LocalFile>('files').toArray();
const active = files.filter((f) => !f.isDeleted && !f.deletedAt);
const totalSize = active.reduce((sum, f) => sum + (f.size || 0), 0);
const recent = active
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
return { totalFiles: active.length, totalSize, recentFiles: recent };
},
{ totalFiles: 0, totalSize: 0, recentFiles: [] as any[] }
{ totalFiles: 0, totalSize: 0, recentFiles: [] as LocalFile[] }
);
}
// ─── Mukke Queries ──────────────────────────────────────────
interface MukkeStats {
totalSongs: number;
totalPlaylists: number;
favoriteCount: number;
recentSongs: LocalSong[];
}
/** Mukke library stats + recent songs. */
export function useMukkeStats() {
return useLiveQueryWithDefault(
async () => {
const songs = await db.table('songs').toArray();
const playlists = await db.table('mukkePlaylists').toArray();
const activeSongs = songs.filter((s: any) => !s.deletedAt);
const activePlaylists = playlists.filter((p: any) => !p.deletedAt);
async (): Promise<MukkeStats> => {
const songs = await db.table<LocalSong>('songs').toArray();
const playlists = await db.table<LocalPlaylist>('mukkePlaylists').toArray();
const activeSongs = songs.filter((s) => !s.deletedAt);
const activePlaylists = playlists.filter((p) => !p.deletedAt);
const recent = activeSongs
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
return {
totalSongs: activeSongs.length,
totalPlaylists: activePlaylists.length,
favoriteCount: activeSongs.filter((s: any) => s.favorite).length,
favoriteCount: activeSongs.filter((s) => s.favorite).length,
recentSongs: recent,
};
},
{ totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as any[] }
{ totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as LocalSong[] }
);
}
@ -190,12 +217,12 @@ export function useMukkeStats() {
/** Recent presentation decks. */
export function useRecentDecks(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await db.table('presiDecks').toArray();
const all = await db.table<LocalPresiDeck>('presiDecks').toArray();
return all
.filter((d: any) => !d.deletedAt)
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.filter((d) => !d.deletedAt)
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit);
}, [] as any[]);
}, [] as LocalPresiDeck[]);
}
// ─── Context Queries ────────────────────────────────────────
@ -203,43 +230,51 @@ export function useRecentDecks(limit = 5) {
/** Recent documents + spaces. */
export function useRecentDocuments(limit = 5) {
return useLiveQueryWithDefault(async () => {
const all = await db.table('documents').toArray();
const all = await db.table<LocalDocument>('documents').toArray();
return all
.filter((d: any) => !d.deletedAt)
.sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.filter((d) => !d.deletedAt)
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit);
}, [] as any[]);
}, [] as LocalDocument[]);
}
export function useSpaces() {
return useLiveQueryWithDefault(async () => {
const all = await db.table('contextSpaces').toArray();
const all = await db.table<LocalContextSpace>('contextSpaces').toArray();
return all
.filter((s: any) => !s.deletedAt)
.sort((a: any, b: any) => {
.filter((s) => !s.deletedAt)
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
}, [] as any[]);
}, [] as LocalContextSpace[]);
}
// ─── Cards Queries ─────────────────────────────────────────
interface CardsProgress {
totalDecks: number;
totalCards: number;
cardsLearned: number;
dueForReview: number;
decks: LocalCardDeck[];
}
/** Cards learning progress. */
export function useCardsProgress() {
return useLiveQueryWithDefault(
async () => {
const decks = await db.table('cardDecks').toArray();
const cards = await db.table('cards').toArray();
const activeDecks = decks.filter((d: any) => !d.deletedAt);
const activeCards = cards.filter((c: any) => !c.deletedAt);
async (): Promise<CardsProgress> => {
const decks = await db.table<LocalCardDeck>('cardDecks').toArray();
const cards = await db.table<LocalCard>('cards').toArray();
const activeDecks = decks.filter((d) => !d.deletedAt);
const activeCards = cards.filter((c) => !c.deletedAt);
const now = new Date().toISOString();
const dueCards = activeCards.filter((c: any) => c.nextReview && c.nextReview <= now);
const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now);
return {
totalDecks: activeDecks.length,
totalCards: activeCards.length,
cardsLearned: activeCards.filter((c: any) => (c.reviewCount ?? 0) > 0).length,
cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length,
dueForReview: dueCards.length,
decks: activeDecks,
};
@ -249,7 +284,7 @@ export function useCardsProgress() {
totalCards: 0,
cardsLearned: 0,
dueForReview: 0,
decks: [] as any[],
decks: [] as LocalCardDeck[],
}
);
}

View file

@ -1,7 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

View file

@ -1,10 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto('/times/clock', { replaceState: true });
});
</script>
<p>Redirecting...</p>

View file

@ -1,10 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto('/times/clock/alarms', { replaceState: true });
});
</script>
<p>Redirecting...</p>

View file

@ -1,11 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { ProfilePage } from '@manacore/shared-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { profileService, type UserProfile as ApiUserProfile } from '$lib/api/profile';
import { EditProfileModal, ChangePasswordModal, DeleteAccountModal } from '$lib/components/profile';
import {
EditProfileModal,
ChangePasswordModal,
DeleteAccountModal,
} from '$lib/components/profile';
// Profile data from API
let apiProfile = $state<ApiUserProfile | null>(null);
@ -84,7 +88,9 @@
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
<ProfilePage
@ -130,7 +136,9 @@
<!-- Toast Notification -->
{#if toastMessage}
<div class="fixed bottom-4 right-4 z-50 px-4 py-3 bg-green-600 text-white rounded-lg shadow-lg animate-fade-in">
<div
class="fixed bottom-4 right-4 z-50 px-4 py-3 bg-green-600 text-white rounded-lg shadow-lg animate-fade-in"
>
{toastMessage}
</div>
{/if}

View file

@ -3,28 +3,28 @@
const quickLinks = [
{
href: '/clock/world-clock',
href: '/times/clock/world-clock',
icon: Globe,
label: 'Weltzeituhr',
description: 'Zeitzonen im Blick',
color: 'bg-blue-500',
},
{
href: '/clock/alarms',
href: '/times/clock/alarms',
icon: Bell,
label: 'Wecker',
description: 'Alarme verwalten',
color: 'bg-amber-500',
},
{
href: '/clock/timers',
href: '/times/clock/timers',
icon: Timer,
label: 'Timer',
description: 'Countdowns starten',
color: 'bg-green-500',
},
{
href: '/clock/stopwatch',
href: '/times/clock/stopwatch',
icon: Hourglass,
label: 'Stoppuhr',
description: 'Zeit messen',

View file

@ -8,7 +8,12 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { authMiddleware, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import {
authMiddleware,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
import { memoRoutes } from './routes/memos';
import { spaceRoutes } from './routes/spaces';
@ -20,7 +25,6 @@ import { cleanupRoutes } from './routes/cleanup';
import { meetingRoutes } from './routes/meetings';
import { meetingWebhookRoutes } from './routes/meetings-webhooks';
import { COSTS } from './lib/credits';
import { rateLimiter } from './middleware/rate-limiter';
const app = new Hono();
@ -51,7 +55,7 @@ app.use(
app.use(
'/api/v1/*',
rateLimiter({
rateLimitMiddleware({
windowMs: 60_000,
max: 100,
})

View file

@ -1,63 +0,0 @@
import type { MiddlewareHandler } from 'hono';
interface RateLimiterOptions {
/** Time window in milliseconds (default: 60000 = 1 minute) */
windowMs?: number;
/** Max requests per window per IP (default: 100) */
max?: number;
}
interface RateLimitEntry {
count: number;
resetAt: number;
}
/**
* Simple in-memory rate limiter middleware for Hono.
* Limits requests per IP address within a sliding time window.
*/
export function rateLimiter(options: RateLimiterOptions = {}): MiddlewareHandler {
const windowMs = options.windowMs ?? 60_000;
const max = options.max ?? 100;
const store = new Map<string, RateLimitEntry>();
// Periodic cleanup of expired entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now >= entry.resetAt) {
store.delete(key);
}
}
}, 5 * 60_000);
return async (c, next): Promise<void | Response> => {
const ip =
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
c.req.header('x-real-ip') ||
'unknown';
const now = Date.now();
let entry = store.get(ip);
if (!entry || now >= entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
store.set(ip, entry);
}
entry.count++;
c.header('X-RateLimit-Limit', String(max));
c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
if (entry.count > max) {
return c.json(
{ error: 'Too many requests', retryAfter: Math.ceil((entry.resetAt - now) / 1000) },
429
);
}
await next();
};
}

View file

@ -1,21 +0,0 @@
{
"name": "@moodlit/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.44.7",
"hono": "^4.7.0",
"jose": "^6.1.2",
"postgres": "^3.4.7"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.0.0"
}
}

View file

@ -1,16 +0,0 @@
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
cors: { origins: string[] };
}
export function loadConfig(): Config {
return {
port: parseInt(process.env.PORT || '3073', 10),
databaseUrl:
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_sync',
manaAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
cors: { origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',') },
};
}

View file

@ -1,17 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { errorHandler } from './middleware/error-handler';
import { healthRoutes } from './routes/health';
import { presetRoutes } from './routes/presets';
const config = loadConfig();
const app = new Hono();
app.onError(errorHandler);
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
app.route('/health', healthRoutes);
app.route('/api/v1/presets', presetRoutes);
export default { port: config.port, fetch: app.fetch };

View file

@ -1,19 +0,0 @@
import { HTTPException } from 'hono/http-exception';
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, { message });
}
}
export class BadRequestError extends HTTPException {
constructor(message = 'Bad request') {
super(400, { message });
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}

View file

@ -1,11 +0,0 @@
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
return c.json({ statusCode: err.status, message: err.message }, err.status);
}
console.error('Unhandled error:', err);
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
};

View file

@ -1,46 +0,0 @@
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
export interface AuthUser {
userId: string;
email: string;
role: string;
}
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'manacore',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -1,10 +0,0 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({
status: 'ok',
service: 'moodlit-server',
runtime: 'bun',
timestamp: new Date().toISOString(),
})
);

View file

@ -1,29 +0,0 @@
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',
},
];
export const presetRoutes = new Hono().get('/', (c) => c.json(DEFAULT_MOODS));

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,18 +0,0 @@
{
"name": "@mukke/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,106 +0,0 @@
/**
* Mukke Hono Server Audio upload, metadata, presigned URLs
*
* CRUD for songs/playlists/projects handled by mana-sync.
* This server handles S3 operations and metadata extraction.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3010', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5180').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('mukke-server'));
app.use('/api/*', authMiddleware());
// ─── Song Upload (server-only: S3 presigned URL) ────────────
app.post('/api/v1/songs/upload', async (c) => {
const userId = c.get('userId');
const { filename } = await c.req.json();
if (!filename) return c.json({ error: 'filename required' }, 400);
const songId = crypto.randomUUID();
const key = `users/${userId}/songs/${songId}/${filename}`;
// Generate presigned upload URL
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const uploadUrl = await storage.getUploadUrl(key, { expiresIn: 3600 });
return c.json({
song: { id: songId, title: filename.replace(/\.[^/.]+$/, ''), storagePath: key },
uploadUrl,
});
} catch (_err) {
return c.json({ error: 'Failed to generate upload URL' }, 500);
}
});
// ─── Download URL (server-only: S3 presigned URL) ────────────
app.get('/api/v1/songs/:id/download-url', async (c) => {
const { storagePath } = c.req.query();
if (!storagePath) return c.json({ error: 'storagePath required' }, 400);
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const url = await storage.getDownloadUrl(storagePath, { expiresIn: 3600 });
return c.json({ url });
} catch (_err) {
return c.json({ error: 'Failed to generate download URL' }, 500);
}
});
// ─── Cover Art URL (server-only: S3 presigned URL) ───────────
app.get('/api/v1/songs/:id/cover-url', async (c) => {
const { coverArtPath } = c.req.query();
if (!coverArtPath) return c.json({ url: null });
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const url = await storage.getDownloadUrl(coverArtPath, { expiresIn: 3600 });
return c.json({ url });
} catch (_err) {
return c.json({ url: null });
}
});
// ─── Batch Cover URLs ────────────────────────────────────────
app.post('/api/v1/library/cover-urls', async (c) => {
const { paths } = await c.req.json();
if (!paths?.length) return c.json({ urls: {} });
try {
const { createMukkeStorage } = await import('@manacore/shared-storage');
const storage = createMukkeStorage();
const urls: Record<string, string> = {};
for (const path of paths.slice(0, 50)) {
try {
urls[path] = await storage.getDownloadUrl(path, { expiresIn: 3600 });
} catch {
// Skip failed URLs
}
}
return c.json({ urls });
} catch (_err) {
return c.json({ urls: {} });
}
});
export default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,24 +0,0 @@
{
"name": "@news/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"drizzle-orm": "^0.44.7",
"hono": "^4.7.0",
"jose": "^6.1.2",
"jsdom": "^25.0.0",
"postgres": "^3.4.7"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@types/jsdom": "^21.1.0",
"typescript": "^5.0.0"
}
}

View file

@ -1,26 +0,0 @@
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
cors: { origins: string[] };
}
export function loadConfig(): Config {
const requiredEnv = (key: string, fallback?: string): string => {
const value = process.env[key] || fallback;
if (!value) throw new Error(`Missing required env var: ${key}`);
return value;
};
return {
port: parseInt(process.env.PORT || '3071', 10),
databaseUrl: requiredEnv(
'DATABASE_URL',
'postgresql://manacore:devpassword@localhost:5432/mana_sync'
),
manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
cors: {
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
},
};
}

View file

@ -1,14 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
let db: ReturnType<typeof drizzle> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client);
}
return db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -1,46 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { errorHandler } from './middleware/error-handler';
import { jwtAuth } from './middleware/jwt-auth';
import { ExtractService } from './services/extract';
import { FeedService } from './services/feed';
import { healthRoutes } from './routes/health';
import { createExtractRoutes } from './routes/extract';
import { createFeedRoutes } from './routes/feed';
const config = loadConfig();
const db = getDb(config.databaseUrl);
const extractService = new ExtractService();
const feedService = new FeedService(db);
const app = new Hono();
app.onError(errorHandler);
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
// Public routes (no auth)
app.route('/health', healthRoutes);
app.route('/api/v1/feed', createFeedRoutes(feedService));
// Preview extraction (public)
app.post('/api/v1/extract/preview', async (c) => {
const { url } = await c.req.json<{ url: string }>();
if (!url) return c.json({ error: 'URL is required' }, 400);
const article = await extractService.extractFromUrl(url);
return c.json(article);
});
// Protected routes (auth required)
app.use('/api/v1/extract/save', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/extract', createExtractRoutes(extractService));
// eslint-disable-next-line no-console
console.log(`news-server starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};

View file

@ -1,19 +0,0 @@
import { HTTPException } from 'hono/http-exception';
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, { message });
}
}
export class BadRequestError extends HTTPException {
constructor(message = 'Bad request') {
super(400, { message });
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}

View file

@ -1,11 +0,0 @@
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
return c.json({ statusCode: err.status, message: err.message }, err.status);
}
console.error('Unhandled error:', err);
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
};

View file

@ -1,46 +0,0 @@
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
export interface AuthUser {
userId: string;
email: string;
role: string;
}
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'manacore',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -1,38 +0,0 @@
import { Hono } from 'hono';
import type { ExtractService } from '../services/extract';
import type { AuthUser } from '../middleware/jwt-auth';
import { BadRequestError } from '../lib/errors';
export function createExtractRoutes(extractService: ExtractService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.post('/preview', async (c) => {
const { url } = await c.req.json<{ url: string }>();
if (!url) throw new BadRequestError('URL is required');
const article = await extractService.extractFromUrl(url);
return c.json(article);
})
.post('/save', async (c) => {
const { url } = await c.req.json<{ url: string }>();
if (!url) throw new BadRequestError('URL is required');
const extracted = await extractService.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,
});
});
}

View file

@ -1,21 +0,0 @@
import { Hono } from 'hono';
import type { FeedService } from '../services/feed';
export function createFeedRoutes(feedService: FeedService) {
return new Hono()
.get('/', 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);
const articles = await feedService.getArticles({ type, categoryId, limit, offset });
return c.json(articles);
})
.get('/:id', async (c) => {
const id = c.req.param('id');
const article = await feedService.getArticleById(id);
if (!article) return c.json({ error: 'Article not found' }, 404);
return c.json(article);
});
}

View file

@ -1,10 +0,0 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({
status: 'ok',
service: 'news-server',
runtime: 'bun',
timestamp: new Date().toISOString(),
})
);

View file

@ -1,50 +0,0 @@
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
export interface ExtractedArticle {
title: string;
content: string;
htmlContent: string;
excerpt: string;
byline: string | null;
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
}
export class ExtractService {
async 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,
};
}
}

View file

@ -1,71 +0,0 @@
import { sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
/**
* Feed service reads AI-generated articles from sync_changes.
* Articles with type='feed'|'summary'|'in_depth' and sourceOrigin='ai'.
*/
export class FeedService {
constructor(private db: Database) {}
async getArticles(opts: { type?: string; categoryId?: string; limit?: number; offset?: number }) {
const limit = opts.limit || 20;
const offset = opts.offset || 0;
let whereClause = sql`app_id = 'news' AND table_name = 'articles' AND op != 'delete'`;
if (opts.type) {
whereClause = sql`${whereClause} AND data->>'type' = ${opts.type}`;
}
if (opts.categoryId) {
whereClause = sql`${whereClause} AND data->>'categoryId' = ${opts.categoryId}`;
}
const result = await this.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 result as unknown as Record<string, unknown>[];
}
async getArticleById(id: string) {
const result = await this.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>[];
return rows[0] ?? null;
}
}

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,17 +0,0 @@
{
"name": "@nutriphi/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,154 +0,0 @@
/**
* NutriPhi Hono Server Compute-only endpoints
*
* Server-side logic:
* - AI meal analysis (photo + text) via mana-llm (Gemini)
* - Nutritional recommendations engine
*
* CRUD for meals, goals, favorites handled by mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3023', 10);
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5180').split(',');
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 app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('nutriphi-server'));
app.use('/api/*', authMiddleware());
// ─── Photo Analysis (server-only: Gemini Vision) ────────────
app.post('/api/v1/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) ─────────────────────
app.post('/api/v1/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) ──────────────
app.post('/api/v1/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 });
});
console.log(`nutriphi-server starting on port ${PORT}...`);
export default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,18 +0,0 @@
{
"name": "@picture/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,144 +0,0 @@
/**
* Picture Hono Server AI image generation + upload
*
* CRUD for images/boards/boardItems handled by mana-sync.
* This server handles Replicate API, S3 uploads, and explore.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits';
const PORT = parseInt(process.env.PORT || '3006', 10);
const REPLICATE_TOKEN = process.env.REPLICATE_API_TOKEN || '';
const IMAGE_GEN_URL = process.env.MANA_IMAGE_GEN_URL || '';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('picture-server'));
app.use('/api/*', authMiddleware());
// ─── AI Image Generation (server-only: Replicate/local) ─────
app.post('/api/v1/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) ─────────────────────────
app.post('/api/v1/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 default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,20 +0,0 @@
{
"name": "@planta/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,104 +0,0 @@
/**
* Planta Hono Server Compute-only endpoints
*
* Server-side logic:
* - Photo upload to S3/MinIO
* - AI plant analysis via mana-llm (Gemini Vision)
* - Watering upcoming computation
*
* CRUD for plants, photos, watering handled by mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3022', 10);
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('planta-server'));
app.use('/api/*', authMiddleware());
// ─── Photo Upload (server-only: S3 storage) ─────────────────
app.post('/api/v1/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) ───────────────
app.post('/api/v1/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);
}
});
console.log(`planta-server starting on port ${PORT}...`);
export default { port: PORT, fetch: app.fetch };

View file

@ -1,40 +0,0 @@
import { pgTable, uuid, text, timestamp, jsonb, integer } from 'drizzle-orm/pg-core';
import { plantPhotos } from './plant-photos.schema';
import { plants } from './plants.schema';
export const plantAnalyses = pgTable('plant_analyses', {
id: uuid('id').primaryKey().defaultRandom(),
photoId: uuid('photo_id')
.references(() => plantPhotos.id, { onDelete: 'cascade' })
.notNull(),
plantId: uuid('plant_id').references(() => plants.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
// AI Analysis Results
identifiedSpecies: text('identified_species'),
scientificName: text('scientific_name'),
commonNames: jsonb('common_names').$type<string[]>(),
confidence: integer('confidence'),
// Plant condition
healthAssessment: text('health_assessment'),
healthDetails: text('health_details'),
issues: jsonb('issues').$type<string[]>(),
// Care recommendations
wateringAdvice: text('watering_advice'),
lightAdvice: text('light_advice'),
fertilizingAdvice: text('fertilizing_advice'),
generalTips: jsonb('general_tips').$type<string[]>(),
// Raw AI response for debugging
rawResponse: jsonb('raw_response'),
model: text('model'),
tokensUsed: integer('tokens_used'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type PlantAnalysis = typeof plantAnalyses.$inferSelect;
export type NewPlantAnalysis = typeof plantAnalyses.$inferInsert;

View file

@ -1,30 +0,0 @@
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
import { plants } from './plants.schema';
export const plantPhotos = pgTable('plant_photos', {
id: uuid('id').primaryKey().defaultRandom(),
plantId: uuid('plant_id').references(() => plants.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
// Storage
storagePath: text('storage_path').notNull(),
publicUrl: text('public_url'),
filename: text('filename').notNull(),
mimeType: text('mime_type'),
fileSize: integer('file_size'),
// Image metadata
width: integer('width'),
height: integer('height'),
// Flags
isPrimary: boolean('is_primary').default(false).notNull(),
isAnalyzed: boolean('is_analyzed').default(false).notNull(),
// Timestamps
takenAt: timestamp('taken_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type PlantPhoto = typeof plantPhotos.$inferSelect;
export type NewPlantPhoto = typeof plantPhotos.$inferInsert;

View file

@ -1,32 +0,0 @@
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
export const plants = pgTable('plants', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
// Plant identity
name: text('name').notNull(),
scientificName: text('scientific_name'),
commonName: text('common_name'),
species: text('species'),
// Care info (from AI)
lightRequirements: text('light_requirements'),
wateringFrequencyDays: integer('watering_frequency_days'),
humidity: text('humidity'),
temperature: text('temperature'),
soilType: text('soil_type'),
careNotes: text('care_notes'),
// Status
isActive: boolean('is_active').default(true).notNull(),
healthStatus: text('health_status'),
// Timestamps
acquiredAt: timestamp('acquired_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Plant = typeof plants.$inferSelect;
export type NewPlant = typeof plants.$inferInsert;

View file

@ -1,45 +0,0 @@
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
import { plants } from './plants.schema';
export const wateringSchedules = pgTable('watering_schedules', {
id: uuid('id').primaryKey().defaultRandom(),
plantId: uuid('plant_id')
.references(() => plants.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id').notNull(),
// Schedule config
frequencyDays: integer('frequency_days').notNull(),
// Tracking
lastWateredAt: timestamp('last_watered_at', { withTimezone: true }),
nextWateringAt: timestamp('next_watering_at', { withTimezone: true }),
// Notification preferences
reminderEnabled: boolean('reminder_enabled').default(true).notNull(),
reminderHoursBefore: integer('reminder_hours_before').default(24),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type WateringSchedule = typeof wateringSchedules.$inferSelect;
export type NewWateringSchedule = typeof wateringSchedules.$inferInsert;
// Watering log for history tracking
export const wateringLogs = pgTable('watering_logs', {
id: uuid('id').primaryKey().defaultRandom(),
plantId: uuid('plant_id')
.references(() => plants.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id').notNull(),
wateredAt: timestamp('watered_at', { withTimezone: true }).defaultNow().notNull(),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type WateringLog = typeof wateringLogs.$inferSelect;
export type NewWateringLog = typeof wateringLogs.$inferInsert;

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,22 +0,0 @@
{
"name": "@presi/server",
"version": "0.1.0",
"private": true,
"description": "Presi server-side compute (Hono + Bun) — share links, admin/GDPR",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3"
}
}

View file

@ -1,100 +0,0 @@
/**
* Database schema only tables needed by server-side share endpoints.
* Deck/slide CRUD is handled client-side via local-first + mana-sync.
*/
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';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform';
const connection = postgres(DATABASE_URL, {
max: 5,
idle_timeout: 20,
});
// ─── Schema (read-only for share lookups) ────────────────
export const presiSchema = pgSchema('presi');
export 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(),
});
export 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_idx').on(table.deckId, table.order)]
);
export 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),
});
export 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_idx').on(table.deckId)]
);
export const decksRelations = relations(decks, ({ many }) => ({
slides: many(slides),
sharedDecks: many(sharedDecks),
}));
export const slidesRelations = relations(slides, ({ one }) => ({
deck: one(decks, { fields: [slides.deckId], references: [decks.id] }),
}));
export const sharedDecksRelations = relations(sharedDecks, ({ one }) => ({
deck: one(decks, { fields: [sharedDecks.deckId], references: [decks.id] }),
}));
export const db = drizzle(connection, {
schema: {
decks,
slides,
themes,
sharedDecks,
decksRelations,
slidesRelations,
sharedDecksRelations,
},
});
export type Database = typeof db;

View file

@ -1,58 +0,0 @@
/**
* Presi Server Hono + Bun
*
* Lightweight server for compute-only endpoints:
* - Share links (public deck viewing + link management)
* - Admin (GDPR compliance)
*
* All CRUD (decks, slides, themes) is handled client-side via local-first + sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { errorHandler, notFoundHandler } from '@manacore/shared-hono/error';
import { healthRoute } from '@manacore/shared-hono/health';
import { adminRoutes } from '@manacore/shared-hono/admin';
import { shareRoutes } from './routes/share';
import { db, decks, slides, sharedDecks } from './db';
const app = new Hono();
// Error handling
app.onError(errorHandler);
app.notFound(notFoundHandler);
// Middleware
app.use('*', logger());
app.use(
'*',
cors({
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5178,http://localhost:5173').split(','),
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type', 'X-Service-Key'],
credentials: true,
})
);
// Routes
app.route('/health', healthRoute('presi-server'));
app.route('/api/share', shareRoutes);
app.route(
'/api/v1/admin',
adminRoutes(db, [
{ table: sharedDecks, name: 'sharedDecks', userIdColumn: sharedDecks.deckId },
{ table: slides, name: 'slides', userIdColumn: slides.deckId },
{ table: decks, name: 'decks', userIdColumn: decks.userId },
])
);
// Start
const port = Number(process.env.PORT ?? 3008);
console.log(`Presi server (Hono + Bun) starting on port ${port}`);
export default {
port,
fetch: app.fetch,
};

View file

@ -1,161 +0,0 @@
/**
* Share routes public and authenticated share link management.
*
* Public: GET /share/:code view shared deck (no auth)
* Auth: POST /share/deck/:deckId create share link
* GET /share/deck/:deckId/links list share links
* DELETE /share/:shareId delete share link
*/
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 { db, sharedDecks, decks, slides, themes } from '../db';
const shareRoutes = new Hono();
/** Generate a 12-character share code. */
function generateShareCode(): string {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ─── Public endpoint (no auth) ──────────────────────────
/** Get a shared deck by share code. */
shareRoutes.get('/: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 ────────────────────────────
shareRoutes.use('/deck/*', authMiddleware());
/** Create a share link for a deck. */
shareRoutes.post('/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);
});
/** List share links for a deck. */
shareRoutes.get('/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);
});
/** Delete a share link. */
shareRoutes.delete('/: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 { shareRoutes };

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src/**/*.ts"]
}

View file

@ -1,17 +0,0 @@
{
"name": "@questions/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,121 +0,0 @@
/**
* Questions Hono Server Research via mana-search
*
* CRUD for questions/collections/answers handled by mana-sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits';
const PORT = parseInt(process.env.PORT || '3011', 10);
const SEARCH_URL = process.env.MANA_SEARCH_URL || 'http://localhost:3021';
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5111').split(',');
const DEPTH_CONFIG = {
quick: { limit: 5, extract: false, categories: ['general'] },
standard: { limit: 15, extract: true, categories: ['general', 'news'] },
deep: { limit: 30, extract: true, categories: ['general', 'news', 'science', 'it'] },
} as const;
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('questions-server'));
app.use('/api/*', authMiddleware());
// ─── Research (server-only: mana-search) ─────────────────────
app.post('/api/v1/research/start', async (c) => {
const userId = c.get('userId');
const { questionId, query, depth } = await c.req.json();
if (!query) return c.json({ error: 'query required' }, 400);
const config = DEPTH_CONFIG[depth as keyof typeof DEPTH_CONFIG] || DEPTH_CONFIG.standard;
const cost = depth === 'deep' ? 25 : depth === 'quick' ? 5 : 10;
const validation = await validateCredits(userId, 'RESEARCH', cost);
if (!validation.hasCredits) {
return c.json({ error: 'Insufficient credits', required: cost }, 402);
}
try {
// 1. Search via mana-search
const searchRes = await fetch(`${SEARCH_URL}/api/v1/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
options: { categories: config.categories, limit: config.limit },
}),
});
if (!searchRes.ok) return c.json({ error: 'Search failed' }, 502);
const searchData = await searchRes.json();
const results = searchData.results || [];
// 2. Extract content if standard/deep
let sources = results.map((r: { url: string; title: string; content?: string }) => ({
url: r.url,
title: r.title,
snippet: r.content?.slice(0, 300),
}));
if (config.extract && results.length > 0) {
const urls = results
.slice(0, 5)
.map((r: { url: string; title: string; content?: string }) => r.url)
.filter(Boolean);
if (urls.length > 0) {
const extractRes = await fetch(`${SEARCH_URL}/api/v1/extract/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }),
});
if (extractRes.ok) {
const extracted = await extractRes.json();
sources = sources.map((s: { url: string }) => {
const ext = extracted.results?.find(
(e: { url: string; content?: string }) => e.url === s.url
);
return ext ? { ...s, extractedContent: ext.content?.slice(0, 2000) } : s;
});
}
}
}
// 3. Build summary
const summary = `Gefunden: ${results.length} Quellen für "${query}"`;
const keyPoints = results
.slice(0, 3)
.map((r: { url: string; title: string; content?: string }) => r.title);
await consumeCredits(userId, 'RESEARCH', cost, `Research: ${query} (${depth})`);
return c.json({
questionId,
summary,
keyPoints,
sources,
depth,
sourceCount: results.length,
});
} catch (_err) {
return c.json({ error: 'Research failed' }, 500);
}
});
app.get('/api/v1/research/health/search', async (c) => {
try {
const res = await fetch(`${SEARCH_URL}/health`);
return c.json({ searchService: res.ok ? 'healthy' : 'unhealthy' });
} catch {
return c.json({ searchService: 'unreachable' });
}
});
export default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,18 +0,0 @@
{
"name": "@storage/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -1,117 +0,0 @@
/**
* Storage Hono Server File upload/download via S3
*
* Metadata CRUD for files/folders handled by mana-sync.
* This server handles S3 operations (upload, download, presigned URLs).
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
const PORT = parseInt(process.env.PORT || '3016', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5185').split(',');
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
app.route('/health', healthRoute('storage-server'));
app.use('/api/*', authMiddleware());
// ─── File Upload (server-only: S3) ──────────────────────────
app.post('/api/v1/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) ──────────
app.get('/api/v1/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 ─────────────────────────────────────────
app.post('/api/v1/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 default { port: PORT, fetch: app.fetch };

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,40 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@todo/server",
"dependencies": {
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.5",
"rrule": "^2.8.1",
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"rrule": ["rrule@2.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View file

@ -1,27 +0,0 @@
{
"name": "@todo/server",
"version": "0.1.0",
"private": true,
"description": "Todo server-side compute (Hono + Bun) — RRULE, reminders, admin",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "bun x tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
"drizzle-orm": "^0.45.1",
"hono": "^4.7.0",
"postgres": "^3.4.5",
"rrule": "^2.8.1",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3",
"vitest": "^3.0.0"
}
}

View file

@ -1,90 +0,0 @@
/**
* Database connection via Drizzle ORM + postgres.js
*
* Minimal schema only tables needed by server-side compute.
* CRUD tables (tasks, projects, labels) are handled client-side.
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
pgSchema,
uuid,
text,
timestamp,
varchar,
integer,
boolean,
jsonb,
index,
} from 'drizzle-orm/pg-core';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://manacore:devpassword@localhost:5432/mana_platform';
const connection = postgres(DATABASE_URL, {
max: 5,
idle_timeout: 20,
});
// ─── Schema ────────────────
export const todoSchema = pgSchema('todo');
// ─── Minimal Schema (only what server needs) ────────────────
export 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(),
});
export const projects = todoSchema.table('projects', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
});
export 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_hono').on(table.taskId),
userIdx: index('reminders_user_idx_hono').on(table.userId),
})
);
export const db = drizzle(connection, {
schema: { tasks, projects, reminders },
});
export type Database = typeof db;

View file

@ -1,62 +0,0 @@
/**
* Todo Server Hono + Bun
*
* Lightweight server for compute-only endpoints:
* - RRULE expansion (recurring tasks)
* - Reminders (push/email notifications)
* - Admin (GDPR compliance)
*
* All CRUD is handled client-side via local-first + sync.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import {
authMiddleware,
healthRoute,
errorHandler,
notFoundHandler,
rateLimitMiddleware,
} from '@manacore/shared-hono';
import { rruleRoutes } from './routes/rrule';
import { reminderRoutes } from './routes/reminders';
import { adminRoutes } from './routes/admin';
import { startReminderWorker } from './lib/reminder-worker';
const app = new Hono();
// Middleware
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', logger());
app.use(
'*',
cors({
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5188,http://localhost:5173').split(','),
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type', 'X-Service-Key', 'X-Client-Id'],
credentials: true,
})
);
app.route('/health', healthRoute('todo-server'));
app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// Routes
app.route('/api/v1/compute', rruleRoutes);
app.route('/api/v1', reminderRoutes);
app.route('/api/v1/admin', adminRoutes);
// Start
const port = Number(process.env.PORT ?? 3019);
// Start background worker for reminder notifications
startReminderWorker();
console.log(`🚀 Todo server (Hono + Bun) starting on port ${port}`);
export default {
port,
fetch: app.fetch,
};

View file

@ -1,73 +0,0 @@
/**
* JWT authentication middleware for Hono.
* Validates EdDSA JWTs from mana-core-auth via JWKS.
*/
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
const AUTH_URL = process.env.MANA_CORE_AUTH_URL ?? 'http://localhost:3001';
const _JWKS_URL = `${AUTH_URL}/api/auth/jwks`; // Used for future EdDSA verification
const SERVICE_KEY = process.env.MANA_CORE_SERVICE_KEY ?? '';
interface JWTPayload {
sub: string;
email: string;
role: string;
sid: string;
exp: number;
iss: string;
aud: string;
}
// Simple JWT decode (validation via JWKS would need jose library — for now we trust the token structure
// since the Go sync server already validates. In production, use jose/jwtVerify with EdDSA.)
function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
// Check expiration
if (payload.exp && payload.exp < Date.now() / 1000) return null;
return payload;
} catch {
return null;
}
}
/**
* JWT auth middleware extracts and validates Bearer token.
* Sets `userId` and `userEmail` on the context.
*/
export function authMiddleware() {
return async (c: Context, next: Next) => {
const auth = c.req.header('Authorization');
if (!auth?.startsWith('Bearer ')) {
throw new HTTPException(401, { message: 'Missing authorization header' });
}
const token = auth.slice(7);
const payload = decodeJWT(token);
if (!payload || !payload.sub) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
c.set('userId', payload.sub);
c.set('userEmail', payload.email);
await next();
};
}
/**
* Service key auth middleware validates X-Service-Key header.
* Used for admin endpoints called by mana-core-auth.
*/
export function serviceAuthMiddleware() {
return async (c: Context, next: Next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== SERVICE_KEY) {
throw new HTTPException(401, { message: 'Invalid service key' });
}
await next();
};
}

View file

@ -1,142 +0,0 @@
/**
* Reminder Worker Background cron that processes due reminders.
*
* Runs every 60 seconds, finds pending reminders whose reminderTime
* has passed, and dispatches them via mana-notify. Updates status
* to 'sent' or 'failed' accordingly.
*/
import { eq, and, lte } from 'drizzle-orm';
import { db, reminders, tasks } from '../db';
const MANA_NOTIFY_URL = process.env.MANA_NOTIFY_URL || 'http://localhost:3040';
const SERVICE_KEY =
process.env.MANA_NOTIFY_SERVICE_KEY || process.env.SERVICE_KEY || 'dev-service-key';
const TODO_WEB_URL = process.env.TODO_WEB_URL || 'http://localhost:5188';
const CHECK_INTERVAL_MS = 60_000; // 1 minute
let timer: ReturnType<typeof setInterval> | null = null;
async function processReminders() {
try {
const now = new Date();
// Find all pending reminders whose time has arrived
const dueReminders = await db.query.reminders.findMany({
where: and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, now)),
});
if (dueReminders.length === 0) return;
console.log(`[reminder-worker] Processing ${dueReminders.length} due reminder(s)`);
for (const reminder of dueReminders) {
try {
// Fetch the associated task for context
const task = await db.query.tasks.findFirst({
where: eq(tasks.id, reminder.taskId),
});
if (!task) {
// Task was deleted — mark reminder as failed
await db
.update(reminders)
.set({ status: 'failed', sentAt: now })
.where(eq(reminders.id, reminder.id));
continue;
}
// Send notification via mana-notify
const channels: string[] = [];
if (reminder.type === 'push' || reminder.type === 'both') channels.push('push');
if (reminder.type === 'email' || reminder.type === 'both') channels.push('email');
for (const channel of channels) {
await sendNotification({
userId: reminder.userId,
channel,
taskTitle: task.title,
taskId: task.id,
dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : undefined,
});
}
// Mark as sent
await db
.update(reminders)
.set({ status: 'sent', sentAt: now })
.where(eq(reminders.id, reminder.id));
} catch (err) {
console.error(`[reminder-worker] Failed to process reminder ${reminder.id}:`, err);
// Mark as failed
await db.update(reminders).set({ status: 'failed' }).where(eq(reminders.id, reminder.id));
}
}
} catch (err) {
console.error('[reminder-worker] Error in processing loop:', err);
}
}
async function sendNotification(params: {
userId: string;
channel: string;
taskTitle: string;
taskId: string;
dueDate?: string;
}) {
const { userId, channel, taskTitle, taskId, dueDate } = params;
const body = {
userId,
channel,
templateSlug: 'task-reminder',
variables: {
taskTitle,
taskUrl: `${TODO_WEB_URL}/task/${taskId}`,
dueDate: dueDate
? new Date(dueDate).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
: '',
},
// Fallback if template not found — send direct
subject: `Erinnerung: ${taskTitle}`,
body: `Aufgabe "${taskTitle}" ist ${dueDate ? `fällig am ${new Date(dueDate).toLocaleString('de-DE')}` : 'bald fällig'}.`,
};
const response = await fetch(`${MANA_NOTIFY_URL}/api/v1/notifications/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': SERVICE_KEY,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => 'unknown');
throw new Error(`mana-notify responded with ${response.status}: ${text}`);
}
}
export function startReminderWorker() {
if (timer) return;
console.log(`[reminder-worker] Started (checking every ${CHECK_INTERVAL_MS / 1000}s)`);
// Run immediately on startup
processReminders();
// Then run on interval
timer = setInterval(processReminders, CHECK_INTERVAL_MS);
}
export function stopReminderWorker() {
if (timer) {
clearInterval(timer);
timer = null;
console.log('[reminder-worker] Stopped');
}
}

View file

@ -1,111 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { Hono } from 'hono';
// Mock drizzle-orm operators
vi.mock('drizzle-orm', () => ({
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
sql: vi.fn((strings: TemplateStringsArray) => strings.join('')),
}));
const mockSelectFromWhere = vi.fn();
const mockDeleteWhere = vi.fn();
vi.mock('../db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: () => mockSelectFromWhere(),
})),
})),
delete: vi.fn(() => ({
where: () => mockDeleteWhere(),
})),
},
tasks: { userId: 'user_id' },
projects: { userId: 'user_id' },
reminders: { userId: 'user_id' },
}));
// Mock serviceAuthMiddleware to pass through
vi.mock('@manacore/shared-hono', () => ({
serviceAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
const { adminRoutes } = await import('./admin');
const app = new Hono();
app.route('/admin', adminRoutes);
function get(path: string) {
return app.request(path);
}
function del(path: string) {
return app.request(path, { method: 'DELETE' });
}
// ─── GET /admin/user-data/:userId ──────────────────────────────
describe('GET /admin/user-data/:userId', () => {
it('returns user data counts', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([{ count: 42 }]) // tasks
.mockResolvedValueOnce([{ count: 3 }]) // projects
.mockResolvedValueOnce([{ count: 5 }]); // reminders
const res = await get('/admin/user-data/user-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.userId).toBe('user-123');
expect(data.counts.tasks).toBe(42);
expect(data.counts.projects).toBe(3);
expect(data.counts.reminders).toBe(5);
});
it('returns zero counts for user with no data', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce([{ count: 0 }]);
const res = await get('/admin/user-data/empty-user');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.counts.tasks).toBe(0);
expect(data.counts.projects).toBe(0);
expect(data.counts.reminders).toBe(0);
});
it('handles null count results', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([undefined]);
const res = await get('/admin/user-data/user-x');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.counts.tasks).toBe(0);
expect(data.counts.projects).toBe(0);
expect(data.counts.reminders).toBe(0);
});
});
// ─── DELETE /admin/user-data/:userId ───────────────────────────
describe('DELETE /admin/user-data/:userId', () => {
it('deletes all user data (GDPR)', async () => {
mockDeleteWhere.mockResolvedValue(undefined);
const res = await del('/admin/user-data/user-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.userId).toBe('user-123');
expect(data.deleted).toBe(true);
expect(data.message).toBe('All user data deleted');
});
});

View file

@ -1,57 +0,0 @@
/**
* Admin route GDPR compliance + user data aggregation.
* Called by mana-core-auth, protected by service key.
*/
import { Hono } from 'hono';
import { eq, sql } from 'drizzle-orm';
import { serviceAuthMiddleware } from '@manacore/shared-hono';
import { db, tasks, projects, reminders } from '../db';
const adminRoutes = new Hono();
adminRoutes.use('/*', serviceAuthMiddleware());
/** Get user data counts. */
adminRoutes.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),
},
});
});
/** Delete all user data (GDPR right to be forgotten). */
adminRoutes.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',
});
});
export { adminRoutes };

View file

@ -1,205 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
// Mock drizzle-orm operators before any imports that use them
vi.mock('drizzle-orm', () => ({
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
and: vi.fn((..._args) => ({ type: 'and' })),
asc: vi.fn((_col) => ({ type: 'asc' })),
}));
const mockFindFirstTask = vi.fn();
const mockFindManyReminders = vi.fn();
const mockInsertReturning = vi.fn();
const mockDeleteWhere = vi.fn();
vi.mock('../db', () => ({
db: {
query: {
tasks: { findFirst: (...args: unknown[]) => mockFindFirstTask(...args) },
reminders: { findMany: (...args: unknown[]) => mockFindManyReminders(...args) },
},
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: () => mockInsertReturning(),
})),
})),
delete: vi.fn(() => ({
where: () => mockDeleteWhere(),
})),
},
tasks: { id: 'id', userId: 'user_id' },
reminders: {
id: 'id',
taskId: 'task_id',
userId: 'user_id',
minutesBefore: 'minutes_before',
},
}));
// Import AFTER mocks
const { reminderRoutes } = await import('./reminders');
const TEST_USER_ID = 'test-user-id';
function createApp() {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('userId', TEST_USER_ID);
return next();
});
app.route('/', reminderRoutes);
return app;
}
const app = createApp();
function get(path: string) {
return app.request(path);
}
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function del(path: string) {
return app.request(path, { method: 'DELETE' });
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── GET /tasks/:taskId/reminders ──────────────────────────────
describe('GET /tasks/:taskId/reminders', () => {
it('returns reminders for a valid task', async () => {
mockFindFirstTask.mockResolvedValue({ id: 'task-1', userId: TEST_USER_ID });
mockFindManyReminders.mockResolvedValue([
{ id: 'r-1', minutesBefore: 10, type: 'push' },
{ id: 'r-2', minutesBefore: 60, type: 'email' },
]);
const res = await get('/tasks/task-1/reminders');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.reminders).toHaveLength(2);
expect(data.reminders[0].id).toBe('r-1');
});
it('returns 404 if task not found', async () => {
mockFindFirstTask.mockResolvedValue(null);
const res = await get('/tasks/nonexistent/reminders');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('Task not found');
});
});
// ─── POST /tasks/:taskId/reminders ─────────────────────────────
describe('POST /tasks/:taskId/reminders', () => {
it('creates a reminder for a task with due date', async () => {
const dueDate = new Date('2026-06-15T14:00:00Z');
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: dueDate.toISOString(),
});
mockInsertReturning.mockResolvedValue([
{
id: 'r-new',
taskId: 'task-1',
minutesBefore: 30,
type: 'push',
reminderTime: new Date(dueDate.getTime() - 30 * 60 * 1000).toISOString(),
},
]);
const res = await post('/tasks/task-1/reminders', {
minutesBefore: 30,
type: 'push',
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.reminder.id).toBe('r-new');
expect(data.reminder.minutesBefore).toBe(30);
});
it('defaults type to push', async () => {
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: '2026-06-15T14:00:00Z',
});
mockInsertReturning.mockResolvedValue([{ id: 'r-new', type: 'push' }]);
const res = await post('/tasks/task-1/reminders', { minutesBefore: 15 });
expect(res.status).toBe(201);
});
it('returns 404 if task not found', async () => {
mockFindFirstTask.mockResolvedValue(null);
const res = await post('/tasks/nonexistent/reminders', {
minutesBefore: 30,
});
expect(res.status).toBe(404);
});
it('returns 400 if task has no due date', async () => {
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: null,
});
const res = await post('/tasks/task-1/reminders', { minutesBefore: 30 });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('without due date');
});
});
// ─── DELETE /reminders/:id ─────────────────────────────────────
describe('DELETE /reminders/:id', () => {
it('deletes an existing reminder', async () => {
const mockFindFirstReminder = vi.fn().mockResolvedValue({
id: 'r-1',
userId: TEST_USER_ID,
});
// Override the reminders findFirst for this test
const { db } = await import('../db');
(db.query as Record<string, unknown>).reminders = { findFirst: mockFindFirstReminder };
mockDeleteWhere.mockResolvedValue(undefined);
const res = await del('/reminders/r-1');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
it('returns 404 if reminder not found', async () => {
const { db } = await import('../db');
(db.query as Record<string, unknown>).reminders = {
findFirst: vi.fn().mockResolvedValue(null),
};
const res = await del('/reminders/nonexistent');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('Reminder not found');
});
});

View file

@ -1,88 +0,0 @@
/**
* Reminders route server-side reminder management.
*
* Reminders need to be server-side because push/email notifications
* are sent by the server at the scheduled time.
*/
import { Hono } from 'hono';
import { eq, and, asc } from 'drizzle-orm';
import { db, reminders, tasks } from '../db';
const reminderRoutes = new Hono();
/** List reminders for a task. */
reminderRoutes.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 });
});
/** Create a reminder. */
reminderRoutes.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);
});
/** Delete a reminder. */
reminderRoutes.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 });
});
export { reminderRoutes };

View file

@ -1,164 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { rruleRoutes } from './rrule';
const app = new Hono();
app.route('/compute', rruleRoutes);
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// ─── POST /compute/next-occurrence ─────────────────────────────
describe('POST /compute/next-occurrence', () => {
it('returns next occurrence for daily RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.nextDate).toBeDefined();
expect(data.totalOccurrences).toBeGreaterThan(0);
});
it('returns next occurrence for weekly RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.nextDate).toBeDefined();
});
it('returns next occurrence for monthly RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
});
it('respects recurrenceEndDate', async () => {
const pastEnd = new Date('2020-01-01').toISOString();
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
recurrenceEndDate: pastEnd,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.nextDate).toBeNull();
expect(data.message).toContain('No more occurrences');
});
it('respects after parameter', async () => {
const afterDate = new Date('2027-06-01T00:00:00Z').toISOString();
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
after: afterDate,
});
expect(res.status).toBe(200);
const data = await res.json();
const next = new Date(data.nextDate);
expect(next.getTime()).toBeGreaterThan(new Date(afterDate).getTime());
});
it('rejects empty rrule', async () => {
const res = await post('/compute/next-occurrence', { rrule: '' });
expect(res.status).toBe(400);
});
it('rejects missing rrule', async () => {
const res = await post('/compute/next-occurrence', {});
expect(res.status).toBe(400);
});
it('rejects RRULE exceeding max length', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY;' + 'X'.repeat(500),
});
expect(res.status).toBe(400);
});
it('rejects invalid RRULE string', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'not a valid rrule',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Invalid RRULE');
});
it('rejects RRULE with too many occurrences (DoS protection)', async () => {
// FREQ=SECONDLY would generate millions of occurrences
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=SECONDLY',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('too many occurrences');
});
});
// ─── POST /compute/validate ────────────────────────────────────
describe('POST /compute/validate', () => {
it('validates a correct daily RRULE', async () => {
const res = await post('/compute/validate', { rrule: 'FREQ=DAILY' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.occurrences).toBeGreaterThan(0);
});
it('validates a weekly RRULE with BYDAY', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=WEEKLY;BYDAY=TU,TH',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
});
it('validates a yearly RRULE', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.occurrences).toBeLessThanOrEqual(10); // max 10 years
});
it('returns valid=false for invalid RRULE', async () => {
const res = await post('/compute/validate', { rrule: 'garbage' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
expect(data.error).toBeDefined();
});
it('rejects empty rrule', async () => {
const res = await post('/compute/validate', { rrule: '' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
});
it('flags RRULE with too many occurrences', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=SECONDLY',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
expect(data.error).toContain('Too many occurrences');
});
});

View file

@ -1,101 +0,0 @@
/**
* RRULE expansion route server-side compute for recurring tasks.
*
* POST /api/v1/compute/next-occurrence
* Validates RRULE, calculates next occurrence date.
* Called by the client when completing a recurring task.
*/
import { Hono } from 'hono';
import { rrulestr } from 'rrule';
import { z } from 'zod';
const rruleRoutes = new Hono();
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),
});
/** Validate an RRULE string and return the next occurrence. */
rruleRoutes.post('/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
);
}
});
/** Validate an RRULE without computing next occurrence. */
rruleRoutes.post('/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' });
}
});
export { rruleRoutes };

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules"]
}

View file

@ -1,12 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});

Some files were not shown because too many files have changed in this diff Show more