Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions app/Console/Commands/CertificateFillEmptyNames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace App\Console\Commands;

use App\Excellence;
use Illuminate\Console\Command;

class CertificateFillEmptyNames extends Command
{
protected $signature = 'certificate:fill-empty-names
{--edition=2025 : Target edition year}
{--type=all : excellence|super-organiser|all}
{--fallback=email : email = use local part of email; placeholder = use "Certificate Holder"}
{--placeholder="Certificate Holder" : When fallback=placeholder, this value is used}
{--limit=0 : Max rows to update (0 = all)}
{--dry-run : Only report, do not update}';

protected $description = 'Set name_for_certificate for Excellence rows where it is empty (so certs can generate)';

public function handle(): int
{
$edition = (int) $this->option('edition');
$typeOption = strtolower(trim((string) $this->option('type')));
$fallback = strtolower(trim((string) $this->option('fallback')));
$placeholder = trim((string) $this->option('placeholder')) ?: 'Certificate Holder';
$limit = max(0, (int) $this->option('limit'));
$dryRun = (bool) $this->option('dry-run');

$types = $this->resolveTypes($typeOption);
if ($types === null) {
$this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'.");
return self::FAILURE;
}

$query = Excellence::query()
->where('edition', $edition)
->whereIn('type', $types)
->where(function ($q) {
$q->whereNull('name_for_certificate')->orWhere('name_for_certificate', '');
})
->with('user')
->orderBy('id');

if ($limit > 0) {
$query->limit($limit);
}

$rows = $query->get();
if ($rows->isEmpty()) {
$this->info('No rows with empty name_for_certificate found.');
return self::SUCCESS;
}

$this->info('Rows with empty name: ' . $rows->count() . ($dryRun ? ' (dry-run, no changes)' : ''));
$updated = 0;

foreach ($rows as $e) {
$user = $e->user;
$email = $user?->email ?? '';
$name = $this->fallbackName($fallback, $placeholder, $email, $user);
if ($name === '') {
$name = 'Certificate Holder';
}
$name = $this->truncateName($name, 40);

if (! $dryRun) {
$e->update(['name_for_certificate' => $name]);
}
$updated++;
$this->line(" " . ($dryRun ? 'Would set' : 'Set') . " excellence id {$e->id} ({$email}) => " . $name);
}

$this->newLine();
$this->info($dryRun ? "Dry-run: would update {$updated} row(s). Run without --dry-run to apply." : "Updated {$updated} row(s).");

return self::SUCCESS;
}

private function resolveTypes(string $typeOption): ?array
{
return match ($typeOption) {
'all' => ['Excellence', 'SuperOrganiser'],
'excellence' => ['Excellence'],
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
default => null,
};
}

private function fallbackName(string $fallback, string $placeholder, string $email, $user): string
{
if ($fallback === 'placeholder') {
return $placeholder;
}
if ($email === '') {
return $placeholder;
}
$local = explode('@', $email)[0] ?? '';
$local = preg_replace('/[^a-zA-Z0-9._-]/', ' ', $local);
$local = str_replace(['.', '_', '-'], ' ', $local);
$local = ucwords(strtolower(trim(preg_replace('/\s+/', ' ', $local))));
return $local !== '' ? $local : $placeholder;
}

private function truncateName(string $name, int $max = 40): string
{
if (mb_strlen($name) <= $max) {
return $name;
}
return mb_substr($name, 0, $max - 1) . '…';
}
}
103 changes: 103 additions & 0 deletions app/Console/Commands/CertificateOrphanedExcellence.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace App\Console\Commands;

