diff --git a/app/Console/Commands/DisableExpiredRegistrationPeriods.php b/app/Console/Commands/DisableExpiredRegistrationPeriods.php new file mode 100644 index 000000000..47ea9ab19 --- /dev/null +++ b/app/Console/Commands/DisableExpiredRegistrationPeriods.php @@ -0,0 +1,60 @@ +registrationStatusService->disableExpiredPeriods(); + + if ($expiredPeriods->isEmpty()) { + $this->info('No expired registration periods found.'); + + return Command::SUCCESS; + } + + foreach ($expiredPeriods as $period) { + $this->line(sprintf( + 'Marked registration period as done: %s (ended: %s)', + $period->name, + $period->ends_at->format('Y-m-d H:i') + )); + } + + $this->info(sprintf( + 'Successfully completed %d expired registration period(s).', + $expiredPeriods->count() + )); + + return Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/AdminRegistrationController.php b/app/Http/Controllers/Admin/AdminRegistrationController.php index 5452a6d56..a8b231481 100644 --- a/app/Http/Controllers/Admin/AdminRegistrationController.php +++ b/app/Http/Controllers/Admin/AdminRegistrationController.php @@ -29,7 +29,10 @@ class AdminRegistrationController extends BasePageController public function index(Request $request): View|RedirectResponse { + $now = now(); + $this->setAdminPrefs(); + $this->registrationStatusService->disableExpiredPeriods($now); $editingPeriod = null; if ($request->filled('edit_period')) { @@ -44,11 +47,21 @@ class AdminRegistrationController extends BasePageController } } - $status = $this->registrationStatusService->resolve(); + $status = $this->registrationStatusService->resolve($now); $periods = RegistrationPeriod::query() ->with(['createdByUser', 'updatedByUser']) - ->orderByDesc('starts_at') ->get(); + [$pastPeriods, $currentPeriods] = $periods->partition( + fn (RegistrationPeriod $period): bool => $period->hasEndedAt($now) + ); + + $currentPeriods = $currentPeriods + ->sortBy(fn (RegistrationPeriod $period): int => $period->starts_at?->getTimestamp() ?? PHP_INT_MAX) + ->values(); + $pastPeriods = $pastPeriods + ->sortByDesc(fn (RegistrationPeriod $period): int => $period->ends_at?->getTimestamp() ?? 0) + ->values(); + $history = RegistrationStatusHistory::query() ->with(['changedByUser', 'registrationPeriod']) ->orderByDesc('created_at') @@ -87,7 +100,8 @@ class AdminRegistrationController extends BasePageController 'meta_description' => 'Manage registration status, scheduled open periods, and registration activity.', 'registrationStatus' => $status, 'statusOptions' => $this->registrationStatusService->statusOptions(), - 'periods' => $periods, + 'currentPeriods' => $currentPeriods, + 'pastPeriods' => $pastPeriods, 'editingPeriod' => $editingPeriod, 'history' => $history, 'recentSuccessfulRegistrations' => $recentSuccessfulRegistrations, @@ -158,6 +172,14 @@ class AdminRegistrationController extends BasePageController /** @var User|null $admin */ $admin = Auth::user(); + if ($period->hasEndedAt()) { + $this->registrationStatusService->disableExpiredPeriods(); + + return redirect() + ->route('admin.registrations.index') + ->with('success', 'That scheduled open-registration period has already passed and is now marked as done.'); + } + $this->registrationStatusService->togglePeriod( $period, $admin, diff --git a/app/Models/RegistrationPeriod.php b/app/Models/RegistrationPeriod.php index 655967c9b..b9318f80c 100644 --- a/app/Models/RegistrationPeriod.php +++ b/app/Models/RegistrationPeriod.php @@ -83,4 +83,11 @@ class RegistrationPeriod extends Model && $this->starts_at->lte($at) && $this->ends_at->gte($at); } + + public function hasEndedAt(?CarbonInterface $at = null): bool + { + $at ??= now(); + + return $this->ends_at !== null && $this->ends_at->lt($at); + } } diff --git a/app/Models/RegistrationStatusHistory.php b/app/Models/RegistrationStatusHistory.php index 4db95d147..c29c532fc 100644 --- a/app/Models/RegistrationStatusHistory.php +++ b/app/Models/RegistrationStatusHistory.php @@ -17,6 +17,8 @@ class RegistrationStatusHistory extends Model public const ACTION_PERIOD_TOGGLED = 'period_toggled'; + public const ACTION_PERIOD_COMPLETED = 'period_completed'; + public const ACTION_PERIOD_DELETED = 'period_deleted'; protected $table = 'registration_status_history'; diff --git a/app/Services/RegistrationStatusService.php b/app/Services/RegistrationStatusService.php index f6484408d..554114d21 100644 --- a/app/Services/RegistrationStatusService.php +++ b/app/Services/RegistrationStatusService.php @@ -9,6 +9,7 @@ use App\Models\RegistrationStatusHistory; use App\Models\Settings; use App\Models\User; use Carbon\CarbonInterface; +use Illuminate\Support\Collection; class RegistrationStatusService { @@ -220,6 +221,41 @@ class RegistrationStatusService return $period; } + /** + * @return Collection + */ + public function disableExpiredPeriods(?CarbonInterface $at = null): Collection + { + $at ??= now(); + + $expiredPeriods = RegistrationPeriod::query() + ->where('is_enabled', true) + ->where('ends_at', '<', $at) + ->get(); + + foreach ($expiredPeriods as $period) { + $period->forceFill([ + 'is_enabled' => false, + 'updated_by' => null, + ])->save(); + + RegistrationStatusHistory::record( + RegistrationStatusHistory::ACTION_PERIOD_COMPLETED, + sprintf('Scheduled open period "%s" completed after reaching its end time.', $period->name), + null, + null, + null, + $period->id, + [ + 'completed_at' => $at->toDateTimeString(), + 'period' => $this->serializePeriod($period), + ] + ); + } + + return $expiredPeriods; + } + public function deletePeriod(RegistrationPeriod $period, ?User $changedBy = null, ?string $note = null): void { $snapshot = $this->serializePeriod($period); diff --git a/resources/views/admin/registrations/index.blade.php b/resources/views/admin/registrations/index.blade.php index 37c121322..ebbd6433b 100644 --- a/resources/views/admin/registrations/index.blade.php +++ b/resources/views/admin/registrations/index.blade.php @@ -16,6 +16,7 @@ ? 'border border-emerald-500/30 bg-emerald-600 text-white shadow-sm dark:border-emerald-300/20 dark:bg-emerald-500 dark:text-slate-950' : 'border border-slate-400/30 bg-slate-600 text-white shadow-sm dark:border-slate-300/20 dark:bg-slate-500 dark:text-slate-950'; }; + $periodDoneClasses = 'border border-cyan-500/30 bg-cyan-600 text-white shadow-sm dark:border-cyan-300/20 dark:bg-cyan-500 dark:text-slate-950'; $primaryButtonClasses = 'inline-flex items-center rounded-lg border border-blue-700 bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-blue-300/20 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:focus:ring-offset-gray-900'; $secondaryButtonClasses = 'inline-flex items-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600 dark:focus:ring-offset-gray-900'; $editButtonClasses = 'inline-flex items-center rounded-md border border-sky-700 bg-sky-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 dark:border-sky-300/20 dark:bg-sky-500 dark:text-white dark:hover:bg-sky-400 dark:focus:ring-offset-gray-900'; @@ -193,88 +194,183 @@ - -
-

