Initial commit: Visigine (Vite client + Express/SQLite backend)

Container-ready via docker/ compose (frontend nginx + backend Node). Compose adjusted for Coolify on the prod server: frontend uses expose:80 (no host binding — host 8080 is taken by the Coolify proxy; Traefik routes visigine.de), backend ALLOWED_ORIGINS=https://visigine.de. Secrets stay in server/.env (git-ignored); see server/.env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:06:48 +02:00
commit e344f1b7e7
88 changed files with 11764 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
import { Router } from 'express'
import * as repo from '../db/repo.js'
import { runMonitoring, RunError } from '../lib/monitoring/run.js'
import { generateQueries } from '../lib/monitoring/generate-queries.js'
import { runAnalysisPipeline, validateUrl } from '../lib/pipeline.js'
import { getProviderModes } from '../lib/providers/index.js'
const router = Router()
function serializeClient(c) {
if (!c) return null
let aliases = []
try { aliases = JSON.parse(c.brand_aliases || '[]') } catch { /* keep [] */ }
return { ...c, brand_aliases: aliases }
}
function mentionRate(mentions, runs) {
if (!runs || runs <= 0) return null
return Number((mentions / runs).toFixed(4))
}
// ─── clients ──────────────────────────────────────────────────────
router.get('/clients', (_req, res) => {
const rows = repo.listClients().map((c) => ({
id: c.id,
hostname: c.hostname,
name: c.name,
status: c.status,
queries_count: c.queries_count,
last_run_at: c.last_run_at,
mention_rate_30d: mentionRate(c.mentions_30d, c.runs_30d),
runs_30d: c.runs_30d,
mentions_30d: c.mentions_30d,
}))
res.json({ clients: rows, providerModes: getProviderModes() })
})
router.post('/clients', async (req, res) => {
const body = req.body || {}
if (!body.url) return res.status(400).json({ error: 'URL erforderlich.' })
const v = validateUrl(body.url)
if (v.error) return res.status(v.error.http).json({ error: v.error.msg })
// If hostname already exists, return existing instead of duplicating.
const existing = repo.getClientByHost(v.host)
if (existing) return res.json(serializeClient(existing))
// Pull siteData from the analyze pipeline if name/description not provided.
let { hostname, name, description, language, brand_aliases } = body
hostname = hostname || v.host
if (!name || !description) {
try {
const out = await runAnalysisPipeline(v.targetUrl, { debugMode: false })
if (!out.error) {
const sd = out._siteData || {}
name = name || sd.name || v.host
description = description || sd.description || null
language = language || sd.language || 'de'
}
} catch (e) {
console.warn('[admin-monitoring] prefill failed:', e?.message || e)
}
}
const aliasesJson = JSON.stringify(Array.isArray(brand_aliases) ? brand_aliases : [])
try {
const created = repo.insertClient({
hostname,
url: v.targetUrl,
name: name || v.host,
description,
brand_aliases: aliasesJson,
language: language || 'de',
})
return res.status(201).json(serializeClient(created))
} catch (err) {
if (String(err?.message || '').includes('UNIQUE')) {
return res.status(409).json({ error: 'Marke existiert bereits.' })
}
console.error('[admin-monitoring] insert error', err)
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
}
})
router.get('/clients/:id', (req, res) => {
const c = repo.getClient(Number(req.params.id))
if (!c) return res.status(404).json({ error: 'Marke nicht gefunden.' })
res.json(serializeClient(c))
})
router.patch('/clients/:id', (req, res) => {
const id = Number(req.params.id)
const existing = repo.getClient(id)
if (!existing) return res.status(404).json({ error: 'Marke nicht gefunden.' })
const patch = req.body || {}
if (patch.status && !['active', 'paused', 'archived'].includes(patch.status)) {
return res.status(400).json({ error: 'Ungültiger Status.' })
}
if (patch.brand_aliases !== undefined && Array.isArray(patch.brand_aliases)) {
patch.brand_aliases = JSON.stringify(patch.brand_aliases)
}
const updated = repo.updateClient(id, patch)
res.json(serializeClient(updated))
})
router.delete('/clients/:id', (req, res) => {
const ok = repo.deleteClient(Number(req.params.id))
if (!ok) return res.status(404).json({ error: 'Marke nicht gefunden.' })
res.json({ deleted: true })
})
// ─── queries ──────────────────────────────────────────────────────
router.post('/clients/:id/generate-queries', async (req, res) => {
const id = Number(req.params.id)
const c = repo.getClient(id)
if (!c) return res.status(404).json({ error: 'Marke nicht gefunden.' })
const previousCount = repo.listAllQueries(id).length
const result = await generateQueries({
name: c.name,
description: c.description,
url: c.url,
hostname: c.hostname,
})
if (!result.queries || result.queries.length === 0) {
return res.status(502).json({ error: 'Query-Generierung fehlgeschlagen. Bitte später erneut versuchen.' })
}
repo.replaceQueries(id, result.queries)
return res.json({
generated: result.queries.length,
replaced: previousCount,
warning: result.warning || null,
})
})
router.get('/clients/:id/queries', (req, res) => {
const id = Number(req.params.id)
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
res.json({ queries: repo.listAllQueries(id) })
})
router.post('/clients/:id/queries', (req, res) => {
const id = Number(req.params.id)
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
const text = String(req.body?.text || '').trim()
if (text.length < 5) return res.status(400).json({ error: 'Query zu kurz.' })
const q = repo.insertQuery(id, text)
res.status(201).json(q)
})
router.patch('/queries/:id', (req, res) => {
const id = Number(req.params.id)
if (!repo.getQuery(id)) return res.status(404).json({ error: 'Query nicht gefunden.' })
const patch = req.body || {}
if (patch.text !== undefined) patch.text = String(patch.text).trim()
if (patch.text !== undefined && patch.text.length < 5) {
return res.status(400).json({ error: 'Query zu kurz.' })
}
const q = repo.updateQuery(id, patch)
res.json(q)
})
router.delete('/queries/:id', (req, res) => {
const ok = repo.deleteQuery(Number(req.params.id))
if (!ok) return res.status(404).json({ error: 'Query nicht gefunden.' })
res.json({ deleted: true })
})
// ─── runs ─────────────────────────────────────────────────────────
router.post('/clients/:id/run', async (req, res) => {
const id = Number(req.params.id)
try {
const summary = await runMonitoring(id)
res.json(summary)
} catch (err) {
if (err instanceof RunError) {
const status = err.code === 'CLIENT_NOT_FOUND' ? 404 : 400
const message = err.code === 'CLIENT_NOT_FOUND' ? 'Marke nicht gefunden.'
: err.code === 'NO_ACTIVE_QUERIES' ? 'Keine aktiven Queries — bitte zuerst generieren oder anlegen.'
: err.code === 'CLIENT_NOT_ACTIVE' ? 'Marke ist pausiert oder archiviert.'
: err.code
return res.status(status).json({ error: message, code: err.code })
}
console.error('[admin-monitoring] run error', err)
return res.status(500).json({ error: 'Ein interner Fehler ist aufgetreten.' })
}
})
router.get('/clients/:id/runs', (req, res) => {
const id = Number(req.params.id)
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
const page = Math.max(1, Number(req.query.page || 1))
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)))
const offset = (page - 1) * limit
res.json({
page,
limit,
total: repo.countRuns(id),
runs: repo.recentRuns(id, limit, offset),
})
})
router.get('/clients/:id/stats', (req, res) => {
const id = Number(req.params.id)
if (!repo.getClient(id)) return res.status(404).json({ error: 'Marke nicht gefunden.' })
const byProvider = repo.statsByProvider(id)
const totals = repo.totalsLast30d(id)
res.json({
byProvider: byProvider.map((r) => ({
provider: r.provider,
total: r.total,
mentions: r.mentions,
cost: Number((r.cost || 0).toFixed(6)),
mention_rate: mentionRate(r.mentions, r.total),
last_run: r.last_run,
})),
totalRuns30d: totals?.total || 0,
totalMentions30d: totals?.mentions || 0,
mentionRate30d: mentionRate(totals?.mentions, totals?.total),
totalCost30d: Number(((totals?.cost) || 0).toFixed(6)),
})
})
export default router