use App\Excellence;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class CertificateOrphanedExcellence extends Command
{
protected $signature = 'certificate:orphaned-excellence
{--edition=2025 : Target edition year}
{--type=all : excellence|super-organiser|all}
{--ids= : Comma-separated excellence IDs to check (optional)}
{--export= : CSV path to export orphaned rows}
{--delete : Delete orphaned excellence rows (cannot send cert without user)}';

protected $description = 'List or fix Excellence rows whose user record is missing (Missing related user record)';

public function handle(): int
{
$edition = (int) $this->option('edition');
$typeOption = strtolower(trim((string) $this->option('type')));
$idsOption = trim((string) $this->option('ids'));
$exportPath = trim((string) $this->option('export'));
$delete = (bool) $this->option('delete');

$types = $this->resolveTypes($typeOption);
if ($types === null) {
$this->error("Invalid --type value: {$typeOption}. Use 'excellence', 'super-organiser', or 'all'.");
return self::FAILURE;
}

$query = Excellence::query()
->where('edition', $edition)
->whereIn('type', $types)
->whereNotExists(function ($q) {
$q->select(DB::raw(1))
->from('users')
->whereColumn('users.id', 'excellences.user_id');
})
->orderBy('id');

if ($idsOption !== '') {
$ids = array_filter(array_map('intval', explode(',', $idsOption)));
if (! empty($ids)) {
$query->whereIn('id', $ids);
}
}

$rows = $query->get();
if ($rows->isEmpty()) {
$this->info('No orphaned Excellence rows found (all have a related user).');
return self::SUCCESS;
}

$this->warn('Orphaned Excellence rows (user_id points to missing user): ' . $rows->count());
$this->table(
['id', 'user_id', 'type', 'edition', 'name_for_certificate'],
$rows->map(fn ($e) => [$e->id, $e->user_id, $e->type, $e->edition, $e->name_for_certificate ?? ''])->toArray()
);

if ($exportPath !== '') {
$path = str_starts_with($exportPath, '/') ? $exportPath : base_path($exportPath);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->error("Failed to create directory: {$dir}");
return self::FAILURE;
}
$fh = @fopen($path, 'wb');
if (! $fh) {
$this->error("Failed to open: {$path}");
return self::FAILURE;
}
fputcsv($fh, ['excellence_id', 'user_id', 'type', 'edition', 'name_for_certificate']);
foreach ($rows as $e) {
fputcsv($fh, [$e->id, $e->user_id, $e->type, $e->edition, $e->name_for_certificate ?? '']);
}
fclose($fh);
$this->info("Exported: {$path}");
}

if ($delete) {
$ids = $rows->pluck('id')->toArray();
Excellence::query()->whereIn('id', $ids)->delete();
$this->info('Deleted ' . count($ids) . ' orphaned Excellence row(s). They will no longer appear in preflight or send.');
} else {
$this->line('Tip: Use --delete to remove these rows so they are excluded from certificate generation/send.');
}

return self::SUCCESS;
}

private function resolveTypes(string $typeOption): ?array
{
return match ($typeOption) {
'all' => ['Excellence', 'SuperOrganiser'],
'excellence' => ['Excellence'],
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
default => null,
};
}
}
49 changes: 48 additions & 1 deletion app/Console/Commands/CertificatePreflight.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class CertificatePreflight extends Command
{--limit=0 : Max records to test (0 = all)}
{--batch-size=500 : Process in batches; 0 = single run}
{--only-pending : Test only rows without certificate_url}
{--emails= : Comma-separated emails to test only}
{--emails-file= : Path to file with one email per line (to test only those)}
{--export= : Optional CSV path for failures (only failures written)}';

protected $description = 'Dry-run compile certificates (no S3 upload, no DB updates) and report failures';
Expand All @@ -25,6 +27,8 @@ public function handle(): int
$limit = max(0, (int) $this->option('limit'));
$batchSize = max(0, (int) $this->option('batch-size'));
$onlyPending = (bool) $this->option('only-pending');
$emailsOption = trim((string) $this->option('emails'));
$emailsFilePath = trim((string) $this->option('emails-file'));
$exportPath = trim((string) $this->option('export'));

$types = $this->resolveTypes($typeOption);
Expand All @@ -33,23 +37,36 @@ public function handle(): int
return self::FAILURE;
}

