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:
21
server/db/index.js
Normal file
21
server/db/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Synchronous SQLite initialization. On startup ensure the data directory
|
||||
// exists, open the DB, and run schema.sql (idempotent CREATE IF NOT EXISTS).
|
||||
import Database from 'better-sqlite3'
|
||||
import { readFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const defaultPath = resolve(here, '..', 'data', 'visigine.db')
|
||||
const dbPath = process.env.DB_PATH ? resolve(process.env.DB_PATH) : defaultPath
|
||||
|
||||
mkdirSync(dirname(dbPath), { recursive: true })
|
||||
|
||||
export const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL') // better concurrent reads
|
||||
db.pragma('foreign_keys = ON') // enforce ON DELETE CASCADE
|
||||
|
||||
const schema = readFileSync(new URL('./schema.sql', import.meta.url), 'utf8')
|
||||
db.exec(schema)
|
||||
|
||||
console.log(`[db] opened ${dbPath}`)
|
||||
177
server/db/repo.js
Normal file
177
server/db/repo.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// Repository layer. Every exported function is one prepared statement or a
|
||||
// small transaction. No ORM — plain SQL behind named functions.
|
||||
import { db } from './index.js'
|
||||
|
||||
const stmts = {
|
||||
listClients: db.prepare(`
|
||||
SELECT
|
||||
c.*,
|
||||
(SELECT COUNT(*) FROM queries q WHERE q.client_id = c.id) AS queries_count,
|
||||
(SELECT COUNT(*) FROM runs r
|
||||
WHERE r.client_id = c.id
|
||||
AND r.ran_at >= datetime('now', '-30 days')
|
||||
AND r.error IS NULL) AS runs_30d,
|
||||
(SELECT COUNT(*) FROM runs r
|
||||
WHERE r.client_id = c.id
|
||||
AND r.ran_at >= datetime('now', '-30 days')
|
||||
AND r.error IS NULL
|
||||
AND r.mentioned = 1) AS mentions_30d
|
||||
FROM clients c
|
||||
ORDER BY c.hostname
|
||||
`),
|
||||
getClient: db.prepare(`SELECT * FROM clients WHERE id = ?`),
|
||||
getClientByHost: db.prepare(`SELECT * FROM clients WHERE hostname = ?`),
|
||||
insertClient: db.prepare(`
|
||||
INSERT INTO clients (hostname, url, name, description, brand_aliases, language)
|
||||
VALUES (@hostname, @url, @name, @description, @brand_aliases, @language)
|
||||
`),
|
||||
updateClient: db.prepare(`
|
||||
UPDATE clients
|
||||
SET name = COALESCE(@name, name),
|
||||
description = COALESCE(@description, description),
|
||||
brand_aliases = COALESCE(@brand_aliases, brand_aliases),
|
||||
status = COALESCE(@status, status)
|
||||
WHERE id = @id
|
||||
`),
|
||||
deleteClient: db.prepare(`DELETE FROM clients WHERE id = ?`),
|
||||
touchClientRun: db.prepare(`UPDATE clients SET last_run_at = datetime('now') WHERE id = ?`),
|
||||
|
||||
listActiveQueries: db.prepare(`SELECT * FROM queries WHERE client_id = ? AND active = 1 ORDER BY id`),
|
||||
listAllQueries: db.prepare(`SELECT * FROM queries WHERE client_id = ? ORDER BY id`),
|
||||
getQuery: db.prepare(`SELECT * FROM queries WHERE id = ?`),
|
||||
insertQuery: db.prepare(`INSERT INTO queries (client_id, text) VALUES (?, ?)`),
|
||||
updateQuery: db.prepare(`
|
||||
UPDATE queries
|
||||
SET text = COALESCE(@text, text),
|
||||
active = COALESCE(@active, active)
|
||||
WHERE id = @id
|
||||
`),
|
||||
deleteQuery: db.prepare(`DELETE FROM queries WHERE id = ?`),
|
||||
deleteAllQueries: db.prepare(`DELETE FROM queries WHERE client_id = ?`),
|
||||
|
||||
insertRun: db.prepare(`
|
||||
INSERT INTO runs
|
||||
(client_id, query_id, provider, mentioned, position, snippet, response_full, ms, cost_usd, error)
|
||||
VALUES
|
||||
(@client_id, @query_id, @provider, @mentioned, @position, @snippet, @response_full, @ms, @cost_usd, @error)
|
||||
`),
|
||||
getRun: db.prepare(`
|
||||
SELECT r.*, q.text AS query_text FROM runs r
|
||||
JOIN queries q ON q.id = r.query_id
|
||||
WHERE r.id = ?
|
||||
`),
|
||||
recentRuns: db.prepare(`
|
||||
SELECT r.*, q.text AS query_text FROM runs r
|
||||
JOIN queries q ON q.id = r.query_id
|
||||
WHERE r.client_id = ?
|
||||
ORDER BY r.ran_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`),
|
||||
countRuns: db.prepare(`SELECT COUNT(*) AS n FROM runs WHERE client_id = ?`),
|
||||
statsByProvider: db.prepare(`
|
||||
SELECT provider,
|
||||
COUNT(*) AS total,
|
||||
SUM(mentioned) AS mentions,
|
||||
SUM(cost_usd) AS cost,
|
||||
MAX(ran_at) AS last_run
|
||||
FROM runs
|
||||
WHERE client_id = ?
|
||||
AND ran_at >= datetime('now', '-30 days')
|
||||
AND error IS NULL
|
||||
GROUP BY provider
|
||||
ORDER BY provider
|
||||
`),
|
||||
totalsLast30d: db.prepare(`
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(mentioned) AS mentions,
|
||||
SUM(cost_usd) AS cost
|
||||
FROM runs
|
||||
WHERE client_id = ?
|
||||
AND ran_at >= datetime('now', '-30 days')
|
||||
AND error IS NULL
|
||||
`),
|
||||
}
|
||||
|
||||
// Bulk insert with a transaction — used by generateQueries.
|
||||
const insertQueriesTx = db.transaction((clientId, texts) => {
|
||||
for (const text of texts) stmts.insertQuery.run(clientId, text)
|
||||
})
|
||||
const replaceQueriesTx = db.transaction((clientId, texts) => {
|
||||
stmts.deleteAllQueries.run(clientId)
|
||||
for (const text of texts) stmts.insertQuery.run(clientId, text)
|
||||
})
|
||||
|
||||
// ─── clients ───────────────────────────────────────────────────────
|
||||
export function listClients() { return stmts.listClients.all() }
|
||||
export function getClient(id) { return stmts.getClient.get(id) }
|
||||
export function getClientByHost(host) { return stmts.getClientByHost.get(host) }
|
||||
export function insertClient(row) {
|
||||
const info = stmts.insertClient.run({
|
||||
hostname: row.hostname,
|
||||
url: row.url,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
brand_aliases: row.brand_aliases ?? '[]',
|
||||
language: row.language ?? 'de',
|
||||
})
|
||||
return getClient(info.lastInsertRowid)
|
||||
}
|
||||
export function updateClient(id, patch) {
|
||||
stmts.updateClient.run({
|
||||
id,
|
||||
name: patch.name ?? null,
|
||||
description: patch.description ?? null,
|
||||
brand_aliases: patch.brand_aliases ?? null,
|
||||
status: patch.status ?? null,
|
||||
})
|
||||
return getClient(id)
|
||||
}
|
||||
export function deleteClient(id) { return stmts.deleteClient.run(id).changes > 0 }
|
||||
export function touchClientRun(id) { stmts.touchClientRun.run(id) }
|
||||
|
||||
// ─── queries ───────────────────────────────────────────────────────
|
||||
export function listActiveQueries(clientId) { return stmts.listActiveQueries.all(clientId) }
|
||||
export function listAllQueries(clientId) { return stmts.listAllQueries.all(clientId) }
|
||||
export function getQuery(id) { return stmts.getQuery.get(id) }
|
||||
export function insertQuery(clientId, text) {
|
||||
const info = stmts.insertQuery.run(clientId, text)
|
||||
return getQuery(info.lastInsertRowid)
|
||||
}
|
||||
export function insertQueries(clientId, texts) { insertQueriesTx(clientId, texts) }
|
||||
export function replaceQueries(clientId, texts) {
|
||||
replaceQueriesTx(clientId, texts)
|
||||
return texts.length
|
||||
}
|
||||
export function updateQuery(id, patch) {
|
||||
stmts.updateQuery.run({
|
||||
id,
|
||||
text: patch.text ?? null,
|
||||
active: patch.active === undefined ? null : (patch.active ? 1 : 0),
|
||||
})
|
||||
return getQuery(id)
|
||||
}
|
||||
export function deleteQuery(id) { return stmts.deleteQuery.run(id).changes > 0 }
|
||||
|
||||
// ─── runs ──────────────────────────────────────────────────────────
|
||||
export function insertRun(row) {
|
||||
const info = stmts.insertRun.run({
|
||||
client_id: row.client_id,
|
||||
query_id: row.query_id,
|
||||
provider: row.provider,
|
||||
mentioned: row.mentioned ? 1 : 0,
|
||||
position: row.position ?? null,
|
||||
snippet: row.snippet ?? null,
|
||||
response_full: row.response_full ?? null,
|
||||
ms: row.ms ?? 0,
|
||||
cost_usd: row.cost_usd ?? 0,
|
||||
error: row.error ?? null,
|
||||
})
|
||||
return info.lastInsertRowid
|
||||
}
|
||||
export function getRun(id) { return stmts.getRun.get(id) }
|
||||
export function recentRuns(clientId, limit = 50, offset = 0) {
|
||||
return stmts.recentRuns.all(clientId, limit, offset)
|
||||
}
|
||||
export function countRuns(clientId) { return stmts.countRuns.get(clientId).n }
|
||||
export function statsByProvider(clientId) { return stmts.statsByProvider.all(clientId) }
|
||||
export function totalsLast30d(clientId) { return stmts.totalsLast30d.get(clientId) }
|
||||
44
server/db/schema.sql
Normal file
44
server/db/schema.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Brand we monitor. One row per tracked brand.
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
brand_aliases TEXT NOT NULL DEFAULT '[]',
|
||||
language TEXT NOT NULL DEFAULT 'de',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','archived')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_run_at TEXT
|
||||
);
|
||||
|
||||
-- Search queries that simulate potential customer questions.
|
||||
-- Each active query is sent to every available provider on every run.
|
||||
CREATE TABLE IF NOT EXISTS queries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- One row per (client, query, provider, ran_at). Stores the full LLM
|
||||
-- response for review plus the mention detection result.
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
query_id INTEGER NOT NULL REFERENCES queries(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
ran_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
mentioned INTEGER NOT NULL DEFAULT 0 CHECK (mentioned IN (0,1)),
|
||||
position INTEGER,
|
||||
snippet TEXT,
|
||||
response_full TEXT,
|
||||
ms INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_client_ran ON runs(client_id, ran_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_query ON runs(query_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_queries_client ON queries(client_id);
|
||||
Reference in New Issue
Block a user