Skip to content

Commit c46d1d4

Browse files
authored
Merge pull request #3449 from codeeu/dev
200203 cert edit
2 parents fbef6d4 + fc55e6a commit c46d1d4

6 files changed

Lines changed: 193 additions & 22 deletions

File tree

app/Console/Commands/CertificateReassignUser.php

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class CertificateReassignUser extends Command
1010
{
1111
protected $signature = 'certificate:reassign-user
1212
{--from-email= : Current account email (certificates will be moved FROM this user)}
13+
{--from-user-id= : Alternatively, source user ID (use when the from user no longer exists by email)}
1314
{--to-email= : Target account email (certificates will be moved TO this user)}
1415
{--dry-run : List what would be moved, do not update}';
1516

@@ -18,39 +19,58 @@ class CertificateReassignUser extends Command
1819
public function handle(): int
1920
{
2021
$fromEmail = trim((string) $this->option('from-email'));
22+
$fromUserId = $this->option('from-user-id');
2123
$toEmail = trim((string) $this->option('to-email'));
2224
$dryRun = (bool) $this->option('dry-run');
2325

24-
if ($fromEmail === '' || $toEmail === '') {
25-
$this->error('Provide both --from-email and --to-email.');
26+
if ($toEmail === '') {
27+
$this->error('Provide --to-email (target account).');
2628
return self::FAILURE;
2729
}
28-
29-
if (strtolower($fromEmail) === strtolower($toEmail)) {
30-
$this->error('From and to email must be different.');
30+
if ($fromEmail === '' && ($fromUserId === null || $fromUserId === '')) {
31+
$this->error('Provide either --from-email or --from-user-id (source of certificates).');
3132
return self::FAILURE;
3233
}
3334

34-
$fromUser = User::where('email', $fromEmail)->first();
35-
$toUser = User::where('email', $toEmail)->first();
35+
$fromUser = null;
36+
$fromLabel = '';
37+
if ($fromUserId !== null && $fromUserId !== '') {
38+
$fromUser = User::find((int) $fromUserId);
39+
$fromLabel = 'user_id ' . (int) $fromUserId;
40+
} else {
41+
$fromUser = User::where('email', $fromEmail)->first();
42+
$fromLabel = $fromEmail;
43+
}
3644

3745
if (! $fromUser) {
38-
$this->error("User not found: {$fromEmail}");
46+
$this->error('Source user not found: ' . ($fromLabel ?: $fromEmail));
47+
return self::FAILURE;
48+
}
49+
50+
if ($fromEmail !== '' && strtolower($fromUser->email) === strtolower($toEmail)) {
51+
$this->error('From and to must be different.');
3952
return self::FAILURE;
4053
}
54+
55+
$toUser = User::where('email', $toEmail)->first();
4156
if (! $toUser) {
4257
$this->error("User not found: {$toEmail}");
4358
return self::FAILURE;
4459
}
4560

61+
if ($fromUser->id === $toUser->id) {
62+
$this->error('From and to user are the same. Nothing to do.');
63+
return self::FAILURE;
64+
}
65+
4666
$rows = Excellence::where('user_id', $fromUser->id)->orderBy('edition')->orderBy('type')->get();
4767

4868
if ($rows->isEmpty()) {
49-
$this->info("No certificate rows found for {$fromEmail} (user_id {$fromUser->id}). Nothing to move.");
69+
$this->info("No certificate rows found for {$fromLabel} (user_id {$fromUser->id}). Nothing to move.");
5070
return self::SUCCESS;
5171
}
5272

53-
$this->info("Found {$rows->count()} certificate row(s) for {$fromEmail} (user_id {$fromUser->id}).");
73+
$this->info("Found {$rows->count()} certificate row(s) for {$fromLabel} (user_id {$fromUser->id}).");
5474
$this->info("Target: {$toEmail} (user_id {$toUser->id}).");
5575
$this->newLine();
5676

@@ -63,6 +83,27 @@ public function handle(): int
6383
])->toArray();
6484
$this->table(['id', 'edition', 'type', 'has PDF', 'sent'], $table);
6585

86+
// Check first: target must not already have an excellence for the same edition+type (unique constraint)
87+
$conflicts = [];
88+
foreach ($rows as $e) {
89+
$exists = Excellence::where('user_id', $toUser->id)
90+
->where('edition', $e->edition)
91+
->where('type', $e->type)
92+
->exists();
93+
if ($exists) {
94+
$conflicts[] = "edition {$e->edition}, type {$e->type}";
95+
}
96+
}
97+
if (count($conflicts) > 0) {
98+
$this->newLine();
99+
$this->error('Cannot reassign: target user already has certificate(s) for the same edition+type:');
100+
foreach ($conflicts as $c) {
101+
$this->line(' - ' . $c);
102+
}
103+
$this->line('Resolve duplicates (e.g. merge or delete one side) before reassigning.');
104+
return self::FAILURE;
105+
}
106+
66107
if ($dryRun) {
67108
$this->newLine();
68109
$this->warn('Dry run: no changes made. Run without --dry-run to reassign.');
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\CertificateExcellence;
6+
use App\Excellence;
7+
use App\User;
8+
use Illuminate\Console\Command;
9+
10+
/**
11+
* Regenerate a single certificate PDF and overwrite the existing file on S3
12+
* so the same URL serves the corrected PDF. No email sent; user does not need to notice.
13+
*
14+
* Use when the PDF shows the wrong year (e.g. 2024 instead of 2023) and you want
15+
* to fix it without changing the link or notifying the recipient.
16+
*/
17+
class CertificateRegenerateInPlace extends Command
18+
{
19+
protected $signature = 'certificate:regenerate-in-place
20+
{--email= : User email (certificate owner)}
21+
{--certificate-url= : Exact certificate URL to replace (if user has multiple certs)}
22+
{--edition= : Year to show on the certificate (e.g. 2023); default: use value from DB}
23+
{--dry-run : Show what would be done, do not regenerate}';
24+
25+
protected $description = 'Regenerate one certificate PDF in place (same URL); no email sent. Use to fix wrong year or content.';
26+
27+
public function handle(): int
28+
{
29+
$email = trim((string) $this->option('email'));
30+
$certificateUrl = trim((string) $this->option('certificate-url'));
31+
$editionOverride = $this->option('edition');
32+
$dryRun = (bool) $this->option('dry-run');
33+
34+
if ($email === '') {
35+
$this->error('Provide --email (certificate owner).');
36+
return self::FAILURE;
37+
}
38+
39+
$user = User::where('email', $email)->first();
40+
if (! $user) {
41+
$this->error("User not found: {$email}");
42+
return self::FAILURE;
43+
}
44+
45+
$excellence = null;
46+
if ($certificateUrl !== '') {
47+
$excellence = Excellence::where('user_id', $user->id)
48+
->where(function ($q) use ($certificateUrl) {
49+
$q->where('certificate_url', $certificateUrl)
50+
->orWhere('certificate_url', 'like', '%' . basename(parse_url($certificateUrl, PHP_URL_PATH)) . '%');
51+
})
52+
->first();
53+
if (! $excellence) {
54+
$this->error('No excellence record found for this user with that certificate URL.');
55+
return self::FAILURE;
56+
}
57+
} else {
58+
$candidates = Excellence::where('user_id', $user->id)
59+
->whereNotNull('certificate_url')
60+
->orderBy('edition')
61+
->orderBy('type')
62+
->get();
63+
if ($candidates->isEmpty()) {
64+
$this->error("No certificate(s) found for {$email}.");
65+
return self::FAILURE;
66+
}
67+
if ($candidates->count() > 1) {
68+
$this->error('User has more than one certificate. Specify --certificate-url= to choose which one to regenerate.');
69+
$this->table(
70+
['id', 'edition', 'type', 'certificate_url'],
71+
$candidates->map(fn (Excellence $e) => [$e->id, $e->edition, $e->type, $e->certificate_url])->toArray()
72+
);
73+
return self::FAILURE;
74+
}
75+
$excellence = $candidates->first();
76+
}
77+
78+
$excellence->load('user');
79+
$edition = $editionOverride !== null && $editionOverride !== ''
80+
? (int) $editionOverride
81+
: (int) $excellence->edition;
82+
$name = $excellence->name_for_certificate ?? trim(($excellence->user->firstname ?? '') . ' ' . ($excellence->user->lastname ?? ''));
83+
$name = $name !== '' ? $name : 'Unknown';
84+
$type = $excellence->type === 'SuperOrganiser' ? 'super-organiser' : 'excellence';
85+
$numberOfActivities = $type === 'super-organiser' ? (int) $excellence->user->activities($edition) : 0;
86+
87+
$this->info("Certificate: excellence id {$excellence->id}, edition {$excellence->edition} (will generate with year {$edition}), type {$excellence->type}.");
88+
$this->line('URL (unchanged): ' . $excellence->certificate_url);
89+
90+
if ($dryRun) {
91+
$this->warn('Dry run: no regeneration. Run without --dry-run to overwrite the PDF on S3.');
92+
return self::SUCCESS;
93+
}
94+
95+
try {
96+
$cert = new CertificateExcellence(
97+
$edition,
98+
$name,
99+
$type,
100+
$numberOfActivities,
101+
(int) $excellence->user->id,
102+
(string) ($excellence->user->email ?? '')
103+
);
104+
$url = $cert->generateReplacing($excellence->certificate_url);
105+
$excellence->update([
106+
'certificate_url' => $url,
107+
'certificate_generation_error' => null,
108+
]);
109+
$this->info('Done. PDF regenerated; same link now serves the corrected certificate. No email sent.');
110+
} catch (\Throwable $e) {
111+
$this->error('Regeneration failed: ' . $e->getMessage());
112+
$excellence->update(['certificate_generation_error' => $e->getMessage()]);
113+
114+
return self::FAILURE;
115+
}
116+
117+
return self::SUCCESS;
118+
}
119+
}

app/Console/Commands/TrainingResourcesReport.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ private function learnTeachResourcesStats(Carbon $baseline): array
118118
'total_resources_now' => $total,
119119
'added_since_baseline' => $addedSince,
120120
'baseline_date' => $baseline->format('Y-m-d'),
121-
'note' => 'Learn & Teach resources at /resources/learn-and-teach (ResourceItem). Not the same as the static /training modules; both are "training-related" content.',
121+
'note' => 'Learn & Teach resources at /resources/ (ResourceItem). Not the same as the static /training modules; both are "training-related" content.',
122122
];
123123
}
124124

app/Http/Controllers/CertificateBackendController.php

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,23 @@ class CertificateBackendController extends Controller
3333
public const EDITION_DEFAULT = 2025;
3434

3535
/** In local, send immediately so you see success/failure (e.g. in mail log or Mailtrap). In production, queue. */
36-
private function sendCertificateMail(string $email, $mailable): void
36+
private function sendCertificateMail(string $email, $mailable, bool $sync = false): void
3737
{
38-
if (app()->environment('local')) {
38+
if ($sync || app()->environment('local')) {
3939
Mail::to($email)->send($mailable);
4040
} else {
4141
Mail::to($email)->queue($mailable);
4242
}
4343
}
4444

45-
private function mailSentMessage(): string
45+
private function mailSentMessage(bool $sync = false): string
4646
{
47-
return app()->environment('local')
48-
? 'Email sent. (Check storage/logs or your mail catcher.)'
49-
: 'Email queued. It will be sent when the queue worker runs.';
47+
if ($sync || app()->environment('local')) {
48+
return app()->environment('local')
49+
? 'Email sent. (Check storage/logs or your mail catcher.)'
50+
: 'Email sent.';
51+
}
52+
return 'Email queued. It will be sent when the queue worker runs.';
5053
}
5154

5255
public const TYPES = [
@@ -382,6 +385,7 @@ public function errorsList(Request $request)
382385
* Manual: generate and/or send for one user by email.
383386
* generate_only=true: only generate certificate (do not send).
384387
* send_only=true: only send email (certificate must already exist).
388+
* send_sync=true: when sending, send immediately (no queue) so this user gets the email now.
385389
* Otherwise: generate if needed, then send.
386390
*/
387391
public function manualCreateSend(Request $request): JsonResponse
@@ -392,6 +396,7 @@ public function manualCreateSend(Request $request): JsonResponse
392396
$certType = $type === 'SuperOrganiser' ? 'super-organiser' : 'excellence';
393397
$generateOnly = $request->boolean('generate_only');
394398
$sendOnly = $request->boolean('send_only');
399+
$sendSync = $request->boolean('send_sync');
395400

396401
$excellenceId = $request->get('excellence_id');
397402
$userEmail = $request->get('user_email');
@@ -455,15 +460,15 @@ public function manualCreateSend(Request $request): JsonResponse
455460

456461
try {
457462
if ($type === 'SuperOrganiser') {
458-
$this->sendCertificateMail($user->email, new NotifySuperOrganiser($user, $edition, $excellence->certificate_url));
463+
$this->sendCertificateMail($user->email, new NotifySuperOrganiser($user, $edition, $excellence->certificate_url), $sendSync);
459464
} else {
460-
$this->sendCertificateMail($user->email, new NotifyWinner($user, $edition, $excellence->certificate_url));
465+
$this->sendCertificateMail($user->email, new NotifyWinner($user, $edition, $excellence->certificate_url), $sendSync);
461466
}
462467
$excellence->update(['notified_at' => Carbon::now(), 'certificate_sent_error' => null]);
463468

464469
return response()->json([
465470
'ok' => true,
466-
'message' => $this->mailSentMessage(),
471+
'message' => $this->mailSentMessage($sendSync),
467472
'certificate_url' => $excellence->certificate_url,
468473
]);
469474
} catch (\Throwable $e) {

app/Providers/AppServiceProvider.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ function ($view) {
154154

155155
//Livewire::paginationView('vendor.livewire.pagination');
156156

157-
$this->commands([\App\Console\Commands\CertificateReassignUser::class]);
157+
$this->commands([
158+
\App\Console\Commands\CertificateReassignUser::class,
159+
\App\Console\Commands\CertificateRegenerateInPlace::class,
160+
]);
158161

159162
$this->bootAuth();
160163
$this->bootEvent();

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@
9292
<input type="email" id="manual-email" placeholder="user@example.com" style="margin-left: 0.5rem; padding: 0.25rem;">
9393
<button type="button" id="btn-manual-generate" class="px-6 py-3 ml-2 font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90">Generate certificate only</button>
9494
<button type="button" id="btn-manual-send" class="px-6 py-3 ml-2 font-semibold text-white rounded-full duration-300 cursor-pointer bg-primary hover:opacity-90">Send email only</button>
95+
<label style="margin-left: 1rem; cursor: pointer;"><input type="checkbox" id="manual-send-now" checked style="margin-right: 0.25rem;">Send now (no queue)</label>
9596
<span id="manual-result" style="margin-left: 0.5rem;"></span>
9697
</div>
98+
<p style="margin-top: 0.25rem; font-size: 0.9rem; color: #666;">Use <strong>Send now (no queue)</strong> to send the email immediately for this user only, so they get it without running the queue worker.</p>
9799
</details>
98100

99101
{{-- Search --}}
@@ -467,7 +469,8 @@ function onResendAllFailedClick(e) {
467469
if (!email) { showError('Enter email.'); return; }
468470
clearError();
469471
if (resultEl) resultEl.textContent = 'Sending…';
470-
postJson(apiUrl('/manual-create-send'), { user_email: email, send_only: true }).then(function(r) {
472+
var sendNow = document.getElementById('manual-send-now') && document.getElementById('manual-send-now').checked;
473+
postJson(apiUrl('/manual-create-send'), { user_email: email, send_only: true, send_sync: sendNow }).then(function(r) {
471474
if (resultEl) resultEl.textContent = r.ok ? (r.message || 'Email sent.') : r.message;
472475
if (!r.ok) showError(r.message);
473476
if (r.ok) { loadStatus(); loadList(currentPage); }

0 commit comments

Comments
 (0)