$emailList = $this->resolveEmailList($emailsOption, $emailsFilePath);
if ($emailList === null) {
return self::FAILURE;
}

$baseQuery = Excellence::query()
->where('edition', $edition)
->whereIn('type', $types)
->with('user')
->orderBy('type')
->orderBy('id');

if (! empty($emailList)) {
$baseQuery->whereHas('user', fn ($q) => $q->whereIn('email', $emailList));
}

if ($onlyPending) {
$baseQuery->whereNull('certificate_url');
}

$totalToTest = (clone $baseQuery)->count();
if ($totalToTest === 0) {
$this->info('No recipients found for the selected filters.');
$this->info(empty($emailList) ? 'No recipients found for the selected filters.' : 'No Excellence rows found for the given email list (edition/type may not match).');
return self::SUCCESS;
}

if (! empty($emailList)) {
$this->info('Restricted to '.count($emailList).' email(s) from list; '.$totalToTest.' Excellence row(s) match.');
}

if ($limit > 0) {
$totalToTest = min($totalToTest, $limit);
}
Expand Down Expand Up @@ -178,6 +195,36 @@ public function handle(): int
return self::SUCCESS;
}

/**
* @return array<string>|null Returns list of emails, or null on error (e.g. file not found).
*/
private function resolveEmailList(string $emailsOption, string $emailsFilePath): ?array
{
$list = [];
if ($emailsOption !== '') {
foreach (array_map('trim', explode(',', $emailsOption)) as $e) {
if ($e !== '') {
$list[] = $e;
}
}
}
if ($emailsFilePath !== '') {
$path = str_starts_with($emailsFilePath, '/') ? $emailsFilePath : base_path($emailsFilePath);
if (! is_file($path) || ! is_readable($path)) {
$this->error("Emails file not found or not readable: {$path}");
return null;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$e = trim($line);
if ($e !== '') {
$list[] = $e;
}
}
}
return array_values(array_unique($list));
}

private function resolveTypes(string $typeOption): ?array
{
return match ($typeOption) {
Expand Down
50 changes: 50 additions & 0 deletions app/Console/Commands/CertificateTestTwo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Console\Commands;

use App\CertificateExcellence;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;

class CertificateTestTwo extends Command
{
protected $signature = 'certificate:test-two
{--edition=2025 : Edition year}
{--no-pdf : Only run preflight (no S3), do not keep PDFs}';

protected $description = 'Generate two test certificates locally: one Greek name, one Russian name';

public function handle(): int
{
$edition = (int) $this->option('edition');
$preflightOnly = (bool) $this->option('no-pdf');

$tests = [
['name' => 'Βασιλική Μπαμπαλή', 'label' => 'Greek'],
['name' => 'Иван Петров', 'label' => 'Russian'],
];

foreach ($tests as $test) {
$this->info("Testing {$test['label']}: {$test['name']}");
$cert = new CertificateExcellence($edition, $test['name'], 'excellence', 0, 999999, 'test@example.com');
$this->line(' is_greek(): ' . ($cert->is_greek() ? 'true' : 'false'));

try {
if ($preflightOnly) {
$cert->preflight();
$this->info(" Preflight OK (no PDF kept).");
} else {
$url = $cert->generate();
$this->info(" Generated: {$url}");
}
} catch (\Throwable $e) {
$this->error(' Failed: ' . $e->getMessage());
return self::FAILURE;
}
}

$this->newLine();
$this->info('Both certificates OK. Greek uses _greek template; Russian uses default template.');
return self::SUCCESS;
}
}
2 changes: 1 addition & 1 deletion resources/latex/excellence_greek-2025.tex
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
\vspace{8.3cm}

{\centering\Huge\
\begin{otherlanguage*}{russian}
\begin{otherlanguage*}{greek}
\textbf{<CERTIFICATE_HOLDER_NAME>}
\end{otherlanguage*}
\par}
Expand Down
Loading