Skip to content

Commit 36ff221

Browse files
authored
Merge pull request #3399 from codeeu/dev
Dev
2 parents abdfa66 + e997eae commit 36ff221

5 files changed

Lines changed: 215 additions & 11 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Excellence;
6+
use App\Jobs\GenerateCertificateBatchJob;
7+
use App\Jobs\SendCertificateBatchJob;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Support\Facades\Cache;
10+
11+
class CertificateClearGeneration extends Command
12+
{
13+
protected $signature = 'certificate:clear-generation
14+
{--edition=2025 : Target edition year}
15+
{--type=all : excellence|super-organiser|all}
16+
{--clear-send-state : Also clear notified_at and certificate_sent_error}
17+
{--yes : Skip confirmation prompt}';
18+
19+
protected $description = 'Clear certificate generation state for a year/type (optionally send state too)';
20+
21+
public function handle(): int
22+
{
23+
$edition = (int) $this->option('edition');
24+
$typeOption = strtolower(trim((string) $this->option('type')));
25+
$clearSendState = (bool) $this->option('clear-send-state');
26+
$skipConfirm = (bool) $this->option('yes');
27+
28+
$types = $this->resolveTypes($typeOption);
29+
if ($types === null) {
30+
$this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'.");
31+
return self::FAILURE;
32+
}
33+
34+
if (! $skipConfirm) {
35+
$typeLabel = implode(', ', $types);
36+
$message = "This will clear certificate generation state for edition {$edition}, type(s): {$typeLabel}."
37+
. ($clearSendState ? ' It will ALSO clear send state (notified_at/sent_error).' : '');
38+
if (! $this->confirm($message . ' Continue?', false)) {
39+
$this->warn('Cancelled.');
40+
return self::SUCCESS;
41+
}
42+
}
43+
44+
$update = [
45+
'certificate_url' => null,
46+
'certificate_generation_error' => null,
47+
];
48+
if ($clearSendState) {
49+
$update['notified_at'] = null;
50+
$update['certificate_sent_error'] = null;
51+
}
52+
53+
$totalUpdated = 0;
54+
foreach ($types as $type) {
55+
$updated = Excellence::query()
56+
->where('edition', $edition)
57+
->where('type', $type)
58+
->update($update);
59+
60+
$totalUpdated += $updated;
61+
$this->info("Updated rows for {$type}: {$updated}");
62+
63+
Cache::forget(sprintf(GenerateCertificateBatchJob::CACHE_KEY_RUNNING, $edition, $type));
64+
Cache::forget(sprintf(GenerateCertificateBatchJob::CACHE_KEY_CANCELLED, $edition, $type));
65+
Cache::forget(sprintf(SendCertificateBatchJob::CACHE_KEY_SEND_RUNNING, $edition, $type));
66+
}
67+
68+
$this->info("Done. Total rows updated: {$totalUpdated}");
69+
$this->line('Tip: run certificate:stats next to verify counts.');
70+
71+
return self::SUCCESS;
72+
}
73+
74+
/**
75+
* @return array<int, string>|null
76+
*/
77+
private function resolveTypes(string $typeOption): ?array
78+
{
79+
return match ($typeOption) {
80+
'all' => ['Excellence', 'SuperOrganiser'],
81+
'excellence' => ['Excellence'],
82+
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
83+
default => null,
84+
};
85+
}
86+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Excellence;
6+
use Illuminate\Console\Command;
7+
8+
class CertificateFailuresReport extends Command
9+
{
10+
protected $signature = 'certificate:failures-report
11+
{--edition=2025 : Target edition year}
12+
{--type=all : excellence|super-organiser|all}
13+
{--limit=0 : Show first N rows (0 = all)}
14+
{--export= : Optional CSV export path (absolute or relative to project root)}';
15+
16+
protected $description = 'List/export certificate generation failures for review before sending emails';
17+
18+
public function handle(): int
19+
{
20+
$edition = (int) $this->option('edition');
21+
$typeOption = strtolower(trim((string) $this->option('type')));
22+
$limit = max(0, (int) $this->option('limit'));
23+
$exportPath = trim((string) $this->option('export'));
24+
25+
$types = $this->resolveTypes($typeOption);
26+
if ($types === null) {
27+
$this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'.");
28+
return self::FAILURE;
29+
}
30+
31+
$rows = Excellence::query()
32+
->where('edition', $edition)
33+
->whereIn('type', $types)
34+
->whereNotNull('certificate_generation_error')
35+
->with('user')
36+
->orderBy('type')
37+
->orderBy('id')
38+
->get();
39+
40+
if ($rows->isEmpty()) {
41+
$this->info("No generation failures found for edition {$edition}, type {$typeOption}.");
42+
return self::SUCCESS;
43+
}
44+
45+
$displayRows = $limit > 0 ? $rows->take($limit) : $rows;
46+
$table = $displayRows->map(function (Excellence $e) {
47+
$name = $e->name_for_certificate;
48+
if (! $name && $e->user) {
49+
$name = trim(($e->user->firstname ?? '') . ' ' . ($e->user->lastname ?? ''));
50+
}
51+
return [
52+
'id' => $e->id,
53+
'type' => $e->type,
54+
'user_id' => $e->user_id,
55+
'email' => $e->user?->email ?? '',
56+
'name' => $name ?? '',
57+
'error' => $e->certificate_generation_error,
58+
];
59+
})->values()->all();
60+
61+
$this->info('Generation failures: ' . $rows->count());
62+
if ($limit > 0 && $rows->count() > $limit) {
63+
$this->line("Showing first {$limit} rows.");
64+
}
65+
$this->table(['id', 'type', 'user_id', 'email', 'name', 'error'], $table);
66+
67+
if ($exportPath !== '') {
68+
$fullPath = $this->resolvePath($exportPath);
69+
$dir = dirname($fullPath);
70+
if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) {
71+
$this->error("Failed to create directory: {$dir}");
72+
return self::FAILURE;
73+
}
74+
75+
$fh = @fopen($fullPath, 'wb');
76+
if (! $fh) {
77+
$this->error("Failed to open export file: {$fullPath}");
78+
return self::FAILURE;
79+
}
80+
81+
fputcsv($fh, ['id', 'type', 'edition', 'user_id', 'email', 'name_for_certificate', 'certificate_generation_error']);
82+
foreach ($rows as $e) {
83+
$name = $e->name_for_certificate;
84+
if (! $name && $e->user) {
85+
$name = trim(($e->user->firstname ?? '') . ' ' . ($e->user->lastname ?? ''));
86+
}
87+
fputcsv($fh, [
88+
$e->id,
89+
$e->type,
90+
$e->edition,
91+
$e->user_id,
92+
$e->user?->email ?? '',
93+
$name ?? '',
94+
$e->certificate_generation_error,
95+
]);
96+
}
97+
fclose($fh);
98+
$this->info("Exported CSV: {$fullPath}");
99+
}
100+
101+
$this->line('Note: send flows already exclude rows without certificate_url, so failed generation rows are not emailed.');
102+
return self::SUCCESS;
103+
}
104+
105+
/**
106+
* @return array<int, string>|null
107+
*/
108+
private function resolveTypes(string $typeOption): ?array
109+
{
110+
return match ($typeOption) {
111+
'all' => ['Excellence', 'SuperOrganiser'],
112+
'excellence' => ['Excellence'],
113+
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
114+
default => null,
115+
};
116+
}
117+
118+
private function resolvePath(string $path): string
119+
{
120+
if (str_starts_with($path, '/')) {
121+
return $path;
122+
}
123+
return base_path($path);
124+
}
125+
}

