diff --git a/app/Console/Commands/CertificateFillEmptyNames.php b/app/Console/Commands/CertificateFillEmptyNames.php new file mode 100644 index 000000000..c25b5479e --- /dev/null +++ b/app/Console/Commands/CertificateFillEmptyNames.php @@ -0,0 +1,111 @@ +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) . '…'; + } +} diff --git a/app/Console/Commands/CertificateOrphanedExcellence.php b/app/Console/Commands/CertificateOrphanedExcellence.php new file mode 100644 index 000000000..cf959829a --- /dev/null +++ b/app/Console/Commands/CertificateOrphanedExcellence.php @@ -0,0 +1,103 @@ +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, + }; + } +} diff --git a/app/Console/Commands/CertificatePreflight.php b/app/Console/Commands/CertificatePreflight.php index 08aaeb3a5..e2241881d 100644 --- a/app/Console/Commands/CertificatePreflight.php +++ b/app/Console/Commands/CertificatePreflight.php @@ -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'; @@ -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); @@ -33,6 +37,11 @@ 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) @@ -40,16 +49,24 @@ public function handle(): int ->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); } @@ -178,6 +195,36 @@ public function handle(): int return self::SUCCESS; } + /** + * @return array|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) { diff --git a/app/Console/Commands/CertificateTestTwo.php b/app/Console/Commands/CertificateTestTwo.php new file mode 100644 index 000000000..c6a7fe7c9 --- /dev/null +++ b/app/Console/Commands/CertificateTestTwo.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/resources/latex/excellence_greek-2025.tex b/resources/latex/excellence_greek-2025.tex index 34c96760f..ea3f61acb 100644 --- a/resources/latex/excellence_greek-2025.tex +++ b/resources/latex/excellence_greek-2025.tex @@ -40,7 +40,7 @@ \vspace{8.3cm} {\centering\Huge\ -\begin{otherlanguage*}{russian} +\begin{otherlanguage*}{greek} \textbf{} \end{otherlanguage*} \par}