Skip to content
Merged

Dev #3434

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
65 changes: 47 additions & 18 deletions app/Console/Commands/CertificateGenerateWindow.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,48 @@ class CertificateGenerateWindow extends Command
{
protected $signature = 'certificate:generate-window
{--edition=2025 : Target edition year}
{--type=excellence : excellence|super-organiser}
{--limit=500 : Max recipients to process in this run}
{--type=all : excellence|super-organiser|all}
{--limit=500 : Max recipients to process per type in this run}
{--include-failed : Include rows with previous generation errors}';

protected $description = 'Generate certificates in controlled windows (e.g. 500 at a time)';
protected $description = 'Generate certificates in controlled windows (e.g. 500 at a time); use --type=all for both certs';

public function handle(): int
{
$edition = (int) $this->option('edition');
$limit = max(1, (int) $this->option('limit'));
$includeFailed = (bool) $this->option('include-failed');
$typeInput = strtolower(trim((string) $this->option('type')));
$type = match ($typeInput) {
'excellence' => 'Excellence',
'super-organiser', 'superorganiser' => 'SuperOrganiser',
default => null,
};

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

$totalOk = 0;
$totalFailed = 0;
$any = false;

foreach ($types as $type) {
$result = $this->generateWindowForType($edition, $type, $limit, $includeFailed);
$totalOk += $result['ok'];
$totalFailed += $result['failed'];
if ($result['processed'] > 0) {
$any = true;
}
}

$this->newLine();
$this->info("Generate window(s) complete. Total success: {$totalOk}, Total failed: {$totalFailed}.");
if ($any) {
$this->line('Run the same command again to process the next batch.');
}
return self::SUCCESS;
}

/** @return array{processed: int, ok: int, failed: int} */
private function generateWindowForType(int $edition, string $type, int $limit, bool $includeFailed): array
{
$query = Excellence::query()
->where('edition', $edition)
->where('type', $type)
Expand All @@ -48,12 +67,13 @@ public function handle(): int

$rows = $query->get();
if ($rows->isEmpty()) {
$this->info('No pending recipients found for this window.');
return self::SUCCESS;
$this->line(" [{$type}] No pending recipients for this window.");
return ['processed' => 0, 'ok' => 0, 'failed' => 0];
}

$this->info("Generating {$rows->count()} {$type} certificates for edition {$edition}...");
$this->info("[{$type}] Generating {$rows->count()} certificates (edition {$edition})...");
$bar = $this->output->createProgressBar($rows->count());
$bar->setFormat(" %current%/%max% [%bar%] %percent:3s%%");
$bar->start();

$ok = 0;
Expand Down Expand Up @@ -97,10 +117,19 @@ public function handle(): int
}

$bar->finish();
$this->newLine(2);
$this->info("Window complete. Success: {$ok}, Failed: {$failed}.");
$this->line('Run the same command again to process the next window.');
$this->newLine();
$this->line(" [{$type}] Done. Success: {$ok}, Failed: {$failed}.");
return ['processed' => $rows->count(), 'ok' => $ok, 'failed' => $failed];
}

