diff --git a/config/routes.php b/config/routes.php
index 16bcd971..8e500162 100644
--- a/config/routes.php
+++ b/config/routes.php
@@ -71,6 +71,11 @@ $route->addGroup('/angeltypes', function (RouteCollector $route): void {
$route->get('/about', 'AngelTypesController@about');
});
+// Shifts
+$route->addGroup('/shifts', function (RouteCollector $route): void {
+ $route->get('/random', 'ShiftsController@random');
+});
+
// News
$route->get('/meetings', 'NewsController@meetings');
$route->addGroup(
diff --git a/includes/pages/user_shifts.php b/includes/pages/user_shifts.php
index 2b38d9ca..06b18f2e 100644
--- a/includes/pages/user_shifts.php
+++ b/includes/pages/user_shifts.php
@@ -281,7 +281,9 @@ function view_user_shifts()
$end_day = $shiftsFilter->getEnd()->format('Y-m-d');
$end_time = $shiftsFilter->getEnd()->format('H:i');
+ $canSignUpForShifts = true;
if (config('signup_requires_arrival') && !$user->state->arrived) {
+ $canSignUpForShifts = false;
info(render_user_arrived_hint((bool) $user->state->user_info));
}
@@ -344,7 +346,11 @@ function view_user_shifts()
'set_last_4h' => __('last 4h'),
'set_next_4h' => __('next 4h'),
'set_next_8h' => __('next 8h'),
- 'buttons' => button(
+ 'random' => auth()->can('user_shifts') && $canSignUpForShifts ? button(
+ url('/shifts/random'),
+ icon('dice-4-fill') . __('shifts.random')
+ ) : '',
+ 'dashboard' => button(
public_dashboard_link(),
icon('speedometer2') . __('Public Dashboard')
),
diff --git a/resources/lang/de_DE/additional.po b/resources/lang/de_DE/additional.po
index 1975b54c..5e68b2b1 100644
--- a/resources/lang/de_DE/additional.po
+++ b/resources/lang/de_DE/additional.po
@@ -254,6 +254,9 @@ msgstr ""
"Da die gelöschte Schicht bereits vergangen ist, "
"haben wir einen entsprechenden Arbeitseinsatz hinzugefügt."
+msgid "notification.shift.no_next_found"
+msgstr "Es wurde keine verfügbare Schicht gefunden."
+
msgid "user.edit.success"
msgstr "Benutzer erfolgreich bearbeitet."
diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po
index 104844cb..3677eac9 100644
--- a/resources/lang/de_DE/default.po
+++ b/resources/lang/de_DE/default.po
@@ -222,6 +222,9 @@ msgstr "Das Aufbau Start Datum muss vor dem Abbau Ende Datum liegen."
msgid "Public Dashboard"
msgstr "Öffentliches Dashboard"
+msgid "shifts.random"
+msgstr "Zufällige Schicht"
+
msgid "%s has been subscribed to the shift."
msgstr "%s wurde in die Schicht eingetragen."
diff --git a/resources/lang/en_US/additional.po b/resources/lang/en_US/additional.po
index 3e7d1dc1..7be350ba 100644
--- a/resources/lang/en_US/additional.po
+++ b/resources/lang/en_US/additional.po
@@ -253,6 +253,9 @@ msgstr ""
"Since the deleted shift was already done, "
"we added a worklog entry instead, to keep your work hours correct."
+msgid "notification.shift.no_next_found"
+msgstr "There is no available shift."
+
msgid "user.edit.success"
msgstr "User edited successfully."
diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po
index cec17e52..167da913 100644
--- a/resources/lang/en_US/default.po
+++ b/resources/lang/en_US/default.po
@@ -749,6 +749,9 @@ msgstr "Count"
msgid "general.created_at"
msgstr "Created at"
+msgid "shifts.random"
+msgstr "Zufällige Schicht"
+
msgid "shifts.history"
msgstr "Shifts history"
diff --git a/resources/views/pages/user-shifts.html b/resources/views/pages/user-shifts.html
index 91504522..00cb53f3 100644
--- a/resources/views/pages/user-shifts.html
+++ b/resources/views/pages/user-shifts.html
@@ -47,7 +47,10 @@
- %buttons%
+ %random%
+
+
+ %dashboard%
diff --git a/src/Controllers/ShiftsController.php b/src/Controllers/ShiftsController.php
new file mode 100644
index 00000000..7f25a07e
--- /dev/null
+++ b/src/Controllers/ShiftsController.php
@@ -0,0 +1,119 @@
+auth->user();
+ $nextFreeShifts = $this->getNextFreeShifts($user);
+
+ if ($nextFreeShifts->isEmpty()) {
+ $this->addNotification('notification.shift.no_next_found', NotificationType::WARNING);
+ return $this->redirect->to($this->url->to('/shifts'));
+ }
+
+ /** @var Shift $randomShift */
+ $randomShift = $nextFreeShifts
+ ->collect()
+ // Prefer soon starting shifts
+ ->groupBy('start')
+ ->first()
+ // Select one at random
+ ->random();
+
+ return $this->redirect->to($this->url->to('/shifts', ['action' => 'view', 'shift_id' => $randomShift->id]));
+ }
+
+ protected function getNextFreeShifts(User $user): Collection | DbCollection
+ {
+ $angelTypes = $user
+ ->userAngelTypes()
+ ->select('angel_types.id')
+ ->whereNested(function (Builder $query): void {
+ $query
+ ->where('angel_types.restricted', false)
+ ->orWhereNot('confirm_user_id', false);
+ });
+ $shifts = $user->shiftEntries()->select('shift_id');
+
+ $freeShifts = Shift::query()
+ ->select('shifts.*')
+ // Load needed from shift if no schedule configured, else from room
+ ->leftJoin('schedule_shift', 'schedule_shift.shift_id', '=', 'shifts.id')
+ ->leftJoin('needed_angel_types', function (JoinClause $query): void {
+ $query->on('needed_angel_types.shift_id', '=', 'shifts.id')
+ ->whereNull('schedule_shift.shift_id');
+ })
+ ->leftJoin('needed_angel_types AS nas', function (JoinClause $query): void {
+ $query->on('nas.location_id', '=', 'shifts.location_id')
+ ->whereNotNull('schedule_shift.shift_id');
+ })
+ // Not already signed in
+ ->whereNotIn('shifts.id', $shifts)
+ // Same angel types
+ ->where(function (EloquentBuilder $query) use ($angelTypes): void {
+ $query
+ ->whereIn('needed_angel_types.angel_type_id', $angelTypes)
+ ->orWhereIn('nas.angel_type_id', $angelTypes);
+ })
+ // Starts soon
+ ->where('shifts.start', '>', Carbon::now())
+ // Where help needed
+ ->where(function (Builder $query): void {
+ $query
+ ->from('shift_entries')
+ ->selectRaw('COUNT(*)')
+ ->where(fn(Builder $query) => $this->queryShiftEntries($query));
+ }, '<', Shift::query()->raw('COALESCE(needed_angel_types.count, nas.count)'))
+ ->limit(10)
+ ->orderBy('start');
+
+ return $freeShifts->get();
+ }
+
+ protected function queryShiftEntries(Builder $query): void
+ {
+ $query->select('id')
+ ->from('shift_entries')
+ ->where(function (Builder $query): void {
+ $query->where('shift_entries.shift_id', $query->raw('needed_angel_types.shift_id'))
+ ->where('shift_entries.angel_type_id', $query->raw('needed_angel_types.angel_type_id'));
+ })
+ ->orWhere(function (Builder $query): void {
+ $query->where('shift_entries.shift_id', $query->raw('nas.shift_id'))
+ ->where('shift_entries.angel_type_id', $query->raw('nas.angel_type_id'));
+ })
+ ->groupBy(['shift_entries.shift_id', 'shift_entries.angel_type_id']);
+ }
+}
diff --git a/tests/Unit/Controllers/ShiftsControllerTest.php b/tests/Unit/Controllers/ShiftsControllerTest.php
new file mode 100644
index 00000000..3f5baf40
--- /dev/null
+++ b/tests/Unit/Controllers/ShiftsControllerTest.php
@@ -0,0 +1,122 @@
+createModels();
+
+ $this->setExpects($this->redirect, 'to', ['http://localhost/shifts'], $this->response);
+ $this->setExpects($this->auth, 'user', null, $this->user);
+
+ $controller = new ShiftsController($this->auth, $this->redirect, $this->url);
+
+ $return = $controller->random();
+ $this->assertEquals($this->response, $return);
+ $this->assertHasNotification('notification.shift.no_next_found', NotificationType::WARNING);
+ }
+
+ /**
+ * @covers \Engelsystem\Controllers\ShiftsController::random
+ * @covers \Engelsystem\Controllers\ShiftsController::getNextFreeShifts
+ * @covers \Engelsystem\Controllers\ShiftsController::queryShiftEntries
+ */
+ public function testRandom(): void
+ {
+ $this->createModels();
+ $this->setExpects($this->auth, 'user', null, $this->user, $this->atLeastOnce());
+ $start = Carbon::now()->addHour();
+
+ $otherUser = User::factory()->create();
+ [$userAngelType, $otherAngelType] = AngelType::factory(2)->create();
+ [$possibleShift1, $possibleShift2, $otherAngelTypeShift, $alreadySubscribedShift] = Shift::factory(4)
+ ->create(['start' => $start]);
+ $this->user->userAngelTypes()->attach($userAngelType, ['confirm_user_id' => $this->user->id]);
+ NeededAngelType::factory()->create([
+ 'shift_id' => $possibleShift1->id,
+ 'angel_type_id' => $userAngelType->id,
+ 'count' => 2,
+ ]);
+ NeededAngelType::factory()->create([
+ 'shift_id' => $possibleShift2->id,
+ 'angel_type_id' => $userAngelType->id,
+ 'count' => 1,
+ ]);
+ NeededAngelType::factory()->create([
+ 'shift_id' => $otherAngelTypeShift->id,
+ 'angel_type_id' => $otherAngelType->id,
+ 'count' => 3,
+ ]);
+ ShiftEntry::factory()->create([
+ 'shift_id' => $alreadySubscribedShift->id,
+ 'angel_type_id' => $userAngelType->id,
+ 'user_id' => $this->user->id,
+ ]);
+
+ $otherUser->userAngelTypes()->attach($userAngelType, ['confirm_user_id' => $otherUser->id]);
+ ShiftEntry::factory()->create([
+ 'shift_id' => $possibleShift1->id,
+ 'angel_type_id' => $userAngelType->id,
+ 'user_id' => $otherUser,
+ ]);
+
+ $this->redirect->expects($this->exactly(10))
+ ->method('to')
+ ->willReturnCallback(function (string $url) use ($possibleShift1, $possibleShift2) {
+ parse_str(parse_url($url)['query'], $parameters);
+ $this->assertTrue(Str::startsWith($url, 'http://localhost/shifts'));
+ $this->assertArrayHasKey('shift_id', $parameters);
+ $shiftId = $parameters['shift_id'];
+ $this->assertTrue(in_array($shiftId, [$possibleShift1->id, $possibleShift2->id]));
+ return $this->response;
+ });
+
+ $controller = new ShiftsController($this->auth, $this->redirect, $this->url);
+
+ $return = $controller->random();
+ $this->assertEquals($this->response, $return);
+
+ // Try multiple times
+ for ($i = 1; $i < 10; $i++) {
+ $controller->random();
+ }
+ }
+
+ protected function createModels(): void
+ {
+ $this->user = User::factory()->create();
+
+ $this->auth = $this->createMock(Authenticator::class);
+
+ $this->redirect = $this->createMock(Redirector::class);
+
+ $this->url = $this->app->make(UrlGeneratorInterface::class);
+ }
+}