Files
Greenlens/server/lib/scanEvents.js
2026-08-05 19:39:22 +02:00

106 lines
3.2 KiB
JavaScript

const { run, all } = require('./postgres');
/**
* Anonymes Scan-Logging für aggregierte Auswertungen (Content, Katalogpflege).
*
* Bewusst OHNE user_id, Bildreferenz oder Idempotency-Key: Die Tabelle ist von
* Anfang an nicht personenbeziehbar, damit später nichts nachträglich
* anonymisiert werden muss. Wer wann gescannt hat, steht weiterhin in
* billing_accounts bzw. PostHog — hier geht es nur um das Was.
*
* Schreibfehler dürfen einen Scan nie scheitern lassen, siehe recordScanEvent.
*/
const ensureScanEventSchema = async (db) => {
await run(
db,
`CREATE TABLE IF NOT EXISTS scan_events (
id BIGSERIAL PRIMARY KEY,
scan_type TEXT NOT NULL,
species TEXT,
confidence DOUBLE PRECISION,
diagnosis TEXT,
health_status TEXT,
locale TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
);
await run(db, 'CREATE INDEX IF NOT EXISTS idx_scan_events_created_at ON scan_events (created_at DESC)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_scan_events_species ON scan_events (LOWER(species))');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_scan_events_scan_type ON scan_events (scan_type)');
};
const trimOrNull = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, 200) : null;
};
const toNumberOrNull = (value) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
/**
* Best effort. Ein fehlgeschlagenes Log darf den Scan des Nutzers nicht
* beeinflussen — deshalb wird hier geloggt und geschluckt, nicht geworfen.
*/
const recordScanEvent = async (db, event) => {
try {
await run(
db,
`INSERT INTO scan_events (scan_type, species, confidence, diagnosis, health_status, locale)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
trimOrNull(event?.scanType) || 'unknown',
trimOrNull(event?.species),
toNumberOrNull(event?.confidence),
trimOrNull(event?.diagnosis),
trimOrNull(event?.healthStatus),
trimOrNull(event?.locale),
],
);
} catch (error) {
console.warn('scan_events write failed (ignored):', error.message);
}
};
/**
* Aggregat für spätere Content-Auswertungen. Erst ab nennenswerter Menge
* aussagekräftig — die Mindestmenge bewusst im Aufrufer prüfen, nicht hier.
*/
const getScanEventStats = async (db, { since = null, limit = 100 } = {}) => {
const params = [];
let whereClause = '';
if (since) {
params.push(since);
whereClause = 'WHERE created_at >= $1';
}
params.push(limit);
const topSpecies = await all(
db,
`SELECT species, COUNT(*)::int AS scans, ROUND(AVG(confidence)::numeric, 3) AS avg_confidence
FROM scan_events
${whereClause}${whereClause ? ' AND' : 'WHERE'} species IS NOT NULL
GROUP BY species
ORDER BY scans DESC
LIMIT $${params.length}`,
params,
);
const totals = await all(
db,
`SELECT scan_type, COUNT(*)::int AS scans FROM scan_events ${whereClause} GROUP BY scan_type`,
since ? [since] : [],
);
return { topSpecies, totals };
};
module.exports = {
ensureScanEventSchema,
getScanEventStats,
recordScanEvent,
};