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')
+ No errors recorded.
+
+
+ Step 1 – Generate: Step 2 – Send: (only after certificates are generated)Certificate errors – {{ $type }} ({{ $edition }})
+ Back to list
+
+
+
+
+
+
+ @endif
+
+
+
+
+ @foreach($rows as $r)
+ ID
+ Name
+ Email
+ Generation error
+ Send error
+
+
+ @endforeach
+
+ {{ $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 ?? '–' }}
+ Certificate Backend – Excellence & Super Organiser
+
+
+
+ {{-- Edition --}}
+ Manual – one user (generate and send are separate)
+
-@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')
+