Skip to content

Commit 426926a

Browse files
authored
⚡ improve performance on status endpoint; add from/to filter (#4805)
1 parent 00a79ba commit 426926a

7 files changed

Lines changed: 131 additions & 28 deletions

File tree

API_CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ Check back here regularly to stay ahead of removals.
4242

4343
---
4444

45+
# 2026-05-18
46+
47+
**`GET /api/v1/status`: new `from`/`to` parameters and performance fix:**
48+
The endpoint now accepts optional `from` and `to` date parameters (ISO 8601 date format, e.g. `2024-01-01`) to control the departure time window.
49+
Without parameters, the window defaults to the last 7 days up to now+20 minutes.
50+
The window between `from` and `to` must not exceed 365 days.
51+
Exception: when `user_id` is set to the authenticated user's own ID, no time limit applies and `from`/`to` are fully optional.
52+
The previously implicit hard upper bound of `now()+20min` is now the default value of `to` and can be overridden.
53+
Results are ordered by departure descending (previously: status creation date descending).
54+
55+
(Background: This query was very painful on the production database and causes some headache...)
56+
57+
---
58+
4559
# 2026-05-14
4660

4761
**New endpoint `GET /api/v1/tags/suggestions`:**

app/Http/Controllers/API/v1/StatusController.php

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ public function getLivePositionForStatus($ids): AnonymousResourceCollection
366366
#[OA\Get(
367367
path: '/status',
368368
operationId: 'listStatuses',
369-
description: 'Returns paginated list of statuses, filtered by given parameters',
369+
description: 'Returns cursor-paginated statuses filtered by given parameters. The departure window (from..to) defaults to the last 7 days and must not exceed 365 days.',
370370
summary: '[Auth optional] List and filter statuses',
371371
tags: ['Status'],
372372
parameters: [
@@ -384,6 +384,22 @@ public function getLivePositionForStatus($ids): AnonymousResourceCollection
384384
schema: new OA\Schema(type: 'integer'),
385385
example: 42,
386386
),
387+
new OA\Parameter(
388+
name: 'from',
389+
description: 'Lower bound for departure (date, e.g. 2024-01-01). Defaults to 7 days before "to".',
390+
in: 'query',
391+
required: false,
392+
schema: new OA\Schema(type: 'string', format: 'date'),
393+
example: '2024-01-01',
394+
),
395+
new OA\Parameter(
396+
name: 'to',
397+
description: 'Upper bound for departure (date, e.g. 2024-01-31). Defaults to now+20min. Range from..to must not exceed 365 days.',
398+
in: 'query',
399+
required: false,
400+
schema: new OA\Schema(type: 'string', format: 'date'),
401+
example: '2024-01-31',
402+
),
387403
new OA\Parameter(
388404
name: 'origin_text',
389405
description: 'Filter by origin station name',
@@ -432,47 +448,80 @@ public function getLivePositionForStatus($ids): AnonymousResourceCollection
432448
public function list(Request $request): AnonymousResourceCollection
433449
{
434450
$validated = $request->validate([
435-
// generic filters
436451
'body' => ['nullable', 'string', 'max:32'],
437452
'user_id' => ['nullable', 'integer', 'exists:users,id'],
438-
439-
// Filters for origin/destination
453+
'from' => ['nullable', 'date'],
454+
'to' => ['nullable', 'date'],
440455
'origin_text' => ['nullable', 'string', 'max:64'],
441456
'origin_id' => ['nullable', 'integer', 'exists:train_stations,id'],
442457
'destination_text' => ['nullable', 'string', 'max:64'],
443458
'destination_id' => ['nullable', 'integer', 'exists:train_stations,id'],
444459
]);
445460

446461
$user = auth()->user();
447-
$query = Status::query()->orderByDesc('created_at');
462+
$isOwnSearch = isset($validated['user_id']) && (int) $validated['user_id'] === $user->id;
463+
464+
if ($isOwnSearch) {
465+
// own search ignores day limit (=> less checkins to search)
466+
$to = isset($validated['to']) ? Carbon::parse($validated['to'])->endOfDay() : null;
467+
$from = isset($validated['from']) ? Carbon::parse($validated['from'])->startOfDay() : null;
468+
469+
if ($from !== null && $to !== null && $from->isAfter($to)) {
470+
throw ValidationException::withMessages(['from' => [__('errors.date-range-order')]]);
471+
}
472+
} else {
473+
$to = isset($validated['to']) ? Carbon::parse($validated['to'])->endOfDay() : now()->addMinutes(20);
474+
$from = isset($validated['from']) ? Carbon::parse($validated['from'])->startOfDay() : $to->copy()->subDays(7);
475+
476+
if ($from->isAfter($to)) {
477+
throw ValidationException::withMessages(['from' => [__('errors.date-range-order')]]);
478+
}
479+
if ($from->diffInDays($to) > 365) {
480+
throw ValidationException::withMessages(['from' => [__('errors.date-range-max')]]);
481+
}
482+
}
483+
484+
$query = Status::query()->orderByDesc('train_checkins.departure');
448485

449486
if (isset($validated['body'])) {
450487
$query->where('body', 'like', '%' . $validated['body'] . '%');
451488
}
452489

453-
$query->join('train_checkins', 'train_checkins.status_id', '=', 'statuses.id')
490+
$hasOriginFilter = isset($validated['origin_text']) || isset($validated['origin_id']);
491+
$hasDestinationFilter = isset($validated['destination_text']) || isset($validated['destination_id']);
492+
493+
$checkinJoin = in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)
494+
? DB::raw('`train_checkins` FORCE INDEX (idx_tc_departure_status)')
495+
: 'train_checkins'; // force best index in mysql and mariadb, but fall back here for sqlite / tests
496+
497+
$query->join($checkinJoin, 'train_checkins.status_id', '=', 'statuses.id')
454498
->join('users', 'statuses.user_id', '=', 'users.id')
455-
->join('train_stopovers as origin_stopover', 'train_checkins.origin_stopover_id', '=', 'origin_stopover.id')
456-
->join('train_stations as origin_station', 'origin_stopover.train_station_id', '=', 'origin_station.id')
457-
->join('train_stopovers as destination_stopover', 'train_checkins.destination_stopover_id', '=', 'destination_stopover.id')
458-
->join('train_stations as destination_station', 'destination_stopover.train_station_id', '=', 'destination_station.id')
459-
->when(isset($validated['origin_text']), function ($q) use ($validated) {
460-
$q->where('origin_station.name', 'like', '%' . $validated['origin_text'] . '%');
461-
})
462-
->when(isset($validated['origin_id']), function ($q) use ($validated) {
463-
$q->where('origin_station.id', $validated['origin_id']);
464-
})
465-
->when(isset($validated['destination_text']), function ($q) use ($validated) {
466-
$q->where('destination_station.name', 'like', '%' . $validated['destination_text'] . '%');
467-
})
468-
->when(isset($validated['destination_id']), function ($q) use ($validated) {
469-
$q->where('destination_station.id', $validated['destination_id']);
470-
})
471-
->when(isset($validated['user_id']), function ($q) use ($validated) {
472-
$q->where('users.id', $validated['user_id']);
473-
})
499+
->when($hasOriginFilter, fn ($q) => $q
500+
->join('train_stopovers as origin_stopover', 'train_checkins.origin_stopover_id', '=', 'origin_stopover.id')
501+
->join('train_stations as origin_station', 'origin_stopover.train_station_id', '=', 'origin_station.id')
502+
)
503+
->when($hasDestinationFilter, fn ($q) => $q
504+
->join('train_stopovers as destination_stopover', 'train_checkins.destination_stopover_id', '=', 'destination_stopover.id')
505+
->join('train_stations as destination_station', 'destination_stopover.train_station_id', '=', 'destination_station.id')
506+
)
507+
->when(isset($validated['origin_text']), fn ($q) => $q
508+
->where('origin_station.name', 'like', '%' . $validated['origin_text'] . '%')
509+
)
510+
->when(isset($validated['origin_id']), fn ($q) => $q
511+
->where('origin_station.id', $validated['origin_id'])
512+
)
513+
->when(isset($validated['destination_text']), fn ($q) => $q
514+
->where('destination_station.name', 'like', '%' . $validated['destination_text'] . '%')
515+
)
516+
->when(isset($validated['destination_id']), fn ($q) => $q
517+
->where('destination_station.id', $validated['destination_id'])
518+
)
519+
->when(isset($validated['user_id']), fn ($q) => $q
520+
->where('users.id', $validated['user_id'])
521+
)
474522
->where(\App\Http\Controllers\Backend\Transport\StatusController::filterStatusVisibility($user))
475-
->where('train_checkins.departure', '<', now()->addMinutes(20))
523+
->when($from !== null, fn ($q) => $q->where('train_checkins.departure', '>=', $from))
524+
->when($to !== null, fn ($q) => $q->where('train_checkins.departure', '<=', $to))
476525
->whereNotIn('statuses.user_id', $user->mutedUsers()->select('muted_id'))
477526
->whereNotIn('statuses.user_id', $user->blockedUsers()->select('blocked_id'))
478527
->whereNotIn('statuses.user_id', $user->blockedByUsers()->select('user_id'))

lang/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@
196196
"error.bad-request": "Die Anfrage ist ungültig.",
197197
"error.login": "Falsche Anmeldedaten",
198198
"error.status.not-authorized": "Du bist nicht berechtigt, diesen Status zu sehen.",
199+
"errors.date-range-max": "Der Zeitraum zwischen \"von\" und \"bis\" darf maximal 365 Tage betragen.",
200+
"errors.date-range-order": "\"von\" muss vor \"bis\" liegen.",
199201
"errors.403.alt_text": "Ein Bahnhof im dichten Nebel. In der Ferne sieht man mehrere rote Signale",
200202
"errors.403.lead": "Mayday, mayday, mayday, du hast keine Berechtigung für diesen Inhalt.",
201203
"errors.404.alt_text": "Bild einer alten Bahnstrecke im grünen Wald. Auf den Schwellen befindet sich Moos und das Gebüsch wächst über die Gleise.",

lang/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@
196196
"error.bad-request": "The request is invalid.",
197197
"error.login": "Wrong credentials",
198198
"error.status.not-authorized": "You're not authorised to see this status.",
199+
"errors.date-range-max": "The date range between \"from\" and \"to\" must not exceed 365 days.",
200+
"errors.date-range-order": "\"from\" must be before \"to\".",
199201
"errors.403.alt_text": "A train station in thick fog. Several red signals can be seen in the distance.",
200202
"errors.403.lead": "Mayday, mayday, mayday, you don’t have permission to access this content.",
201203
"errors.404.alt_text": "Image of an old railway line in a green forest. There is moss on the sleepers and bushes are growing over the tracks.",

lang/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@
194194
"error.bad-request": "La requête est invalide.",
195195
"error.login": "Données de connexion incorrectes",
196196
"error.status.not-authorized": "Tu n'es pas autorisé·e à voir cet enregistrement.",
197+
"errors.date-range-max": "La plage de dates entre « from » et « to » ne doit pas dépasser 365 jours.",
198+
"errors.date-range-order": "« from » doit être antérieur à « to ».",
197199
"errors.403.alt_text": "Une gare dans un épais brouillard. Au loin, on aperçoit plusieurs signaux fermés.",
198200
"errors.403.lead": "Mayday, mayday, mayday, tu n'as pas l'autorisation d'accéder à ce contenu.",
199201
"errors.404.alt_text": "Image d'une ancienne voie ferrée dans une forêt verdoyante. Il y a de la mousse sur les traverses et des buissons poussent sur les rails.",

resources/types/Api.gen.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4595,7 +4595,7 @@ export class Api<
45954595
}),
45964596

