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 --}}
+
+ On the server, bulk Generate and Send run in the background. You must run php artisan queue:work (or use a process manager like Supervisor) so jobs are processed. Manual buttons and single Resend work immediately without the queue.
+
+
+ {{-- Visible error (in case alerts are blocked) --}}
+
+
{{-- Stats --}}
Total: –
@@ -37,7 +45,22 @@
Sent: –
Generation failed: –
Send failed: –
-
Generation in progress…
+
+
+ {{-- Progress: Generating --}}
+
+
Generating certificates… 0 of 0
+
+
+
+ {{-- Progress: Sending --}}
+
+
Sending emails… 0 of 0
+
{{-- 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();