From d34b6368a55f8ec2d1707946a902364d15d27d0f Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Mon, 9 Feb 2026 11:09:28 +0000 Subject: [PATCH] cert generation backend --- .../CertificateBackendController.php | 13 ++ app/Jobs/SendCertificateBatchJob.php | 8 + .../views/certificate-backend/index.blade.php | 146 ++++++++++++++---- 3 files changed, 141 insertions(+), 26 deletions(-) diff --git a/app/Http/Controllers/CertificateBackendController.php b/app/Http/Controllers/CertificateBackendController.php index b690a46b5..1d863c64f 100644 --- a/app/Http/Controllers/CertificateBackendController.php +++ b/app/Http/Controllers/CertificateBackendController.php @@ -127,6 +127,17 @@ public function status(Request $request): JsonResponse } } + $sendRunningKey = sprintf(SendCertificateBatchJob::CACHE_KEY_SEND_RUNNING, $edition, $type); + $sendRunningValue = Cache::get($sendRunningKey); + $sendRunning = false; + if ($sendRunningValue !== null && is_numeric($sendRunningValue) && (time() - (int) $sendRunningValue) < 7200) { + $sendRunning = true; + } else { + if ($sendRunningValue !== null) { + Cache::forget($sendRunningKey); + } + } + $hasGenErrorCol = Schema::hasColumn('excellences', 'certificate_generation_error'); $hasSentErrorCol = Schema::hasColumn('excellences', 'certificate_sent_error'); @@ -138,6 +149,7 @@ public function status(Request $request): JsonResponse 'generation_failed' => $hasGenErrorCol ? $q()->whereNotNull('certificate_generation_error')->count() : 0, 'send_failed' => $hasSentErrorCol ? $q()->whereNotNull('certificate_sent_error')->count() : 0, 'generation_running' => $generationRunning, + 'send_running' => $sendRunning, ]; return response()->json($stats); @@ -151,6 +163,7 @@ public function status(Request $request): JsonResponse 'generation_failed' => 0, 'send_failed' => 0, 'generation_running' => false, + 'send_running' => false, 'message' => $message, ], 500); } diff --git a/app/Jobs/SendCertificateBatchJob.php b/app/Jobs/SendCertificateBatchJob.php index 76528a48d..d69b8c47b 100644 --- a/app/Jobs/SendCertificateBatchJob.php +++ b/app/Jobs/SendCertificateBatchJob.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; class SendCertificateBatchJob implements ShouldQueue @@ -18,6 +19,8 @@ class SendCertificateBatchJob implements ShouldQueue use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public const BATCH_SIZE = 100; + public const CACHE_KEY_SEND_RUNNING = 'certificate_send_running_%s_%s'; + public const CACHE_TTL = 86400; public function __construct( public int $edition, @@ -27,6 +30,9 @@ public function __construct( public function handle(): void { + $runningKey = sprintf(self::CACHE_KEY_SEND_RUNNING, $this->edition, $this->type); + Cache::put($runningKey, time(), self::CACHE_TTL); + // Send to: has cert and (not yet sent or had send error) $query = Excellence::query() ->where('edition', $this->edition) @@ -78,6 +84,8 @@ public function handle(): void if ($hasMore) { self::dispatch($this->edition, $this->type, $nextOffset); + } else { + Cache::forget($runningKey); } } } diff --git a/resources/views/certificate-backend/index.blade.php b/resources/views/certificate-backend/index.blade.php index f541c901a..d23157eb3 100644 --- a/resources/views/certificate-backend/index.blade.php +++ b/resources/views/certificate-backend/index.blade.php @@ -30,6 +30,14 @@

+ {{-- Server note: queue worker required for bulk actions --}} + + + {{-- Visible error (in case alerts are blocked) --}} + + {{-- Stats --}}
Total:
@@ -37,7 +45,22 @@
Sent:
Generation failed:
Send failed:
- +
+ + {{-- Progress: Generating --}} + + + {{-- Progress: Sending --}} + {{-- Step 1: Generate (always separate from Step 2: Send) --}} @@ -100,6 +123,18 @@ const basePath = '{{ url("/admin/certificate-backend") }}'.replace(/\/$/, ''); let currentPage = 1; let searchQuery = ''; + let statusInterval = null; + + function showError(msg) { + const el = document.getElementById('last-error'); + if (!el) return; + el.textContent = msg || ''; + el.classList.toggle('hidden', !msg); + } + + function clearError() { + showError(''); + } function apiUrl(path, params = {}) { const segment = path.replace(/^\//, ''); @@ -151,6 +186,42 @@ function postJson(url, body = {}) { }).then(handleResponse); } + function updateProgress(data) { + const total = Number(data.total) || 0; + const generated = Number(data.generated) || 0; + const sent = Number(data.sent) || 0; + const genRunning = !!data.generation_running; + const sendRunning = !!data.send_running; + + const progGen = document.getElementById('progress-generate'); + const progGenText = document.getElementById('progress-generate-text'); + const progGenBar = document.getElementById('progress-generate-bar'); + const progSend = document.getElementById('progress-send'); + const progSendText = document.getElementById('progress-send-text'); + const progSendBar = document.getElementById('progress-send-bar'); + + if (progGen) { + progGen.classList.toggle('hidden', !genRunning); + if (genRunning) { + progGenText.textContent = generated + ' of ' + total + ' generated'; + progGenBar.style.width = (total > 0 ? Math.round((generated / total) * 100) : 0) + '%'; + } + } + if (progSend) { + progSend.classList.toggle('hidden', !sendRunning); + if (sendRunning) { + progSendText.textContent = sent + ' of ' + total + ' sent'; + progSendBar.style.width = (total > 0 ? Math.round((sent / total) * 100) : 0) + '%'; + } + } + + if (genRunning || sendRunning) { + if (!statusInterval) statusInterval = setInterval(loadStatus, 2000); + } else { + if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } + } + } + function loadStatus() { fetchJson(apiUrl('/status')).then(data => { document.getElementById('stat-total').textContent = data.total ?? '–'; @@ -158,8 +229,9 @@ function loadStatus() { document.getElementById('stat-sent').textContent = data.sent ?? '–'; document.getElementById('stat-gen-failed').textContent = data.generation_failed ?? '–'; document.getElementById('stat-send-failed').textContent = data.send_failed ?? '–'; - const runEl = document.getElementById('stat-running'); - if (data.generation_running) runEl.style.display = 'block'; else runEl.style.display = 'none'; + updateProgress(data); + }).catch(function(err) { + showError(err.message || 'Could not load status.'); }); } @@ -212,52 +284,73 @@ function pagination(current, last, total) { window.location.href = '{{ url("/admin/certificate-backend") }}?edition=' + this.value + '&type=' + typeSlug; }); - document.getElementById('btn-generate').addEventListener('click', function() { + document.getElementById('btn-generate').addEventListener('click', function(e) { + e.preventDefault(); const btn = this; + clearError(); btn.disabled = true; postJson(apiUrl('/generate/start')).then(r => { - alert(r.message || (r.ok ? 'Started.' : 'Error')); - loadStatus(); - }).catch(function(err) { - alert(err.message || 'Request failed.'); - }).finally(function() { btn.disabled = false; }); + showError(r.ok ? '' : (r.message || 'Error')); + if (r.ok) loadStatus(); + }).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; }); }); - document.getElementById('btn-cancel').addEventListener('click', function() { - postJson(apiUrl('/generate/cancel')).then(r => { alert(r.message); loadStatus(); }).catch(function(err) { alert(err.message || 'Request failed.'); }); + document.getElementById('btn-cancel').addEventListener('click', function(e) { + e.preventDefault(); + clearError(); + postJson(apiUrl('/generate/cancel')).then(r => { showError(r.ok ? '' : r.message); loadStatus(); }).catch(function(err) { showError(err.message || 'Request failed.'); }); }); - document.getElementById('btn-send').addEventListener('click', function() { - postJson(apiUrl('/send/start')).then(r => { alert(r.message); loadStatus(); loadList(currentPage); }).catch(function(err) { alert(err.message || 'Request failed.'); }); + document.getElementById('btn-send').addEventListener('click', function(e) { + e.preventDefault(); + clearError(); + postJson(apiUrl('/send/start')).then(r => { + showError(r.ok ? '' : (r.message || 'Error')); + if (r.ok) loadStatus(); + loadList(currentPage); + }).catch(function(err) { showError(err.message || 'Request failed.'); }); }); - document.getElementById('btn-resend-all-failed').addEventListener('click', function() { - postJson(apiUrl('/resend-all-failed')).then(r => { alert(r.message); loadStatus(); loadList(currentPage); }).catch(function(err) { alert(err.message || 'Request failed.'); }); + document.getElementById('btn-resend-all-failed').addEventListener('click', function(e) { + e.preventDefault(); + clearError(); + postJson(apiUrl('/resend-all-failed')).then(r => { + showError(r.ok ? '' : (r.message || 'Error')); + if (r.ok) loadStatus(); + loadList(currentPage); + }).catch(function(err) { showError(err.message || 'Request failed.'); }); }); - document.getElementById('btn-manual-generate').addEventListener('click', function() { + document.getElementById('btn-manual-generate').addEventListener('click', function(e) { + e.preventDefault(); const email = document.getElementById('manual-email').value.trim(); const resultEl = document.getElementById('manual-result'); - if (!email) { resultEl.textContent = 'Enter email.'; return; } + if (!email) { showError('Enter email.'); return; } + clearError(); resultEl.textContent = 'Generating…'; postJson(apiUrl('/manual-create-send'), { user_email: email, generate_only: true }).then(r => { resultEl.textContent = r.ok ? ('Generated. ' + (r.certificate_url ? 'URL: ' + r.certificate_url : '')) : r.message; + if (!r.ok) showError(r.message); if (r.ok) { loadStatus(); loadList(currentPage); } - }).catch(function(err) { resultEl.textContent = err.message || 'Request failed.'; }); + }).catch(function(err) { resultEl.textContent = ''; showError(err.message || 'Request failed.'); }); }); - document.getElementById('btn-manual-send').addEventListener('click', function() { + document.getElementById('btn-manual-send').addEventListener('click', function(e) { + e.preventDefault(); const email = document.getElementById('manual-email').value.trim(); const resultEl = document.getElementById('manual-result'); - if (!email) { resultEl.textContent = 'Enter email.'; return; } + if (!email) { showError('Enter email.'); return; } + clearError(); resultEl.textContent = 'Sending…'; postJson(apiUrl('/manual-create-send'), { user_email: email, send_only: true }).then(r => { resultEl.textContent = r.ok ? (r.message || 'Email sent.') : r.message; + if (!r.ok) showError(r.message); if (r.ok) { loadStatus(); loadList(currentPage); } - }).catch(function(err) { resultEl.textContent = err.message || 'Request failed.'; }); + }).catch(function(err) { resultEl.textContent = ''; showError(err.message || 'Request failed.'); }); }); - document.getElementById('btn-search').addEventListener('click', function() { + document.getElementById('btn-search').addEventListener('click', function(e) { + e.preventDefault(); searchQuery = document.getElementById('search-input').value.trim(); currentPage = 1; loadList(1); @@ -267,14 +360,15 @@ function pagination(current, last, total) { document.getElementById('recipients-tbody').addEventListener('click', function(e) { const btn = e.target.closest('.resend-one'); if (!btn) return; + e.preventDefault(); const id = btn.dataset.id; btn.disabled = true; + clearError(); const resendUrl = '{{ url("/admin/certificate-backend/resend") }}'.replace(/\/$/, '') + '/' + id; postJson(resendUrl, {}).then(r => { - alert(r.message); - loadStatus(); - loadList(currentPage); - }).catch(function(err) { alert(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; }); + showError(r.ok ? '' : r.message); + if (r.ok) { loadStatus(); loadList(currentPage); } + }).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; }); }); loadStatus();