Add periodic cleanup of orphaned drafts: cleanup job, just command, deploy cron

This commit is contained in:
Pontoporeia
2026-06-11 12:23:49 +02:00
parent a19e9e1454
commit 00fed5f0e3
5 changed files with 148 additions and 7 deletions

View File

@@ -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)`

View File

@@ -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).
*/

View File

@@ -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

View File

@@ -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";}'

55
scripts/cleanup-drafts.php Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env php
<?php
/**
* cleanup-drafts.php — Delete orphaned draft theses older than 24h.
*
* Draft theses are created with status='draft' during the two-phase commit
* in ThesisCreateController. If the file phase throws after COMMIT, the
* draft remains orphaned — no files attached, but blocks the identifier.
*
* Usage:
* php scripts/cleanup-drafts.php # dry-run (list candidates)
* php scripts/cleanup-drafts.php --no-dry-run # actually delete
*
* Exit codes: 0 on success, 1 on error.
*/
declare(strict_types=1);
$root = dirname(__DIR__);
define('APP_ROOT', $root . '/app');
require_once APP_ROOT . '/src/Database.php';
$dryRun = !in_array('--no-dry-run', $argv, true);
try {
$db = new Database();
$result = $db->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);