Skip to content

Commit 0689281

Browse files
authored
♻️ rewrite status and station backend (#4604)
1 parent 7915c13 commit 0689281

File tree

23 files changed

+1992
-26
lines changed

23 files changed

+1992
-26
lines changed

API_CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ Check back here regularly to stay ahead of removals.
3838

3939
# 2026-03-20
4040

41+
Added fields to `StationResource`: `time_offset` (integer, nullable) and `created_at` (ISO 8601 string, nullable).
42+
43+
Added fields to `StationIdentifierResource`: `name` (string, nullable) and `origin` (string, nullable).
44+
4145
`POST /api/v1/report`: `description` is now required (minimum 10 characters). Previously it was optional/nullable.
4246

4347
Added `POST /api/v1/reports` as correctly named replacement for the deprecated `POST /api/v1/report` endpoint.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\API\v1;
6+
7+
use App\Enum\Business;
8+
use App\Enum\StatusVisibility;
9+
use App\Events\StatusUpdateEvent;
10+
use App\Http\Controllers\Backend\Support\LocationController;
11+
use App\Http\Controllers\Backend\Transport\PointsCalculationController;
12+
use App\Http\Controllers\Backend\Transport\TrainCheckinController;
13+
use App\Http\Resources\AdminStatusResource;
14+
use App\Models\Station;
15+
use App\Models\Status;
16+
use App\Models\User;
17+
use Illuminate\Http\Request;
18+
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
19+
use Illuminate\Validation\Rules\Enum;
20+
use OpenApi\Attributes as OA;
21+
22+
class AdminStatusController extends Controller
23+
{
24+
#[OA\Get(
25+
path: '/admin/statuses',
26+
operationId: 'getAdminStatuses',
27+
summary: 'List statuses for admin moderation. Admin only.',
28+
security: [['passport' => []], ['token' => []]],
29+
tags: ['Admin'],
30+
parameters: [
31+
new OA\Parameter(name: 'userQuery', description: 'Filter by user name or username', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
32+
new OA\Parameter(name: 'cursor', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
33+
],
34+
responses: [
35+
new OA\Response(
36+
response: 200,
37+
description: 'Paginated list of statuses.',
38+
content: new OA\JsonContent(
39+
properties: [
40+
new OA\Property(property: 'data', type: 'array', items: new OA\Items(ref: '#/components/schemas/AdminStatusResource')),
41+
],
42+
),
43+
),
44+
new OA\Response(response: 401, description: 'Unauthenticated.'),
45+
new OA\Response(response: 403, description: 'Forbidden.'),
46+
],
47+
)]
48+
public function index(Request $request): AnonymousResourceCollection
49+
{
50+
$this->authorize('adminViewAny', Status::class);
51+
52+
$validated = $request->validate([
53+
'userQuery' => ['nullable', 'string', 'max:255'],
54+
]);
55+
56+
$query = Status::with([
57+
'checkin.originStopover.station',
58+
'checkin.destinationStopover.station',
59+
'user',
60+
])->orderBy('created_at', 'desc');
61+
62+
if (!empty($validated['userQuery'])) {
63+
$query->whereIn(
64+
'user_id',
65+
User::where('name', 'like', '%' . $validated['userQuery'] . '%')
66+
->orWhere('username', 'like', '%' . $validated['userQuery'] . '%')
67+
->pluck('id')
68+
);
69+
}
70+
71+
return AdminStatusResource::collection($query->cursorPaginate(15));
72+
}
73+
74+
#[OA\Get(
75+
path: '/admin/statuses/{id}',
76+
operationId: 'getAdminStatus',
77+
summary: 'Get a single status with all admin details. Admin only.',
78+
security: [['passport' => []], ['token' => []]],
79+
tags: ['Admin'],
80+
parameters: [
81+
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
82+
],
83+
responses: [
84+
new OA\Response(response: 200, description: 'Status details.', content: new OA\JsonContent(properties: [new OA\Property(property: 'data', ref: '#/components/schemas/AdminStatusResource')])),
85+
new OA\Response(response: 403, description: 'Forbidden.'),
86+
new OA\Response(response: 404, description: 'Not found.'),
87+
],
88+
)]
89+
public function show(int $id): AdminStatusResource
90+
{
91+
$this->authorize('adminViewAny', Status::class);
92+
93+
$status = Status::with([
94+
'checkin.originStopover.station',
95+
'checkin.destinationStopover.station',
96+
'checkin.trip.stopovers.station',
97+
'user',
98+
'createdByUser',
99+
'tags',
100+
'client',
101+
'event',
102+
])->findOrFail($id);
103+
104+
return new AdminStatusResource($status);
105+
}
106+
107+
#[OA\Put(
108+
path: '/admin/statuses/{id}',
109+
operationId: 'updateAdminStatus',
110+
summary: 'Update a status including moderation fields. Admin only.',
111+
security: [['passport' => []], ['token' => []]],
112+
requestBody: new OA\RequestBody(
113+
required: true,
114+
content: new OA\JsonContent(
115+
required: ['origin', 'destination', 'visibility'],
116+
properties: [
117+
new OA\Property(property: 'origin', description: 'Origin station ID', type: 'integer'),
118+
new OA\Property(property: 'destination', description: 'Destination station ID', type: 'integer'),
119+
new OA\Property(property: 'body', type: 'string', nullable: true, maxLength: 280),
120+
new OA\Property(property: 'visibility', type: 'integer'),
121+
new OA\Property(property: 'business', type: 'integer', nullable: true),
122+
new OA\Property(property: 'event_id', type: 'integer', nullable: true),
123+
new OA\Property(property: 'points', type: 'integer', nullable: true),
124+
new OA\Property(property: 'moderation_notes', type: 'string', nullable: true, maxLength: 255),
125+
new OA\Property(property: 'lock_visibility', type: 'boolean', nullable: true),
126+
new OA\Property(property: 'hide_body', type: 'boolean', nullable: true),
127+
],
128+
),
129+
),
130+
tags: ['Admin'],
131+
parameters: [
132+
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
133+
],
134+
responses: [
135+
new OA\Response(response: 200, description: 'Updated status.', content: new OA\JsonContent(properties: [new OA\Property(property: 'data', ref: '#/components/schemas/AdminStatusResource')])),
136+
new OA\Response(response: 403, description: 'Forbidden.'),
137+
new OA\Response(response: 404, description: 'Not found.'),
138+
new OA\Response(response: 422, description: 'Validation error.'),
139+
],
140+
)]
141+
public function update(int $id, Request $request): AdminStatusResource
142+
{
143+
$status = Status::with('checkin.trip.stopovers')->findOrFail($id);
144+
$this->authorize('adminUpdate', $status);
145+
146+
$validated = $request->validate([
147+
'origin' => ['required', 'integer', 'exists:train_stations,id'],
148+
'destination' => ['required', 'integer', 'exists:train_stations,id'],
149+
'body' => ['nullable', 'string', 'max:280'],
150+
'visibility' => ['required', new Enum(StatusVisibility::class)],
151+
'business' => ['nullable', new Enum(Business::class)],
152+
'event_id' => ['nullable', 'integer', 'exists:events,id'],
153+
'points' => ['nullable', 'integer', 'gte:0'],
154+
'moderation_notes' => ['nullable', 'string', 'max:255'],
155+
'lock_visibility' => ['nullable', 'boolean'],
156+
'hide_body' => ['nullable', 'boolean'],
157+
]);
158+
159+
$originStation = Station::findOrFail($validated['origin']);
160+
$destinationStation = Station::findOrFail($validated['destination']);
161+
162+
$newOrigin = $status->checkin->trip->stopovers->where('train_station_id', $originStation->id)->first();
163+
$newDestination = $status->checkin->trip->stopovers->where('train_station_id', $destinationStation->id)->first();
164+
165+
$newDeparture = $newOrigin->departure_planned ?? $newOrigin->arrival_planned;
166+
$newArrival = $newDestination->arrival_planned ?? $newDestination->departure_planned;
167+
168+
$distanceInMeters = (new LocationController(
169+
trip: $status->checkin->trip,
170+
origin: $newOrigin,
171+
destination: $newDestination,
172+
))->calculateDistance();
173+
174+
$pointCalculation = PointsCalculationController::calculatePoints(
175+
distanceInMeter: $distanceInMeters,
176+
hafasTravelType: $status->checkin->trip->category,
177+
departure: $newDeparture,
178+
arrival: $newArrival,
179+
tripSource: $status->checkin->trip->source,
180+
timestampOfView: $newDeparture,
181+
);
182+
183+
$status->checkin->update([
184+
'origin_stopover_id' => $newOrigin->id,
185+
'destination_stopover_id' => $newDestination->id,
186+
'departure' => $newDeparture,
187+
'arrival' => $newArrival,
188+
'distance' => $distanceInMeters,
189+
'points' => $validated['points'] ?? $pointCalculation->points,
190+
'duration' => TrainCheckinController::calculateCheckinDuration($status->checkin, false),
191+
]);
192+
193+
StatusUpdateEvent::dispatch($status->refresh());
194+
195+
$payload = [
196+
'visibility' => $validated['visibility'],
197+
'event_id' => $validated['event_id'] ?? null,
198+
'moderation_notes' => $validated['moderation_notes'] ?? null,
199+
];
200+
201+
if (array_key_exists('body', $validated)) {
202+
$payload['body'] = $validated['body'];
203+
}
204+
if (array_key_exists('business', $validated) && $validated['business'] !== null) {
205+
$payload['business'] = $validated['business'];
206+
}
207+
if (array_key_exists('lock_visibility', $validated)) {
208+
$payload['lock_visibility'] = $validated['lock_visibility'] ?? false;
209+
}
210+
if (array_key_exists('hide_body', $validated)) {
211+
$payload['hide_body'] = $validated['hide_body'] ?? false;
212+
}
213+
214+
$status->update($payload);
215+
216+
return new AdminStatusResource($status->fresh([
217+
'checkin.originStopover.station',
218+
'checkin.destinationStopover.station',
219+
'checkin.trip.stopovers.station',
220+
'user',
221+
'createdByUser',
222+
'tags',
223+
'client',
224+
'event',
225+
]));
226+
}
227+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Resources;
6+
7+
use App\Models\Status;
8+
use Illuminate\Http\Resources\Json\JsonResource;
9+
use OpenApi\Attributes as OA;
10+
11+
#[OA\Schema(
12+
title: 'AdminStatusResource',
13+
properties: [
14+
new OA\Property(property: 'id', type: 'integer', example: 12345),
15+
new OA\Property(property: 'body', type: 'string', nullable: true),
16+
new OA\Property(property: 'visibility', type: 'integer', example: 0),
17+
new OA\Property(property: 'business', type: 'integer', example: 0),
18+
new OA\Property(property: 'moderation_notes', type: 'string', nullable: true),
19+
new OA\Property(property: 'lock_visibility', type: 'boolean'),
20+
new OA\Property(property: 'hide_body', type: 'boolean'),
21+
new OA\Property(property: 'event_id', type: 'integer', nullable: true),
22+
new OA\Property(
23+
property: 'user',
24+
properties: [
25+
new OA\Property(property: 'id', type: 'integer'),
26+
new OA\Property(property: 'name', type: 'string'),
27+
new OA\Property(property: 'username', type: 'string'),
28+
],
29+
type: 'object',
30+
),
31+
new OA\Property(
32+
property: 'checkin',
33+
properties: [
34+
new OA\Property(property: 'id', type: 'integer'),
35+
new OA\Property(property: 'origin_station_id', type: 'integer', nullable: true),
36+
new OA\Property(property: 'origin_station_name', type: 'string', nullable: true),
37+
new OA\Property(property: 'destination_station_id', type: 'integer', nullable: true),
38+
new OA\Property(property: 'destination_station_name', type: 'string', nullable: true),
39+
new OA\Property(property: 'departure', type: 'string', format: 'date-time', nullable: true),
40+
new OA\Property(property: 'arrival', type: 'string', format: 'date-time', nullable: true),
41+
new OA\Property(property: 'distance', type: 'integer'),
42+
new OA\Property(property: 'points', type: 'integer'),
43+
new OA\Property(property: 'trip_id', type: 'integer'),
44+
new OA\Property(property: 'linename', type: 'string', nullable: true),
45+
],
46+
type: 'object',
47+
nullable: true,
48+
),
49+
new OA\Property(
50+
property: 'stopovers',
51+
type: 'array',
52+
items: new OA\Items(
53+
properties: [
54+
new OA\Property(property: 'station_id', type: 'integer'),
55+
new OA\Property(property: 'station_name', type: 'string'),
56+
new OA\Property(property: 'arrival_planned', type: 'string', format: 'date-time', nullable: true),
57+
new OA\Property(property: 'departure_planned', type: 'string', format: 'date-time', nullable: true),
58+
],
59+
),
60+
nullable: true,
61+
),
62+
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
63+
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
64+
],
65+
)]
66+
class AdminStatusResource extends JsonResource
67+
{
68+
public function toArray($request): array
69+
{
70+
/** @var Status $this */
71+
return [
72+
'id' => (int) $this->id,
73+
'body' => $this->body,
74+
'visibility' => (int) $this->visibility->value,
75+
'business' => (int) $this->business->value,
76+
'moderation_notes' => $this->moderation_notes,
77+
'lock_visibility' => (bool) $this->lock_visibility,
78+
'hide_body' => (bool) $this->hide_body,
79+
'event_id' => $this->event_id,
80+
'user' => [
81+
'id' => $this->user->id,
82+
'name' => $this->user->name,
83+
'username' => $this->user->username,
84+
],
85+
'checkin' => $this->checkin ? [
86+
'id' => $this->checkin->id,
87+
'origin_station_id' => $this->checkin->originStopover?->train_station_id,
88+
'origin_station_name' => $this->checkin->originStopover?->station?->name,
89+
'destination_station_id' => $this->checkin->destinationStopover?->train_station_id,
90+
'destination_station_name' => $this->checkin->destinationStopover?->station?->name,
91+
'departure' => $this->checkin->departure?->toIso8601String(),
92+
'arrival' => $this->checkin->arrival?->toIso8601String(),
93+
'distance' => (int) $this->checkin->distance,
94+
'points' => (int) $this->checkin->points,
95+
'trip_id' => (int) $this->checkin->trip_id,
96+
'linename' => $this->checkin->trip?->linename,
97+
] : null,
98+
'stopovers' => $this->when(
99+
$this->checkin?->trip?->relationLoaded('stopovers') ?? false,
100+
fn () => $this->checkin->trip->stopovers->map(fn ($s) => [
101+
'station_id' => (int) $s->train_station_id,
102+
'station_name' => $s->station?->name,
103+
'arrival_planned' => $s->arrival_planned?->toIso8601String(),
104+
'departure_planned' => $s->departure_planned?->toIso8601String(),
105+
])->values(),
106+
),
107+
'created_at' => $this->created_at?->toIso8601String(),
108+
'updated_at' => $this->updated_at?->toIso8601String(),
109+
];
110+
}
111+
}

