Skip to content
Merged

Dev #3291

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/Http/Controllers/CertificateBackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand All @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions app/Jobs/SendCertificateBatchJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
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
{
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,
Expand All @@ -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)
Expand Down Expand Up @@ -78,6 +84,8 @@ public function handle(): void

if ($hasMore) {
self::dispatch($this->edition, $this->type, $nextOffset);
} else {
Cache::forget($runningKey);
}
}
}
146 changes: 120 additions & 26 deletions resources/views/certificate-backend/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,37 @@
</select>
</p>

{{-- Server note: queue worker required for bulk actions --}}
<div class="mb-4 p-3 rounded bg-amber-100 text-amber-900" role="alert">
<strong>On the server</strong>, bulk Generate and Send run in the background. You must run <code class="bg-amber-200 px-1">php artisan queue:work</code> (or use a process manager like Supervisor) so jobs are processed. Manual buttons and single Resend work immediately without the queue.
</div>

{{-- Visible error (in case alerts are blocked) --}}
<div id="last-error" class="hidden mb-4 p-3 rounded bg-red-100 text-red-800" role="alert"></div>

{{-- Stats --}}
<div id="stats" class="cert-backend-stats" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div>Total: <strong id="stat-total">–</strong></div>
<div>Generated: <strong id="stat-generated">–</strong></div>
<div>Sent: <strong id="stat-sent">–</strong></div>
<div>Generation failed: <strong id="stat-gen-failed">–</strong></div>
<div>Send failed: <strong id="stat-send-failed">–</strong></div>
<div id="stat-running" style="display: none;">Generation in progress…</div>
</div>

{{-- Progress: Generating --}}
<div id="progress-generate" class="hidden mb-4">
<p class="mb-1"><strong>Generating certificates…</strong> <span id="progress-generate-text">0 of 0</span></p>
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div id="progress-generate-bar" class="h-full bg-primary rounded-full transition-all duration-300" style="width: 0%;"></div>
</div>
</div>

{{-- Progress: Sending --}}
<div id="progress-send" class="hidden mb-4">
<p class="mb-1"><strong>Sending emails…</strong> <span id="progress-send-text">0 of 0</span></p>
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div id="progress-send-bar" class="h-full bg-primary rounded-full transition-all duration-300" style="width: 0%;"></div>
</div>
</div>

{{-- Step 1: Generate (always separate from Step 2: Send) --}}
Expand Down Expand Up @@ -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(/^\//, '');
Expand Down Expand Up @@ -151,15 +186,52 @@ 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 ?? '–';
document.getElementById('stat-generated').textContent = data.generated ?? '–';
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.');
});
}

Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
Loading