@burdenoff/microfe-movethewheels 2026.830.4 → 2026.918.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AssignmentSuggestions.js +135 -0
- package/dist/components/FleetAlertsPanel.js +114 -0
- package/dist/components/VehicleServiceCard.js +176 -0
- package/dist/hooks/useFleet.js +4 -3
- package/dist/hooks/useFleetIntelligence.js +91 -0
- package/dist/movethewheels/components/AssignmentSuggestions.d.ts +17 -0
- package/dist/movethewheels/components/FleetAlertsPanel.d.ts +14 -0
- package/dist/movethewheels/components/VehicleServiceCard.d.ts +10 -0
- package/dist/movethewheels/hooks/useFleetIntelligence.d.ts +64 -0
- package/dist/movethewheels/types/index.d.ts +2 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/sparkles.js +26 -0
- package/dist/pages/FleetPage.js +156 -146
- package/dist/pages/RouteDetailsPage.js +202 -184
- package/dist/pages/VehicleDetailsPage.js +149 -131
- package/dist/utils/graphqlClient.js +14 -5
- package/package.json +1 -1
- package/src/movethewheels/components/AssignmentSuggestions.tsx +234 -0
- package/src/movethewheels/components/FleetAlertsPanel.tsx +148 -0
- package/src/movethewheels/components/VehicleServiceCard.tsx +220 -0
- package/src/movethewheels/hooks/useFleet.ts +12 -2
- package/src/movethewheels/hooks/useFleetIntelligence.ts +226 -0
- package/src/movethewheels/pages/FleetPage.tsx +21 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +33 -1
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +27 -0
- package/src/movethewheels/types/index.ts +2 -0
- package/src/movethewheels/utils/graphqlClient.ts +20 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet intelligence hooks — compliance/maintenance alerts, dispatch
|
|
3
|
+
* suggestions for a route, and logging a completed vehicle service.
|
|
4
|
+
*
|
|
5
|
+
* All three sit on top of wspace-movethewheels-svc's fleet module; the ranking
|
|
6
|
+
* and alert rules live server-side so every surface (web, CLI, MCP) agrees.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
10
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
11
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
12
|
+
|
|
13
|
+
export type FleetAlertType =
|
|
14
|
+
| 'LICENSE_EXPIRED'
|
|
15
|
+
| 'LICENSE_EXPIRING'
|
|
16
|
+
| 'MAINTENANCE_OVERDUE'
|
|
17
|
+
| 'MAINTENANCE_DUE_SOON';
|
|
18
|
+
export type FleetAlertSeverity = 'CRITICAL' | 'WARNING';
|
|
19
|
+
export type FleetAlertSubjectType = 'VEHICLE' | 'DRIVER';
|
|
20
|
+
|
|
21
|
+
export interface FleetAlert {
|
|
22
|
+
id: string;
|
|
23
|
+
type: FleetAlertType;
|
|
24
|
+
severity: FleetAlertSeverity;
|
|
25
|
+
subjectType: FleetAlertSubjectType;
|
|
26
|
+
subjectId: string;
|
|
27
|
+
subjectName: string;
|
|
28
|
+
message: string;
|
|
29
|
+
dueDate: string | null;
|
|
30
|
+
daysUntilDue: number | null;
|
|
31
|
+
kmUntilDue: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VehicleSuggestion {
|
|
35
|
+
score: number;
|
|
36
|
+
reasons: string[];
|
|
37
|
+
vehicle: { id: string; vehicleNumber: string; make: string | null; model: string | null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DriverSuggestion {
|
|
41
|
+
score: number;
|
|
42
|
+
reasons: string[];
|
|
43
|
+
driver: { id: string; firstName: string; lastName: string; rating: number | null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AssignmentSuggestions {
|
|
47
|
+
routeId: string;
|
|
48
|
+
vehicles: VehicleSuggestion[];
|
|
49
|
+
drivers: DriverSuggestion[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RecordVehicleServiceInput {
|
|
53
|
+
/** ISO date-time. Defaults to now on the server. */
|
|
54
|
+
serviceDate?: string;
|
|
55
|
+
mileage?: number;
|
|
56
|
+
intervalDays?: number;
|
|
57
|
+
intervalKm?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FLEET_ALERTS_QUERY = `
|
|
61
|
+
query FleetAlerts {
|
|
62
|
+
fleetAlerts {
|
|
63
|
+
id
|
|
64
|
+
type
|
|
65
|
+
severity
|
|
66
|
+
subjectType
|
|
67
|
+
subjectId
|
|
68
|
+
subjectName
|
|
69
|
+
message
|
|
70
|
+
dueDate
|
|
71
|
+
daysUntilDue
|
|
72
|
+
kmUntilDue
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const ASSIGNMENT_SUGGESTIONS_QUERY = `
|
|
78
|
+
query AssignmentSuggestions($routeId: ID!, $limit: Int) {
|
|
79
|
+
assignmentSuggestions(routeId: $routeId, limit: $limit) {
|
|
80
|
+
routeId
|
|
81
|
+
vehicles {
|
|
82
|
+
score
|
|
83
|
+
reasons
|
|
84
|
+
vehicle {
|
|
85
|
+
id
|
|
86
|
+
vehicleNumber
|
|
87
|
+
make
|
|
88
|
+
model
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
drivers {
|
|
92
|
+
score
|
|
93
|
+
reasons
|
|
94
|
+
driver {
|
|
95
|
+
id
|
|
96
|
+
firstName
|
|
97
|
+
lastName
|
|
98
|
+
rating
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
const RECORD_VEHICLE_SERVICE_MUTATION = `
|
|
106
|
+
mutation RecordVehicleService($id: ID!, $input: RecordVehicleServiceInput!) {
|
|
107
|
+
recordVehicleService(id: $id, input: $input) {
|
|
108
|
+
id
|
|
109
|
+
status
|
|
110
|
+
mileage
|
|
111
|
+
lastServiceDate
|
|
112
|
+
nextServiceDueDate
|
|
113
|
+
nextServiceDueMileage
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
function useGraphqlOptions() {
|
|
119
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
120
|
+
useMoveTheWheels();
|
|
121
|
+
return useMemo(
|
|
122
|
+
() => ({
|
|
123
|
+
apiGatewayUrl,
|
|
124
|
+
authToken,
|
|
125
|
+
workspaceToken,
|
|
126
|
+
workspaceId,
|
|
127
|
+
tenantId,
|
|
128
|
+
actorId: currentUser?.id,
|
|
129
|
+
}),
|
|
130
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface AsyncResource<T> {
|
|
135
|
+
data: T;
|
|
136
|
+
isLoading: boolean;
|
|
137
|
+
error: Error | null;
|
|
138
|
+
refetch: () => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function useFleetAlerts(): AsyncResource<FleetAlert[]> {
|
|
142
|
+
const graphQLOptions = useGraphqlOptions();
|
|
143
|
+
const [data, setData] = useState<FleetAlert[]>([]);
|
|
144
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
145
|
+
const [error, setError] = useState<Error | null>(null);
|
|
146
|
+
const [nonce, setNonce] = useState(0);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
let active = true;
|
|
150
|
+
setIsLoading(true);
|
|
151
|
+
setError(null);
|
|
152
|
+
executeGraphQL<{ fleetAlerts: FleetAlert[] }>(graphQLOptions, FLEET_ALERTS_QUERY)
|
|
153
|
+
.then((result) => {
|
|
154
|
+
if (active) setData(result.fleetAlerts);
|
|
155
|
+
})
|
|
156
|
+
.catch((e: unknown) => {
|
|
157
|
+
if (active) setError(e instanceof Error ? e : new Error('Failed to load fleet alerts'));
|
|
158
|
+
})
|
|
159
|
+
.finally(() => {
|
|
160
|
+
if (active) setIsLoading(false);
|
|
161
|
+
});
|
|
162
|
+
return () => {
|
|
163
|
+
active = false;
|
|
164
|
+
};
|
|
165
|
+
}, [graphQLOptions, nonce]);
|
|
166
|
+
|
|
167
|
+
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
|
168
|
+
return { data, isLoading, error, refetch };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const EMPTY_SUGGESTIONS: AssignmentSuggestions = { routeId: '', vehicles: [], drivers: [] };
|
|
172
|
+
|
|
173
|
+
export function useAssignmentSuggestions(
|
|
174
|
+
routeId: string | undefined,
|
|
175
|
+
limit = 3
|
|
176
|
+
): AsyncResource<AssignmentSuggestions> {
|
|
177
|
+
const graphQLOptions = useGraphqlOptions();
|
|
178
|
+
const [data, setData] = useState<AssignmentSuggestions>(EMPTY_SUGGESTIONS);
|
|
179
|
+
const [isLoading, setIsLoading] = useState(Boolean(routeId));
|
|
180
|
+
const [error, setError] = useState<Error | null>(null);
|
|
181
|
+
const [nonce, setNonce] = useState(0);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!routeId) {
|
|
185
|
+
setData(EMPTY_SUGGESTIONS);
|
|
186
|
+
setIsLoading(false);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
let active = true;
|
|
190
|
+
setIsLoading(true);
|
|
191
|
+
setError(null);
|
|
192
|
+
executeGraphQL<{ assignmentSuggestions: AssignmentSuggestions }>(
|
|
193
|
+
graphQLOptions,
|
|
194
|
+
ASSIGNMENT_SUGGESTIONS_QUERY,
|
|
195
|
+
{ routeId, limit }
|
|
196
|
+
)
|
|
197
|
+
.then((result) => {
|
|
198
|
+
if (active) setData(result.assignmentSuggestions);
|
|
199
|
+
})
|
|
200
|
+
.catch((e: unknown) => {
|
|
201
|
+
if (active) setError(e instanceof Error ? e : new Error('Failed to load suggestions'));
|
|
202
|
+
})
|
|
203
|
+
.finally(() => {
|
|
204
|
+
if (active) setIsLoading(false);
|
|
205
|
+
});
|
|
206
|
+
return () => {
|
|
207
|
+
active = false;
|
|
208
|
+
};
|
|
209
|
+
}, [graphQLOptions, routeId, limit, nonce]);
|
|
210
|
+
|
|
211
|
+
const refetch = useCallback(() => setNonce((n) => n + 1), []);
|
|
212
|
+
return { data, isLoading, error, refetch };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function useRecordVehicleService() {
|
|
216
|
+
const graphQLOptions = useGraphqlOptions();
|
|
217
|
+
return useCallback(
|
|
218
|
+
async (vehicleId: string, input: RecordVehicleServiceInput): Promise<void> => {
|
|
219
|
+
await executeGraphQL(graphQLOptions, RECORD_VEHICLE_SERVICE_MUTATION, {
|
|
220
|
+
id: vehicleId,
|
|
221
|
+
input,
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
[graphQLOptions]
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
} from '../components/ui';
|
|
21
21
|
import { CTAOverflowMenu, EmphasisPanel, PagePurpose, NextSteps } from '@burdenoff/fe-libs/ui';
|
|
22
22
|
import { useFleetStats, useVehicles, useDrivers } from '../hooks/useFleet';
|
|
23
|
+
import { useFleetAlerts, type FleetAlert } from '../hooks/useFleetIntelligence';
|
|
24
|
+
import { FleetAlertsPanel } from '../components/FleetAlertsPanel';
|
|
23
25
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
24
26
|
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
25
27
|
import { joinPath } from '../utils/navigation';
|
|
@@ -50,6 +52,13 @@ export default function FleetPage() {
|
|
|
50
52
|
refetch: refetchDrivers,
|
|
51
53
|
} = useDrivers();
|
|
52
54
|
|
|
55
|
+
const {
|
|
56
|
+
data: alerts,
|
|
57
|
+
isLoading: alertsLoading,
|
|
58
|
+
error: alertsError,
|
|
59
|
+
refetch: refetchAlerts,
|
|
60
|
+
} = useFleetAlerts();
|
|
61
|
+
|
|
53
62
|
const isLoading = statsLoading || vehiclesLoading || driversLoading;
|
|
54
63
|
const error = statsError ?? vehiclesError ?? driversError;
|
|
55
64
|
const refetchAll = () => {
|
|
@@ -64,6 +73,9 @@ export default function FleetPage() {
|
|
|
64
73
|
}
|
|
65
74
|
};
|
|
66
75
|
|
|
76
|
+
const openAlert = (alert: FleetAlert) =>
|
|
77
|
+
goTo(`fleet/${alert.subjectType === 'VEHICLE' ? 'vehicles' : 'drivers'}/${alert.subjectId}`);
|
|
78
|
+
|
|
67
79
|
const onRowKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
|
68
80
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
69
81
|
e.preventDefault();
|
|
@@ -136,6 +148,15 @@ export default function FleetPage() {
|
|
|
136
148
|
]}
|
|
137
149
|
/>
|
|
138
150
|
|
|
151
|
+
<FleetAlertsPanel
|
|
152
|
+
alerts={alerts}
|
|
153
|
+
isLoading={alertsLoading}
|
|
154
|
+
error={alertsError}
|
|
155
|
+
onRetry={refetchAlerts}
|
|
156
|
+
onOpen={openAlert}
|
|
157
|
+
t={t}
|
|
158
|
+
/>
|
|
159
|
+
|
|
139
160
|
{/* Key Metrics */}
|
|
140
161
|
{error && !isLoading ? (
|
|
141
162
|
<Card className="border-status-error-border">
|
|
@@ -41,6 +41,9 @@ import { RouteMap, type RouteStop as MapRouteStop } from '../components/maps';
|
|
|
41
41
|
import { firstRealLatLng } from '../utils/mapMarkers';
|
|
42
42
|
import { useRoute, useRoutes } from '../hooks/useRoutes';
|
|
43
43
|
import { useFleet } from '../hooks/useFleet';
|
|
44
|
+
import { useAssignmentSuggestions } from '../hooks/useFleetIntelligence';
|
|
45
|
+
import { AssignmentSuggestions } from '../components/AssignmentSuggestions';
|
|
46
|
+
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
44
47
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
45
48
|
import { formatDate, formatAddress, formatCurrency } from '../utils/formatters';
|
|
46
49
|
import { joinPath } from '../utils/navigation';
|
|
@@ -374,7 +377,13 @@ export default function RouteDetailsPage() {
|
|
|
374
377
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
375
378
|
const { basePath, navigate } = useMoveTheWheels();
|
|
376
379
|
const { route, isLoading, error, refetch } = useRoute(routeId);
|
|
377
|
-
const { optimizeRoute } = useRoutes();
|
|
380
|
+
const { optimizeRoute, updateRoute } = useRoutes();
|
|
381
|
+
const suggestions = useAssignmentSuggestions(routeId);
|
|
382
|
+
const { t: translate } = useI18n();
|
|
383
|
+
const t = (key: string, fallback: string): string => {
|
|
384
|
+
const value = translate(key);
|
|
385
|
+
return value === key ? fallback : value;
|
|
386
|
+
};
|
|
378
387
|
const { message: notice, notify } = useNotice();
|
|
379
388
|
const [optimizeError, setOptimizeError] = useState<string | null>(null);
|
|
380
389
|
const [optimizing, setOptimizing] = useState(false);
|
|
@@ -397,6 +406,16 @@ export default function RouteDetailsPage() {
|
|
|
397
406
|
}
|
|
398
407
|
};
|
|
399
408
|
|
|
409
|
+
// A rejected assignment (vehicle in maintenance, licence expired, already on a
|
|
410
|
+
// live route) throws with the server's message, which the suggestions card shows.
|
|
411
|
+
const handleAssign = async (patch: { vehicleId?: string; driverId?: string }) => {
|
|
412
|
+
if (!routeId) return;
|
|
413
|
+
await updateRoute(routeId, patch);
|
|
414
|
+
notify(t('movethewheels.routes.suggest.done', 'Assignment saved.'));
|
|
415
|
+
refetch();
|
|
416
|
+
suggestions.refetch();
|
|
417
|
+
};
|
|
418
|
+
|
|
400
419
|
// Handle new route form
|
|
401
420
|
if (!routeId) {
|
|
402
421
|
return <NewRouteForm />;
|
|
@@ -594,6 +613,19 @@ export default function RouteDetailsPage() {
|
|
|
594
613
|
</div>
|
|
595
614
|
|
|
596
615
|
<div className="space-y-6">
|
|
616
|
+
{(route.status === 'planned' || route.status === 'in_progress') && (
|
|
617
|
+
<AssignmentSuggestions
|
|
618
|
+
suggestions={suggestions.data}
|
|
619
|
+
isLoading={suggestions.isLoading}
|
|
620
|
+
error={suggestions.error}
|
|
621
|
+
onRetry={suggestions.refetch}
|
|
622
|
+
assignedVehicleId={route.vehicleId || undefined}
|
|
623
|
+
assignedDriverId={route.driverId || undefined}
|
|
624
|
+
onAssign={handleAssign}
|
|
625
|
+
t={t}
|
|
626
|
+
/>
|
|
627
|
+
)}
|
|
628
|
+
|
|
597
629
|
{/* Route Info — the page's single emphasis zone */}
|
|
598
630
|
<EmphasisPanel className="space-y-4">
|
|
599
631
|
<h2 className="text-base font-semibold text-text-primary">Route Info</h2>
|
|
@@ -26,10 +26,14 @@ import {
|
|
|
26
26
|
} from '../components/ui';
|
|
27
27
|
import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
28
28
|
import { useVehicle, useVehicleDrivers, useFleet } from '../hooks/useFleet';
|
|
29
|
+
import { useRecordVehicleService } from '../hooks/useFleetIntelligence';
|
|
30
|
+
import { VehicleServiceCard } from '../components/VehicleServiceCard';
|
|
29
31
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
30
32
|
import { formatDate, getInitials } from '../utils/formatters';
|
|
33
|
+
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
31
34
|
import { joinPath } from '../utils/navigation';
|
|
32
35
|
import { FormError } from '../components/FormError';
|
|
36
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
33
37
|
import { DocumentsCard } from '../components/DocumentsCard';
|
|
34
38
|
|
|
35
39
|
const VEHICLE_TYPE_OPTIONS = [
|
|
@@ -214,6 +218,13 @@ export default function VehicleDetailsPage() {
|
|
|
214
218
|
const { vehicleId } = useParams<{ vehicleId: string }>();
|
|
215
219
|
const { basePath, navigate } = useMoveTheWheels();
|
|
216
220
|
const { vehicle, isLoading, error, refetch } = useVehicle(vehicleId);
|
|
221
|
+
const recordService = useRecordVehicleService();
|
|
222
|
+
const { message: notice, notify } = useNotice();
|
|
223
|
+
const { t: translate } = useI18n();
|
|
224
|
+
const t = (key: string, fallback: string): string => {
|
|
225
|
+
const value = translate(key);
|
|
226
|
+
return value === key ? fallback : value;
|
|
227
|
+
};
|
|
217
228
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
218
229
|
// Who is behind the wheel — resolved through DriverFilterInput.vehicleId, the
|
|
219
230
|
// same link `assignDriverVehicle` writes.
|
|
@@ -285,6 +296,12 @@ export default function VehicleDetailsPage() {
|
|
|
285
296
|
]}
|
|
286
297
|
/>
|
|
287
298
|
|
|
299
|
+
{notice && (
|
|
300
|
+
<div className="mb-2">
|
|
301
|
+
<Notice message={notice} />
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
|
|
288
305
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
289
306
|
<div className="lg:col-span-2 space-y-6">
|
|
290
307
|
{/* Vehicle Information — the page's single emphasis zone */}
|
|
@@ -440,6 +457,16 @@ export default function VehicleDetailsPage() {
|
|
|
440
457
|
</CardContent>
|
|
441
458
|
</Card>
|
|
442
459
|
|
|
460
|
+
<VehicleServiceCard
|
|
461
|
+
vehicle={vehicle}
|
|
462
|
+
t={t}
|
|
463
|
+
onRecord={async (input) => {
|
|
464
|
+
await recordService(vehicle.id, input);
|
|
465
|
+
notify(t('movethewheels.vehicles.service.saved', 'Service recorded.'));
|
|
466
|
+
refetch();
|
|
467
|
+
}}
|
|
468
|
+
/>
|
|
469
|
+
|
|
443
470
|
{vehicle.currentLocation && (
|
|
444
471
|
<Card>
|
|
445
472
|
<CardHeader>
|
|
@@ -341,6 +341,8 @@ export interface Vehicle {
|
|
|
341
341
|
currentLocation?: Location;
|
|
342
342
|
lastMaintenance?: Date;
|
|
343
343
|
nextMaintenance?: Date;
|
|
344
|
+
/** Odometer reading (km) at which the next service is due. */
|
|
345
|
+
nextMaintenanceMileage?: number;
|
|
344
346
|
fuelLevel?: number;
|
|
345
347
|
mileage?: number;
|
|
346
348
|
documents: VehicleDocument[];
|
|
@@ -132,6 +132,19 @@ export async function executeGraphQL<TData>(
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** Best-effort: the joined `errors[].message` of a non-2xx GraphQL response, or null. */
|
|
136
|
+
async function readGraphqlErrorMessage(response: Response): Promise<string | null> {
|
|
137
|
+
try {
|
|
138
|
+
const payload = (await response.json()) as { errors?: Array<{ message?: string }> };
|
|
139
|
+
const messages = (payload.errors ?? [])
|
|
140
|
+
.map((item) => item.message)
|
|
141
|
+
.filter((message): message is string => typeof message === 'string' && message.length > 0);
|
|
142
|
+
return messages.length > 0 ? messages.join('; ') : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
async function runWithRetries<TData>(
|
|
136
149
|
url: string,
|
|
137
150
|
headers: Record<string, string>,
|
|
@@ -156,7 +169,13 @@ async function runWithRetries<TData>(
|
|
|
156
169
|
});
|
|
157
170
|
|
|
158
171
|
if (!response.ok) {
|
|
159
|
-
|
|
172
|
+
// Transient statuses are retried on the status alone. For anything else the
|
|
173
|
+
// service often answers 4xx with a well-formed GraphQL error (e.g. a business
|
|
174
|
+
// rule rejection) — show that message rather than a bare status code.
|
|
175
|
+
const detail = isTransientStatus(response.status)
|
|
176
|
+
? null
|
|
177
|
+
: await readGraphqlErrorMessage(response);
|
|
178
|
+
lastError = new Error(detail ?? `GraphQL request failed with status ${response.status}`);
|
|
160
179
|
if (canRetry && isTransientStatus(response.status)) continue;
|
|
161
180
|
throw lastError;
|
|
162
181
|
}
|