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

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)
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(`

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,
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); }
});

View File

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

View File

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