Skip to content

Commit 3ae3cc8

Browse files
authored
✨ add delete route segment API endpoint and job (#4588)
1 parent da4a197 commit 3ae3cc8

File tree

7 files changed

+253
-23
lines changed

7 files changed

+253
-23
lines changed

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Dto\Coordinate;
88
use App\Http\Resources\RouteSegmentResource;
99
use App\Jobs\AssignRouteSegmentToStopovers;
10+
use App\Jobs\DeleteRouteSegment;
1011
use App\Models\RouteSegment;
1112
use App\Models\Station;
1213
use App\Models\Stopover;
@@ -220,6 +221,31 @@ public function store(Request $request, TripRepository $tripRepository, GeoServi
220221
new OA\Response(response: 404, description: self::OA_DESC_NOT_FOUND),
221222
],
222223
)]
224+
#[OA\Delete(
225+
path: '/route-segments/{id}',
226+
operationId: 'deleteRouteSegment',
227+
summary: 'Delete a route segment (admin only). All stopovers using this segment are reassigned to another matching segment if available, otherwise their assignment is cleared.',
228+
tags: ['Polyline'],
229+
parameters: [
230+
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
231+
],
232+
responses: [
233+
new OA\Response(response: 202, description: 'Deletion job dispatched. Stopovers will be reassigned and the segment deleted in the background.'),
234+
new OA\Response(response: 401, description: self::OA_DESC_UNAUTHENTICATED),
235+
new OA\Response(response: 403, description: self::OA_DESC_FORBIDDEN),
236+
new OA\Response(response: 404, description: self::OA_DESC_NOT_FOUND),
237+
],
238+
)]
239+
public function destroy(string $id): JsonResponse
240+
{
241+
$segment = RouteSegment::findOrFail($id);
242+
$this->authorize('delete', $segment);
243+
244+
DeleteRouteSegment::dispatch($segment);
245+
246+
return response()->json([], 202);
247+
}
248+
223249
public function assignStopovers(string $id): JsonResponse
224250
{
225251
$segment = RouteSegment::findOrFail($id);

app/Jobs/DeleteRouteSegment.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Jobs;
6+
7+
use App\Models\RouteSegment;
8+
use App\Models\Stopover;
9+
use App\Repositories\TripRepository;
10+
use Illuminate\Bus\Queueable;
11+
use Illuminate\Contracts\Queue\ShouldQueue;
12+
use Illuminate\Foundation\Bus\Dispatchable;
13+
use Illuminate\Queue\InteractsWithQueue;
14+
use Illuminate\Queue\SerializesModels;
15+
use Illuminate\Support\Collection;
16+
use Illuminate\Support\Facades\Log;
17+
use romanzipp\QueueMonitor\Traits\IsMonitored;
18+
19+
class DeleteRouteSegment implements ShouldQueue
20+
{
21+
use Dispatchable, InteractsWithQueue, IsMonitored, Queueable, SerializesModels;
22+
23+
public int $tries = 3;
24+
25+
public int $backoff = 30;
26+
27+
public int $timeout = 600;
28+
29+
public function __construct(private readonly RouteSegment $segment) {}
30+
31+
public function handle(TripRepository $tripRepository): void
32+
{
33+
$segment = $this->segment;
34+
$segmentId = $segment->id;
35+
$affectedTrips = [];
36+
$reassigned = 0;
37+
$nulled = 0;
38+
39+
Stopover::where('route_segment_id', $segmentId)
40+
->with('trip')
41+
->chunkById(200, function (Collection $stopovers) use (
42+
$segment, $segmentId, $tripRepository, &$affectedTrips, &$reassigned, &$nulled,
43+
): void {
44+
$tripIds = $stopovers->pluck('trip_id')->unique();
45+
$allTripStopovers = Stopover::whereIn('trip_id', $tripIds)
46+
->orderBy('arrival_planned')
47+
->get(['id', 'trip_id', 'train_station_id', 'arrival_planned', 'departure_planned'])
48+
->groupBy('trip_id');
49+
50+
foreach ($stopovers as $fromStop) {
51+
$startTime = $fromStop->departure_planned ?? $fromStop->arrival_planned;
52+
$allStopoversForTrip = $allTripStopovers->get($fromStop->trip_id, collect());
53+
54+
// Find the immediately next stop in the trip after the fromStop.
55+
$nextStop = $allStopoversForTrip
56+
->filter(function (Stopover $ts) use ($startTime): bool {
57+
if (!$startTime) {
58+
return true;
59+
}
60+
61+
return $ts->arrival_planned?->gt($startTime)
62+
|| $ts->departure_planned?->gt($startTime);
63+
})
64+
->sortBy('arrival_planned')
65+
->first();
66+
67+
if (!$nextStop || $nextStop->train_station_id !== $segment->to_station_id) {
68+
// Can't determine next stop: null out assignment.
69+
$fromStop->route_segment_id = null;
70+
$fromStop->save();
71+
$nulled++;
72+
$affectedTrips[$fromStop->trip_id] = true;
73+
74+
continue;
75+
}
76+
77+
$endTime = $nextStop->arrival_planned ?? $nextStop->departure_planned;
78+
79+
if (!$startTime || !$endTime) {
80+
$fromStop->route_segment_id = null;
81+
$fromStop->save();
82+
$nulled++;
83+
$affectedTrips[$fromStop->trip_id] = true;
84+
85+
continue;
86+
}
87+
88+
$duration = (int) round($startTime->diffInSeconds($endTime));
89+
$pathType = $fromStop->trip?->category?->getORRProfile();
90+
91+
$replacement = $tripRepository->getRouteSegmentBetweenStops(
92+
start: $fromStop,
93+
end: $nextStop,
94+
duration: $duration,
95+
pathType: $pathType,
96+
excludeId: $segmentId,
97+
);
98+
99+
if ($replacement !== null) {
100+
$tripRepository->setRouteSegmentForStop($fromStop, $replacement);
101+
$reassigned++;
102+
} else {
103+
$fromStop->route_segment_id = null;
104+
$fromStop->save();
105+
$nulled++;
106+
}
107+
108+
$affectedTrips[$fromStop->trip_id] = true;
109+
}
110+
});
111+
112+
$segment->delete();
113+
114+
foreach (array_keys($affectedTrips) as $tripId) {
115+
RecalculateStatusesDistanceForTrip::dispatch($tripId);
116+
}
117+
118+
Log::info('DeleteRouteSegment: Completed', [
119+
'segment_id' => $segmentId,
120+
'reassigned' => $reassigned,
121+
'nulled' => $nulled,
122+
'trips_queued' => count($affectedTrips),
123+
]);
124+
}
125+
}

app/Repositories/TripRepository.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ public function getRouteSegmentBetweenStops(
2121
Stopover $start,
2222
Stopover $end,
2323
int $duration,
24-
OpenRailRoutingProfile $pathType = OpenRailRoutingProfile::ALL_TRACKS
24+
?OpenRailRoutingProfile $pathType = null,
25+
?string $excludeId = null,
2526
): ?RouteSegment {
2627
// Use ±10% tolerance, but at least ±5 minutes for short-distance segments
2728
$tolerance = max(300, (int) round($duration * 0.1));
@@ -30,6 +31,7 @@ public function getRouteSegmentBetweenStops(
3031
->where('to_station_id', $end->train_station_id)
3132
->where(fn ($q) => $q->where('path_type', $pathType)->orWhereNull('path_type'))
3233
->whereBetween('duration', [max(0, $duration - $tolerance), $duration + $tolerance])
34+
->when($excludeId !== null, fn ($q) => $q->where('id', '!=', $excludeId))
3335
->first();
3436
}
3537

resources/types/Api.gen.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3943,6 +3943,21 @@ export class Api<
39433943
...params,
39443944
}),
39453945

