From 455969bdeccb61fcca87168a8b9fb0c7257fccf0 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 18 Jun 2026 21:14:21 +0200 Subject: [PATCH] fix: unique index words.titel_en als partial index + robuster Upsert ohne ON CONFLICT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: partiell WHERE IS NOT NULL, dedup vorher, kein silent-catch - Route: INSERT mit .catch(23505) → UPDATE statt ON CONFLICT (partial index inkompatibel) Co-Authored-By: Claude Sonnet 4.6 --- src/db-migrate.js | 16 ++++++++++++++-- src/routes/words.js | 23 ++++++++++++++++------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/db-migrate.js b/src/db-migrate.js index 28286cf..2c57bbe 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -861,8 +861,20 @@ async function migrate() { await query(`ALTER TABLE words ADD CONSTRAINT words_level_check CHECK (level IN ('A1', 'A2', 'B1'))`).catch(() => {}); // Unique-Index auf titel_en — Voraussetzung für ON CONFLICT im CSV-Import. - // Falls bestehende Duplikate den Index verhindern, muss erst bereinigt werden. - await query(`CREATE UNIQUE INDEX IF NOT EXISTS words_titel_en_key ON words (titel_en)`).catch(() => {}); + // Partiell (WHERE IS NOT NULL) damit bestehende NULL-Zeilen den Index nicht blockieren. + // Doppelte non-null titel_en erst bereinigen, dann Index anlegen. + await query(` + DELETE FROM words w + USING ( + SELECT titel_en, MAX(created_at) AS keep_at + FROM words WHERE titel_en IS NOT NULL + GROUP BY titel_en HAVING COUNT(*) > 1 + ) dup + WHERE w.titel_en = dup.titel_en AND w.created_at < dup.keep_at + `).catch(() => {}); + await query( + `CREATE UNIQUE INDEX IF NOT EXISTS words_titel_en_key ON words (titel_en) WHERE titel_en IS NOT NULL` + ); // enrich_batches — Status-Tracking für Wort-Anreicherungs-Batches (analog category_batches) await query(` diff --git a/src/routes/words.js b/src/routes/words.js index 3819988..d24da26 100644 --- a/src/routes/words.js +++ b/src/routes/words.js @@ -77,17 +77,26 @@ router.post('/', async (req, res, next) => { // Auto: alle 3 Sprachen direkt mitgeliefert + kein expliziter Status → 'translated' const allLangs = titel_de && titel_en && titel_sv; const effectiveStatus = status || (allLangs ? 'translated' : 'requested'); - const result = await query( + // Upsert: neu anlegen oder bei doppeltem titel_en nur conc_m aktualisieren + let result = await query( `INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, status, conc_m, requested_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) - ON CONFLICT (titel_en) DO UPDATE SET conc_m = EXCLUDED.conc_m - RETURNING *, (xmax = 0) AS is_insert`, + VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING *, true AS is_insert`, [titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null, effectiveStatus, conc_m ?? null] - ); + ).catch(async err => { + if (err.code === '23505' && titel_en) { + // Duplikat auf titel_en → conc_m aktualisieren und bestehende Zeile zurückgeben + const upd = await query( + `UPDATE words SET conc_m = $1 WHERE titel_en = $2 RETURNING *, false AS is_insert`, + [conc_m ?? null, titel_en] + ); + return upd; + } + throw err; + }); const row = result.rows[0]; - const { is_insert: _, ...word } = row; - res.status(row.is_insert ? 201 : 200).json({ ...word, picture_ids: [], category_ids: [] }); + const { is_insert, ...word } = row; + res.status(is_insert ? 201 : 200).json({ ...word, picture_ids: [], category_ids: [] }); } catch (err) { next(err); } });