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:
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal 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
119
scripts/upload-pictures.mjs
Normal 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); });
|
||||||
@@ -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
42
src/lib/pairCategories.js
Normal 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 };
|
||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user