Skip to content
Merged
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
223 changes: 223 additions & 0 deletions app/Console/Commands/MatchmakingServiceReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

namespace App\Console\Commands;

use App\MatchmakingProfile;
use Carbon\Carbon;
use Illuminate\Console\Command;

class MatchmakingServiceReport extends Command
{
protected $signature = 'matchmaking:report {--format=text : Output format: text or json }';

protected $description = 'Matching service report: volunteers/organisations registered, distribution (Individual/Organisation, languages, org types, areas), and usage (form time, registrations over time)';

public function handle(): int
{
$format = $this->option('format');

$report = [
'generated_at' => now()->toIso8601String(),
'volunteers_registered' => $this->volunteersRegistered(),
'distribution' => $this->distribution(),
'usage' => $this->usage(),
'notes' => [
'code4europe' => 'Source (e.g. Code4Europe vs other) is not stored in the current schema; all registered profiles are included.',
'most_consulted' => 'Profile view / consultation counts are not tracked in the current schema. To report most consulted individuals/organisations, add view/click tracking.',
],
];

if ($format === 'json') {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}

$this->printTextReport($report);
return self::SUCCESS;
}

private function volunteersRegistered(): array
{
$total = MatchmakingProfile::query()->count();
$individuals = MatchmakingProfile::query()->where('type', MatchmakingProfile::TYPE_VOLUNTEER)->count();
$organisations = MatchmakingProfile::query()->where('type', MatchmakingProfile::TYPE_ORGANISATION)->count();

return [
'total_registered_to_date' => $total,
'profile_individual' => $individuals,
'profile_organisation' => $organisations,
'note' => 'All registrations in the matchmaking database. Code4Europe vs other source is not stored.',
];
}

private function distribution(): array
{
$individuals = MatchmakingProfile::query()
->where('type', MatchmakingProfile::TYPE_VOLUNTEER)
->get(['id', 'languages']);

$languageCounts = [];
foreach ($individuals as $p) {
$langs = $p->languages;
if (is_array($langs)) {
foreach ($langs as $lang) {
$lang = is_string($lang) ? trim($lang) : (string) $lang;
if ($lang !== '') {
$languageCounts[$lang] = ($languageCounts[$lang] ?? 0) + 1;
}
}
}
}
arsort($languageCounts);

$orgs = MatchmakingProfile::query()
->where('type', MatchmakingProfile::TYPE_ORGANISATION)
->get(['id', 'organisation_type', 'country']);

$orgTypeCounts = [];
$areaCounts = [];
foreach ($orgs as $p) {
$types = $p->organisation_type;
if (is_array($types)) {
foreach ($types as $t) {
$t = is_string($t) ? trim($t) : (string) $t;
if ($t !== '') {
$orgTypeCounts[$t] = ($orgTypeCounts[$t] ?? 0) + 1;
}
}
}
$country = $p->country ? trim($p->country) : null;
if ($country !== null && $country !== '') {
$areaCounts[$country] = ($areaCounts[$country] ?? 0) + 1;
}
}
arsort($orgTypeCounts);
arsort($areaCounts);

return [
'profile_individual' => [
'total' => $individuals->count(),
'language_distribution' => $languageCounts,
],
'profile_organisation' => [
'total' => $orgs->count(),
'organisation_type_breakdown' => $orgTypeCounts,
'areas_of_operation' => $areaCounts,
],
];
}

private function usage(): array
{
$withTime = MatchmakingProfile::query()
->whereNotNull('start_time')
->whereNotNull('completion_time')
->get(['start_time', 'completion_time']);

$secondsList = [];
foreach ($withTime as $p) {
$start = $p->start_time instanceof \DateTimeInterface ? $p->start_time : Carbon::parse($p->start_time);
$end = $p->completion_time instanceof \DateTimeInterface ? $p->completion_time : Carbon::parse($p->completion_time);
if ($end > $start) {
$secondsList[] = $end->diffInSeconds($start);
}
}

$avgSeconds = count($secondsList) > 0 ? (int) round(array_sum($secondsList) / count($secondsList)) : null;
$minSeconds = count($secondsList) > 0 ? min($secondsList) : null;
$maxSeconds = count($secondsList) > 0 ? max($secondsList) : null;

$registrationsOverTime = MatchmakingProfile::query()
->selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, count(*) as count')
->groupBy('month')
->orderBy('month')
->pluck('count', 'month')
->all();

return [
'form_completion_time' => [
'profiles_with_start_and_completion' => $withTime->count(),
'average_seconds' => $avgSeconds,
'average_display' => $avgSeconds !== null ? $this->secondsToHuman($avgSeconds) : null,
'min_seconds' => $minSeconds,
'max_seconds' => $maxSeconds,
'note' => 'Time between form start and completion (registration flow).',
],
'registrations_over_time' => $registrationsOverTime,
'most_consulted_individuals_or_organisations' => 'Not available — no view/consultation tracking in the database.',
];
}

private function secondsToHuman(int $seconds): string
{
if ($seconds < 60) {
return $seconds . 's';
}
$m = (int) floor($seconds / 60);
$s = $seconds % 60;
if ($m < 60) {
return $m . 'm ' . $s . 's';
}
$h = (int) floor($m / 60);
$m = $m % 60;
return $h . 'h ' . $m . 'm ' . $s . 's';
}

private function printTextReport(array $report): void
{
$this->newLine();
$this->info('===== Matching service: Database and matching service (teachers & volunteers) =====');
$this->newLine();

$v = $report['volunteers_registered'];
$this->info('1. Volunteers / registrations to date');
$this->line(' Total registered: ' . $v['total_registered_to_date']);
$this->line(' Profile – Individual: ' . $v['profile_individual']);
$this->line(' Profile – Organisation: ' . $v['profile_organisation']);
$this->line(' Note: ' . $v['note']);
$this->newLine();

$d = $report['distribution'];
$this->info('2. Distribution of volunteers');
$this->line(' Profile – Individual: total = ' . $d['profile_individual']['total']);
$this->line(' Language distribution:');
foreach ($d['profile_individual']['language_distribution'] as $lang => $count) {
$this->line(' - ' . $lang . ': ' . $count);
}
$this->newLine();
$this->line(' Profile – Organisation: total = ' . $d['profile_organisation']['total']);
$this->line(' Organisation type breakdown:');
foreach ($d['profile_organisation']['organisation_type_breakdown'] as $type => $count) {
$this->line(' - ' . $type . ': ' . $count);
}
$this->line(' Areas of operation (country):');
foreach ($d['profile_organisation']['areas_of_operation'] as $area => $count) {
$this->line(' - ' . $area . ': ' . $count);
}
$this->newLine();

$u = $report['usage'];
$this->info('3. Usage of the database to date');
$this->line(' Form completion time (time spent on registration form):');
$this->line(' Profiles with start & completion time: ' . $u['form_completion_time']['profiles_with_start_and_completion']);
$this->line(' Average: ' . ($u['form_completion_time']['average_display'] ?? 'n/a'));
if ($u['form_completion_time']['min_seconds'] !== null) {
$this->line(' Min: ' . $this->secondsToHuman($u['form_completion_time']['min_seconds']));
$this->line(' Max: ' . $this->secondsToHuman($u['form_completion_time']['max_seconds']));
}
$this->line(' Note: ' . $u['form_completion_time']['note']);
$this->newLine();
$this->line(' Registrations over time (by month):');
foreach ($u['registrations_over_time'] as $month => $count) {
$this->line(' ' . $month . ': ' . $count);
}
$this->newLine();
$this->line(' Most consulted individuals/organisations: ' . $u['most_consulted_individuals_or_organisations']);
$this->newLine();

$this->comment('Notes:');
$this->line(' - ' . $report['notes']['code4europe']);
$this->line(' - ' . $report['notes']['most_consulted']);
$this->newLine();
}
}
55 changes: 55 additions & 0 deletions app/Http/Controllers/CertificateBackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

