SEO blogpost

This commit is contained in:
2026-08-05 19:39:22 +02:00
parent d7432fa65c
commit cf05e60251
53 changed files with 10592 additions and 125 deletions

View File

@@ -37,6 +37,7 @@ const {
issueToken,
verifyJwt,
} = require('./lib/auth');
const { ensureScanEventSchema, recordScanEvent } = require('./lib/scanEvents');
const {
PlantImportValidationError,
ensurePlantSchema,
@@ -959,6 +960,12 @@ app.post('/v1/scan', guestScanLimiter, async (request, response) => {
};
await storeEndpointResponse(db, endpointId, payload);
await recordScanEvent(db, {
scanType: 'identify',
species: result?.botanicalName || result?.name || null,
confidence: result?.confidence,
locale: language,
});
response.status(200).json(payload);
} catch (error) {
console.error(`Scan error for user ${userId}:`, error);
@@ -1139,6 +1146,13 @@ app.post('/v1/health-check', async (request, response) => {
};
await storeEndpointResponse(db, endpointId, payload);
await recordScanEvent(db, {
scanType: 'health',
diagnosis: analysis?.likelyIssues?.[0]?.title || null,
confidence: analysis?.likelyIssues?.[0]?.confidence,
healthStatus: analysis?.status || null,
locale: language,
});
response.status(200).json(payload);
} catch (error) {
const payload = toApiErrorPayload(error);
@@ -1946,6 +1960,7 @@ const start = async () => {
await ensurePlantSchema(db);
await ensureBillingSchema(db);
await ensureAuthSchema(db);
await ensureScanEventSchema(db);
await seedBootstrapCatalogIfNeeded();
if (isStorageConfigured()) {
await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message));

105
server/lib/scanEvents.js Normal file
View 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,
};