diff --git a/src/lib/deleteCascade.js b/src/lib/deleteCascade.js new file mode 100644 index 0000000..3247e8d --- /dev/null +++ b/src/lib/deleteCascade.js @@ -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 }; diff --git a/src/routes/pairs.js b/src/routes/pairs.js index 92537fd..02b9aa0 100644 --- a/src/routes/pairs.js +++ b/src/routes/pairs.js @@ -2,6 +2,7 @@ const router = require('express').Router(); const { query } = require('../db'); const { fillMissingRow } = require('../lib/translate'); const { loadPairContext, computeReadiness, loadPairContent, translateWordGroup } = require('../lib/pairContent'); +const { deletePairDeep } = require('../lib/deleteCascade'); const STATUSES = ['draft', 'reviewed', 'blocked', 'published']; const ANSWER_TYPES = new Set(['yes_no', 'text', 'question', 'word']); @@ -298,11 +299,11 @@ router.post('/:id/publish', async (req, res, next) => { } catch (err) { next(err); } }); -// DELETE /api/pairs/:id +// DELETE /api/pairs/:id — Pair + (unreferenzierte) Frage/Statements + deren Audios (DB+S3) router.delete('/:id', async (req, res, next) => { try { - const result = await query('DELETE FROM pairs WHERE id = $1 RETURNING id', [req.params.id]); - if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + const deleted = await deletePairDeep(req.params.id); + if (!deleted) return res.status(404).json({ error: 'Not found' }); res.status(204).end(); } catch (err) { next(err); } }); diff --git a/src/routes/pictures.js b/src/routes/pictures.js index c21fa6c..e371dfa 100644 --- a/src/routes/pictures.js +++ b/src/routes/pictures.js @@ -3,6 +3,7 @@ const multer = require('multer'); const { v4: uuidv4 } = require('uuid'); const { query } = require('../db'); const { uploadFile, deleteFile, keyFromUrl } = require('../s3'); +const { deletePictureObjectsDeep } = require('../lib/deleteCascade'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } }); @@ -153,12 +154,15 @@ router.patch('/:id', async (req, res, next) => { } catch (err) { next(err); } }); -// DELETE /api/pictures/:id — Eintrag + Hetzner-Datei löschen +// DELETE /api/pictures/:id — Eintrag + Hetzner-Datei löschen, +// inkl. Objekte des Bildes und deren Pairs (Fragen/Statements/Audios kaskadieren) router.delete('/:id', async (req, res, next) => { try { const existing = await query('SELECT picture_link FROM pictures WHERE id = $1', [req.params.id]); if (!existing.rows.length) return res.status(404).json({ error: 'Not found' }); + await deletePictureObjectsDeep(req.params.id); + const key = keyFromUrl(existing.rows[0].picture_link); if (key) await deleteFile(key).catch(() => {});