mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 12:49:40 +02:00
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:
parent
7ee57b7afd
commit
d8ce4eaf34
309 changed files with 172 additions and 21667 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
process.env.DEV_BYPASS_AUTH = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
process.env.DEV_BYPASS_AUTH = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(',') },
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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(),
|
||||
})
|
||||
);
|
||||
|
|
@ -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));
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(','),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
})
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue