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:
77
src/lib/deleteCascade.js
Normal file
77
src/lib/deleteCascade.js
Normal 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 };
|
||||||
@@ -2,6 +2,7 @@ const router = require('express').Router();
|
|||||||
const { query } = require('../db');
|
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 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']);
|
||||||
@@ -298,11 +299,11 @@ router.post('/:id/publish', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await query('DELETE FROM pairs WHERE id = $1 RETURNING id', [req.params.id]);
|
const deleted = await deletePairDeep(req.params.id);
|
||||||
if (!result.rows.length) return res.status(404).json({ error: 'Not found' });
|
if (!deleted) return res.status(404).json({ error: 'Not found' });
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const multer = require('multer');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { query } = require('../db');
|
const { query } = require('../db');
|
||||||
const { uploadFile, deleteFile, keyFromUrl } = require('../s3');
|
const { uploadFile, deleteFile, keyFromUrl } = require('../s3');
|
||||||
|
const { deletePictureObjectsDeep } = require('../lib/deleteCascade');
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
|
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); }
|
} 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) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const existing = await query('SELECT picture_link FROM pictures WHERE id = $1', [req.params.id]);
|
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' });
|
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);
|
const key = keyFromUrl(existing.rows[0].picture_link);
|
||||||
if (key) await deleteFile(key).catch(() => {});
|
if (key) await deleteFile(key).catch(() => {});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user