namespace App\Http\Controllers;

/**
* Certificate Backend: Excellence & Super Organiser cert generation and sending.
*
* Final considerations (addressed):
* - Error handling: LaTeX input is escaped (CertificateExcellence::tex_escape), generation/send
* failures are caught and stored in certificate_generation_error / certificate_sent_error;
* the Errors page and table show them; manual and batch flows handle exceptions gracefully.
* - Queue: Bulk generate and send use Laravel queues (GenerateCertificateBatchJob, SendCertificateBatchJob)
* so the web request is not blocked; run php artisan queue:work on the server.
* - Testing: Use seeded Excellences or manual "Generate/Regenerate" per row to validate
* LaTeX templates and S3 upload; EventFactory is for participation events, not this backend.
*/
use App\Excellence;
use App\Jobs\GenerateCertificateBatchJob;
use App\Jobs\SendCertificateBatchJob;
Expand Down Expand Up @@ -232,6 +244,49 @@ public function startSend(Request $request): JsonResponse
return response()->json(['ok' => true, 'message' => 'Sending started in batches of ' . SendCertificateBatchJob::BATCH_SIZE . '.']);
}

/**
* Regenerate the certificate PDF for one recipient (by excellence id).
* Works for both "never generated" and "regenerate existing".
*/
public function regenerateOne(Request $request, int $id): JsonResponse
{
$excellence = Excellence::with('user')->findOrFail($id);
$user = $excellence->user;
if (! $user) {
return response()->json(['ok' => false, 'message' => 'User missing.']);
}

$edition = $excellence->edition;
$type = $excellence->type;
$certType = $type === 'SuperOrganiser' ? 'super-organiser' : 'excellence';
$name = $excellence->name_for_certificate ?? trim($user->firstname . ' ' . $user->lastname) ?: 'Unknown';
$numberOfActivities = $type === 'SuperOrganiser' ? $user->activities($edition) : 0;

try {
$cert = new \App\CertificateExcellence(
$edition,
$name,
$certType,
$numberOfActivities,
$user->id,
$user->email
);
$url = $cert->generate();
$excellence->update([
'certificate_url' => $url,
'certificate_generation_error' => null,
]);
return response()->json([
'ok' => true,
'message' => 'Certificate generated.',
'certificate_url' => $url,
]);
} catch (\Throwable $e) {
$excellence->update(['certificate_generation_error' => $e->getMessage()]);
return response()->json(['ok' => false, 'message' => 'Generation failed: ' . $e->getMessage()], 500);
}
}

public function resendOne(Request $request, int $id): JsonResponse
{
$excellence = Excellence::with('user')->findOrFail($id);
Expand Down
45 changes: 33 additions & 12 deletions resources/views/certificate-backend/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@

{{-- 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.
<p class="mb-2"><strong>Bulk Generate and Send</strong> run in the background. To process them:</p>
<ol class="list-decimal list-inside mb-2 space-y-1">
<li>On the server (SSH), start the queue worker once: <code class="bg-amber-200 px-1 rounded">php artisan queue:work</code></li>
<li>Keep it running (or use <strong>Supervisor</strong> so it restarts automatically).</li>
</ol>
<p class="mb-0 text-sm">When the worker is running, the progress bars above update every few seconds—you’ll see “Generating…” / “Sending…” and the counts going up. No button in the browser can start the worker; it has to run as a separate process on the server. Manual “Generate” / “Regenerate” / “Resend” for single rows work immediately without the queue.</p>
</div>

{{-- Visible error (in case alerts are blocked) --}}
Expand Down Expand Up @@ -251,7 +256,10 @@ function loadList(page = 1) {
'<td style="padding: 0.5rem;">' + (row.certificate_generated ? 'Yes' : 'No') + (row.certificate_generation_error ? ' <span style="color:red" title="' + escapeAttr(row.certificate_generation_error) + '">(error)</span>' : '') + '</td>' +
'<td style="padding: 0.5rem;">' + (row.certificate_sent ? (row.notified_at || 'Yes') : 'No') + (row.certificate_sent_error ? ' <span style="color:red" title="' + escapeAttr(row.certificate_sent_error) + '">(error)</span>' : '') + '</td>' +
'<td style="padding: 0.5rem;">' + (row.certificate_url ? '<a href="' + escapeAttr(row.certificate_url) + '" target="_blank" rel="noopener">Open</a>' : '–') + '</td>' +
'<td style="padding: 0.5rem;">' + (row.certificate_url ? '<button type="button" class="px-4 py-2 text-sm font-semibold text-white rounded-full duration-300 cursor-pointer resend-one bg-primary hover:opacity-90" data-id="' + row.id + '">Resend</button>' : '–') + '</td>';
'<td style="padding: 0.5rem;">' +
'<button type="button" class="regenerate-one px-4 py-2 text-sm font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90 mr-1" data-id="' + row.id + '">' + (row.certificate_generated ? 'Regenerate' : 'Generate') + '</button>' +
(row.certificate_url ? '<button type="button" class="resend-one px-4 py-2 text-sm font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90" data-id="' + row.id + '">Resend</button>' : '') +
'</td>';
tbody.appendChild(tr);
});
pagination(data.current_page, data.last_page, data.total);
Expand Down Expand Up @@ -358,17 +366,30 @@ function pagination(current, last, total) {
document.getElementById('search-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') document.getElementById('btn-search').click(); });

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 => {
showError(r.ok ? '' : r.message);
if (r.ok) { loadStatus(); loadList(currentPage); }
}).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; });
const regenerateBtn = e.target.closest('.regenerate-one');
const resendBtn = e.target.closest('.resend-one');
if (regenerateBtn) {
const id = regenerateBtn.dataset.id;
regenerateBtn.disabled = true;
clearError();
const url = '{{ url("/admin/certificate-backend/regenerate") }}'.replace(/\/$/, '') + '/' + id;
postJson(url, {}).then(r => {
showError(r.ok ? '' : r.message);
if (r.ok) { loadStatus(); loadList(currentPage); }
}).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { regenerateBtn.disabled = false; });
return;
}
if (resendBtn) {
const id = resendBtn.dataset.id;
resendBtn.disabled = true;
clearError();
const resendUrl = '{{ url("/admin/certificate-backend/resend") }}'.replace(/\/$/, '') + '/' + id;
postJson(resendUrl, {}).then(r => {
showError(r.ok ? '' : r.message);
if (r.ok) { loadStatus(); loadList(currentPage); }
}).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { resendBtn.disabled = false; });
}
});

loadStatus();
Expand Down
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@
Route::post('/generate/start', [CertificateBackendController::class, 'startGeneration'])->name('generate.start');
Route::post('/generate/cancel', [CertificateBackendController::class, 'cancelGeneration'])->name('generate.cancel');
Route::post('/send/start', [CertificateBackendController::class, 'startSend'])->name('send.start');
Route::post('/regenerate/{id}', [CertificateBackendController::class, 'regenerateOne'])->whereNumber('id')->name('regenerate.one');
Route::post('/resend/{id}', [CertificateBackendController::class, 'resendOne'])->whereNumber('id')->name('resend.one');
Route::post('/resend-all-failed', [CertificateBackendController::class, 'resendAllFailed'])->name('resend.all_failed');
Route::get('/errors', [CertificateBackendController::class, 'errorsList'])->name('errors');
Expand Down
Loading