app/Http/Resources/StationIdentifierResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
properties: [
1212
new OA\Property(property: 'type', type: 'string', example: 'de_db_ril100'),
1313
new OA\Property(property: 'identifier', type: 'string', example: 'RK'),
14+
new OA\Property(property: 'name', type: 'string', example: 'Karlsruhe Hbf', nullable: true),
15+
new OA\Property(property: 'origin', type: 'string', example: 'db', nullable: true),
1416
],
1517
)]
1618
class StationIdentifierResource extends JsonResource
@@ -21,6 +23,8 @@ public function toArray($request): array
2123
return [
2224
'type' => $this->type,
2325
'identifier' => $this->identifier,
26+
'name' => $this->name,
27+
'origin' => $this->origin,
2428
];
2529
}
2630
}

app/Http/Resources/StationResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
type: 'array',
4141
items: new OA\Items(ref: '#/components/schemas/StationIdentifierResource'),
4242
),
43+
new OA\Property(property: 'time_offset', type: 'integer', example: '60', nullable: true),
44+
new OA\Property(property: 'created_at', type: 'string', format: 'date-time', nullable: true),
4345
],
4446
)]
4547
class StationResource extends JsonResource
@@ -54,8 +56,10 @@ public function toArray($request): array
5456
'longitude' => $this->longitude,
5557
'ibnr' => null, // @deprecated - remove after 2026-09-30
5658
'rilIdentifier' => null, // @deprecated - remove after 2026-09-30
59+
'time_offset' => $this->time_offset,
5760
'areas' => AreaResource::collection($this->whenLoaded('areas')),
5861
'identifiers' => StationIdentifierResource::collection($this->whenLoaded('stationIdentifiers')),
62+
'created_at' => $this->created_at?->toIso8601String(),
5963
];
6064
}
6165
}

0 commit comments

Comments
 (0)