106 lines
3.2 KiB
JavaScript
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,
|
|
};
|