app/Http/Controllers/CertificateBackendController.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,14 @@ public function startSend(Request $request): JsonResponse
228228
$pending = Excellence::query()
229229
->where('edition', $edition)
230230
->where('type', $type)
231-
->whereNotNull('certificate_url')
232231
->where(function ($q) {
233232
$q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error');
234233
})
235234
->limit(1)
236235
->exists();
237236

238237
if (! $pending) {
239-
return response()->json(['ok' => false, 'message' => 'No pending recipients with generated certificates.']);
238+
return response()->json(['ok' => false, 'message' => 'No pending recipients to send.']);
240239
}
241240

242241
SendCertificateBatchJob::dispatch($edition, $type, 0);
@@ -326,7 +325,6 @@ public function resendAllFailed(Request $request): JsonResponse
326325
$count = Excellence::query()
327326
->where('edition', $edition)
328327
->where('type', $type)
329-
->whereNotNull('certificate_url')
330328
->where(function ($q) {
331329
$q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error');
332330
})
@@ -451,10 +449,6 @@ public function manualCreateSend(Request $request): JsonResponse
451449
]);
452450
}
453451

454-
if ($sendOnly && ! $excellence->certificate_url) {
455-
return response()->json(['ok' => false, 'message' => 'No certificate yet. Generate first.']);
456-
}
457-
458452
try {
459453
if ($type === 'SuperOrganiser') {
460454
$this->sendCertificateMail($user->email, new NotifySuperOrganiser($user, $edition, $excellence->certificate_url));

app/Jobs/SendCertificateBatchJob.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ public function handle(): void
3333
$runningKey = sprintf(self::CACHE_KEY_SEND_RUNNING, $this->edition, $this->type);
3434
Cache::put($runningKey, time(), self::CACHE_TTL);
3535

36-
// Send to: has cert and (not yet sent or had send error)
36+
// Send to: all qualified recipients for this edition/type that are unsent or had send error.
37+
// certificate_url may be null; email templates fall back to certificate pages where users generate themselves.
3738
$query = Excellence::query()
3839
->where('edition', $this->edition)
3940
->where('type', $this->type)
40-
->whereNotNull('certificate_url')
4141
->where(function ($q) {
4242
$q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error');
4343
})
@@ -73,7 +73,6 @@ public function handle(): void
7373
$hasMore = Excellence::query()
7474
->where('edition', $this->edition)
7575
->where('type', $this->type)
76-
->whereNotNull('certificate_url')
7776
->where(function ($q) {
7877
$q->whereNull('notified_at')->orWhereNotNull('certificate_sent_error');
7978
})

resources/views/certificate-backend/index.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
<button type="button" id="btn-generate" class="px-6 py-3 mr-2 font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90">Generate certificates</button>
7878
<button type="button" id="btn-cancel" class="px-6 py-3 font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90">Cancel generation</button>
7979
</div>
80-
<p style="margin-bottom: 0.5rem;"><strong>Step 2 – Send:</strong> (only after certificates are generated)</p>
80+
<p style="margin-bottom: 0.5rem;"><strong>Step 2 – Send:</strong> (sends to all qualified recipients; certificate can be generated from email link)</p>
8181
<div>
8282
<button type="button" id="btn-send" class="px-6 py-3 mr-2 font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90">Send emails (batches of 100)</button>
8383
<button type="button" id="btn-resend-all-failed" class="px-6 py-3 font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90">Resend all failed / unsent</button>

0 commit comments

Comments
 (0)