Scheduled Open Periods

-

Manage temporary windows when registrations should be publicly open.

-
- - @if($periods->isEmpty()) -
- -

No scheduled open-registration periods have been created yet.

+
+ +
+

Current & Upcoming Open Periods

+

Active and upcoming registration windows stay here until their end time.

- @else -
- - - - - - - - - - - - - @foreach($periods as $period) - - - - - - - + @if($currentPeriods->isEmpty()) +
+ +

No current or upcoming open-registration periods are scheduled.

+
+ @else +
+
NameStatusWindowNotesUpdated ByActions
-

{{ $period->name }}

- @if($registrationStatus['active_period'] && $registrationStatus['active_period']->id === $period->id) - - Active Right Now - - @endif -
- - {{ $period->is_enabled ? 'Enabled' : 'Disabled' }} - - -

{{ $period->starts_at->format('Y-m-d H:i') }}

-

to {{ $period->ends_at->format('Y-m-d H:i') }}

-
- {{ $period->notes ? Str::limit($period->notes, 100) : '—' }} - - {{ $period->updatedByUser?->username ?? $period->createdByUser?->username ?? 'System' }} - -
- - Edit - -
- @csrf - -
- -
- @csrf - @method('DELETE') - -
-
-
+ + + + + + + + - @endforeach - -
NameStatusWindowNotesUpdated ByActions
+ + + @foreach($currentPeriods as $period) + + +

{{ $period->name }}

+ @if($registrationStatus['active_period'] && $registrationStatus['active_period']->id === $period->id) + + Active Right Now + + @endif + + + + {{ $period->is_enabled ? 'Enabled' : 'Disabled' }} + + + +

{{ $period->starts_at->format('Y-m-d H:i') }}

+

to {{ $period->ends_at->format('Y-m-d H:i') }}

