SEO blogpost
This commit is contained in:
105
server/lib/scanEvents.js
Normal file
105
server/lib/scanEvents.js
Normal file
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user