From 9738d3e35a44142a60857643c6ce705380bd03a8 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 15 Jun 2026 12:55:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Profil-Kategorien=20+=20Begr=C3=BC?= =?UTF-8?q?=C3=9Fung=20in=20Zielsprache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit languages.greeting (de/en/sv geseedet), neue pair_categories-Tabelle (abgeleitet aus statement- und objektverknüpften Wörtern via word_categories) inkl. Backfill für bereits veröffentlichte Pairs. derivePairCategories() wird beim Publish (pairs + pipeline) aufgerufen. /auth/me liefert language_target_greeting, /auth/stats liefert categories[] mit Punkten je Kategorie fürs Profil. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 29 +++++++++ scripts/upload-pictures.mjs | 119 ++++++++++++++++++++++++++++++++++++ src/db-migrate.js | 51 ++++++++++++++-- src/lib/pairCategories.js | 42 +++++++++++++ src/routes/auth.js | 20 +++++- src/routes/pairs.js | 8 +++ src/routes/pipeline.js | 4 ++ 7 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 CLAUDE.md create mode 100644 scripts/upload-pictures.mjs create mode 100644 src/lib/pairCategories.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4982834 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# CLAUDE.md + +REST-API für das snakkimo-Projekt. Node/Express + PostgreSQL (`pg`, kein ORM), Bild-Assets auf Hetzner Object Storage (S3-kompatibel). Ausführliche API-Doku in [README.md](README.md). + +## Befehle +- `npm run dev` — lokaler Server mit nodemon (Hot-Reload) +- `npm start` — Produktion (`node src/index.js`) +- Keine Tests / kein Linter konfiguriert. + +## Architektur +- Einstieg: [src/index.js](src/index.js) — registriert alle Routen, jede `/api/*`-Route ist mit der `auth`-Middleware geschützt. +- **Migrationen laufen automatisch beim Boot** ([src/db-migrate.js](src/db-migrate.js)), bevor der Server lauscht. Idempotent halten: `CREATE TABLE IF NOT EXISTS`, Spalten-Renames mit `.catch(() => {})`. Es gibt **kein** separates Migrations-Tool — Schema-Änderungen hier eintragen. +- `src/db.js` exportiert `query(text, params)` und `pool`. Immer parametrisierte Queries (`$1, $2 …`), nie String-Interpolation von User-Input. +- `src/routes/` — eine Datei pro Entität. `src/lib/`, `src/middleware/`, `src/s3.js`, `src/voices.js` für geteilte Logik. + +## Konventionen +- **Code-Kommentare auf Deutsch**, Code/Bezeichner auf Englisch (dem Bestand folgen). +- Route-Handler-Muster: `async (req, res, next) => { try { … } catch (err) { next(err); } }`. Fehler an den zentralen Error-Handler in `index.js` durchreichen, nicht selbst 500en. +- Listen-Endpoints: `limit`/`offset` aus Query, `limit` hart begrenzen (z. B. `Math.min(parseInt(limit), 500)`). +- Status-Felder gegen eine `STATUSES`-Whitelist prüfen → bei Verstoß `400`. +- **Sprachen-Suffixe: `_de`, `_en`, `_sv`.** `_se` ist veraltet (falscher ISO-639-1-Code) und wird beim Boot zu `_sv` umbenannt — niemals neue `_se`-Spalten anlegen. + +## Auth (zwei Pfade, siehe [src/middleware/auth.js](src/middleware/auth.js)) +1. Statische Tokens aus `API_TOKENS` (komma-separiert) → Server-zu-Server / Admin, keine Rollenprüfung. +2. JWT aus `/auth/login` · `/auth/register`. Rolle `end-user` bekommt auf allen `/api/*` bewusst **403** (App-Gating). + +Öffentlich (ohne Auth): `GET /health`, `/auth/*`. + +Konfig über `.env` (siehe [.env.example](.env.example)). Deployment via Coolify/Docker. diff --git a/scripts/upload-pictures.mjs b/scripts/upload-pictures.mjs new file mode 100644 index 0000000..3f0ca70 --- /dev/null +++ b/scripts/upload-pictures.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Uploads all images from a directory to Hetzner S3 + pictures table. + * Re-encodes each file to WebP at 85% quality via cwebp. + * + * Usage: + * TOKEN=your-dev-token node scripts/upload-pictures.mjs /path/to/folder + * TOKEN=... BASE_URL=https://hyggecraftery.com/api/snakkimo node scripts/upload-pictures.mjs /path/to/folder + */ + +import { readdir, readFile, unlink, writeFile } from 'fs/promises'; +import { execSync } from 'child_process'; +import { join, basename, extname } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; + +const TOKEN = process.env.TOKEN; +const BASE_URL = (process.env.BASE_URL || 'https://hyggecraftery.com/api/snakkimo/api').replace(/\/$/, ''); +const CONCURRENCY = 4; + +if (!TOKEN) { + console.error('ERROR: TOKEN env var required. Run: TOKEN=your-token node scripts/upload-pictures.mjs '); + process.exit(1); +} + +const dir = process.argv[2]; +if (!dir) { + console.error('ERROR: Pass the image directory as argument.'); + process.exit(1); +} + +function extractDesign(filename) { + const name = basename(filename, extname(filename)); + // Strip trailing _xxxxxxxx hash (8 hex chars) + return name.replace(/_[0-9a-f]{8}$/i, '').replace(/_/g, ' '); +} + +async function apiPost(path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`POST ${path} → ${res.status}: ${await res.text()}`); + return res.json(); +} + +async function apiUpload(pictureId, fileBuffer) { + const form = new FormData(); + const blob = new Blob([fileBuffer], { type: 'image/webp' }); + form.append('file', blob, `${pictureId}.webp`); + const res = await fetch(`${BASE_URL}/pictures/${pictureId}/upload`, { + method: 'POST', + headers: { Authorization: `Bearer ${TOKEN}` }, + body: form, + }); + if (!res.ok) throw new Error(`upload → ${res.status}: ${await res.text()}`); + return res.json(); +} + +async function processFile(filePath) { + const filename = basename(filePath); + const design = extractDesign(filename); + const tmpFile = join(tmpdir(), `${randomUUID()}.webp`); + + try { + // Re-encode to webp at 85% quality + execSync(`cwebp -q 85 "${filePath}" -o "${tmpFile}" -quiet`, { stdio: 'pipe' }); + + const buffer = await readFile(tmpFile); + + // 1. Create picture record + const picture = await apiPost('/pictures', { design }); + + // 2. Upload file + await apiUpload(picture.id, buffer); + + return { ok: true, design, id: picture.id }; + } finally { + await unlink(tmpFile).catch(() => {}); + } +} + +async function run() { + const files = (await readdir(dir)) + .filter(f => /\.(webp|jpg|jpeg|png)$/i.test(f)) + .map(f => join(dir, f)); + + console.log(`Found ${files.length} files. Uploading with concurrency ${CONCURRENCY}...\n`); + + let done = 0; + const errors = []; + + // Process in chunks of CONCURRENCY + for (let i = 0; i < files.length; i += CONCURRENCY) { + const chunk = files.slice(i, i + CONCURRENCY); + const results = await Promise.allSettled(chunk.map(processFile)); + + for (let j = 0; j < results.length; j++) { + const r = results[j]; + done++; + if (r.status === 'fulfilled') { + console.log(`[${done}/${files.length}] ✓ ${r.value.design} (${r.value.id})`); + } else { + const name = basename(chunk[j]); + console.error(`[${done}/${files.length}] ✗ ${name}: ${r.reason.message}`); + errors.push({ file: name, error: r.reason.message }); + } + } + } + + console.log(`\nDone. ${done - errors.length} succeeded, ${errors.length} failed.`); + if (errors.length) { + console.error('\nFailed files:'); + errors.forEach(e => console.error(` ${e.file}: ${e.error}`)); + } +} + +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/db-migrate.js b/src/db-migrate.js index e9159c1..a7eeafb 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -331,6 +331,41 @@ async function migrate() { ) `); + // M2M: pairs <-> categories — abgeleitet aus den verknüpften Wörtern (Statements + Objekte). + // Wird beim Publish materialisiert (src/lib/pairCategories.js). Basis für die Kategorie-Punkte im Profil. + await query(` + CREATE TABLE IF NOT EXISTS pair_categories ( + pair_id UUID NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, + category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + PRIMARY KEY (pair_id, category_id) + ) + `); + + // Backfill: Kategorien für bereits veröffentlichte Pairs ableiten. Idempotent (ON CONFLICT DO NOTHING), + // nach dem Erstlauf praktisch leer, da neue Pairs ihre Kategorien beim Publish selbst materialisieren. + await query(` + INSERT INTO pair_categories (pair_id, category_id) + SELECT DISTINCT pid, category_id FROM ( + SELECT p.id AS pid, wc.category_id + FROM pairs p + JOIN ( + SELECT statement_id, word_id FROM statement_positive_words + UNION + SELECT statement_id, word_id FROM statement_negative_words + ) sw ON sw.statement_id IN (p.positive_statement_id, p.negative_statement_id) + JOIN word_categories wc ON wc.word_id = sw.word_id + WHERE p.status = 'published' + UNION + SELECT op.pair_id AS pid, wc.category_id + FROM object_pairs op + JOIN pairs p2 ON p2.id = op.pair_id AND p2.status = 'published' + JOIN object_words ow ON ow.object_id = op.object_id + JOIN word_categories wc ON wc.word_id = ow.word_id + ) src + WHERE category_id IS NOT NULL + ON CONFLICT (pair_id, category_id) DO NOTHING + `).catch(() => {}); + // pairs.answer_type → single TEXT (was TEXT[], now back to single value + new 'question' type) await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_answer_type_check`).catch(() => {}); await query(` @@ -444,6 +479,9 @@ async function migrate() { FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); + // Begrüßung pro Sprache (in der Sprache selbst, z. B. sv = "Hej") — für die persönliche Profil-Anrede + await query(`ALTER TABLE languages ADD COLUMN IF NOT EXISTS greeting TEXT`).catch(() => {}); + // user_names await query(` CREATE TABLE IF NOT EXISTS user_names ( @@ -489,12 +527,17 @@ async function migrate() { // Full unique constraint (not partial) so ON CONFLICT works cleanly await query(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en)`).catch(() => {}); await query(` - INSERT INTO languages (short_en, titel_de, titel_en, titel_sv, status, published_at) + INSERT INTO languages (short_en, titel_de, titel_en, titel_sv, greeting, status, published_at) VALUES - ('en', 'Englisch', 'English', 'Engelska', 'published', NOW()), - ('sv', 'Schwedisch', 'Swedish', 'Svenska', 'published', NOW()) - ON CONFLICT (short_en) DO UPDATE SET status = EXCLUDED.status, published_at = COALESCE(languages.published_at, EXCLUDED.published_at) + ('en', 'Englisch', 'English', 'Engelska', 'Hi', 'published', NOW()), + ('sv', 'Schwedisch', 'Swedish', 'Svenska', 'Hej', 'published', NOW()) + ON CONFLICT (short_en) DO UPDATE SET + status = EXCLUDED.status, + published_at = COALESCE(languages.published_at, EXCLUDED.published_at), + greeting = COALESCE(languages.greeting, EXCLUDED.greeting) `).catch(() => {}); + // Deutsch wird separat angelegt → Begrüßung nachtragen + await query(`UPDATE languages SET greeting = 'Hallo' WHERE short_en = 'de' AND greeting IS NULL`).catch(() => {}); // Seed bbox for watermelon test object (only if bbox_x is still NULL) await query(` diff --git a/src/lib/pairCategories.js b/src/lib/pairCategories.js new file mode 100644 index 0000000..2bf97fa --- /dev/null +++ b/src/lib/pairCategories.js @@ -0,0 +1,42 @@ +const { query } = require('../db'); + +// Leitet die Kategorien eines (oder mehrerer) Pairs aus den verknüpften Wörtern ab und +// materialisiert sie in pair_categories. Quellen: +// - Statements (positiv/negativ) → statement_*_words → word_categories +// - Objekte → object_words → word_categories +// (Questions haben keine Wort-M2M und entfallen.) +// Re-Run-sicher: löscht vorhandene Zuordnungen der betroffenen Pairs und schreibt neu, +// damit eine erneute Veröffentlichung nach Inhaltsänderungen die Kategorien aktualisiert. +async function derivePairCategories(pairIds) { + const ids = (Array.isArray(pairIds) ? pairIds : [pairIds]).filter(Boolean); + if (!ids.length) return 0; + + await query(`DELETE FROM pair_categories WHERE pair_id = ANY($1)`, [ids]); + + const r = await query( + `INSERT INTO pair_categories (pair_id, category_id) + SELECT DISTINCT pid, category_id FROM ( + SELECT p.id AS pid, wc.category_id + FROM pairs p + JOIN ( + SELECT statement_id, word_id FROM statement_positive_words + UNION + SELECT statement_id, word_id FROM statement_negative_words + ) sw ON sw.statement_id IN (p.positive_statement_id, p.negative_statement_id) + JOIN word_categories wc ON wc.word_id = sw.word_id + WHERE p.id = ANY($1) + UNION + SELECT op.pair_id AS pid, wc.category_id + FROM object_pairs op + JOIN object_words ow ON ow.object_id = op.object_id + JOIN word_categories wc ON wc.word_id = ow.word_id + WHERE op.pair_id = ANY($1) + ) src + WHERE category_id IS NOT NULL + ON CONFLICT (pair_id, category_id) DO NOTHING`, + [ids] + ); + return r.rowCount; +} + +module.exports = { derivePairCategories }; diff --git a/src/routes/auth.js b/src/routes/auth.js index 87c72ba..b4b4b7b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -141,7 +141,8 @@ router.get('/me', requireJwt, async (req, res, next) => { COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep, up.last_practice_at, ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel, - lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel + lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel, + lt.greeting AS language_target_greeting FROM users u LEFT JOIN users_public up ON up.user_id = u.id LEFT JOIN user_names un ON un.id = up.username_id @@ -275,6 +276,22 @@ router.get('/stats', requireJwt, async (req, res, next) => { return { label, value: acc && acc.seen > 0 ? acc.correct / acc.seen : 0, seen: acc?.seen || 0 }; }); + // Punkte je Kategorie (Lebensmittel/Tiere/Beruf …) — abgeleitet über pair_categories. + // Mehrfach-Kategorien eines Pairs zählen bewusst zu jeder Kategorie. + const categoryRows = await query( + `SELECT c.id, c.titel_de AS label, + COALESCE(SUM(upp.earned_points), 0)::int AS points, + COALESCE(SUM(upp.seen_count), 0)::int AS seen + FROM user_pair_progress upp + JOIN pair_categories pc ON pc.pair_id = upp.pair_id + JOIN categories c ON c.id = pc.category_id + WHERE upp.user_id = $1 + GROUP BY c.id, c.titel_de + HAVING SUM(upp.earned_points) > 0 + ORDER BY points DESC`, + [userId] + ); + const t = totals.rows[0] || { pairs_practiced: 0, total_seen: 0, total_correct: 0 }; const td = today.rows[0] || { ep: 0, cards: 0, daily_goal_ep: 30 }; @@ -288,6 +305,7 @@ router.get('/stats', requireJwt, async (req, res, next) => { accuracy: t.total_seen > 0 ? t.total_correct / t.total_seen : 0, }, skills, + categories: categoryRows.rows, }); } catch (err) { next(err); } }); diff --git a/src/routes/pairs.js b/src/routes/pairs.js index 02b9aa0..486eee1 100644 --- a/src/routes/pairs.js +++ b/src/routes/pairs.js @@ -3,6 +3,7 @@ const { query } = require('../db'); const { fillMissingRow } = require('../lib/translate'); const { loadPairContext, computeReadiness, loadPairContent, translateWordGroup } = require('../lib/pairContent'); const { deletePairDeep } = require('../lib/deleteCascade'); +const { derivePairCategories } = require('../lib/pairCategories'); const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const ANSWER_TYPES = new Set(['yes_no', 'text', 'question', 'word']); @@ -131,6 +132,11 @@ router.patch('/:id', async (req, res, next) => { values ); if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + + // Beim Veröffentlichen Kategorien aus den verknüpften Wörtern ableiten (best effort). + if (req.body.status === 'published') + await derivePairCategories(result.rows[0].id).catch(() => {}); + res.json(result.rows[0]); } catch (err) { next(err); } }); @@ -295,6 +301,8 @@ router.post('/:id/publish', async (req, res, next) => { `UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1 RETURNING *`, [p.id, now]); + await derivePairCategories(p.id).catch(() => {}); + res.json({ ...upd.rows[0], published_languages: [lang] }); } catch (err) { next(err); } }); diff --git a/src/routes/pipeline.js b/src/routes/pipeline.js index 4376a7d..0b7baa3 100644 --- a/src/routes/pipeline.js +++ b/src/routes/pipeline.js @@ -7,6 +7,7 @@ const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePai const { describeError } = require('./audios'); const { PLACEHOLDER_RE, TOKEN_RE, stripLeakedTokens } = require('../lib/placeholders'); const { invalidateAudio } = require('../lib/reviewPairs'); +const { derivePairCategories } = require('../lib/pairCategories'); // ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ── @@ -406,6 +407,9 @@ router.post('/picture/:id/publish', async (req, res, next) => { await query(`UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [pairIds, now]); + // Kategorien der veröffentlichten Pairs aus ihren Wörtern ableiten (best effort). + await derivePairCategories(pairIds).catch(() => {}); + // Verlinkte Wörter: nur 'generated' → 'published' (translated bleibt für die Bild-Generierung // im ServerMonitor-Flow; published würde diesen Schritt überspringen) let publishedWords = 0;