From c31d4662ec10c1be54e1c1c59aaaf4123e295969 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 24 Feb 2026 16:16:17 +0000 Subject: [PATCH] certs fix --- .../Commands/CertificateClearGeneration.php | 86 ++++++++++++ .../Commands/CertificateFailuresReport.php | 125 ++++++++++++++++++ .../CertificateBackendController.php | 8 +- app/Jobs/SendCertificateBatchJob.php | 5 +- .../views/certificate-backend/index.blade.php | 2 +- 5 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 app/Console/Commands/CertificateClearGeneration.php create mode 100644 app/Console/Commands/CertificateFailuresReport.php diff --git a/app/Console/Commands/CertificateClearGeneration.php b/app/Console/Commands/CertificateClearGeneration.php new file mode 100644 index 000000000..ec96f3bdf --- /dev/null +++ b/app/Console/Commands/CertificateClearGeneration.php @@ -0,0 +1,86 @@ +option('edition'); + $typeOption = strtolower(trim((string) $this->option('type'))); + $clearSendState = (bool) $this->option('clear-send-state'); + $skipConfirm = (bool) $this->option('yes'); + + $types = $this->resolveTypes($typeOption); + if ($types === null) { + $this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'."); + return self::FAILURE; + } + + if (! $skipConfirm) { + $typeLabel = implode(', ', $types); + $message = "This will clear certificate generation state for edition {$edition}, type(s): {$typeLabel}." + . ($clearSendState ? ' It will ALSO clear send state (notified_at/sent_error).' : ''); + if (! $this->confirm($message . ' Continue?', false)) { + $this->warn('Cancelled.'); + return self::SUCCESS; + } + } + + $update = [ + 'certificate_url' => null, + 'certificate_generation_error' => null, + ]; + if ($clearSendState) { + $update['notified_at'] = null; + $update['certificate_sent_error'] = null; + } + + $totalUpdated = 0; + foreach ($types as $type) { + $updated = Excellence::query() + ->where('edition', $edition) + ->where('type', $type) + ->update($update); + + $totalUpdated += $updated; + $this->info("Updated rows for {$type}: {$updated}"); + + Cache::forget(sprintf(GenerateCertificateBatchJob::CACHE_KEY_RUNNING, $edition, $type)); + Cache::forget(sprintf(GenerateCertificateBatchJob::CACHE_KEY_CANCELLED, $edition, $type)); + Cache::forget(sprintf(SendCertificateBatchJob::CACHE_KEY_SEND_RUNNING, $edition, $type)); + } + + $this->info("Done. Total rows updated: {$totalUpdated}"); + $this->line('Tip: run certificate:stats next to verify counts.'); + + return self::SUCCESS; + } + + /** + * @return array|null + */ + private function resolveTypes(string $typeOption): ?array + { + return match ($typeOption) { + 'all' => ['Excellence', 'SuperOrganiser'], + 'excellence' => ['Excellence'], + 'super-organiser', 'superorganiser' => ['SuperOrganiser'], + default => null, + }; + } +} diff --git a/app/Console/Commands/CertificateFailuresReport.php b/app/Console/Commands/CertificateFailuresReport.php new file mode 100644 index 000000000..dda39f225 --- /dev/null +++ b/app/Console/Commands/CertificateFailuresReport.php @@ -0,0 +1,125 @@ +option('edition'); + $typeOption = strtolower(trim((string) $this->option('type'))); + $limit = max(0, (int) $this->option('limit')); + $exportPath = trim((string) $this->option('export')); + + $types = $this->resolveTypes($typeOption); + if ($types === null) { + $this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'."); + return self::FAILURE; + } + + $rows = Excellence::query() + ->where('edition', $edition) + ->whereIn('type', $types) + ->whereNotNull('certificate_generation_error') + ->with('user') + ->orderBy('type') + ->orderBy('id') + ->get(); + + if ($rows->isEmpty()) { + $this->info("No generation failures found for edition {$edition}, type {$typeOption}."); + return self::SUCCESS; + } + + $displayRows = $limit > 0 ? $rows->take($limit) : $rows; + $table = $displayRows->map(function (Excellence $e) { + $name = $e->name_for_certificate; + if (! $name && $e->user) { + $name = trim(($e->user->firstname ?? '') . ' ' . ($e->user->lastname ?? '')); + } + return [ + 'id' => $e->id, + 'type' => $e->type, + 'user_id' => $e->user_id, + 'email' => $e->user?->email ?? '', + 'name' => $name ?? '', + 'error' => $e->certificate_generation_error, + ]; + })->values()->all(); + + $this->info('Generation failures: ' . $rows->count()); + if ($limit > 0 && $rows->count() > $limit) { + $this->line("Showing first {$limit} rows."); + } + $this->table(['id', 'type', 'user_id', 'email', 'name', 'error'], $table); + + if ($exportPath !== '') { + $fullPath = $this->resolvePath($exportPath); + $dir = dirname($fullPath); + if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) { + $this->error("Failed to create directory: {$dir}"); + return self::FAILURE; + } + + $fh = @fopen($fullPath, 'wb'); + if (! $fh) { + $this->error("Failed to open export file: {$fullPath}"); + return self::FAILURE; + } + + fputcsv($fh, ['id', 'type', 'edition', 'user_id', 'email', 'name_for_certificate', 'certificate_generation_error']); + foreach ($rows as $e) { + $name = $e->name_for_certificate; + if (! $name && $e->user) { + $name = trim(($e->user->firstname ?? '') . ' ' . ($e->user->lastname ?? '')); + } + fputcsv($fh, [ + $e->id, + $e->type, + $e->edition, + $e->user_id, + $e->user?->email ?? '', + $name ?? '', + $e->certificate_generation_error, + ]); + } + fclose($fh); + $this->info("Exported CSV: {$fullPath}"); + } + + $this->line('Note: send flows already exclude rows without certificate_url, so failed generation rows are not emailed.'); + return self::SUCCESS; + } + + /** + * @return array|null + */ + private function resolveTypes(string $typeOption): ?array + { + return match ($typeOption) { + 'all' => ['Excellence', 'SuperOrganiser'], + 'excellence' => ['Excellence'], + 'super-organiser', 'superorganiser' => ['SuperOrganiser'], + default => null, + }; + } + + private function resolvePath(string $path): string + { + if (str_starts_with($path, '/')) { + return $path; + } + return base_path($path); + } +} diff --git a/app/Http/Controllers/CertificateBackendController.php b/app/Http/Controllers/CertificateBackendController.php index 980c8ad94..fe293b73c 100644 --- a/app/Http/Controllers/CertificateBackendController.php +++ b/app/Http/Controllers/CertificateBackendController.php @@ -228,7 +228,6 @@ public function startSend(Request $request): JsonResponse $pending = Excellence::query() ->where('edition', $edition) ->where('type', $type) - ->whereNotNull('certificate_url') ->where(function ($q) { $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); }) @@ -236,7 +235,7 @@ public function startSend(Request $request): JsonResponse ->exists(); if (! $pending) { - return response()->json(['ok' => false, 'message' => 'No pending recipients with generated certificates.']); + return response()->json(['ok' => false, 'message' => 'No pending recipients to send.']); } SendCertificateBatchJob::dispatch($edition, $type, 0); @@ -326,7 +325,6 @@ public function resendAllFailed(Request $request): JsonResponse $count = Excellence::query() ->where('edition', $edition) ->where('type', $type) - ->whereNotNull('certificate_url') ->where(function ($q) { $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); }) @@ -451,10 +449,6 @@ public function manualCreateSend(Request $request): JsonResponse ]); } - 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)); diff --git a/app/Jobs/SendCertificateBatchJob.php b/app/Jobs/SendCertificateBatchJob.php index 94f42da1e..4e8dd633f 100644 --- a/app/Jobs/SendCertificateBatchJob.php +++ b/app/Jobs/SendCertificateBatchJob.php @@ -33,11 +33,11 @@ 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) + // Send to: all qualified recipients for this edition/type that are unsent or had send error. + // certificate_url may be null; email templates fall back to certificate pages where users generate themselves. $query = Excellence::query() ->where('edition', $this->edition) ->where('type', $this->type) - ->whereNotNull('certificate_url') ->where(function ($q) { $q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error'); }) @@ -73,7 +73,6 @@ public function handle(): void $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'); }) diff --git a/resources/views/certificate-backend/index.blade.php b/resources/views/certificate-backend/index.blade.php index ca619dcb6..a0f8820f3 100644 --- a/resources/views/certificate-backend/index.blade.php +++ b/resources/views/certificate-backend/index.blade.php @@ -77,7 +77,7 @@ -

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

+

Step 2 – Send: (sends to all qualified recipients; certificate can be generated from email link)