45974597
/**
4598-
* @description Returns paginated list of statuses, filtered by given parameters
4598+
* @description Returns cursor-paginated statuses filtered by given parameters. The departure window (from..to) defaults to the last 7 days and must not exceed 365 days.
45994599
*
46004600
* @tags Status
46014601
* @name ListStatuses
@@ -4614,6 +4614,18 @@ export class Api<
46144614
* @example 42
46154615
*/
46164616
user_id?: number;
4617+
/**
4618+
* Lower bound for departure (date, e.g. 2024-01-01). Defaults to 7 days before "to".
4619+
* @format date
4620+
* @example "2024-01-01"
4621+
*/
4622+
from?: string;
4623+
/**
4624+
* Upper bound for departure (date, e.g. 2024-01-31). Defaults to now+20min. Range from..to must not exceed 365 days.
4625+
* @format date
4626+
* @example "2024-01-31"
4627+
*/
4628+
to?: string;
46174629
/**
46184630
* Filter by origin station name
46194631
* @example "Central Station"

storage/api-docs/api-docs.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5747,7 +5747,7 @@
57475747
"Status"
57485748
],
57495749
"summary": "[Auth optional] List and filter statuses",
5750-
"description": "Returns paginated list of statuses, filtered by given parameters",
5750+
"description": "Returns cursor-paginated statuses filtered by given parameters. The departure window (from..to) defaults to the last 7 days and must not exceed 365 days.",
57515751
"operationId": "listStatuses",
57525752
"parameters": [
57535753
{
@@ -5768,6 +5768,28 @@
57685768
},
57695769
"example": 42
57705770
},
5771+
{
5772+
"name": "from",
5773+
"in": "query",
5774+
"description": "Lower bound for departure (date, e.g. 2024-01-01). Defaults to 7 days before \"to\".",
5775+
"required": false,
5776+
"schema": {
5777+
"type": "string",
5778+
"format": "date"
5779+
},
5780+
"example": "2024-01-01"
5781+
},
5782+
{
5783+
"name": "to",
5784+
"in": "query",
5785+
"description": "Upper bound for departure (date, e.g. 2024-01-31). Defaults to now+20min. Range from..to must not exceed 365 days.",
5786+
"required": false,
5787+
"schema": {
5788+
"type": "string",
5789+
"format": "date"
5790+
},
5791+
"example": "2024-01-31"
5792+
},
57715793
{
57725794
"name": "origin_text",
57735795
"in": "query",

0 commit comments

Comments
 (0)