mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Add periodic cleanup of orphaned drafts: cleanup job, just command, deploy cron
This commit is contained in:
8
TODO.md
8
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)` ✓
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
7
deploy/xamxam-cleanup.cron
Normal file
7
deploy/xamxam-cleanup.cron
Normal 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
|
||||
32
justfile
32
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";}'
|
||||
|
||||
55
scripts/cleanup-drafts.php
Executable file
55
scripts/cleanup-drafts.php
Executable 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);
|
||||
Reference in New Issue
Block a user