feat: Profil-Kategorien + Begrüßung in Zielsprache

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:55:57 +02:00
parent 508d6993ee
commit 9738d3e35a
7 changed files with 268 additions and 5 deletions

29
CLAUDE.md Normal file
View File

@@ -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.

119
scripts/upload-pictures.mjs Normal file
View File

@@ -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 <dir>');
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); });

View File

@@ -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) // 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(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_answer_type_check`).catch(() => {});
await query(` await query(`
@@ -444,6 +479,9 @@ async function migrate() {
FOR EACH ROW EXECUTE FUNCTION update_updated_at() 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 // user_names
await query(` await query(`
CREATE TABLE IF NOT EXISTS user_names ( CREATE TABLE IF NOT EXISTS user_names (
@@ -489,12 +527,17 @@ async function migrate() {
// Full unique constraint (not partial) so ON CONFLICT works cleanly // 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(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en)`).catch(() => {});
await query(` 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 VALUES
('en', 'Englisch', 'English', 'Engelska', 'published', NOW()), ('en', 'Englisch', 'English', 'Engelska', 'Hi', 'published', NOW()),
('sv', 'Schwedisch', 'Swedish', 'Svenska', '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) 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(() => {}); `).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) // Seed bbox for watermelon test object (only if bbox_x is still NULL)
await query(` await query(`

42
src/lib/pairCategories.js Normal file
View File

@@ -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 };

View File

@@ -141,7 +141,8 @@ router.get('/me', requireJwt, async (req, res, next) => {
COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep, COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep,
up.last_practice_at, up.last_practice_at,
ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel, 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 FROM users u
LEFT JOIN users_public up ON up.user_id = u.id LEFT JOIN users_public up ON up.user_id = u.id
LEFT JOIN user_names un ON un.id = up.username_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 }; 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 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 }; 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, accuracy: t.total_seen > 0 ? t.total_correct / t.total_seen : 0,
}, },
skills, skills,
categories: categoryRows.rows,
}); });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@@ -3,6 +3,7 @@ const { query } = require('../db');
const { fillMissingRow } = require('../lib/translate'); const { fillMissingRow } = require('../lib/translate');
const { loadPairContext, computeReadiness, loadPairContent, translateWordGroup } = require('../lib/pairContent'); const { loadPairContext, computeReadiness, loadPairContent, translateWordGroup } = require('../lib/pairContent');
const { deletePairDeep } = require('../lib/deleteCascade'); const { deletePairDeep } = require('../lib/deleteCascade');
const { derivePairCategories } = require('../lib/pairCategories');
const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const STATUSES = ['draft', 'reviewed', 'blocked', 'published'];
const ANSWER_TYPES = new Set(['yes_no', 'text', 'question', 'word']); const ANSWER_TYPES = new Set(['yes_no', 'text', 'question', 'word']);
@@ -131,6 +132,11 @@ router.patch('/:id', async (req, res, next) => {
values values
); );
if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); 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]); res.json(result.rows[0]);
} catch (err) { next(err); } } 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 *`, `UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1 RETURNING *`,
[p.id, now]); [p.id, now]);
await derivePairCategories(p.id).catch(() => {});
res.json({ ...upd.rows[0], published_languages: [lang] }); res.json({ ...upd.rows[0], published_languages: [lang] });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@@ -7,6 +7,7 @@ const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePai
const { describeError } = require('./audios'); const { describeError } = require('./audios');
const { PLACEHOLDER_RE, TOKEN_RE, stripLeakedTokens } = require('../lib/placeholders'); const { PLACEHOLDER_RE, TOKEN_RE, stripLeakedTokens } = require('../lib/placeholders');
const { invalidateAudio } = require('../lib/reviewPairs'); const { invalidateAudio } = require('../lib/reviewPairs');
const { derivePairCategories } = require('../lib/pairCategories');
// ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ── // ── 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) await query(`UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2)
WHERE id = ANY($1)`, [pairIds, now]); 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 // Verlinkte Wörter: nur 'generated' → 'published' (translated bleibt für die Bild-Generierung
// im ServerMonitor-Flow; published würde diesen Schritt überspringen) // im ServerMonitor-Flow; published würde diesen Schritt überspringen)
let publishedWords = 0; let publishedWords = 0;