SEO blogpost
This commit is contained in:
@@ -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
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