+ + + {{ $period->notes ? Str::limit($period->notes, 100) : '—' }} + + + {{ $period->updatedByUser?->username ?? $period->createdByUser?->username ?? 'System' }} + + +
+ + Edit + + +
name}'? If it is currently active, registrations will fall back to the manual baseline." + : "Enable the scheduled open period '{$period->name}'? If the current time is within its window, registrations may open immediately." }}" + data-title="{{ $period->is_enabled ? 'Disable Scheduled Period' : 'Enable Scheduled Period' }}" + data-type="{{ $period->is_enabled ? 'warning' : 'success' }}" + data-confirm-text="{{ $period->is_enabled ? 'Disable' : 'Enable' }}" + @submit.prevent="submit()"> + @csrf + +
+ +
+ @csrf + @method('DELETE') + +
+
+ + + @endforeach + + +
+ @endif +
+ + +
+

Past Open Periods

+

Completed registration windows move here automatically and are marked as done.

- @endif -
+ + @if($pastPeriods->isEmpty()) +
+ +

No past open-registration periods have been recorded yet.

+
+ @else +
+ + + + + + + + + + + + + @foreach($pastPeriods as $period) + + + + + + + + + @endforeach + +
NameStatusWindowNotesUpdated ByActions
+

{{ $period->name }}

+
+ + Done + + +

{{ $period->starts_at->format('Y-m-d H:i') }}

+

to {{ $period->ends_at->format('Y-m-d H:i') }}

+
+ {{ $period->notes ? Str::limit($period->notes, 100) : '—' }} + + {{ $period->updatedByUser?->username ?? $period->createdByUser?->username ?? 'System' }} + +
+ + Edit + + +
+ @csrf + @method('DELETE') + +
+
+
+
+ @endif + +
diff --git a/routes/console.php b/routes/console.php index a578a799d..5bf3cfb94 100644 --- a/routes/console.php +++ b/routes/console.php @@ -29,6 +29,8 @@ Schedule::command('nntmux:delete-unverified-users')->twiceDaily(1, 13); Schedule::command('nntmux:update-expired-roles')->daily(); // Automatically disable promotions that have passed their end_date Schedule::command('nntmux:disable-expired-promotions')->daily(); +// Automatically mark completed registration periods as done shortly after they end +Schedule::command('nntmux:disable-expired-registration-periods')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('nntmux:remove-bad')->hourly()->withoutOverlapping(); Schedule::command('cloudflare:reload')->daily(); Schedule::command('cache:prune-stale-tags')->hourly(); diff --git a/tests/Feature/AdminRegistrationControllerTest.php b/tests/Feature/AdminRegistrationControllerTest.php index c54b84ab6..c33e78486 100644 --- a/tests/Feature/AdminRegistrationControllerTest.php +++ b/tests/Feature/AdminRegistrationControllerTest.php @@ -233,6 +233,84 @@ class AdminRegistrationControllerTest extends TestCase $this->assertContains(RegistrationStatusHistory::ACTION_PERIOD_DELETED, $actions); } + public function test_expired_registration_periods_are_disabled_and_shown_as_done(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + DB::table('registration_periods')->insert([ + 'name' => 'Current Window', + 'starts_at' => now()->subMinutes(30), + 'ends_at' => now()->addHour(), + 'is_enabled' => true, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('registration_periods')->insert([ + 'name' => 'Upcoming Disabled Window', + 'starts_at' => now()->addHours(2), + 'ends_at' => now()->addHours(4), + 'is_enabled' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('registration_periods')->insert([ + 'name' => 'Completed Window', + 'starts_at' => now()->subHours(3), + 'ends_at' => now()->subMinutes(10), + 'is_enabled' => true, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->artisan('nntmux:disable-expired-registration-periods') + ->expectsOutputToContain('Successfully completed 1 expired registration period(s).') + ->assertExitCode(0); + + $completedPeriod = DB::table('registration_periods')->where('name', 'Completed Window')->first(); + $this->assertNotNull($completedPeriod); + $this->assertSame(0, (int) $completedPeriod->is_enabled); + + $currentPeriod = DB::table('registration_periods')->where('name', 'Current Window')->first(); + $this->assertNotNull($currentPeriod); + $this->assertSame(1, (int) $currentPeriod->is_enabled); + + $history = DB::table('registration_status_history') + ->where('action', RegistrationStatusHistory::ACTION_PERIOD_COMPLETED) + ->latest('id') + ->first(); + + $this->assertNotNull($history); + $this->assertStringContainsString('completed after reaching its end time', $history->description); + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.registrations.index')); + + $response->assertOk(); + $response->assertSeeInOrder([ + 'Current & Upcoming Open Periods', + 'Current Window', + 'Upcoming Disabled Window', + 'Past Open Periods', + 'Completed Window', + ]); + $response->assertSee('Active Right Now'); + $response->assertSee('Done'); + $response->assertSee('x-data="confirmForm"', false); + $response->assertSee('data-title="Disable Scheduled Period"', false); + $response->assertSee('data-title="Enable Scheduled Period"', false); + $response->assertSee('data-title="Delete Scheduled Period"', false); + $response->assertSee('data-title="Delete Past Period"', false); + } + private function createSchema(): void { Schema::create('settings', function (Blueprint $table): void {