3946+
/**
3947+
* No description
3948+
*
3949+
* @tags Polyline
3950+
* @name DeleteRouteSegment
3951+
* @summary Delete a route segment (admin only). All stopovers using this segment are reassigned to another matching segment if available, otherwise their assignment is cleared.
3952+
* @request DELETE:/route-segments/{id}
3953+
*/
3954+
deleteRouteSegment: (id: string, params: RequestParams = {}) =>
3955+
this.request<void, void>({
3956+
path: `/route-segments/${id}`,
3957+
method: "DELETE",
3958+
...params,
3959+
}),
3960+
39463961
/**
39473962
* No description
39483963
*

resources/vue/components/Admin/RouteSegmentList.vue

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script setup lang="ts">
22
import { onMounted, ref } from 'vue';
3-
import type { RouteSegmentResource } from '../../../types/Api.gen';
3+
import { Api, type RouteSegmentResource } from '../../../types/Api.gen';
4+
5+
const api = new Api({ baseUrl: window.location.origin + '/api/v1' });
46
57
const props = defineProps<{
68
fromStationId: number;
@@ -10,6 +12,7 @@ const props = defineProps<{
1012
1113
const segments = ref<RouteSegmentResource[]>([]);
1214
const error = ref<string | null>(null);
15+
const deletingIds = ref<Set<string>>(new Set());
1316
1417
function formatDuration(seconds: number | null): string {
1518
if (seconds === null) return '';
@@ -28,25 +31,42 @@ function segmentUrl(id: string): string {
2831
return `/admin/routesegment/${id}`;
2932
}
3033
31-
onMounted(async () => {
34+
async function fetchSegments(): Promise<void> {
35+
error.value = null;
3236
try {
33-
const params = new URLSearchParams({
34-
from_station_id: String(props.fromStationId),
35-
to_station_id: String(props.toStationId),
36-
});
37-
const res = await fetch(`/api/v1/route-segments?${params}`, {
38-
headers: { Accept: 'application/json' },
37+
const res = await api.routeSegments.listRouteSegments({
38+
from_station_id: props.fromStationId,
39+
to_station_id: props.toStationId,
3940
});
40-
if (!res.ok) {
41-
error.value = `Error ${res.status}: ${res.statusText}`;
42-
return;
43-
}
44-
const json = await res.json();
45-
segments.value = json.data;
41+
segments.value = res.data?.data ?? [];
42+
} catch (e) {
43+
error.value = e instanceof Error ? e.message : 'Unknown error';
44+
}
45+
}
46+
47+
async function deleteSegment(id: string): Promise<void> {
48+
if (
49+
!confirm(
50+
'Delete this route segment? All stopovers using it will be reassigned to another matching segment if possible.',
51+
)
52+
) {
53+
return;
54+
}
55+
56+
deletingIds.value = new Set([...deletingIds.value, id]);
57+
try {
58+
await api.routeSegments.deleteRouteSegment(id);
59+
segments.value = segments.value.filter((s) => s.id !== id);
4660
} catch (e) {
4761
error.value = e instanceof Error ? e.message : 'Unknown error';
62+
} finally {
63+
const next = new Set(deletingIds.value);
64+
next.delete(id);
65+
deletingIds.value = next;
4866
}
49-
});
67+
}
68+
69+
onMounted(fetchSegments);
5070
</script>
5171

5272
<template>
@@ -80,12 +100,22 @@ onMounted(async () => {
80100
<td>{{ formatDistance(segment.distance) }}</td>
81101
<td>{{ segment.pathType ?? '' }}</td>
82102
<td>
83-
<a
84-
v-if="segment.id !== currentSegmentId"
85-
:href="segmentUrl(segment.id)"
86-
class="btn btn-primary btn-sm"
87-
>Open</a
88-
>
103+
<template v-if="segment.id !== currentSegmentId">
104+
<a :href="segmentUrl(segment.id)" class="btn btn-primary btn-sm me-1">Open</a>
105+
<button
106+
v-if="segments.length > 1"
107+
class="btn btn-danger btn-sm"
108+
:disabled="deletingIds.has(segment.id)"
109+
@click="deleteSegment(segment.id)"
110+
>
111+
<span
112+
v-if="deletingIds.has(segment.id)"
113+
class="spinner-border spinner-border-sm"
114+
role="status"
115+
></span>
116+
<span v-else>Delete</span>
117+
</button>
118+
</template>
89119
<span v-else class="badge bg-secondary">Current</span>
90120
</td>
91121
</tr>

routes/api.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@
200200
Route::apiResource('alerts', AlertController::class);
201201
Route::put('/operators/{oldOperatorId}/merge/{newOperatorId}', [OperatorController::class, 'merge']); // currently admin/backend only
202202

203-
Route::apiResource('route-segments', RouteSegmentController::class)->only(['index', 'show', 'store']);
203+
Route::apiResource('route-segments', RouteSegmentController::class)->only(['index', 'show', 'store', 'destroy']);
204204
Route::post('route-segments/{id}/assign-stopovers', [RouteSegmentController::class, 'assignStopovers']);
205205

206206
Route::prefix('community')->group(static function () {

storage/api-docs/api-docs.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,6 +1906,38 @@
19061906
"description": "Not Found"
19071907
}
19081908
}
1909+
},
1910+
"delete": {
1911+
"tags": [
1912+
"Polyline"
1913+
],
1914+
"summary": "Delete a route segment (admin only). All stopovers using this segment are reassigned to another matching segment if available, otherwise their assignment is cleared.",
1915+
"operationId": "deleteRouteSegment",
1916+
"parameters": [
1917+
{
1918+
"name": "id",
1919+
"in": "path",
1920+
"required": true,
1921+
"schema": {
1922+
"type": "string",
1923+
"format": "uuid"
1924+
}
1925+
}
1926+
],
1927+
"responses": {
1928+
"202": {
1929+
"description": "Deletion job dispatched. Stopovers will be reassigned and the segment deleted in the background."
1930+
},
1931+
"401": {
1932+
"description": "Unauthenticated"
1933+
},
1934+
"403": {
1935+
"description": "Forbidden"
1936+
},
1937+
"404": {
1938+
"description": "Not Found"
1939+
}
1940+
}
19091941
}
19101942
},
19111943
"/route-segments/{id}/assign-stopovers": {

0 commit comments

Comments
 (0)