return self::SUCCESS;
/** @return array<string>|null */
private function resolveTypes(string $typeInput): ?array
{
return match ($typeInput) {
'all' => ['Excellence', 'SuperOrganiser'],
'excellence' => ['Excellence'],
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
default => null,
};
}
}
65 changes: 47 additions & 18 deletions app/Console/Commands/CertificateSendWindow.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,48 @@ class CertificateSendWindow extends Command
{
protected $signature = 'certificate:send-window
{--edition=2025 : Target edition year}
{--type=excellence : excellence|super-organiser}
{--limit=500 : Max recipients to send in this run}
{--type=all : excellence|super-organiser|all}
{--limit=500 : Max recipients to send per type in this run}
{--include-send-failed : Include rows that previously failed sending}';

protected $description = 'Send certificate emails in controlled windows (e.g. 500 at a time)';
protected $description = 'Send certificate emails in controlled windows (e.g. 500 at a time); use --type=all for both certs';

public function handle(): int
{
$edition = (int) $this->option('edition');
$limit = max(1, (int) $this->option('limit'));
$includeSendFailed = (bool) $this->option('include-send-failed');
$typeInput = strtolower(trim((string) $this->option('type')));
$type = match ($typeInput) {
'excellence' => 'Excellence',
'super-organiser', 'superorganiser' => 'SuperOrganiser',
default => null,
};

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

$totalQueued = 0;
$totalFailed = 0;
$any = false;

foreach ($types as $type) {
$result = $this->sendWindowForType($edition, $type, $limit, $includeSendFailed);
$totalQueued += $result['queued'];
$totalFailed += $result['failed'];
if ($result['processed'] > 0) {
$any = true;
}
}

$this->newLine();
$this->info("Send window(s) complete. Total queued: {$totalQueued}, Total failed: {$totalFailed}.");
if ($any) {
$this->line('Run the same command again to process the next batch (or ensure queue worker is running: php artisan queue:work).');
}
return self::SUCCESS;
}

/** @return array{processed: int, queued: int, failed: int} */
private function sendWindowForType(int $edition, string $type, int $limit, bool $includeSendFailed): array
{
$query = Excellence::query()
->where('edition', $edition)
->where('type', $type)
Expand All @@ -51,12 +70,13 @@ public function handle(): int

$rows = $query->get();
if ($rows->isEmpty()) {
$this->info('No recipients found for this send window.');
return self::SUCCESS;
$this->line(" [{$type}] No recipients left for this window.");
return ['processed' => 0, 'queued' => 0, 'failed' => 0];
}

$this->info("Queueing {$rows->count()} {$type} emails for edition {$edition}...");
$this->info("[{$type}] Queueing {$rows->count()} emails (edition {$edition})...");
$bar = $this->output->createProgressBar($rows->count());
$bar->setFormat(" %current%/%max% [%bar%] %percent:3s%%");
$bar->start();

$queued = 0;
Expand Down Expand Up @@ -89,10 +109,19 @@ public function handle(): int
}

$bar->finish();
$this->newLine(2);
$this->info("Send window complete. Queued: {$queued}, Failed: {$failed}.");
$this->line('Run again to process the next send window.');
$this->newLine();
$this->line(" [{$type}] Done. Queued: {$queued}, Failed: {$failed}.");
return ['processed' => $rows->count(), 'queued' => $queued, 'failed' => $failed];
}

return self::SUCCESS;
/** @return array<string>|null */
private function resolveTypes(string $typeInput): ?array
{
return match ($typeInput) {
'all' => ['Excellence', 'SuperOrganiser'],
'excellence' => ['Excellence'],
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
default => null,
};
}
}
26 changes: 17 additions & 9 deletions app/Console/Commands/CertificateStats.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class CertificateStats extends Command
{
protected $signature = 'certificate:stats
{--edition=2025 : Target edition year}
{--type=excellence : excellence|super-organiser}
{--type=all : excellence|super-organiser|all}
{--sample=10 : Number of sample user IDs to show for missing/extra}';

protected $description = 'Show diagnostic counts for certificate backend recipients and processing status';
Expand All @@ -23,12 +23,20 @@ public function handle(): int
$typeOption = (string) $this->option('type');
$sampleSize = max(0, (int) $this->option('sample'));

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

foreach ($types as $normalizedType) {
$this->outputStatsForType($edition, $normalizedType, $sampleSize);
}
return self::SUCCESS;
}

private function outputStatsForType(int $edition, string $normalizedType, int $sampleSize): void
{
$eligibleUserIds = $this->eligibleUserIds($edition, $normalizedType);
$eligibleUserIds = array_values(array_unique(array_filter(array_map('intval', $eligibleUserIds))));

Expand Down Expand Up @@ -91,16 +99,16 @@ public function handle(): int
$this->line('Sample missing user IDs: ' . $this->sampleCsv($missingUserIds, $sampleSize));
$this->line('Sample extra user IDs: ' . $this->sampleCsv($extraUserIds, $sampleSize));
}

return self::SUCCESS;
}

private function normalizeType(string $typeOption): ?string
/** @return array<string>|null */
private function resolveTypes(string $typeOption): ?array
{
$slug = strtolower(trim($typeOption));
return match ($slug) {
'excellence' => 'Excellence',
'super-organiser', 'superorganiser' => 'SuperOrganiser',
'all' => ['Excellence', 'SuperOrganiser'],
'excellence' => ['Excellence'],
'super-organiser', 'superorganiser' => ['SuperOrganiser'],
default => null,
};
}
Expand Down
Loading