Skip to content
Merged
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
16 changes: 6 additions & 10 deletions app/Http/Controllers/API/v1/StatusController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
16 changes: 7 additions & 9 deletions app/Http/Controllers/StatusController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down
111 changes: 111 additions & 0 deletions app/Repositories/CheckinRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions tests/Feature/APIv1/StatusTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading