diff --git a/app/Http/Controllers/API/v1/StatusController.php b/app/Http/Controllers/API/v1/StatusController.php index 7dc2246ea..4c1860cf4 100644 --- a/app/Http/Controllers/API/v1/StatusController.php +++ b/app/Http/Controllers/API/v1/StatusController.php @@ -10,13 +10,13 @@ use App\Http\Controllers\Backend\Support\LocationController; use App\Http\Controllers\Backend\User\DashboardController; use App\Http\Controllers\StatusController as StatusBackend; -use App\Http\Controllers\UserController as UserBackend; use App\Http\Resources\StatusResource; use App\Http\Resources\StopoverResource; use App\Models\Status; use App\Models\Stopover; use App\Models\Ticket; use App\Models\Trip; +use App\Repositories\CheckinRepository; use App\Services\Checkin\CheckinService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -916,16 +916,12 @@ public function getStopovers(string $parameters): JsonResponse )] public function getActiveStatus(): StatusResource|JsonResponse { - $latestStatuses = UserBackend::statusesForUser(Auth::user()); - if ($latestStatuses->count() > 0) { - foreach ($latestStatuses as $status) { - if ($status->checkin->originStopover?->departure?->isPast() - && $status->checkin->destinationStopover?->arrival?->isFuture()) { - return new StatusResource($status); - } - } + $status = app(CheckinRepository::class)->getActiveStatusForUser(Auth::user()); + + if ($status === null) { + return response()->json(null, 204); } - return response()->json(null, 204); + return new StatusResource($status); } } diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php index 4a0228574..589ef157e 100644 --- a/app/Http/Controllers/StatusController.php +++ b/app/Http/Controllers/StatusController.php @@ -17,6 +17,7 @@ use App\Models\Status; use App\Models\User; use App\Notifications\StatusLiked; +use App\Repositories\CheckinRepository; use Carbon\Carbon; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Auth\Authenticatable; @@ -72,14 +73,11 @@ public static function getStatus(int $statusId): Status private static function getActiveStatusIds(): array { - return Cache::remember(CacheKey::ACTIVE_STATUSES_RAW, 60, function () { - return Status::join('train_checkins', 'statuses.id', '=', 'train_checkins.status_id') - ->where('train_checkins.departure', '>', now()->subHours(config('trwl.max_journey_hours'))) - ->where('train_checkins.departure', '<', now()) - ->where('train_checkins.arrival', '>', now()) - ->pluck('statuses.id') - ->toArray(); - }); + return Cache::remember( + CacheKey::ACTIVE_STATUSES_RAW, + 60, + fn () => app(CheckinRepository::class)->getActiveStatusIds() + ); } public static function getActiveStatuses(): Collection @@ -120,7 +118,7 @@ public static function getActiveStatuses(): Collection return $query->get() ->reject(fn (Status $status) => $status->checkin === null) - ->sortByDesc(fn (Status $status) => $status->checkin->departure) + ->sortByDesc(fn (Status $status) => $status->checkin->display_departure->time) ->values(); } diff --git a/app/Repositories/CheckinRepository.php b/app/Repositories/CheckinRepository.php index c673305d3..c4e579540 100644 --- a/app/Repositories/CheckinRepository.php +++ b/app/Repositories/CheckinRepository.php @@ -6,12 +6,123 @@ use App\Dto\Internal\IcsExportStatus; use App\Models\Checkin; +use App\Models\Status; use App\Models\User; use Carbon\Carbon; +use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\DB; use Throwable; class CheckinRepository { + private const ORIGIN_STOPOVER_ALIAS = 'active_origin_stopover'; + + private const DESTINATION_STOPOVER_ALIAS = 'active_destination_stopover'; + + /** + * How far behind the planned arrival a checkin may still be running. + */ + private const REALTIME_ARRIVAL_BUFFER_HOURS = 6; + + public function whereCurrentlyActive(Builder $query): Builder + { + $maxJourneyHours = (int) config('trwl.max_journey_hours'); + $maxDelayHours = (int) config('trwl.max_delay_hours'); + + $departure = self::effectiveDepartureExpression(); + $arrival = self::effectiveArrivalExpression(); + + return $query + ->leftJoin( + 'train_stopovers as ' . self::ORIGIN_STOPOVER_ALIAS, + 'train_checkins.origin_stopover_id', + '=', + self::ORIGIN_STOPOVER_ALIAS . '.id' + ) + ->leftJoin( + 'train_stopovers as ' . self::DESTINATION_STOPOVER_ALIAS, + 'train_checkins.destination_stopover_id', + '=', + self::DESTINATION_STOPOVER_ALIAS . '.id' + ) + ->whereBetween('train_checkins.departure', [ + now()->subHours($maxJourneyHours + $maxDelayHours), + now()->addHours($maxDelayHours), + ]) + ->where('train_checkins.arrival', '>', now()->subHours(self::REALTIME_ARRIVAL_BUFFER_HOURS)) + ->where($departure, '<=', now()) + ->where($departure, '>', now()->subHours($maxJourneyHours)) + ->where($arrival, '>', now()); + } + + /** + * The departure Träwelling displays for a checkin: manual > realtime > planned. + * Requires the joins added by {@see self::whereCurrentlyActive()}. + */ + private static function effectiveDepartureExpression(): Expression + { + return DB::raw(sprintf( + 'COALESCE(train_checkins.manual_departure, %1$s.departure_real, %1$s.departure_planned, train_checkins.departure)', + self::ORIGIN_STOPOVER_ALIAS + )); + } + + /** + * The arrival Träwelling displays for a checkin: manual > realtime > planned. + * Requires the joins added by {@see self::whereCurrentlyActive()}. + */ + private static function effectiveArrivalExpression(): Expression + { + return DB::raw(sprintf( + 'COALESCE(train_checkins.manual_arrival, %1$s.arrival_real, %1$s.arrival_planned, train_checkins.arrival)', + self::DESTINATION_STOPOVER_ALIAS + )); + } + + /** + * Ids of all statuses whose checkin is running right now, regardless of visibility. + * Queried on the checkins alone so the (departure, arrival, status_id) index can carry the whole filter. + * + * @return int[] + */ + public function getActiveStatusIds(): array + { + $query = Checkin::query()->select('train_checkins.status_id'); + + return $this->whereCurrentlyActive($query)->pluck('status_id')->toArray(); + } + + /** + * The status of the journey the user is currently on, or null if they are not travelling. + * If more than one checkin is running, the one that started last is returned. + */ + public function getActiveStatusForUser(User $user): ?Status + { + $query = Status::query() + ->join('train_checkins', 'statuses.id', '=', 'train_checkins.status_id') + ->where('train_checkins.user_id', $user->id) + ->with([ + 'event', + 'likes', + 'mentions.mentioned', + 'client', + 'user', + 'createdByUser', + 'tags', + 'checkin.statusTags', + 'checkin.originStopover.station', + 'checkin.destinationStopover.station', + 'checkin.trip.stopovers.station', + 'checkin.trip.operator', + 'checkin.trip.motisSourceLicense', + ]) + ->select('statuses.*') + ->orderByDesc('train_checkins.departure'); + + return $this->whereCurrentlyActive($query)->first(); + } + /** * Returns the earliest and latest departure timestamp for a user's checkins, * or null for each if the user has no checkins. diff --git a/tests/Feature/APIv1/StatusTest.php b/tests/Feature/APIv1/StatusTest.php index 4541698f7..39c57bf7c 100644 --- a/tests/Feature/APIv1/StatusTest.php +++ b/tests/Feature/APIv1/StatusTest.php @@ -112,6 +112,73 @@ public function test_active_statuses_dont_show_statuses_from_the_future(): void $response->assertNoContent(); } + /** + * A user may correct the arrival by hand when the provider has no or wrong realtime data. + * That manual time wins over everything else, so the checkin stays active until it is reached. + * + * @see https://github.com/Traewelling/traewelling/issues/4940 + */ + public function test_active_status_uses_manual_times_before_realtime_and_planned(): void + { + $user = User::factory()->create(); + Passport::actingAs($user, ['*']); + + // according to the provider the journey ended an hour ago, the user says it ends in an hour + $checkin = Checkin::factory([ + 'user_id' => $user->id, + 'departure' => Date::now()->subHours(2), + 'arrival' => Date::now()->subHour(), + 'manual_arrival' => Date::now()->addHour(), + ])->create(); + + $response = $this->get('/api/v1/user/statuses/active'); + $response->assertOk(); + $this->assertEquals($checkin->status_id, $response->json('data.id')); + + $response = $this->get('/api/v1/statuses'); + $response->assertOk(); + $this->assertContains($checkin->status_id, $response->json('data.*.id')); + } + + public function test_active_status_uses_realtime_before_planned_arrival(): void + { + $user = User::factory()->create(); + Passport::actingAs($user, ['*']); + + // the trip is delayed: planned arrival has passed, but the train is still on its way + $delayedCheckin = Checkin::factory([ + 'user_id' => $user->id, + 'departure' => Date::now()->subHours(2), + 'arrival' => Date::now()->subMinutes(10), + ])->create(); + $delayedCheckin->destinationStopover->update(['arrival_real' => Date::now()->addMinutes(20)]); + + $response = $this->get('/api/v1/user/statuses/active'); + $response->assertOk(); + $this->assertEquals($delayedCheckin->status_id, $response->json('data.id')); + + // the trip was early: it has arrived although the planned arrival is still ahead + $delayedCheckin->destinationStopover->update(['arrival_real' => Date::now()->subMinutes(10)]); + $delayedCheckin->update(['arrival' => Date::now()->addMinutes(20)]); + + $this->get('/api/v1/user/statuses/active')->assertNoContent(); + } + + public function test_active_status_ignores_checkins_whose_manual_departure_is_still_ahead(): void + { + $user = User::factory()->create(); + Passport::actingAs($user, ['*']); + + Checkin::factory([ + 'user_id' => $user->id, + 'departure' => Date::now()->subHours(2), + 'arrival' => Date::now()->addHours(2), + 'manual_departure' => Date::now()->addHour(), + ])->create(); + + $this->get('/api/v1/user/statuses/active')->assertNoContent(); + } + public function test_status_update(): void { $user = User::factory()->create();