mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(apps): create Hono compute servers for Traces, Planta, NutriPhi
Add lightweight Hono + Bun servers for server-only compute endpoints. CRUD is handled by mana-sync, these handle AI + file upload only. Traces: AI guide generation, location sync (Port 3026) Planta: Photo upload (S3), AI plant analysis (Port 3022) NutriPhi: AI meal analysis (photo+text), recommendations (Port 3023) Each uses @manacore/shared-hono for auth/health/errors. ~100-200 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d26196590
commit
d3d11e661d
30 changed files with 1161 additions and 221 deletions
17
apps/nutriphi/apps/server/package.json
Normal file
17
apps/nutriphi/apps/server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
154
apps/nutriphi/apps/server/src/index.ts
Normal file
154
apps/nutriphi/apps/server/src/index.ts
Normal file
|
|
@ -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 };
|
||||
11
apps/nutriphi/apps/server/tsconfig.json
Normal file
11
apps/nutriphi/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
20
apps/planta/apps/server/package.json
Normal file
20
apps/planta/apps/server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
104
apps/planta/apps/server/src/index.ts
Normal file
104
apps/planta/apps/server/src/index.ts
Normal file
|
|
@ -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 };
|
||||
40
apps/planta/apps/server/src/plant-analyses.schema.ts
Normal file
40
apps/planta/apps/server/src/plant-analyses.schema.ts
Normal file
|
|
@ -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<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;
|
||||
30
apps/planta/apps/server/src/plant-photos.schema.ts
Normal file
30
apps/planta/apps/server/src/plant-photos.schema.ts
Normal file
|
|
@ -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;
|
||||
32
apps/planta/apps/server/src/plants.schema.ts
Normal file
32
apps/planta/apps/server/src/plants.schema.ts
Normal file
|
|
@ -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;
|
||||
45
apps/planta/apps/server/src/watering.schema.ts
Normal file
45
apps/planta/apps/server/src/watering.schema.ts
Normal file
|
|
@ -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;
|
||||
11
apps/planta/apps/server/tsconfig.json
Normal file
11
apps/planta/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
19
apps/traces/apps/server/package.json
Normal file
19
apps/traces/apps/server/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
15
apps/traces/apps/server/src/db.ts
Normal file
15
apps/traces/apps/server/src/db.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | 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<typeof getDb>;
|
||||
108
apps/traces/apps/server/src/index.ts
Normal file
108
apps/traces/apps/server/src/index.ts
Normal file
|
|
@ -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 };
|
||||
231
apps/traces/apps/server/src/schema.ts
Normal file
231
apps/traces/apps/server/src/schema.ts
Normal file
|
|
@ -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;
|
||||
168
apps/traces/apps/server/src/services/guide.ts
Normal file
168
apps/traces/apps/server/src/services/guide.ts
Normal file
|
|
@ -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<string> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
11
apps/traces/apps/server/tsconfig.json
Normal file
11
apps/traces/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <noreply@mana.how>"),
|
||||
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 <noreply@mana.how>"),
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue