From 0c6523fcbea8d7381777c4c6be1a7e102522bb67 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Mon, 9 Feb 2026 10:24:12 +0000 Subject: [PATCH] cert generation backend --- app/CertificateExcellence.php | 21 +- app/Excellence.php | 2 + .../CertificateBackendController.php | 385 ++++++++++++++++++ .../EnsureSuperCertificateAdmin.php | 24 ++ app/Imports/ResourcesImport.php | 11 +- app/Jobs/GenerateCertificateBatchJob.php | 121 ++++++ app/Jobs/SendCertificateBatchJob.php | 83 ++++ app/Mail/NotifySuperOrganiser.php | 6 +- app/Mail/NotifyWinner.php | 6 +- bootstrap/app.php | 1 + ...cate_error_fields_to_excellences_table.php | 29 ++ .../certificate-backend/errors.blade.php | 44 ++ .../views/certificate-backend/index.blade.php | 267 ++++++++++++ .../en/notify-super-organiser.blade.php | 2 +- .../views/emails/en/notify-winner.blade.php | 2 +- .../layout/menu-profile-dropdown.blade.php | 9 + routes/web.php | 15 + 17 files changed, 1015 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/CertificateBackendController.php create mode 100644 app/Http/Middleware/EnsureSuperCertificateAdmin.php create mode 100644 app/Jobs/GenerateCertificateBatchJob.php create mode 100644 app/Jobs/SendCertificateBatchJob.php create mode 100644 database/migrations/2025_02_09_100000_add_certificate_error_fields_to_excellences_table.php create mode 100644 resources/views/certificate-backend/errors.blade.php create mode 100644 resources/views/certificate-backend/index.blade.php diff --git a/app/CertificateExcellence.php b/app/CertificateExcellence.php index 04894e3cf..a61a5c6ee 100644 --- a/app/CertificateExcellence.php +++ b/app/CertificateExcellence.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Log; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; @@ -22,22 +23,32 @@ class CertificateExcellence private $number_of_activities; private $type; - public function __construct($edition, $name_for_certificate, $type = 'excellence', $number_of_activities = 0) + /** + * @param int $edition e.g. 2025 + * @param string $name_for_certificate + * @param string $type 'excellence' or 'super-organiser' + * @param int $number_of_activities For super-organiser only + * @param int|null $user_id When set (backend generation), used for unique filenames instead of auth + * @param string|null $user_email When set (backend generation), used in template instead of auth user + */ + public function __construct($edition, $name_for_certificate, $type = 'excellence', $number_of_activities = 0, $user_id = null, $user_email = null) { $this->edition = $edition; $this->name_of_certificate_holder = $name_for_certificate; - $this->email_of_certificate_holder = auth()->user()->email ?? ''; - $this->personalized_template_name = $edition . '-' . auth()->id(); + $this->email_of_certificate_holder = $user_email ?? (auth()->check() ? (auth()->user()->email ?? '') : ''); + $effectiveUserId = $user_id ?? (auth()->check() ? auth()->id() : 0); + $random = Str::random(10); + $this->personalized_template_name = $edition . '-' . $effectiveUserId . '-' . $random; $this->resource_path = resource_path() . '/latex'; $this->pdflatex = config('codeweek.pdflatex_path'); - $this->id = auth()->id() . '-' . str_random(10); + $this->id = $effectiveUserId . '-' . $random; $this->number_of_activities = $number_of_activities; $this->type = $type ?: 'excellence'; // e.g. "excellence-2025.tex" or "super-organiser-2025.tex" $this->templateName = "{$this->type}-{$this->edition}.tex"; - Log::info('User ID ' . auth()->id() . " generating {$this->type} certificate with name: " . $name_for_certificate); + Log::info('Generating ' . $this->type . ' certificate for user_id ' . $effectiveUserId . ' with name: ' . $name_for_certificate); } /** diff --git a/app/Excellence.php b/app/Excellence.php index 6b031577b..c25976919 100644 --- a/app/Excellence.php +++ b/app/Excellence.php @@ -50,7 +50,9 @@ class Excellence extends Model 'type', 'name_for_certificate', 'certificate_url', + 'certificate_generation_error', 'notified_at', + 'certificate_sent_error', ]; /** diff --git a/app/Http/Controllers/CertificateBackendController.php b/app/Http/Controllers/CertificateBackendController.php new file mode 100644 index 000000000..7e404947d --- /dev/null +++ b/app/Http/Controllers/CertificateBackendController.php @@ -0,0 +1,385 @@ +environment('local')) { + Mail::to($email)->send($mailable); + } else { + Mail::to($email)->queue($mailable); + } + } + + private function mailSentMessage(): string + { + return app()->environment('local') + ? 'Email sent. (Check storage/logs or your mail catcher.)' + : 'Email queued. It will be sent when the queue worker runs.'; + } + + public const TYPES = [ + 'excellence' => 'Excellence', + 'super-organiser' => 'SuperOrganiser', + ]; + + public function index(Request $request): View + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + return view('certificate-backend.index', [ + 'edition' => $edition, + 'typeSlug' => $typeSlug, + 'type' => $type, + ]); + } + + public function listRecipients(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + $search = $request->get('search', ''); + + $query = Excellence::query() + ->where('edition', $edition) + ->where('type', $type) + ->with('user') + ->orderBy('id'); + + if ($search !== '') { + $query->where(function ($q) use ($search) { + $q->where('name_for_certificate', 'like', '%' . $search . '%') + ->orWhereHas('user', function ($uq) use ($search) { + $uq->where('email', 'like', '%' . $search . '%') + ->orWhere('firstname', 'like', '%' . $search . '%') + ->orWhere('lastname', 'like', '%' . $search . '%'); + }); + }); + } + + $perPage = (int) $request->get('per_page', 50); + $paginator = $query->paginate(min($perPage, 200)); + + $items = $paginator->getCollection()->map(function (Excellence $e) { + $user = $e->user; + return [ + 'id' => $e->id, + 'user_id' => $e->user_id, + 'name' => $e->name_for_certificate ?? ($user ? trim($user->firstname . ' ' . $user->lastname) : ''), + 'email' => $user?->email ?? '', + 'certificate_url' => $e->certificate_url, + 'certificate_generated' => ! empty($e->certificate_url), + 'notified_at' => $e->notified_at, + 'certificate_sent' => ! empty($e->notified_at), + 'certificate_generation_error' => $e->certificate_generation_error, + 'certificate_sent_error' => $e->certificate_sent_error, + ]; + }); + + return response()->json([ + 'data' => $items, + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ]); + } + + public function status(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + $runningKey = sprintf(GenerateCertificateBatchJob::CACHE_KEY_RUNNING, $edition, $type); + $runningValue = Cache::get($runningKey); + $generationRunning = false; + if ($runningValue !== null) { + $startedAt = is_numeric($runningValue) ? (int) $runningValue : null; + if ($startedAt && (time() - $startedAt) < GenerateCertificateBatchJob::RUNNING_STALE_SECONDS) { + $generationRunning = true; + } else { + Cache::forget($runningKey); + } + } + + $stats = [ + 'total' => Excellence::query()->where('edition', $edition)->where('type', $type)->count(), + 'generated' => Excellence::query()->where('edition', $edition)->where('type', $type)->whereNotNull('certificate_url')->count(), + 'sent' => Excellence::query()->where('edition', $edition)->where('type', $type)->whereNotNull('notified_at')->count(), + 'generation_failed' => Excellence::query()->where('edition', $edition)->where('type', $type)->whereNotNull('certificate_generation_error')->count(), + 'send_failed' => Excellence::query()->where('edition', $edition)->where('type', $type)->whereNotNull('certificate_sent_error')->count(), + 'generation_running' => $generationRunning, + ]; + + return response()->json($stats); + } + + public function startGeneration(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + $runningKey = sprintf(GenerateCertificateBatchJob::CACHE_KEY_RUNNING, $edition, $type); + $cancelledKey = sprintf(GenerateCertificateBatchJob::CACHE_KEY_CANCELLED, $edition, $type); + + $runningValue = Cache::get($runningKey); + if ($runningValue !== null) { + $startedAt = is_numeric($runningValue) ? (int) $runningValue : null; + if ($startedAt && (time() - $startedAt) < GenerateCertificateBatchJob::RUNNING_STALE_SECONDS) { + return response()->json(['ok' => false, 'message' => 'Generation already in progress.']); + } + Cache::forget($runningKey); + } + + Cache::forget($cancelledKey); + Cache::put($runningKey, time(), GenerateCertificateBatchJob::CACHE_TTL_SECONDS); + + GenerateCertificateBatchJob::dispatch($edition, $type, 0); + + return response()->json(['ok' => true, 'message' => 'Generation started.']); + } + + public function cancelGeneration(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + $cancelledKey = sprintf(GenerateCertificateBatchJob::CACHE_KEY_CANCELLED, $edition, $type); + Cache::put($cancelledKey, true, GenerateCertificateBatchJob::CACHE_TTL_SECONDS); + + return response()->json(['ok' => true, 'message' => 'Cancellation requested. Current batch will finish, then generation will stop.']); + } + + public function startSend(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + $pending = Excellence::query() + ->where('edition', $edition) + ->where('type', $type) + ->whereNotNull('certificate_url') + ->where(function ($q) { + $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); + }) + ->limit(1) + ->exists(); + + if (! $pending) { + return response()->json(['ok' => false, 'message' => 'No pending recipients with generated certificates.']); + } + + SendCertificateBatchJob::dispatch($edition, $type, 0); + + return response()->json(['ok' => true, 'message' => 'Sending started in batches of ' . SendCertificateBatchJob::BATCH_SIZE . '.']); + } + + public function resendOne(Request $request, int $id): JsonResponse + { + $excellence = Excellence::with('user')->findOrFail($id); + $user = $excellence->user; + + if (! $user || ! $user->email) { + return response()->json(['ok' => false, 'message' => 'User or email missing.']); + } + + if (! $excellence->certificate_url) { + return response()->json(['ok' => false, 'message' => 'Certificate not generated yet. Generate first.']); + } + + try { + if ($excellence->type === 'SuperOrganiser') { + $this->sendCertificateMail($user->email, new NotifySuperOrganiser($user, $excellence->edition, $excellence->certificate_url)); + } else { + $this->sendCertificateMail($user->email, new NotifyWinner($user, $excellence->edition, $excellence->certificate_url)); + } + $excellence->update([ + 'notified_at' => Carbon::now(), + 'certificate_sent_error' => null, + ]); + return response()->json(['ok' => true, 'message' => $this->mailSentMessage()]); + } catch (\Throwable $e) { + $excellence->update(['certificate_sent_error' => $e->getMessage()]); + return response()->json(['ok' => false, 'message' => $e->getMessage()], 500); + } + } + + public function resendAllFailed(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + $count = Excellence::query() + ->where('edition', $edition) + ->where('type', $type) + ->whereNotNull('certificate_url') + ->where(function ($q) { + $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); + }) + ->count(); + + if ($count === 0) { + return response()->json(['ok' => false, 'message' => 'No failed or unsent recipients.']); + } + + SendCertificateBatchJob::dispatch($edition, $type, 0); + + return response()->json(['ok' => true, 'message' => "Sending queued for {$count} recipient(s)."]); + } + + public function errorsList(Request $request) + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + + $rows = Excellence::query() + ->where('edition', $edition) + ->where('type', $type) + ->where(function ($q) { + $q->whereNotNull('certificate_generation_error')->orWhereNotNull('certificate_sent_error'); + }) + ->with('user') + ->orderBy('id') + ->get(); + + if ($request->wantsJson()) { + $items = $rows->map(fn (Excellence $e) => [ + 'id' => $e->id, + 'name' => $e->name_for_certificate ?? ($e->user ? trim($e->user->firstname . ' ' . $e->user->lastname) : ''), + 'email' => $e->user?->email ?? '', + 'certificate_generation_error' => $e->certificate_generation_error, + 'certificate_sent_error' => $e->certificate_sent_error, + ]); + return response()->json(['data' => $items]); + } + + return view('certificate-backend.errors', [ + 'edition' => $edition, + 'typeSlug' => $typeSlug, + 'type' => $type, + 'rows' => $rows, + ]); + } + + /** + * Manual: generate and/or send for one user by email. + * generate_only=true: only generate certificate (do not send). + * send_only=true: only send email (certificate must already exist). + * Otherwise: generate if needed, then send. + */ + public function manualCreateSend(Request $request): JsonResponse + { + $edition = (int) $request->get('edition', self::EDITION_DEFAULT); + $typeSlug = $request->get('type', 'excellence'); + $type = self::TYPES[$typeSlug] ?? 'Excellence'; + $certType = $type === 'SuperOrganiser' ? 'super-organiser' : 'excellence'; + $generateOnly = $request->boolean('generate_only'); + $sendOnly = $request->boolean('send_only'); + + $excellenceId = $request->get('excellence_id'); + $userEmail = $request->get('user_email'); + + if ($excellenceId) { + $excellence = Excellence::with('user')->find($excellenceId); + if (! $excellence) { + return response()->json(['ok' => false, 'message' => 'Excellence record not found.']); + } + } elseif ($userEmail) { + $user = \App\User::where('email', $userEmail)->first(); + if (! $user) { + return response()->json(['ok' => false, 'message' => 'User not found with that email.']); + } + $excellence = Excellence::firstOrCreate( + ['user_id' => $user->id, 'edition' => $edition, 'type' => $type], + ['name_for_certificate' => trim($user->firstname . ' ' . $user->lastname)] + ); + $excellence->load('user'); + } else { + return response()->json(['ok' => false, 'message' => 'Provide excellence_id or user_email.']); + } + + $user = $excellence->user; + if (! $user) { + return response()->json(['ok' => false, 'message' => 'User missing for this record.']); + } + + $name = $excellence->name_for_certificate ?? trim($user->firstname . ' ' . $user->lastname); + $numberOfActivities = $type === 'SuperOrganiser' ? $user->activities($edition) : 0; + + if (! $sendOnly && ! $excellence->certificate_url) { + 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]); + } catch (\Throwable $e) { + $excellence->update(['certificate_generation_error' => $e->getMessage()]); + + return response()->json(['ok' => false, 'message' => 'Generation failed: ' . $e->getMessage()], 500); + } + } + + if ($generateOnly) { + return response()->json([ + 'ok' => true, + 'message' => 'Certificate generated.', + 'certificate_url' => $excellence->certificate_url, + ]); + } + + if ($sendOnly && ! $excellence->certificate_url) { + return response()->json(['ok' => false, 'message' => 'No certificate yet. Generate first.']); + } + + try { + if ($type === 'SuperOrganiser') { + $this->sendCertificateMail($user->email, new NotifySuperOrganiser($user, $edition, $excellence->certificate_url)); + } else { + $this->sendCertificateMail($user->email, new NotifyWinner($user, $edition, $excellence->certificate_url)); + } + $excellence->update(['notified_at' => Carbon::now(), 'certificate_sent_error' => null]); + + return response()->json([ + 'ok' => true, + 'message' => $this->mailSentMessage(), + 'certificate_url' => $excellence->certificate_url, + ]); + } catch (\Throwable $e) { + $excellence->update(['certificate_sent_error' => $e->getMessage()]); + + return response()->json(['ok' => false, 'message' => 'Send failed: ' . $e->getMessage()], 500); + } + } +} diff --git a/app/Http/Middleware/EnsureSuperCertificateAdmin.php b/app/Http/Middleware/EnsureSuperCertificateAdmin.php new file mode 100644 index 000000000..e707a43f6 --- /dev/null +++ b/app/Http/Middleware/EnsureSuperCertificateAdmin.php @@ -0,0 +1,24 @@ +user() || $request->user()->email !== self::ALLOWED_EMAIL) { + abort(403, 'Access denied. This area is restricted to the certificate administrator.'); + } + + return $next($request); + } +} diff --git a/app/Imports/ResourcesImport.php b/app/Imports/ResourcesImport.php index 3f3f4f9a2..29b682735 100644 --- a/app/Imports/ResourcesImport.php +++ b/app/Imports/ResourcesImport.php @@ -195,15 +195,18 @@ protected function processRow(array $row, int $rowIndex): ?Model $itemAttributes['groups'] = $groups; } - $item = ResourceItem::where('name', $name)->where('source', $source)->first(); + $nameNormalized = mb_strtolower($name); + $sourceNormalized = mb_strtolower(trim($source)); + $item = ResourceItem::whereRaw('LOWER(TRIM(name)) = ?', [$nameNormalized]) + ->whereRaw('(source = ? OR LOWER(TRIM(source)) = ?)', [$source, $sourceNormalized]) + ->first(); if (! $item) { - $item = ResourceItem::where('name', $name)->first(); + $item = ResourceItem::whereRaw('LOWER(TRIM(name)) = ?', [$nameNormalized])->first(); } if ($item) { $item->fill($itemAttributes); - $hadChanges = $item->isDirty(); $item->save(); - if ($this->result && $hadChanges) { + if ($this->result) { $this->result->addUpdated($item); } } else { diff --git a/app/Jobs/GenerateCertificateBatchJob.php b/app/Jobs/GenerateCertificateBatchJob.php new file mode 100644 index 000000000..045ae1671 --- /dev/null +++ b/app/Jobs/GenerateCertificateBatchJob.php @@ -0,0 +1,121 @@ +edition, $this->type); + $cancelledKey = sprintf(self::CACHE_KEY_CANCELLED, $this->edition, $this->type); + + if (Cache::get($cancelledKey)) { + Cache::forget($runningKey); + Log::info("Certificate generation cancelled for edition {$this->edition} type {$this->type}"); + return; + } + + Cache::put($runningKey, time(), self::CACHE_TTL_SECONDS); + + $query = Excellence::query() + ->where('edition', $this->edition) + ->where('type', $this->type) + ->with('user') + ->orderBy('id'); + + // Either no cert yet, or had a generation error (retry) + $query->where(function ($q) { + $q->whereNull('certificate_url') + ->orWhereNotNull('certificate_generation_error'); + }); + + $rows = $query->offset($this->offset)->limit(self::BATCH_SIZE)->get(); + + if ($rows->isEmpty()) { + Cache::forget($runningKey); + Log::info("Certificate generation finished for edition {$this->edition} type {$this->type}"); + return; + } + + $certType = $this->type === 'SuperOrganiser' ? 'super-organiser' : 'excellence'; + + foreach ($rows as $excellence) { + if (Cache::get($cancelledKey)) { + break; + } + + $user = $excellence->user; + $name = $excellence->name_for_certificate ?? ($user ? trim($user->firstname . ' ' . $user->lastname) : '') ?: 'Unknown'; + $email = $user?->email ?? ''; + $userId = $user?->id ?? 0; + + $numberOfActivities = 0; + if ($this->type === 'SuperOrganiser' && $user) { + $numberOfActivities = $user->activities($this->edition); + } + + try { + $cert = new CertificateExcellence( + $this->edition, + $name, + $certType, + $numberOfActivities, + $userId, + $email + ); + $url = $cert->generate(); + $excellence->update([ + 'certificate_url' => $url, + 'certificate_generation_error' => null, + ]); + } catch (\Throwable $e) { + Log::error("Certificate generation failed for excellence id {$excellence->id}: " . $e->getMessage()); + $excellence->update([ + 'certificate_generation_error' => $e->getMessage(), + ]); + } + } + + $nextOffset = $this->offset + $rows->count(); + $hasMore = Excellence::query() + ->where('edition', $this->edition) + ->where('type', $this->type) + ->where(function ($q) { + $q->whereNull('certificate_url')->orWhereNotNull('certificate_generation_error'); + }) + ->offset($nextOffset) + ->limit(1) + ->exists(); + + if ($hasMore && ! Cache::get($cancelledKey)) { + self::dispatch($this->edition, $this->type, $nextOffset); + } else { + Cache::forget($runningKey); + } + } +} diff --git a/app/Jobs/SendCertificateBatchJob.php b/app/Jobs/SendCertificateBatchJob.php new file mode 100644 index 000000000..76528a48d --- /dev/null +++ b/app/Jobs/SendCertificateBatchJob.php @@ -0,0 +1,83 @@ +where('edition', $this->edition) + ->where('type', $this->type) + ->whereNotNull('certificate_url') + ->where(function ($q) { + $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); + }) + ->with('user') + ->orderBy('id'); + + $rows = $query->offset($this->offset)->limit(self::BATCH_SIZE)->get(); + + foreach ($rows as $excellence) { + $user = $excellence->user; + if (! $user || ! $user->email) { + $excellence->update(['certificate_sent_error' => 'No user or email']); + continue; + } + + try { + if ($this->type === 'SuperOrganiser') { + Mail::to($user->email)->queue(new NotifySuperOrganiser($user, $this->edition, $excellence->certificate_url)); + } else { + Mail::to($user->email)->queue(new NotifyWinner($user, $this->edition, $excellence->certificate_url)); + } + $excellence->update([ + 'notified_at' => Carbon::now(), + 'certificate_sent_error' => null, + ]); + } catch (\Throwable $e) { + $excellence->update([ + 'certificate_sent_error' => $e->getMessage(), + ]); + } + } + + $nextOffset = $this->offset + $rows->count(); + $hasMore = Excellence::query() + ->where('edition', $this->edition) + ->where('type', $this->type) + ->whereNotNull('certificate_url') + ->where(function ($q) { + $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); + }) + ->offset($nextOffset) + ->limit(1) + ->exists(); + + if ($hasMore) { + self::dispatch($this->edition, $this->type, $nextOffset); + } + } +} diff --git a/app/Mail/NotifySuperOrganiser.php b/app/Mail/NotifySuperOrganiser.php index f88b76bd1..c2dbb00f1 100644 --- a/app/Mail/NotifySuperOrganiser.php +++ b/app/Mail/NotifySuperOrganiser.php @@ -14,15 +14,19 @@ class NotifySuperOrganiser extends Mailable public $edition; + /** @var string|null When set, the email button links directly to this certificate PDF URL */ + public $certificateUrl; + /** * Create a new message instance. * * @return void */ - public function __construct($user, $edition) + public function __construct($user, $edition, ?string $certificateUrl = null) { $this->user = $user; $this->edition = $edition; + $this->certificateUrl = $certificateUrl; } /** diff --git a/app/Mail/NotifyWinner.php b/app/Mail/NotifyWinner.php index cfdfd8e1e..63e651370 100644 --- a/app/Mail/NotifyWinner.php +++ b/app/Mail/NotifyWinner.php @@ -14,15 +14,19 @@ class NotifyWinner extends Mailable public $edition; + /** @var string|null When set, the email button links directly to this certificate PDF URL */ + public $certificateUrl; + /** * Create a new message instance. * * @return void */ - public function __construct($user, $edition) + public function __construct($user, $edition, ?string $certificateUrl = null) { $this->user = $user; $this->edition = $edition; + $this->certificateUrl = $certificateUrl; } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 9c25a7a52..753772ce0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -42,6 +42,7 @@ 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, + 'super.certificate.admin' => \App\Http\Middleware\EnsureSuperCertificateAdmin::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/database/migrations/2025_02_09_100000_add_certificate_error_fields_to_excellences_table.php b/database/migrations/2025_02_09_100000_add_certificate_error_fields_to_excellences_table.php new file mode 100644 index 000000000..5e9a7b776 --- /dev/null +++ b/database/migrations/2025_02_09_100000_add_certificate_error_fields_to_excellences_table.php @@ -0,0 +1,29 @@ +text('certificate_generation_error')->nullable()->after('certificate_url'); + $table->text('certificate_sent_error')->nullable()->after('notified_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('excellences', function (Blueprint $table) { + $table->dropColumn(['certificate_generation_error', 'certificate_sent_error']); + }); + } +}; diff --git a/resources/views/certificate-backend/errors.blade.php b/resources/views/certificate-backend/errors.blade.php new file mode 100644 index 000000000..6ad9784eb --- /dev/null +++ b/resources/views/certificate-backend/errors.blade.php @@ -0,0 +1,44 @@ +@extends('layout.base') + +@section('content') +
+
+

Certificate errors – {{ $type }} ({{ $edition }})

+ Back to list +
+ + + +
+ @if($rows->isEmpty()) +

No errors recorded.

+ @else + + + + + + + + + + + + @foreach($rows as $r) + + + + + + + + @endforeach + +
IDNameEmailGeneration errorSend error
{{ $r->id }}{{ $r->name_for_certificate ?? ($r->user ? trim($r->user->firstname . ' ' . $r->user->lastname) : '') }}{{ $r->user->email ?? '' }}{{ $r->certificate_generation_error ?? '–' }}{{ $r->certificate_sent_error ?? '–' }}
+ @endif +
+
+@endsection diff --git a/resources/views/certificate-backend/index.blade.php b/resources/views/certificate-backend/index.blade.php new file mode 100644 index 000000000..7237e35e8 --- /dev/null +++ b/resources/views/certificate-backend/index.blade.php @@ -0,0 +1,267 @@ +@extends('layout.base') + +@section('content') +
+
+

Certificate Backend – Excellence & Super Organiser

+
+ Refresh + View errors +
+
+ + {{-- Tabs --}} + + + {{-- Edition --}} +

+ + +

+ + {{-- Stats --}} +
+
Total:
+
Generated:
+
Sent:
+
Generation failed:
+
Send failed:
+ +
+ + {{-- Step 1: Generate (always separate from Step 2: Send) --}} +
+

Step 1 – Generate:

+
+ + +
+

Step 2 – Send: (only after certificates are generated)

+
+ + +
+
+ + {{-- Manual: one user – Generate and Send are separate --}} +
+ Manual – one user (generate and send are separate) +
+ + + + + +
+
+ + {{-- Search --}} +
+ + + +
+ + {{-- Table --}} +
+
Loading…
+ + + + + + + + + + + + + + +
+
+ + +@endsection diff --git a/resources/views/emails/en/notify-super-organiser.blade.php b/resources/views/emails/en/notify-super-organiser.blade.php index 777a0e544..38057fa88 100644 --- a/resources/views/emails/en/notify-super-organiser.blade.php +++ b/resources/views/emails/en/notify-super-organiser.blade.php @@ -12,7 +12,7 @@
-@component('mail::button', ['url' => config('codeweek.app_url') . "/certificates/super-organiser/" . $edition]) +@component('mail::button', ['url' => $certificateUrl ?? config('codeweek.app_url') . '/certificates/super-organiser/' . $edition]) Get your Certificate @endcomponent diff --git a/resources/views/emails/en/notify-winner.blade.php b/resources/views/emails/en/notify-winner.blade.php index a1fd7e1aa..5482a3f32 100644 --- a/resources/views/emails/en/notify-winner.blade.php +++ b/resources/views/emails/en/notify-winner.blade.php @@ -17,7 +17,7 @@
-@component('mail::button', ['url' => "https://codeweek.eu/certificates/excellence/2024"]) +@component('mail::button', ['url' => $certificateUrl ?? config('codeweek.app_url', 'https://codeweek.eu') . '/certificates/excellence/' . $edition]) Get your Certificate @endcomponent diff --git a/resources/views/layout/menu-profile-dropdown.blade.php b/resources/views/layout/menu-profile-dropdown.blade.php index e69f86a90..b3df4fd99 100644 --- a/resources/views/layout/menu-profile-dropdown.blade.php +++ b/resources/views/layout/menu-profile-dropdown.blade.php @@ -81,6 +81,15 @@ @endrole +@if(auth()->user()->email === 'bernard@matrixinternet.ie') +
  • + + + Certificate backend (Excellence & Super Organiser) + +
  • +@endif + @role('super admin|leading teacher admin')
  • diff --git a/routes/web.php b/routes/web.php index e33a54008..096513280 100644 --- a/routes/web.php +++ b/routes/web.php @@ -59,6 +59,7 @@ use App\Http\Controllers\SearchResourcesController; use App\Http\Controllers\StaticPageController; use App\Http\Controllers\SuperOrganiserController; +use App\Http\Controllers\CertificateBackendController; use App\Http\Controllers\ToolkitsController; use App\Http\Controllers\UnsubscribeController; use App\Http\Controllers\UserController; @@ -646,6 +647,20 @@ }); +// Certificate backend: Excellence & Super Organiser cert generation/sending (bernard@matrixinternet.ie only) +Route::middleware(['auth', 'super.certificate.admin'])->prefix('admin/certificate-backend')->name('certificate_backend.')->group(function () { + Route::get('/', [CertificateBackendController::class, 'index'])->name('index'); + Route::get('/list', [CertificateBackendController::class, 'listRecipients'])->name('list'); + Route::get('/status', [CertificateBackendController::class, 'status'])->name('status'); + 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('/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'); + Route::post('/manual-create-send', [CertificateBackendController::class, 'manualCreateSend'])->name('manual_create_send'); +}); + Route::middleware('role:super admin|ambassador')->group(function () { Route::get('/pending', [PendingEventsController::class, 'index'])->name('pending'); Route::get('/review', [ReviewController::class, 'index'])->name('review');