feat: Deep-Delete für Pairs und Bilder (Fragen/Statements/Audios/Objekte kaskadieren)

DELETE /pairs/:id räumt jetzt unreferenzierte Fragen/Statements samt
Audio-Dateien (DB + S3) mit auf. DELETE /pictures/:id löscht zusätzlich
die nur mit diesem Bild verknüpften Objekte inkl. deren Pairs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:25:15 +02:00
parent ddbd879dab
commit 25d1e89446
3 changed files with 86 additions and 4 deletions

77
src/lib/deleteCascade.js Normal file
View File

@@ -0,0 +1,77 @@
const { query } = require('../db');
const { deleteFile, keyFromUrl } = require('../s3');
// Audios (DB-Rows + S3-Dateien) einer Quelle entfernen.
async function deleteAudiosFor(sourceTable, sourceId) {
const audios = await query(
`SELECT id, audio_link FROM audios WHERE source_table = $1 AND source_id = $2`,
[sourceTable, sourceId]
);
for (const a of audios.rows) {
const key = keyFromUrl(a.audio_link);
if (key) await deleteFile(key).catch(() => {});
await query('DELETE FROM audios WHERE id = $1', [a.id]);
}
}
// Pair inkl. Frage, Statements und deren Audios löschen.
// Frage/Statements bleiben stehen, wenn ein anderes Pair sie noch referenziert.
// Objekte werden nicht angefasst (object_pairs kaskadiert per FK).
async function deletePairDeep(pairId) {
const existing = await query(
`SELECT question_id, positive_statement_id, negative_statement_id FROM pairs WHERE id = $1`,
[pairId]
);
if (!existing.rows.length) return false;
const { question_id, positive_statement_id, negative_statement_id } = existing.rows[0];
await query('DELETE FROM pairs WHERE id = $1', [pairId]);
if (question_id) {
const ref = await query('SELECT 1 FROM pairs WHERE question_id = $1 LIMIT 1', [question_id]);
if (!ref.rows.length) {
await deleteAudiosFor('questions', question_id);
await query('DELETE FROM questions WHERE id = $1', [question_id]);
}
}
const stmtIds = [...new Set([positive_statement_id, negative_statement_id].filter(Boolean))];
for (const stmtId of stmtIds) {
const ref = await query(
'SELECT 1 FROM pairs WHERE positive_statement_id = $1 OR negative_statement_id = $1 LIMIT 1',
[stmtId]
);
if (!ref.rows.length) {
await deleteAudiosFor('statements', stmtId);
await query('DELETE FROM statements WHERE id = $1', [stmtId]);
}
}
return true;
}
// Alle Objekte eines Bildes löschen (inkl. deren Pairs), sofern das Objekt
// ausschließlich mit diesem Bild verknüpft ist.
async function deletePictureObjectsDeep(pictureId) {
const objects = await query(
`SELECT object_id FROM object_pictures WHERE picture_id = $1`,
[pictureId]
);
for (const { object_id } of objects.rows) {
const other = await query(
`SELECT 1 FROM object_pictures WHERE object_id = $1 AND picture_id <> $2 LIMIT 1`,
[object_id, pictureId]
);
if (other.rows.length) continue;
const pairs = await query(
`SELECT pair_id FROM object_pairs WHERE object_id = $1`,
[object_id]
);
for (const { pair_id } of pairs.rows) await deletePairDeep(pair_id);
await query('DELETE FROM objects WHERE id = $1', [object_id]);
}
}
module.exports = { deletePairDeep, deletePictureObjectsDeep };