diff --git a/TODO.md b/TODO.md index d129b4e..5910059 100644 --- a/TODO.md +++ b/TODO.md @@ -1,19 +1,17 @@ # TODO -> Last updated: 2026-06-11 12:10 +> Last updated: 2026-06-11 > Context: Form Accessibility & Resilience improvements for XAMXAM thesis submission platform -## In Progress - ## Pending - [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows - [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES` -- [ ] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`just cleanup-drafts`) -- [ ] #form-setup-helper Add `ThesisFormSetup` helper class to reduce bootstrap duplication across add/edit/partage `(partage/index.php)` `(admin/add.php)` `(admin/edit.php)` ## Completed +- [x] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`Database.php`, `justfile`, `deploy/xamxam-cleanup.cron`, `scripts/cleanup-drafts.php`) ✓ +- [x] #form-setup-helper Add `FormBootstrap` helper class to reduce bootstrap duplication across add/edit/partage `(admin/add.php)` `(admin/edit.php)` ✓ - [x] #two-phase-commit Add two-phase commit: INSERT thesis `status='draft'`, COMMIT, move files, UPDATE to `active` `(ThesisCreateController.php)` ✓ - [x] #filepond-preserve Preserve FilePond temp file IDs on partage validation redirect `(partage/index.php)` `(FilepondHandler.php)` ✓ - [x] #refactor-partage Extract partage form page chrome to `templates/partage/form-page.php` `(partage/index.php)` ✓ diff --git a/app/src/Database.php b/app/src/Database.php index 4b3460c..e950ce8 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -2307,6 +2307,59 @@ class Database return $newId; } + /** + * Find and optionally delete orphaned draft theses older than a threshold. + * + * Draft theses are created with status='draft' before file operations + * (two-phase commit). If the file phase throws after COMMIT, the draft + * remains orphaned indefinitely — no files are attached but the row blocks + * the identifier number. + * + * @param int $olderThanHours Drafts older than this many hours are candidates. + * @param bool $dryRun When true, only list candidates without deleting. + * @return array{deleted: int, candidates: array} Deleted count + list of IDs found. + */ + public function cleanupOrphanedDrafts(int $olderThanHours = 24, bool $dryRun = true): array + { + $cutoff = date('Y-m-d H:i:s', strtotime("-{$olderThanHours} hours")); + + // Draft theses with no files attached = orphaned submissions. + $stmt = $this->pdo->prepare( + "SELECT t.id, t.identifier, t.title, t.submitted_at + FROM theses t + WHERE t.status = 'draft' + AND t.deleted_at IS NULL + AND t.submitted_at < ? + AND NOT EXISTS ( + SELECT 1 FROM thesis_files tf WHERE tf.thesis_id = t.id + ) + ORDER BY t.submitted_at ASC" + ); + $stmt->execute([$cutoff]); + $candidates = $stmt->fetchAll(); + + if ($dryRun) { + return ['deleted' => 0, 'candidates' => $candidates]; + } + + $deleted = 0; + require_once __DIR__ . '/Audit.php'; + $actor = Audit::actor(); + foreach ($candidates as $row) { + $id = (int)$row['id']; + $old = $this->fetchRow('theses', $id); + // Hard-delete the orphan row and its junction records. + // Cascade covers thesis_authors, thesis_tags, thesis_files (empty), + // thesis_supervisors, thesis_languages, thesis_formats. + $this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$id]); + Audit::log($this, $actor, 'DELETE', 'theses', $id, $old, null); + $deleted++; + error_log("[cleanup-drafts] Deleted orphaned draft thesis {$id} ({$row['identifier']}) — submitted {$row['submitted_at']}"); + } + + return ['deleted' => $deleted, 'candidates' => $candidates]; + } + /** * Soft-delete a single thesis (sets deleted_at). */ diff --git a/deploy/xamxam-cleanup.cron b/deploy/xamxam-cleanup.cron new file mode 100644 index 0000000..1379f6b --- /dev/null +++ b/deploy/xamxam-cleanup.cron @@ -0,0 +1,7 @@ +# XAMXAM — orphaned draft cleanup cron job +# Installed to /etc/cron.d/xamxam-cleanup (system cron format: minute hour dom month dow user command) +# +# Deletes draft theses older than 24h that have no attached files. +# Runs every 4 hours — drafts only become eligible after 24h, so this is ample. +# The script is a dry-run unless --no-dry-run is passed. +0 */4 * * * www-data php /var/www/xamxam/scripts/cleanup-drafts.php --no-dry-run >> /var/log/xamxam-cleanup.log 2>&1 diff --git a/justfile b/justfile index eea281d..8c167f3 100644 --- a/justfile +++ b/justfile @@ -319,6 +319,27 @@ deploy-list-backups: # List all existing backups on the server (most recent last). ssh xamxam "ls -lth /var/backups/xamxam/ 2>/dev/null || echo 'No backups yet.'" +[group('deploy')] +deploy-cleanup-cron: + # Install cron job for orphaned draft cleanup (every 4 hours, 24h threshold). + # Creates /etc/cron.d/xamxam-cleanup and log file on the server. + @echo "📋 Installing draft cleanup cron job…" + rsync -v scripts/cleanup-drafts.php xamxam:/var/www/xamxam/scripts/cleanup-drafts.php + ssh xamxam "chown www-data:xamxam /var/www/xamxam/scripts/cleanup-drafts.php && chmod 755 /var/www/xamxam/scripts/cleanup-drafts.php" + rsync -v deploy/xamxam-cleanup.cron xamxam:/tmp/xamxam-cleanup.cron + ssh -t xamxam "sudo install -o root -g root -m 644 /tmp/xamxam-cleanup.cron /etc/cron.d/xamxam-cleanup && rm -f /tmp/xamxam-cleanup.cron" + ssh -t xamxam "sudo touch /var/log/xamxam-cleanup.log && sudo chown www-data:www-data /var/log/xamxam-cleanup.log && sudo chmod 644 /var/log/xamxam-cleanup.log" + @echo "✅ Cleanup cron installed." + @echo " Cron file: /etc/cron.d/xamxam-cleanup" + @echo " Script: /var/www/xamxam/scripts/cleanup-drafts.php" + @echo " Log file: /var/log/xamxam-cleanup.log" + @echo "" + @echo "Verify with: just deploy-check-cleanup-log" + +[group('deploy')] +deploy-check-cleanup-log: + ssh xamxam "tail -20 /var/log/xamxam-cleanup.log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'" + [group('deploy')] test-restore remote_gz_path: # Test-restore a production backup snapshot to a local temp DB and verify. @@ -351,8 +372,8 @@ deploy-migrate-storage dry_run='' target_host='xamxam': ssh {{target_host}} 'rm -f /var/www/xamxam/migrate-storage-paths.php' [group('deploy')] -deploy-all-first: deploy deploy-backup - # One-shot: full initial deploy including backup cron. +deploy-all-first: deploy deploy-backup deploy-cleanup-cron + # One-shot: full initial deploy including backup and cleanup cron jobs. # ============================================================================ # Testing @@ -434,3 +455,10 @@ clean: @rm -f app/error.log @rm -rf app/storage/cache/rate_limit/* @rm -f /tmp/xamxam-*.log /tmp/xamxam-*.pid + +[group('utils')] +cleanup-drafts dry_run='': + # List (dry-run) or delete orphaned draft theses older than 24h. + # Pass --no-dry-run to actually delete: + # just cleanup-drafts --no-dry-run + @php -r 'define("APP_ROOT", getcwd()."/app");require APP_ROOT."/src/Database.php";$db=new Database();$dry="{{dry_run}}"!=="--no-dry-run";$res=$db->cleanupOrphanedDrafts(24,$dry);$c=count($res["candidates"]);if($c===0){echo"✅ No orphaned drafts found.\n";}elseif($dry){echo"🔍 Found {$c} orphaned draft(s):\n";foreach($res["candidates"]as$row){printf(" → #%d %s \"%s\" (submitted %s)\n",$row["id"],$row["identifier"],$row["title"],$row["submitted_at"]);}echo"\nRun \"just cleanup-drafts --no-dry-run\" to delete them.\n";}else{echo"🗑 Deleted {$res["deleted"]} orphaned draft(s).\n";}' diff --git a/scripts/cleanup-drafts.php b/scripts/cleanup-drafts.php new file mode 100755 index 0000000..97b7f60 --- /dev/null +++ b/scripts/cleanup-drafts.php @@ -0,0 +1,55 @@ +#!/usr/bin/env php +cleanupOrphanedDrafts(24, $dryRun); +} catch (Exception $e) { + error_log('[cleanup-drafts] Error: ' . $e->getMessage()); + exit(1); +} + +$count = count($result['candidates']); + +if ($count === 0) { + exit(0); // nothing to do — quiet exit +} + +if ($dryRun) { + foreach ($result['candidates'] as $row) { + printf( + "DRY-RUN → #%d %s \"%s\" (submitted %s)\n", + $row['id'], + $row['identifier'], + $row['title'], + $row['submitted_at'] + ); + } + echo "Found {$count} orphaned draft(s). Re-run with --no-dry-run to delete.\n"; + exit(0); +} + +printf("Deleted %d orphaned draft(s).\n", $result['deleted']); +exit(0);