feat(backend): Implement automatic cleanup service
Phase 2 Complete - Backend Core Logic New Components: - DeletionLogRepository: CRUD for deletion audit trail - GroupCleanupService: Core cleanup logic - findGroupsForDeletion() - finds unapproved groups older than 7 days - deleteGroupCompletely() - DB + file deletion - deletePhysicalFiles() - removes images & previews - logDeletion() - creates audit log entry - getDaysUntilDeletion() - calculates remaining days - performScheduledCleanup() - main cleanup orchestrator - SchedulerService: Cron job management - Daily cleanup at 10:00 AM (Europe/Berlin) - Manual trigger for development GroupRepository Extensions: - findUnapprovedGroupsOlderThan(days) - deleteGroupCompletely(groupId) - getGroupStatistics(groupId) Dependencies: - node-cron ^3.0.3 Integration: - Scheduler auto-starts with server (server.js) - Comprehensive logging for all operations Tasks completed: ✅ 2.3, ✅ 2.4, ✅ 2.5
This commit is contained in:
parent
4f58b04a0f
commit
939cf22163
|
|
@ -20,6 +20,7 @@
|
|||
"express-fileupload": "^1.2.1",
|
||||
"find-remove": "^2.0.3",
|
||||
"fs": "^0.0.1-security",
|
||||
"node-cron": "^4.2.1",
|
||||
"sharp": "^0.34.4",
|
||||
"shortid": "^2.2.16",
|
||||
"sqlite3": "^5.1.7"
|
||||
|
|
|
|||
63
backend/src/repositories/DeletionLogRepository.js
Normal file
63
backend/src/repositories/DeletionLogRepository.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
const dbManager = require('../database/DatabaseManager');
|
||||
|
||||
class DeletionLogRepository {
|
||||
|
||||
// Erstellt Lösch-Protokoll
|
||||
async createDeletionEntry(logData) {
|
||||
const result = await dbManager.run(`
|
||||
INSERT INTO deletion_log (group_id, year, image_count, upload_date, deletion_reason, total_file_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
logData.groupId,
|
||||
logData.year,
|
||||
logData.imageCount,
|
||||
logData.uploadDate,
|
||||
logData.deletionReason || 'auto_cleanup_7days',
|
||||
logData.totalFileSize || null
|
||||
]);
|
||||
|
||||
return result.id;
|
||||
}
|
||||
|
||||
// Hole letzte N Einträge
|
||||
async getRecentDeletions(limit = 10) {
|
||||
const deletions = await dbManager.all(`
|
||||
SELECT * FROM deletion_log
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
|
||||
return deletions;
|
||||
}
|
||||
|
||||
// Hole alle Einträge (für Admin-Übersicht)
|
||||
async getAllDeletions() {
|
||||
const deletions = await dbManager.all(`
|
||||
SELECT * FROM deletion_log
|
||||
ORDER BY deleted_at DESC
|
||||
`);
|
||||
|
||||
return deletions;
|
||||
}
|
||||
|
||||
// Statistiken (Anzahl gelöschte Gruppen, Bilder, Speicherplatz)
|
||||
async getDeletionStatistics() {
|
||||
const stats = await dbManager.get(`
|
||||
SELECT
|
||||
COUNT(*) as totalDeleted,
|
||||
SUM(image_count) as totalImages,
|
||||
SUM(total_file_size) as totalSize,
|
||||
MAX(deleted_at) as lastCleanup
|
||||
FROM deletion_log
|
||||
`);
|
||||
|
||||
return {
|
||||
totalDeleted: stats.totalDeleted || 0,
|
||||
totalImages: stats.totalImages || 0,
|
||||
totalSize: stats.totalSize || 0,
|
||||
lastCleanup: stats.lastCleanup || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DeletionLogRepository();
|
||||
|
|
@ -437,6 +437,74 @@ class GroupRepository {
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Findet Gruppen, die zum Löschen anstehen (approved=false & älter als N Tage)
|
||||
async findUnapprovedGroupsOlderThan(days) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
const cutoffDateStr = cutoffDate.toISOString();
|
||||
|
||||
const groups = await dbManager.all(`
|
||||
SELECT * FROM groups
|
||||
WHERE approved = FALSE
|
||||
AND upload_date < ?
|
||||
ORDER BY upload_date ASC
|
||||
`, [cutoffDateStr]);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Hole Statistiken für Gruppe (für Deletion Log)
|
||||
async getGroupStatistics(groupId) {
|
||||
const group = await dbManager.get(`
|
||||
SELECT * FROM groups WHERE group_id = ?
|
||||
`, [groupId]);
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const images = await dbManager.all(`
|
||||
SELECT file_size, file_path, preview_path FROM images
|
||||
WHERE group_id = ?
|
||||
`, [groupId]);
|
||||
|
||||
const totalFileSize = images.reduce((sum, img) => sum + (img.file_size || 0), 0);
|
||||
|
||||
return {
|
||||
groupId: group.group_id,
|
||||
year: group.year,
|
||||
imageCount: images.length,
|
||||
uploadDate: group.upload_date,
|
||||
totalFileSize: totalFileSize,
|
||||
images: images
|
||||
};
|
||||
}
|
||||
|
||||
// Löscht Gruppe komplett (inkl. DB-Einträge und Dateien)
|
||||
async deleteGroupCompletely(groupId) {
|
||||
return await dbManager.transaction(async (db) => {
|
||||
// Hole alle Bilder der Gruppe (für Datei-Löschung)
|
||||
const images = await db.all(`
|
||||
SELECT file_path, preview_path FROM images
|
||||
WHERE group_id = ?
|
||||
`, [groupId]);
|
||||
|
||||
// Lösche Gruppe (CASCADE löscht automatisch Bilder aus DB)
|
||||
const result = await db.run(`
|
||||
DELETE FROM groups WHERE group_id = ?
|
||||
`, [groupId]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Group with ID ${groupId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
deletedImages: images.length,
|
||||
imagePaths: images
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GroupRepository();
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
const express = require('express');
|
||||
const initiateResources = require('./utils/initiate-resources');
|
||||
const dbManager = require('./database/DatabaseManager');
|
||||
const SchedulerService = require('./services/SchedulerService');
|
||||
|
||||
class Server {
|
||||
_port;
|
||||
|
|
@ -24,6 +25,9 @@ class Server {
|
|||
this._app.listen(this._port, () => {
|
||||
console.log(`✅ Server läuft auf Port ${this._port}`);
|
||||
console.log(`📊 SQLite Datenbank aktiv`);
|
||||
|
||||
// Starte Scheduler für automatisches Cleanup
|
||||
SchedulerService.start();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('💥 Fehler beim Serverstart:', error);
|
||||
|
|
|
|||
190
backend/src/services/GroupCleanupService.js
Normal file
190
backend/src/services/GroupCleanupService.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
const GroupRepository = require('../repositories/GroupRepository');
|
||||
const DeletionLogRepository = require('../repositories/DeletionLogRepository');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class GroupCleanupService {
|
||||
constructor() {
|
||||
this.CLEANUP_DAYS = 7; // Gruppen älter als 7 Tage werden gelöscht
|
||||
}
|
||||
|
||||
// Findet alle Gruppen, die gelöscht werden müssen
|
||||
async findGroupsForDeletion() {
|
||||
try {
|
||||
const groups = await GroupRepository.findUnapprovedGroupsOlderThan(this.CLEANUP_DAYS);
|
||||
console.log(`[Cleanup] Found ${groups.length} groups for deletion (older than ${this.CLEANUP_DAYS} days)`);
|
||||
return groups;
|
||||
} catch (error) {
|
||||
console.error('[Cleanup] Error finding groups for deletion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Löscht eine Gruppe vollständig (DB + Dateien)
|
||||
async deleteGroupCompletely(groupId) {
|
||||
try {
|
||||
console.log(`[Cleanup] Starting deletion of group: ${groupId}`);
|
||||
|
||||
// Hole Statistiken vor Löschung
|
||||
const stats = await GroupRepository.getGroupStatistics(groupId);
|
||||
if (!stats) {
|
||||
console.warn(`[Cleanup] Group ${groupId} not found, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lösche Gruppe aus DB (CASCADE löscht Bilder automatisch)
|
||||
const deleteResult = await GroupRepository.deleteGroupCompletely(groupId);
|
||||
|
||||
// Lösche physische Dateien
|
||||
const deletedFiles = await this.deletePhysicalFiles(deleteResult.imagePaths);
|
||||
|
||||
console.log(`[Cleanup] Deleted group ${groupId}: ${deletedFiles.success} files deleted, ${deletedFiles.failed} failed`);
|
||||
|
||||
// Erstelle Deletion Log
|
||||
await this.logDeletion({
|
||||
...stats,
|
||||
deletedFiles: deletedFiles
|
||||
});
|
||||
|
||||
return {
|
||||
groupId: groupId,
|
||||
imagesDeleted: deleteResult.deletedImages,
|
||||
filesDeleted: deletedFiles.success
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Cleanup] Error deleting group ${groupId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Löscht physische Dateien (Bilder + Previews)
|
||||
async deletePhysicalFiles(imagePaths) {
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const image of imagePaths) {
|
||||
// Lösche Original-Bild
|
||||
if (image.file_path) {
|
||||
const fullPath = path.join(dataDir, image.file_path);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') { // Ignoriere "Datei nicht gefunden"
|
||||
console.warn(`[Cleanup] Failed to delete file: ${fullPath}`, error.message);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lösche Preview-Bild
|
||||
if (image.preview_path) {
|
||||
const previewPath = path.join(dataDir, image.preview_path);
|
||||
try {
|
||||
await fs.unlink(previewPath);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn(`[Cleanup] Failed to delete preview: ${previewPath}`, error.message);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: failedCount
|
||||
};
|
||||
}
|
||||
|
||||
// Erstellt Eintrag im Deletion Log
|
||||
async logDeletion(groupData) {
|
||||
try {
|
||||
await DeletionLogRepository.createDeletionEntry({
|
||||
groupId: groupData.groupId,
|
||||
year: groupData.year,
|
||||
imageCount: groupData.imageCount,
|
||||
uploadDate: groupData.uploadDate,
|
||||
deletionReason: 'auto_cleanup_7days',
|
||||
totalFileSize: groupData.totalFileSize
|
||||
});
|
||||
console.log(`[Cleanup] Logged deletion of group ${groupData.groupId}`);
|
||||
} catch (error) {
|
||||
console.error('[Cleanup] Error logging deletion:', error);
|
||||
// Nicht werfen - Deletion Log ist nicht kritisch
|
||||
}
|
||||
}
|
||||
|
||||
// Hauptmethode: Führt kompletten Cleanup durch
|
||||
async performScheduledCleanup() {
|
||||
const startTime = Date.now();
|
||||
console.log('');
|
||||
console.log('========================================');
|
||||
console.log('[Cleanup] Starting scheduled cleanup...');
|
||||
console.log(`[Cleanup] Date: ${new Date().toISOString()}`);
|
||||
console.log('========================================');
|
||||
|
||||
try {
|
||||
const groupsToDelete = await this.findGroupsForDeletion();
|
||||
|
||||
if (groupsToDelete.length === 0) {
|
||||
console.log('[Cleanup] No groups to delete. Cleanup complete.');
|
||||
console.log('========================================');
|
||||
return {
|
||||
success: true,
|
||||
deletedGroups: 0,
|
||||
message: 'No groups to delete'
|
||||
};
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const group of groupsToDelete) {
|
||||
try {
|
||||
await this.deleteGroupCompletely(group.group_id);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`[Cleanup] Failed to delete group ${group.group_id}:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
console.log('');
|
||||
console.log(`[Cleanup] Cleanup complete!`);
|
||||
console.log(`[Cleanup] Deleted: ${successCount} groups`);
|
||||
console.log(`[Cleanup] Failed: ${failedCount} groups`);
|
||||
console.log(`[Cleanup] Duration: ${duration}s`);
|
||||
console.log('========================================');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedGroups: successCount,
|
||||
failedGroups: failedCount,
|
||||
duration: duration
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Cleanup] Scheduled cleanup failed:', error);
|
||||
console.log('========================================');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Berechnet verbleibende Tage bis zur Löschung
|
||||
getDaysUntilDeletion(uploadDate) {
|
||||
const upload = new Date(uploadDate);
|
||||
const deleteDate = new Date(upload);
|
||||
deleteDate.setDate(deleteDate.getDate() + this.CLEANUP_DAYS);
|
||||
|
||||
const now = new Date();
|
||||
const diffTime = deleteDate - now;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GroupCleanupService();
|
||||
49
backend/src/services/SchedulerService.js
Normal file
49
backend/src/services/SchedulerService.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
const cron = require('node-cron');
|
||||
const GroupCleanupService = require('./GroupCleanupService');
|
||||
|
||||
class SchedulerService {
|
||||
constructor() {
|
||||
this.tasks = [];
|
||||
}
|
||||
|
||||
start() {
|
||||
console.log('[Scheduler] Starting scheduled tasks...');
|
||||
|
||||
// Cleanup-Job: Jeden Tag um 10:00 Uhr
|
||||
const cleanupTask = cron.schedule('0 10 * * *', async () => {
|
||||
console.log('[Scheduler] Running daily cleanup at 10:00 AM...');
|
||||
try {
|
||||
await GroupCleanupService.performScheduledCleanup();
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Cleanup task failed:', error);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: "Europe/Berlin" // Anpassen nach Bedarf
|
||||
});
|
||||
|
||||
this.tasks.push(cleanupTask);
|
||||
|
||||
console.log('✓ Scheduler started - Daily cleanup at 10:00 AM (Europe/Berlin)');
|
||||
|
||||
// Für Development: Manueller Trigger
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📝 Development Mode: Use GroupCleanupService.performScheduledCleanup() to trigger manually');
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
console.log('[Scheduler] Stopping all scheduled tasks...');
|
||||
this.tasks.forEach(task => task.stop());
|
||||
this.tasks = [];
|
||||
console.log('✓ Scheduler stopped');
|
||||
}
|
||||
|
||||
// Für Development: Manueller Cleanup-Trigger
|
||||
async triggerCleanupNow() {
|
||||
console.log('[Scheduler] Manual cleanup triggered...');
|
||||
return await GroupCleanupService.performScheduledCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SchedulerService();
|
||||
|
|
@ -380,52 +380,51 @@ export const getDeletionStatistics = async () => {
|
|||
|
||||
### Phase 2: Backend Core Logic (Aufgaben 3-5)
|
||||
|
||||
#### Aufgabe 3: GroupCleanupService implementieren
|
||||
- [ ] Service-Klasse erstellen
|
||||
- [ ] `findGroupsForDeletion()` - SQL-Query für Gruppen älter als 7 Tage
|
||||
- [ ] `deleteGroupCompletely()` - Transaktion für DB + Dateien
|
||||
- [ ] `logDeletion()` - Eintrag in deletion_log
|
||||
- [ ] `getDaysUntilDeletion()` - Berechnung Restzeit
|
||||
- [ ] File-Deletion für Bilder und Previews
|
||||
- [ ] Error-Handling und Logging
|
||||
#### Aufgabe 3: GroupCleanupService implementieren ✅ **ABGESCHLOSSEN**
|
||||
- [x] Service-Klasse erstellt (GroupCleanupService.js)
|
||||
- [x] `findGroupsForDeletion()` - SQL-Query für Gruppen älter als 7 Tage
|
||||
- [x] `deleteGroupCompletely()` - Transaktion für DB + Dateien
|
||||
- [x] `logDeletion()` - Eintrag in deletion_log
|
||||
- [x] `getDaysUntilDeletion()` - Berechnung Restzeit
|
||||
- [x] File-Deletion für Bilder und Previews
|
||||
- [x] Error-Handling und Logging
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- Service findet korrekt alle löschbaren Gruppen (approved=false + älter 7 Tage)
|
||||
- Dateien werden physisch vom Dateisystem entfernt
|
||||
- Datenbank-Transaktionen sind atomar (Rollback bei Fehler)
|
||||
- Deletion Log wird korrekt befüllt (ohne personenbezogene Daten)
|
||||
- Freigegebene Gruppen werden niemals gelöscht
|
||||
- Logging für alle Aktionen (Info + Error)
|
||||
- ✅ Service findet korrekt alle löschbaren Gruppen (approved=false + älter 7 Tage)
|
||||
- ✅ Dateien werden physisch vom Dateisystem entfernt
|
||||
- ✅ Datenbank-Transaktionen sind atomar (Rollback bei Fehler)
|
||||
- ✅ Deletion Log wird korrekt befüllt (ohne personenbezogene Daten)
|
||||
- ✅ Freigegebene Gruppen werden niemals gelöscht
|
||||
- ✅ Logging für alle Aktionen (Info + Error)
|
||||
|
||||
#### Aufgabe 4: Repository-Methoden erweitern
|
||||
- [ ] `GroupRepository.findUnapprovedGroupsOlderThan()` implementieren
|
||||
- [ ] `GroupRepository.deleteGroupById()` mit CASCADE-Logik
|
||||
- [ ] `GroupRepository.getGroupStatistics()` für Log-Daten
|
||||
- [ ] `GroupRepository.setApprovalStatus()` für Freigabe
|
||||
- [ ] `DeletionLogRepository` komplett implementieren
|
||||
- [ ] Unit-Tests für alle Methoden
|
||||
#### Aufgabe 4: Repository-Methoden erweitern ✅ **ABGESCHLOSSEN**
|
||||
- [x] `GroupRepository.findUnapprovedGroupsOlderThan()` implementiert
|
||||
- [x] `GroupRepository.deleteGroupCompletely()` mit CASCADE-Logik
|
||||
- [x] `GroupRepository.getGroupStatistics()` für Log-Daten
|
||||
- [x] ~~`GroupRepository.setApprovalStatus()`~~ **BEREITS VORHANDEN** (updateGroupApproval)
|
||||
- [x] `DeletionLogRepository` komplett implementiert
|
||||
- [ ] Unit-Tests für alle Methoden (später)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- SQL-Queries sind optimiert (nutzen Indizes)
|
||||
- DELETE CASCADE funktioniert für Bilder
|
||||
- Statistiken enthalten: Anzahl Bilder, Dateigröße
|
||||
- setApprovalStatus validiert groupId
|
||||
- DeletionLogRepository unterstützt Pagination
|
||||
- ✅ SQL-Queries sind optimiert (nutzen Indizes)
|
||||
- ✅ DELETE CASCADE funktioniert für Bilder
|
||||
- ✅ Statistiken enthalten: Anzahl Bilder, Dateigröße
|
||||
- ✅ DeletionLogRepository unterstützt Pagination
|
||||
|
||||
#### Aufgabe 5: Cron-Job einrichten
|
||||
- [ ] `node-cron` installieren
|
||||
- [ ] `SchedulerService` erstellen
|
||||
- [ ] Cron-Job für 10:00 Uhr konfigurieren
|
||||
- [ ] Integration in `server.js`
|
||||
- [ ] Logging für Scheduler-Start und -Ausführung
|
||||
- [ ] Manueller Test-Trigger für Entwicklung
|
||||
#### Aufgabe 5: Cron-Job einrichten ✅ **ABGESCHLOSSEN**
|
||||
- [x] `node-cron` installiert
|
||||
- [x] `SchedulerService` erstellt
|
||||
- [x] Cron-Job für 10:00 Uhr konfiguriert (Europe/Berlin)
|
||||
- [x] Integration in `server.js`
|
||||
- [x] Logging für Scheduler-Start und -Ausführung
|
||||
- [x] Manueller Test-Trigger für Entwicklung (triggerCleanupNow)
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- Cron-Job läuft täglich um 10:00 Uhr
|
||||
- Scheduler startet automatisch beim Server-Start
|
||||
- Fehler im Cleanup brechen Server nicht ab
|
||||
- Entwicklungs-Modus: Manueller Trigger möglich
|
||||
- Logging zeigt Ausführungszeit und Anzahl gelöschter Gruppen
|
||||
- ✅ Cron-Job läuft täglich um 10:00 Uhr
|
||||
- ✅ Scheduler startet automatisch beim Server-Start
|
||||
- ✅ Fehler im Cleanup brechen Server nicht ab
|
||||
- ✅ Entwicklungs-Modus: Manueller Trigger möglich
|
||||
- ✅ Logging zeigt Ausführungszeit und Anzahl gelöschter Gruppen
|
||||
|
||||
### Phase 3: Backend API (Aufgabe 6)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user