diff --git a/apps/nutriphi/apps/server/package.json b/apps/nutriphi/apps/server/package.json new file mode 100644 index 000000000..b17f6de48 --- /dev/null +++ b/apps/nutriphi/apps/server/package.json @@ -0,0 +1,17 @@ +{ + "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" + } +} diff --git a/apps/nutriphi/apps/server/src/index.ts b/apps/nutriphi/apps/server/src/index.ts new file mode 100644 index 000000000..2cb966beb --- /dev/null +++ b/apps/nutriphi/apps/server/src/index.ts @@ -0,0 +1,154 @@ +/** + * 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 }; diff --git a/apps/nutriphi/apps/server/tsconfig.json b/apps/nutriphi/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/nutriphi/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/planta/apps/server/package.json b/apps/planta/apps/server/package.json new file mode 100644 index 000000000..4496ad17a --- /dev/null +++ b/apps/planta/apps/server/package.json @@ -0,0 +1,20 @@ +{ + "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" + } +} diff --git a/apps/planta/apps/server/src/index.ts b/apps/planta/apps/server/src/index.ts new file mode 100644 index 000000000..a5294163b --- /dev/null +++ b/apps/planta/apps/server/src/index.ts @@ -0,0 +1,104 @@ +/** + * 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 }; diff --git a/apps/planta/apps/server/src/plant-analyses.schema.ts b/apps/planta/apps/server/src/plant-analyses.schema.ts new file mode 100644 index 000000000..4172d38e7 --- /dev/null +++ b/apps/planta/apps/server/src/plant-analyses.schema.ts @@ -0,0 +1,40 @@ +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(), + confidence: integer('confidence'), + + // Plant condition + healthAssessment: text('health_assessment'), + healthDetails: text('health_details'), + issues: jsonb('issues').$type(), + + // Care recommendations + wateringAdvice: text('watering_advice'), + lightAdvice: text('light_advice'), + fertilizingAdvice: text('fertilizing_advice'), + generalTips: jsonb('general_tips').$type(), + + // 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; diff --git a/apps/planta/apps/server/src/plant-photos.schema.ts b/apps/planta/apps/server/src/plant-photos.schema.ts new file mode 100644 index 000000000..12b9f6c9b --- /dev/null +++ b/apps/planta/apps/server/src/plant-photos.schema.ts @@ -0,0 +1,30 @@ +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; diff --git a/apps/planta/apps/server/src/plants.schema.ts b/apps/planta/apps/server/src/plants.schema.ts new file mode 100644 index 000000000..14d619172 --- /dev/null +++ b/apps/planta/apps/server/src/plants.schema.ts @@ -0,0 +1,32 @@ +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; diff --git a/apps/planta/apps/server/src/watering.schema.ts b/apps/planta/apps/server/src/watering.schema.ts new file mode 100644 index 000000000..999c22207 --- /dev/null +++ b/apps/planta/apps/server/src/watering.schema.ts @@ -0,0 +1,45 @@ +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; diff --git a/apps/planta/apps/server/tsconfig.json b/apps/planta/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/planta/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/traces/apps/server/package.json b/apps/traces/apps/server/package.json new file mode 100644 index 000000000..1443feda5 --- /dev/null +++ b/apps/traces/apps/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "@traces/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" + } +} diff --git a/apps/traces/apps/server/src/db.ts b/apps/traces/apps/server/src/db.ts new file mode 100644 index 000000000..6c2aab56f --- /dev/null +++ b/apps/traces/apps/server/src/db.ts @@ -0,0 +1,15 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let db: ReturnType> | null = null; + +export function getDb(url: string) { + if (!db) { + const client = postgres(url, { max: 10 }); + db = drizzle(client, { schema }); + } + return db; +} + +export type Database = ReturnType; diff --git a/apps/traces/apps/server/src/index.ts b/apps/traces/apps/server/src/index.ts new file mode 100644 index 000000000..ab40a12a0 --- /dev/null +++ b/apps/traces/apps/server/src/index.ts @@ -0,0 +1,108 @@ +/** + * Traces Hono Server — Compute-only endpoints + * + * Handles server-side logic that can't run in the browser: + * - AI guide generation (mana-llm) + * - POI discovery (mana-search) + * - Location sync with city detection + * + * CRUD for locations, cities, places, POIs handled by mana-sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; +import { getDb } from './db'; +import { GuideService } from './services/guide'; + +const PORT = parseInt(process.env.PORT || '3026', 10); +const DB_URL = + process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/traces'; +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; +const SEARCH_URL = process.env.MANA_SEARCH_URL || 'http://localhost:3021'; +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); + +const db = getDb(DB_URL); +const guideService = new GuideService(db, LLM_URL, SEARCH_URL); + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); + +// Health +app.route('/health', healthRoute('traces-server')); + +// All compute routes require auth +app.use('/api/*', authMiddleware()); + +// ─── Guide Generation (server-only: AI + search) ──────────── + +app.post('/api/v1/guides/generate', async (c) => { + const userId = c.get('userId'); + const body = await c.req.json(); + const guide = await guideService.generateGuide(userId, body); + return c.json(guide, 201); +}); + +app.get('/api/v1/guides', async (c) => { + const userId = c.get('userId'); + return c.json(await guideService.getUserGuides(userId)); +}); + +app.get('/api/v1/guides/:id', async (c) => { + const userId = c.get('userId'); + const guide = await guideService.getGuideDetail(userId, c.req.param('id')); + if (!guide) return c.json({ error: 'Not found' }, 404); + return c.json(guide); +}); + +app.delete('/api/v1/guides/:id', async (c) => { + const userId = c.get('userId'); + await guideService.deleteGuide(userId, c.req.param('id')); + return c.json({ success: true }); +}); + +// ─── Location Sync (server-only: city detection) ──────────── + +app.post('/api/v1/locations/sync', async (c) => { + const userId = c.get('userId'); + const { items } = await c.req.json(); + // Bulk insert locations + detect cities + const { locations } = await import('./schema'); + + let synced = 0; + for (const item of items || []) { + try { + await db + .insert(locations) + .values({ + userId, + latitude: item.latitude, + longitude: item.longitude, + recordedAt: new Date(item.recordedAt), + accuracy: item.accuracy, + altitude: item.altitude, + speed: item.speed, + source: item.source || 'foreground', + addressFormatted: item.address, + city: item.city, + country: item.country, + countryCode: item.countryCode, + }) + .onConflictDoNothing(); + synced++; + } catch { + // Skip duplicates + } + } + + return c.json({ synced, total: items?.length || 0 }); +}); + +// ─── Start ────────────────────────────────────────────────── + +console.log(`traces-server starting on port ${PORT}...`); + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/traces/apps/server/src/schema.ts b/apps/traces/apps/server/src/schema.ts new file mode 100644 index 000000000..3533bf130 --- /dev/null +++ b/apps/traces/apps/server/src/schema.ts @@ -0,0 +1,231 @@ +import { + pgTable, + uuid, + text, + doublePrecision, + timestamp, + integer, + pgEnum, + index, + uniqueIndex, +} from 'drizzle-orm/pg-core'; + +// ============================================ +// Enums +// ============================================ + +export const locationSourceEnum = pgEnum('location_source', [ + 'foreground', + 'background', + 'manual', + 'photo-import', +]); + +export const deviceMotionEnum = pgEnum('device_motion', [ + 'stationary', + 'walking', + 'driving', + 'unknown', +]); + +export const poiCategoryEnum = pgEnum('poi_category', [ + 'building', + 'monument', + 'church', + 'museum', + 'palace', + 'bridge', + 'park', + 'square', + 'sculpture', + 'fountain', + 'historic_site', + 'other', +]); + +export const guideStatusEnum = pgEnum('guide_status', ['generating', 'ready', 'error']); + +// ============================================ +// Tables +// ============================================ + +export const locations = pgTable( + 'locations', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(), + accuracy: doublePrecision('accuracy'), + altitude: doublePrecision('altitude'), + speed: doublePrecision('speed'), + source: locationSourceEnum('source').default('foreground'), + deviceMotion: deviceMotionEnum('device_motion'), + addressFormatted: text('address_formatted'), + street: text('street'), + houseNumber: text('house_number'), + city: text('city'), + postalCode: text('postal_code'), + country: text('country'), + countryCode: text('country_code'), + cityId: uuid('city_id').references(() => cities.id), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('locations_user_id_idx').on(table.userId), + index('locations_recorded_at_idx').on(table.recordedAt), + index('locations_city_id_idx').on(table.cityId), + index('locations_user_recorded_idx').on(table.userId, table.recordedAt), + ] +); + +export const cities = pgTable( + 'cities', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + country: text('country').notNull(), + countryCode: text('country_code').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [uniqueIndex('cities_name_country_code_idx').on(table.name, table.countryCode)] +); + +export const cityVisits = pgTable( + 'city_visits', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + cityId: uuid('city_id') + .notNull() + .references(() => cities.id, { onDelete: 'cascade' }), + firstVisitAt: timestamp('first_visit_at', { withTimezone: true }).notNull(), + lastVisitAt: timestamp('last_visit_at', { withTimezone: true }).notNull(), + totalDurationMs: integer('total_duration_ms').default(0).notNull(), + visitCount: integer('visit_count').default(1).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + uniqueIndex('city_visits_user_city_idx').on(table.userId, table.cityId), + index('city_visits_user_id_idx').on(table.userId), + ] +); + +export const places = pgTable( + 'places', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + name: text('name').notNull(), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + radiusMeters: integer('radius_meters').default(100).notNull(), + addressFormatted: text('address_formatted'), + cityId: uuid('city_id').references(() => cities.id), + visitCount: integer('visit_count').default(0).notNull(), + totalDurationMs: integer('total_duration_ms').default(0).notNull(), + firstVisitAt: timestamp('first_visit_at', { withTimezone: true }), + lastVisitAt: timestamp('last_visit_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('places_user_id_idx').on(table.userId), + index('places_city_id_idx').on(table.cityId), + ] +); + +export const pois = pgTable( + 'pois', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + description: text('description'), + latitude: doublePrecision('latitude').notNull(), + longitude: doublePrecision('longitude').notNull(), + category: poiCategoryEnum('category').default('other').notNull(), + cityId: uuid('city_id') + .notNull() + .references(() => cities.id), + imageUrl: text('image_url'), + sourceUrls: text('source_urls').array(), + aiSummary: text('ai_summary'), + aiSummaryLanguage: text('ai_summary_language'), + aiSummaryGeneratedAt: timestamp('ai_summary_generated_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('pois_city_id_idx').on(table.cityId), + index('pois_lat_lng_idx').on(table.latitude, table.longitude), + ] +); + +export const guides = pgTable( + 'guides', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: text('user_id').notNull(), + cityId: uuid('city_id') + .notNull() + .references(() => cities.id), + title: text('title').notNull(), + description: text('description'), + status: guideStatusEnum('status').default('generating').notNull(), + routePolyline: text('route_polyline'), + estimatedDurationMin: integer('estimated_duration_min'), + distanceMeters: integer('distance_meters'), + language: text('language').default('de').notNull(), + creditsCost: integer('credits_cost'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('guides_user_id_idx').on(table.userId), + index('guides_city_id_idx').on(table.cityId), + ] +); + +export const guidePois = pgTable( + 'guide_pois', + { + id: uuid('id').defaultRandom().primaryKey(), + guideId: uuid('guide_id') + .notNull() + .references(() => guides.id, { onDelete: 'cascade' }), + poiId: uuid('poi_id') + .notNull() + .references(() => pois.id), + sortOrder: integer('sort_order').notNull(), + aiNarrative: text('ai_narrative'), + narrativeLanguage: text('narrative_language').default('de'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('guide_pois_guide_id_idx').on(table.guideId), + index('guide_pois_poi_id_idx').on(table.poiId), + ] +); + +// ============================================ +// Type Exports +// ============================================ + +export type Location = typeof locations.$inferSelect; +export type NewLocation = typeof locations.$inferInsert; +export type City = typeof cities.$inferSelect; +export type NewCity = typeof cities.$inferInsert; +export type CityVisit = typeof cityVisits.$inferSelect; +export type NewCityVisit = typeof cityVisits.$inferInsert; +export type Place = typeof places.$inferSelect; +export type NewPlace = typeof places.$inferInsert; +export type Poi = typeof pois.$inferSelect; +export type NewPoi = typeof pois.$inferInsert; +export type Guide = typeof guides.$inferSelect; +export type NewGuide = typeof guides.$inferInsert; +export type GuidePoi = typeof guidePois.$inferSelect; +export type NewGuidePoi = typeof guidePois.$inferInsert; diff --git a/apps/traces/apps/server/src/services/guide.ts b/apps/traces/apps/server/src/services/guide.ts new file mode 100644 index 000000000..c4a239600 --- /dev/null +++ b/apps/traces/apps/server/src/services/guide.ts @@ -0,0 +1,168 @@ +/** + * Guide Generation Service — AI-powered city guides + * + * Server-only: calls mana-llm for POI summaries and narratives, + * mana-search for POI discovery, and computes optimal routes. + */ + +import { eq, and } from 'drizzle-orm'; +import type { Database } from '../db'; + +export class GuideService { + constructor( + private db: Database, + private llmUrl: string, + private searchUrl: string + ) {} + + async generateGuide( + userId: string, + params: { + cityId: string; + title: string; + language?: string; + maxPois?: number; + } + ) { + const { guides, cities } = await import('../schema'); + + // Get city + const [city] = await this.db.select().from(cities).where(eq(cities.id, params.cityId)).limit(1); + if (!city) throw new Error('City not found'); + + // Create guide in 'generating' state + const [guide] = await this.db + .insert(guides) + .values({ + userId, + cityId: params.cityId, + title: params.title || `Guide: ${city.name}`, + status: 'generating', + language: params.language || 'de', + }) + .returning(); + + // Fire-and-forget async pipeline + this.runPipeline(guide.id, userId, city, params.language || 'de', params.maxPois || 10).catch( + (err) => { + console.error('Guide generation failed:', err); + this.db + .update(guides) + .set({ status: 'error' }) + .where(eq(guides.id, guide.id)) + .catch(() => {}); + } + ); + + return guide; + } + + private async runPipeline( + guideId: string, + userId: string, + city: { id: string; name: string; latitude: number | null; longitude: number | null }, + language: string, + maxPois: number + ) { + const { guides } = await import('../schema'); + + // 1. Find nearby POIs + const nearbyPois = await this.db + .select() + .from(pois) + .where(eq(pois.cityId, city.id)) + .limit(maxPois); + + if (nearbyPois.length === 0) { + await this.db.update(guides).set({ status: 'ready' }).where(eq(guides.id, guideId)); + return; + } + + // 2. Generate AI narratives for each POI + for (let i = 0; i < nearbyPois.length; i++) { + const poi = nearbyPois[i]; + let narrative = poi.aiSummary || ''; + + if (!narrative) { + try { + narrative = await this.generateNarrative(poi.name, city.name, language); + } catch { + narrative = poi.description || poi.name; + } + } + + await this.db.insert(guidePois).values({ + guideId, + poiId: poi.id, + sortOrder: i, + aiNarrative: narrative, + narrativeLanguage: language, + }); + } + + // 3. Mark as ready + await this.db + .update(guides) + .set({ + status: 'ready', + estimatedDurationMin: nearbyPois.length * 15, + }) + .where(eq(guides.id, guideId)); + } + + private async generateNarrative( + poiName: string, + cityName: string, + language: string + ): Promise { + const res = await fetch(`${this.llmUrl}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: `Du bist ein Stadtführer in ${cityName}. Schreibe einen kurzen, informativen Text (max 200 Wörter) über die Sehenswürdigkeit. Sprache: ${language === 'de' ? 'Deutsch' : 'English'}.`, + }, + { role: 'user', content: `Erzähle mir über: ${poiName}` }, + ], + model: 'gemma3:4b', + max_tokens: 300, + }), + }); + + if (!res.ok) throw new Error('LLM failed'); + const data = await res.json(); + return data.choices?.[0]?.message?.content?.trim() || poiName; + } + + async getUserGuides(userId: string) { + const { guides } = await import('../schema'); + return this.db.select().from(guides).where(eq(guides.userId, userId)); + } + + async getGuideDetail(userId: string, guideId: string) { + const { guides } = await import('../schema'); + const [guide] = await this.db + .select() + .from(guides) + .where(and(eq(guides.id, guideId), eq(guides.userId, userId))) + .limit(1); + + if (!guide) return null; + + const waypoints = await this.db + .select() + .from(guidePois) + .innerJoin(pois, eq(guidePois.poiId, pois.id)) + .where(eq(guidePois.guideId, guideId)) + .orderBy(guidePois.sortOrder); + + return { ...guide, waypoints }; + } + + async deleteGuide(userId: string, guideId: string) { + const { guides } = await import('../schema'); + await this.db.delete(guides).where(and(eq(guides.id, guideId), eq(guides.userId, userId))); + } +} diff --git a/apps/traces/apps/server/tsconfig.json b/apps/traces/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/traces/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/services/mana-notify/go.mod b/services/mana-notify/go.mod index e4c68da66..a3d6534a7 100644 --- a/services/mana-notify/go.mod +++ b/services/mana-notify/go.mod @@ -3,6 +3,7 @@ module github.com/manacore/mana-notify go 1.25.0 require ( + github.com/manacore/shared-go v0.0.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.1 github.com/prometheus/client_golang v1.22.0 @@ -24,3 +25,5 @@ require ( golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.5 // indirect ) + +replace github.com/manacore/shared-go => ../../packages/shared-go diff --git a/services/mana-notify/internal/config/config.go b/services/mana-notify/internal/config/config.go index 7366fdbe5..9117abf66 100644 --- a/services/mana-notify/internal/config/config.go +++ b/services/mana-notify/internal/config/config.go @@ -1,9 +1,7 @@ package config import ( - "os" - "strconv" - "strings" + "github.com/manacore/shared-go/envutil" ) type Config struct { @@ -18,7 +16,7 @@ type Config struct { RedisPassword string // Auth - ServiceKey string + ServiceKey string ManaCoreAuthURL string // SMTP (Brevo) @@ -45,54 +43,31 @@ type Config struct { func Load() *Config { return &Config{ - Port: getEnvInt("PORT", 3040), + Port: envutil.GetInt("PORT", 3040), - DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:manacore@localhost:5432/mana_notify"), + DatabaseURL: envutil.Get("DATABASE_URL", "postgresql://manacore:manacore@localhost:5432/mana_notify"), - RedisHost: getEnv("REDIS_HOST", "localhost"), - RedisPort: getEnvInt("REDIS_PORT", 6379), - RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisHost: envutil.Get("REDIS_HOST", "localhost"), + RedisPort: envutil.GetInt("REDIS_PORT", 6379), + RedisPassword: envutil.Get("REDIS_PASSWORD", ""), - ServiceKey: getEnv("SERVICE_KEY", "dev-service-key"), - ManaCoreAuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"), + ServiceKey: envutil.Get("SERVICE_KEY", "dev-service-key"), + ManaCoreAuthURL: envutil.Get("MANA_CORE_AUTH_URL", "http://localhost:3001"), - SMTPHost: getEnv("SMTP_HOST", "smtp-relay.brevo.com"), - SMTPPort: getEnvInt("SMTP_PORT", 587), - SMTPUser: getEnv("SMTP_USER", ""), - SMTPPassword: getEnv("SMTP_PASSWORD", ""), - SMTPFrom: getEnv("SMTP_FROM", "ManaCore "), + SMTPHost: envutil.Get("SMTP_HOST", "smtp-relay.brevo.com"), + SMTPPort: envutil.GetInt("SMTP_PORT", 587), + SMTPUser: envutil.Get("SMTP_USER", ""), + SMTPPassword: envutil.Get("SMTP_PASSWORD", ""), + SMTPFrom: envutil.Get("SMTP_FROM", "ManaCore "), - ExpoAccessToken: getEnv("EXPO_ACCESS_TOKEN", ""), + ExpoAccessToken: envutil.Get("EXPO_ACCESS_TOKEN", ""), - MatrixHomeserverURL: getEnv("MATRIX_HOMESERVER_URL", ""), - MatrixAccessToken: getEnv("MATRIX_ACCESS_TOKEN", ""), + MatrixHomeserverURL: envutil.Get("MATRIX_HOMESERVER_URL", ""), + MatrixAccessToken: envutil.Get("MATRIX_ACCESS_TOKEN", ""), - RateLimitEmailPerMinute: getEnvInt("RATE_LIMIT_EMAIL_PER_MINUTE", 10), - RateLimitPushPerMinute: getEnvInt("RATE_LIMIT_PUSH_PER_MINUTE", 100), + RateLimitEmailPerMinute: envutil.GetInt("RATE_LIMIT_EMAIL_PER_MINUTE", 10), + RateLimitPushPerMinute: envutil.GetInt("RATE_LIMIT_PUSH_PER_MINUTE", 100), - CORSOrigins: getEnvSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"}), + CORSOrigins: envutil.GetSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"}), } } - -func getEnv(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func getEnvInt(key string, fallback int) int { - if v := os.Getenv(key); v != "" { - if i, err := strconv.Atoi(v); err == nil { - return i - } - } - return fallback -} - -func getEnvSlice(key string, fallback []string) []string { - if v := os.Getenv(key); v != "" { - return strings.Split(v, ",") - } - return fallback -} diff --git a/services/mana-notify/internal/handler/common.go b/services/mana-notify/internal/handler/common.go deleted file mode 100644 index 69d3f747e..000000000 --- a/services/mana-notify/internal/handler/common.go +++ /dev/null @@ -1,24 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "time" -) - -func writeJSON(w http.ResponseWriter, status int, data any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} - -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]any{ - "success": false, - "error": map[string]any{ - "statusCode": status, - "message": message, - "timestamp": time.Now().UTC().Format(time.RFC3339), - }, - }) -} diff --git a/services/mana-notify/internal/handler/devices.go b/services/mana-notify/internal/handler/devices.go index 57588a358..9e8374a95 100644 --- a/services/mana-notify/internal/handler/devices.go +++ b/services/mana-notify/internal/handler/devices.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" + "github.com/manacore/shared-go/httputil" + "github.com/manacore/mana-notify/internal/auth" "github.com/manacore/mana-notify/internal/db" ) @@ -20,7 +22,7 @@ func NewDevicesHandler(database *db.DB) *DevicesHandler { func (h *DevicesHandler) Register(w http.ResponseWriter, r *http.Request) { user := auth.GetUser(r) if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized") + httputil.WriteError(w, http.StatusUnauthorized, "unauthorized") return } @@ -32,12 +34,12 @@ func (h *DevicesHandler) Register(w http.ResponseWriter, r *http.Request) { AppID string `json:"appId,omitempty"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if req.PushToken == "" { - writeError(w, http.StatusBadRequest, "pushToken is required") + httputil.WriteError(w, http.StatusBadRequest, "pushToken is required") return } @@ -60,18 +62,18 @@ func (h *DevicesHandler) Register(w http.ResponseWriter, r *http.Request) { user.UserID, req.PushToken, tokenType, nilIfEmpty(req.Platform), nilIfEmpty(req.DeviceName), nilIfEmpty(req.AppID), ).Scan(&id) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to register device") + httputil.WriteError(w, http.StatusInternalServerError, "failed to register device") return } - writeJSON(w, http.StatusCreated, map[string]any{"device": map[string]any{"id": id}}) + httputil.WriteJSON(w, http.StatusCreated, map[string]any{"device": map[string]any{"id": id}}) } // List handles GET /api/v1/devices func (h *DevicesHandler) List(w http.ResponseWriter, r *http.Request) { user := auth.GetUser(r) if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized") + httputil.WriteError(w, http.StatusUnauthorized, "unauthorized") return } @@ -79,7 +81,7 @@ func (h *DevicesHandler) List(w http.ResponseWriter, r *http.Request) { `SELECT id, user_id, push_token, token_type, platform, device_name, app_id, is_active, last_seen_at, created_at, updated_at FROM notify.devices WHERE user_id = $1 AND is_active = true ORDER BY created_at DESC`, user.UserID) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list devices") + httputil.WriteError(w, http.StatusInternalServerError, "failed to list devices") return } defer rows.Close() @@ -94,14 +96,14 @@ func (h *DevicesHandler) List(w http.ResponseWriter, r *http.Request) { devices = append(devices, d) } - writeJSON(w, http.StatusOK, map[string]any{"devices": devices}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"devices": devices}) } // Delete handles DELETE /api/v1/devices/{id} func (h *DevicesHandler) Delete(w http.ResponseWriter, r *http.Request) { user := auth.GetUser(r) if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized") + httputil.WriteError(w, http.StatusUnauthorized, "unauthorized") return } @@ -109,13 +111,13 @@ func (h *DevicesHandler) Delete(w http.ResponseWriter, r *http.Request) { result, err := h.db.Pool.Exec(r.Context(), `UPDATE notify.devices SET is_active = false, updated_at = NOW() WHERE id = $1 AND user_id = $2`, id, user.UserID) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to delete device") + httputil.WriteError(w, http.StatusInternalServerError, "failed to delete device") return } if result.RowsAffected() == 0 { - writeError(w, http.StatusNotFound, "device not found") + httputil.WriteError(w, http.StatusNotFound, "device not found") return } - writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true}) } diff --git a/services/mana-notify/internal/handler/health.go b/services/mana-notify/internal/handler/health.go index 9d1089aec..676adea3c 100644 --- a/services/mana-notify/internal/handler/health.go +++ b/services/mana-notify/internal/handler/health.go @@ -2,6 +2,8 @@ package handler import ( "net/http" + + "github.com/manacore/shared-go/httputil" "time" "github.com/manacore/mana-notify/internal/db" @@ -24,7 +26,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { status = "unhealthy" } - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "status": status, "version": "1.0.0", "service": "mana-notify", diff --git a/services/mana-notify/internal/handler/notifications.go b/services/mana-notify/internal/handler/notifications.go index 16ec586cf..8a274600c 100644 --- a/services/mana-notify/internal/handler/notifications.go +++ b/services/mana-notify/internal/handler/notifications.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" "net/http" + + "github.com/manacore/shared-go/httputil" "time" "github.com/manacore/mana-notify/internal/db" @@ -77,12 +79,12 @@ type BatchRequest struct { func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) { var req SendRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if err := validateSendRequest(&req); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + httputil.WriteError(w, http.StatusBadRequest, err.Error()) return } @@ -93,7 +95,7 @@ func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) { `SELECT id FROM notify.notifications WHERE external_id = $1`, req.ExternalID, ).Scan(&existingID) if err == nil { - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "notification": map[string]any{"id": existingID, "status": "existing"}, "deduplicated": true, }) @@ -105,7 +107,7 @@ func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) { if req.UserID != "" { blocked, reason := h.checkPreferences(r.Context(), req.UserID, req.Channel) if blocked { - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "notification": map[string]any{"status": "cancelled", "reason": reason}, }) return @@ -146,7 +148,7 @@ func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) { ).Scan(¬ificationID) if err != nil { slog.Error("create notification failed", "error", err) - writeError(w, http.StatusInternalServerError, "failed to create notification") + httputil.WriteError(w, http.StatusInternalServerError, "failed to create notification") return } @@ -185,7 +187,7 @@ func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) { h.pool.Enqueue(job) - writeJSON(w, http.StatusAccepted, map[string]any{ + httputil.WriteJSON(w, http.StatusAccepted, map[string]any{ "notification": map[string]any{ "id": notificationID, "status": "pending", @@ -197,22 +199,22 @@ func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) { func (h *NotificationsHandler) Schedule(w http.ResponseWriter, r *http.Request) { var req ScheduleRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } scheduledFor, err := time.Parse(time.RFC3339, req.ScheduledFor) if err != nil { - writeError(w, http.StatusBadRequest, "scheduledFor must be a valid RFC3339 timestamp") + httputil.WriteError(w, http.StatusBadRequest, "scheduledFor must be a valid RFC3339 timestamp") return } if scheduledFor.Before(time.Now()) { - writeError(w, http.StatusBadRequest, "scheduledFor must be in the future") + httputil.WriteError(w, http.StatusBadRequest, "scheduledFor must be in the future") return } if err := validateSendRequest(&req.SendRequest); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + httputil.WriteError(w, http.StatusBadRequest, err.Error()) return } @@ -246,7 +248,7 @@ func (h *NotificationsHandler) Schedule(w http.ResponseWriter, r *http.Request) priority, nilIfEmpty(req.Recipient), nilIfEmpty(req.ExternalID), scheduledFor, ).Scan(¬ificationID) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create notification") + httputil.WriteError(w, http.StatusInternalServerError, "failed to create notification") return } @@ -262,7 +264,7 @@ func (h *NotificationsHandler) Schedule(w http.ResponseWriter, r *http.Request) } h.pool.Enqueue(job) - writeJSON(w, http.StatusAccepted, map[string]any{ + httputil.WriteJSON(w, http.StatusAccepted, map[string]any{ "notification": map[string]any{ "id": notificationID, "status": "pending", @@ -275,16 +277,16 @@ func (h *NotificationsHandler) Schedule(w http.ResponseWriter, r *http.Request) func (h *NotificationsHandler) Batch(w http.ResponseWriter, r *http.Request) { var req BatchRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 5<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if len(req.Notifications) == 0 { - writeError(w, http.StatusBadRequest, "notifications array is required") + httputil.WriteError(w, http.StatusBadRequest, "notifications array is required") return } if len(req.Notifications) > 100 { - writeError(w, http.StatusBadRequest, "maximum 100 notifications per batch") + httputil.WriteError(w, http.StatusBadRequest, "maximum 100 notifications per batch") return } @@ -352,7 +354,7 @@ func (h *NotificationsHandler) Batch(w http.ResponseWriter, r *http.Request) { succeeded++ } - writeJSON(w, http.StatusAccepted, map[string]any{ + httputil.WriteJSON(w, http.StatusAccepted, map[string]any{ "results": results, "succeeded": succeeded, "failed": failed, @@ -363,7 +365,7 @@ func (h *NotificationsHandler) Batch(w http.ResponseWriter, r *http.Request) { func (h *NotificationsHandler) GetNotification(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { - writeError(w, http.StatusBadRequest, "notification id required") + httputil.WriteError(w, http.StatusBadRequest, "notification id required") return } @@ -376,33 +378,33 @@ func (h *NotificationsHandler) GetNotification(w http.ResponseWriter, r *http.Re &n.Attempts, &n.DeliveredAt, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt) if err != nil { - writeError(w, http.StatusNotFound, "notification not found") + httputil.WriteError(w, http.StatusNotFound, "notification not found") return } - writeJSON(w, http.StatusOK, map[string]any{"notification": n}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"notification": n}) } // CancelNotification handles DELETE /api/v1/notifications/{id} func (h *NotificationsHandler) CancelNotification(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { - writeError(w, http.StatusBadRequest, "notification id required") + httputil.WriteError(w, http.StatusBadRequest, "notification id required") return } result, err := h.db.Pool.Exec(r.Context(), `UPDATE notify.notifications SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'pending'`, id) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to cancel notification") + httputil.WriteError(w, http.StatusInternalServerError, "failed to cancel notification") return } if result.RowsAffected() == 0 { - writeError(w, http.StatusNotFound, "notification not found or already processed") + httputil.WriteError(w, http.StatusNotFound, "notification not found or already processed") return } - writeJSON(w, http.StatusOK, map[string]any{"cancelled": true}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"cancelled": true}) } func (h *NotificationsHandler) checkPreferences(ctx context.Context, userID, ch string) (bool, string) { diff --git a/services/mana-notify/internal/handler/preferences.go b/services/mana-notify/internal/handler/preferences.go index 3c98a6b17..31a0d4527 100644 --- a/services/mana-notify/internal/handler/preferences.go +++ b/services/mana-notify/internal/handler/preferences.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" + "github.com/manacore/shared-go/httputil" + "github.com/manacore/mana-notify/internal/auth" "github.com/manacore/mana-notify/internal/db" ) @@ -20,7 +22,7 @@ func NewPreferencesHandler(database *db.DB) *PreferencesHandler { func (h *PreferencesHandler) Get(w http.ResponseWriter, r *http.Request) { user := auth.GetUser(r) if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized") + httputil.WriteError(w, http.StatusUnauthorized, "unauthorized") return } @@ -33,7 +35,7 @@ func (h *PreferencesHandler) Get(w http.ResponseWriter, r *http.Request) { if err != nil { // Return defaults - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "preferences": map[string]any{ "emailEnabled": false, "pushEnabled": true, @@ -44,14 +46,14 @@ func (h *PreferencesHandler) Get(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]any{"preferences": p}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"preferences": p}) } // Update handles PUT /api/v1/preferences func (h *PreferencesHandler) Update(w http.ResponseWriter, r *http.Request) { user := auth.GetUser(r) if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized") + httputil.WriteError(w, http.StatusUnauthorized, "unauthorized") return } @@ -65,7 +67,7 @@ func (h *PreferencesHandler) Update(w http.ResponseWriter, r *http.Request) { CategoryPreferences map[string]any `json:"categoryPreferences,omitempty"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } @@ -87,9 +89,9 @@ func (h *PreferencesHandler) Update(w http.ResponseWriter, r *http.Request) { req.QuietHoursStart, req.QuietHoursEnd, req.Timezone, catJSON, ) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to update preferences") + httputil.WriteError(w, http.StatusInternalServerError, "failed to update preferences") return } - writeJSON(w, http.StatusOK, map[string]any{"updated": true}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"updated": true}) } diff --git a/services/mana-notify/internal/handler/templates.go b/services/mana-notify/internal/handler/templates.go index 86ce39b74..a384430cb 100644 --- a/services/mana-notify/internal/handler/templates.go +++ b/services/mana-notify/internal/handler/templates.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" + "github.com/manacore/shared-go/httputil" + "github.com/manacore/mana-notify/internal/db" tmpl "github.com/manacore/mana-notify/internal/template" ) @@ -23,7 +25,7 @@ func (h *TemplatesHandler) List(w http.ResponseWriter, r *http.Request) { `SELECT id, slug, app_id, channel, subject, body_template, locale, is_active, is_system, variables, created_at, updated_at FROM notify.templates ORDER BY slug`) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list templates") + httputil.WriteError(w, http.StatusInternalServerError, "failed to list templates") return } defer rows.Close() @@ -38,7 +40,7 @@ func (h *TemplatesHandler) List(w http.ResponseWriter, r *http.Request) { templates = append(templates, t) } - writeJSON(w, http.StatusOK, map[string]any{"templates": templates}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"templates": templates}) } // Get handles GET /api/v1/templates/{slug} @@ -56,11 +58,11 @@ func (h *TemplatesHandler) Get(w http.ResponseWriter, r *http.Request) { ).Scan(&t.ID, &t.Slug, &t.AppID, &t.Channel, &t.Subject, &t.BodyTemplate, &t.Locale, &t.IsActive, &t.IsSystem, &t.Variables, &t.CreatedAt, &t.UpdatedAt) if err != nil { - writeError(w, http.StatusNotFound, "template not found") + httputil.WriteError(w, http.StatusNotFound, "template not found") return } - writeJSON(w, http.StatusOK, map[string]any{"template": t}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"template": t}) } // Create handles POST /api/v1/templates @@ -75,12 +77,12 @@ func (h *TemplatesHandler) Create(w http.ResponseWriter, r *http.Request) { Variables any `json:"variables,omitempty"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if req.Slug == "" || req.Channel == "" || req.BodyTemplate == "" { - writeError(w, http.StatusBadRequest, "slug, channel, and bodyTemplate are required") + httputil.WriteError(w, http.StatusBadRequest, "slug, channel, and bodyTemplate are required") return } if req.Locale == "" { @@ -96,11 +98,11 @@ func (h *TemplatesHandler) Create(w http.ResponseWriter, r *http.Request) { req.Slug, nilIfEmpty(req.AppID), req.Channel, nilIfEmpty(req.Subject), req.BodyTemplate, req.Locale, varsJSON, ).Scan(&id) if err != nil { - writeError(w, http.StatusConflict, "template already exists for this slug+locale") + httputil.WriteError(w, http.StatusConflict, "template already exists for this slug+locale") return } - writeJSON(w, http.StatusCreated, map[string]any{"id": id}) + httputil.WriteJSON(w, http.StatusCreated, map[string]any{"id": id}) } // Update handles PUT /api/v1/templates/{slug} @@ -118,7 +120,7 @@ func (h *TemplatesHandler) Update(w http.ResponseWriter, r *http.Request) { Variables any `json:"variables,omitempty"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } @@ -132,15 +134,15 @@ func (h *TemplatesHandler) Update(w http.ResponseWriter, r *http.Request) { nilIfEmpty(req.Subject), nilIfEmpty(req.BodyTemplate), req.IsActive, slug, locale, ) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to update template") + httputil.WriteError(w, http.StatusInternalServerError, "failed to update template") return } if result.RowsAffected() == 0 { - writeError(w, http.StatusNotFound, "template not found or is a system template") + httputil.WriteError(w, http.StatusNotFound, "template not found or is a system template") return } - writeJSON(w, http.StatusOK, map[string]any{"updated": true}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"updated": true}) } // Delete handles DELETE /api/v1/templates/{slug} @@ -150,15 +152,15 @@ func (h *TemplatesHandler) Delete(w http.ResponseWriter, r *http.Request) { result, err := h.db.Pool.Exec(r.Context(), `DELETE FROM notify.templates WHERE slug = $1 AND is_system = false`, slug) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to delete template") + httputil.WriteError(w, http.StatusInternalServerError, "failed to delete template") return } if result.RowsAffected() == 0 { - writeError(w, http.StatusNotFound, "template not found or is a system template") + httputil.WriteError(w, http.StatusNotFound, "template not found or is a system template") return } - writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true}) } // Preview handles POST /api/v1/templates/{slug}/preview @@ -168,17 +170,17 @@ func (h *TemplatesHandler) Preview(w http.ResponseWriter, r *http.Request) { Data map[string]any `json:"data"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } rendered, err := h.engine.RenderBySlug(r.Context(), slug, req.Data, "") if err != nil { - writeError(w, http.StatusNotFound, "template not found") + httputil.WriteError(w, http.StatusNotFound, "template not found") return } - writeJSON(w, http.StatusOK, map[string]any{"subject": rendered.Subject, "body": rendered.Body}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"subject": rendered.Subject, "body": rendered.Body}) } // PreviewCustom handles POST /api/v1/templates/preview @@ -189,7 +191,7 @@ func (h *TemplatesHandler) PreviewCustom(w http.ResponseWriter, r *http.Request) Data map[string]any `json:"data"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } @@ -197,7 +199,7 @@ func (h *TemplatesHandler) PreviewCustom(w http.ResponseWriter, r *http.Request) if req.Subject != "" { s, err := tmpl.RenderDirect(req.Subject, req.Data) if err != nil { - writeError(w, http.StatusBadRequest, "invalid subject template: "+err.Error()) + httputil.WriteError(w, http.StatusBadRequest, "invalid subject template: "+err.Error()) return } subject = s @@ -205,9 +207,9 @@ func (h *TemplatesHandler) PreviewCustom(w http.ResponseWriter, r *http.Request) body, err := tmpl.RenderDirect(req.BodyTemplate, req.Data) if err != nil { - writeError(w, http.StatusBadRequest, "invalid body template: "+err.Error()) + httputil.WriteError(w, http.StatusBadRequest, "invalid body template: "+err.Error()) return } - writeJSON(w, http.StatusOK, map[string]any{"subject": subject, "body": body}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"subject": subject, "body": body}) } diff --git a/services/mana-search/go.mod b/services/mana-search/go.mod index ae6d4a8cf..ac3d3c97d 100644 --- a/services/mana-search/go.mod +++ b/services/mana-search/go.mod @@ -3,6 +3,7 @@ module github.com/manacore/mana-search go 1.25.0 require ( + github.com/manacore/shared-go v0.0.0 github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3 github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 github.com/prometheus/client_golang v1.22.0 @@ -29,3 +30,5 @@ require ( golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.36.5 // indirect ) + +replace github.com/manacore/shared-go => ../../packages/shared-go diff --git a/services/mana-search/internal/config/config.go b/services/mana-search/internal/config/config.go index b96a33466..cc537c731 100644 --- a/services/mana-search/internal/config/config.go +++ b/services/mana-search/internal/config/config.go @@ -1,9 +1,7 @@ package config import ( - "os" - "strconv" - "strings" + "github.com/manacore/shared-go/envutil" ) type Config struct { @@ -35,47 +33,24 @@ type Config struct { func Load() *Config { return &Config{ - Port: getEnvInt("PORT", 3021), + Port: envutil.GetInt("PORT", 3021), - SearxngURL: getEnv("SEARXNG_URL", "http://localhost:8080"), - SearxngTimeout: getEnvInt("SEARXNG_TIMEOUT", 15000), - SearxngDefaultLanguage: getEnv("SEARXNG_DEFAULT_LANGUAGE", "de-DE"), + SearxngURL: envutil.Get("SEARXNG_URL", "http://localhost:8080"), + SearxngTimeout: envutil.GetInt("SEARXNG_TIMEOUT", 15000), + SearxngDefaultLanguage: envutil.Get("SEARXNG_DEFAULT_LANGUAGE", "de-DE"), - RedisHost: getEnv("REDIS_HOST", "localhost"), - RedisPort: getEnvInt("REDIS_PORT", 6379), - RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisHost: envutil.Get("REDIS_HOST", "localhost"), + RedisPort: envutil.GetInt("REDIS_PORT", 6379), + RedisPassword: envutil.Get("REDIS_PASSWORD", ""), RedisPrefix: "mana-search:", - CacheSearchTTL: getEnvInt("CACHE_SEARCH_TTL", 3600), - CacheExtractTTL: getEnvInt("CACHE_EXTRACT_TTL", 86400), + CacheSearchTTL: envutil.GetInt("CACHE_SEARCH_TTL", 3600), + CacheExtractTTL: envutil.GetInt("CACHE_EXTRACT_TTL", 86400), - ExtractTimeout: getEnvInt("EXTRACT_TIMEOUT", 10000), - ExtractMaxLength: getEnvInt("EXTRACT_MAX_LENGTH", 50000), - ExtractUserAgent: getEnv("EXTRACT_USER_AGENT", "Mozilla/5.0 (compatible; ManaSearchBot/1.0; +https://mana.how)"), + ExtractTimeout: envutil.GetInt("EXTRACT_TIMEOUT", 10000), + ExtractMaxLength: envutil.GetInt("EXTRACT_MAX_LENGTH", 50000), + ExtractUserAgent: envutil.Get("EXTRACT_USER_AGENT", "Mozilla/5.0 (compatible; ManaSearchBot/1.0; +https://mana.how)"), - CORSOrigins: getEnvSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173", "http://localhost:8081"}), + CORSOrigins: envutil.GetSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173", "http://localhost:8081"}), } } - -func getEnv(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func getEnvInt(key string, fallback int) int { - if v := os.Getenv(key); v != "" { - if i, err := strconv.Atoi(v); err == nil { - return i - } - } - return fallback -} - -func getEnvSlice(key string, fallback []string) []string { - if v := os.Getenv(key); v != "" { - return strings.Split(v, ",") - } - return fallback -} diff --git a/services/mana-search/internal/handler/common.go b/services/mana-search/internal/handler/common.go deleted file mode 100644 index 69d3f747e..000000000 --- a/services/mana-search/internal/handler/common.go +++ /dev/null @@ -1,24 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "time" -) - -func writeJSON(w http.ResponseWriter, status int, data any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} - -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]any{ - "success": false, - "error": map[string]any{ - "statusCode": status, - "message": message, - "timestamp": time.Now().UTC().Format(time.RFC3339), - }, - }) -} diff --git a/services/mana-search/internal/handler/extract.go b/services/mana-search/internal/handler/extract.go index bf30d6202..10a63ff50 100644 --- a/services/mana-search/internal/handler/extract.go +++ b/services/mana-search/internal/handler/extract.go @@ -3,6 +3,8 @@ package handler import ( "encoding/json" "net/http" + + "github.com/manacore/shared-go/httputil" "net/url" "time" @@ -34,27 +36,27 @@ func (h *ExtractHandler) Extract(w http.ResponseWriter, r *http.Request) { var req extract.ExtractRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if req.URL == "" { - writeError(w, http.StatusBadRequest, "url is required") + httputil.WriteError(w, http.StatusBadRequest, "url is required") return } if _, err := url.ParseRequestURI(req.URL); err != nil { - writeError(w, http.StatusBadRequest, "url must be a valid URL") + httputil.WriteError(w, http.StatusBadRequest, "url must be a valid URL") return } // Validate options if req.Options != nil { if req.Options.MaxLength > 0 && (req.Options.MaxLength < 100 || req.Options.MaxLength > 100000) { - writeError(w, http.StatusBadRequest, "maxLength must be between 100 and 100000") + httputil.WriteError(w, http.StatusBadRequest, "maxLength must be between 100 and 100000") return } if req.Options.Timeout > 0 && (req.Options.Timeout < 1000 || req.Options.Timeout > 30000) { - writeError(w, http.StatusBadRequest, "timeout must be between 1000 and 30000") + httputil.WriteError(w, http.StatusBadRequest, "timeout must be between 1000 and 30000") return } } @@ -68,7 +70,7 @@ func (h *ExtractHandler) Extract(w http.ResponseWriter, r *http.Request) { cached.Meta.Cached = true duration := time.Since(start).Seconds() h.metrics.RecordRequest("extract", "200", duration) - writeJSON(w, http.StatusOK, cached) + httputil.WriteJSON(w, http.StatusOK, cached) return } } @@ -89,7 +91,7 @@ func (h *ExtractHandler) Extract(w http.ResponseWriter, r *http.Request) { duration := time.Since(start).Seconds() h.metrics.RecordRequest("extract", status, duration) - writeJSON(w, http.StatusOK, resp) + httputil.WriteJSON(w, http.StatusOK, resp) } // BulkExtract handles POST /api/v1/extract/bulk @@ -98,22 +100,22 @@ func (h *ExtractHandler) BulkExtract(w http.ResponseWriter, r *http.Request) { var req extract.BulkExtractRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if len(req.URLs) == 0 { - writeError(w, http.StatusBadRequest, "urls is required") + httputil.WriteError(w, http.StatusBadRequest, "urls is required") return } if len(req.URLs) > 20 { - writeError(w, http.StatusBadRequest, "maximum 20 URLs allowed") + httputil.WriteError(w, http.StatusBadRequest, "maximum 20 URLs allowed") return } for _, u := range req.URLs { if _, err := url.ParseRequestURI(u); err != nil { - writeError(w, http.StatusBadRequest, "invalid URL: "+u) + httputil.WriteError(w, http.StatusBadRequest, "invalid URL: "+u) return } } @@ -123,5 +125,5 @@ func (h *ExtractHandler) BulkExtract(w http.ResponseWriter, r *http.Request) { duration := time.Since(start).Seconds() h.metrics.RecordRequest("extract_bulk", "200", duration) - writeJSON(w, http.StatusOK, resp) + httputil.WriteJSON(w, http.StatusOK, resp) } diff --git a/services/mana-search/internal/handler/health.go b/services/mana-search/internal/handler/health.go index 6afdfc68b..15b1d4477 100644 --- a/services/mana-search/internal/handler/health.go +++ b/services/mana-search/internal/handler/health.go @@ -2,6 +2,8 @@ package handler import ( "net/http" + + "github.com/manacore/shared-go/httputil" "time" "github.com/manacore/mana-search/internal/cache" @@ -34,7 +36,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { overall = "degraded" } - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "status": overall, "service": "mana-search", "version": "1.0.0", diff --git a/services/mana-search/internal/handler/search.go b/services/mana-search/internal/handler/search.go index 47157b1d7..4e3f9236c 100644 --- a/services/mana-search/internal/handler/search.go +++ b/services/mana-search/internal/handler/search.go @@ -4,6 +4,8 @@ import ( "encoding/json" "log/slog" "net/http" + + "github.com/manacore/shared-go/httputil" "sort" "time" @@ -37,23 +39,23 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { var req search.SearchRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + httputil.WriteError(w, http.StatusBadRequest, "invalid request body") return } if req.Query == "" { - writeError(w, http.StatusBadRequest, "query is required") + httputil.WriteError(w, http.StatusBadRequest, "query is required") return } // Validate options if req.Options != nil { if req.Options.Limit < 0 || req.Options.Limit > 50 { - writeError(w, http.StatusBadRequest, "limit must be between 1 and 50") + httputil.WriteError(w, http.StatusBadRequest, "limit must be between 1 and 50") return } if req.Options.SafeSearch < 0 || req.Options.SafeSearch > 2 { - writeError(w, http.StatusBadRequest, "safeSearch must be 0, 1, or 2") + httputil.WriteError(w, http.StatusBadRequest, "safeSearch must be 0, 1, or 2") return } } @@ -69,7 +71,7 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { cached.Meta.CacheKey = cacheKey duration := time.Since(start).Seconds() h.metrics.RecordRequest("search", "200", duration) - writeJSON(w, http.StatusOK, cached) + httputil.WriteJSON(w, http.StatusOK, cached) return } } @@ -81,7 +83,7 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { slog.Error("search failed", "error", err, "query", req.Query) duration := time.Since(start).Seconds() h.metrics.RecordRequest("search", "502", duration) - writeError(w, http.StatusBadGateway, err.Error()) + httputil.WriteError(w, http.StatusBadGateway, err.Error()) return } @@ -119,13 +121,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { duration := time.Since(start).Seconds() h.metrics.RecordRequest("search", "200", duration) - writeJSON(w, http.StatusOK, resp) + httputil.WriteJSON(w, http.StatusOK, resp) } // Engines handles GET /api/v1/search/engines func (h *SearchHandler) Engines(w http.ResponseWriter, r *http.Request) { engines := h.provider.GetEngines(r.Context()) - writeJSON(w, http.StatusOK, map[string]any{"engines": engines}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{"engines": engines}) } // Health handles GET /api/v1/search/health @@ -134,7 +136,7 @@ func (h *SearchHandler) Health(w http.ResponseWriter, r *http.Request) { redisHealth := h.cache.HealthCheck(r.Context()) cacheStats := h.cache.Stats() - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "searxng": map[string]any{ "status": sxStatus, "latency": sxLatency, @@ -148,10 +150,10 @@ func (h *SearchHandler) Health(w http.ResponseWriter, r *http.Request) { func (h *SearchHandler) ClearCache(w http.ResponseWriter, r *http.Request) { deleted, err := h.cache.Clear(r.Context()) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to clear cache") + httputil.WriteError(w, http.StatusInternalServerError, "failed to clear cache") return } - writeJSON(w, http.StatusOK, map[string]any{ + httputil.WriteJSON(w, http.StatusOK, map[string]any{ "cleared": true, "keysRemoved": deleted, })