refactor(ts): close the 4 client any's + document a best-effort catch (Phase 1b)
parseJsonSafe/upload-body → a structured ResponseBody type (typed error envelope + index sig) instead of any, no cast churn; declineRecommendation → Recommendation; snowballPlans endpoint typed so the hook drops its (d: any). Annotated the Spending settings-load catch as best-effort (Finding #8) — cosmetic options fall back to defaults, no mount-time nag. Client 0 any's. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
398caec29d
commit
57e7ede670
|
|
@ -54,11 +54,16 @@ const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
|
||||||
// still bounding a truly dead request so it surfaces as an error, not a freeze.
|
// still bounding a truly dead request so it surfaces as an error, not a freeze.
|
||||||
const REQUEST_TIMEOUT_MS = 120_000;
|
const REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
async function parseJsonSafe(res: Response): Promise<any> {
|
// A parsed JSON response body. The error-envelope fields the client reads are
|
||||||
|
// typed; any other (success) field passes through the index signature. Avoids an
|
||||||
|
// `any` at the parse boundary without forcing casts at every read site.
|
||||||
|
type ResponseBody = { message?: string; error?: string; code?: string; details?: unknown[]; token?: string } & Record<string, unknown>;
|
||||||
|
|
||||||
|
async function parseJsonSafe(res: Response): Promise<ResponseBody | null> {
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
try { return JSON.parse(text); } catch { return null; }
|
try { return JSON.parse(text) as ResponseBody; } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _fetch<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
|
async function _fetch<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
|
||||||
|
|
@ -192,8 +197,8 @@ export const api = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let data: any = {};
|
let data: ResponseBody = {};
|
||||||
try { data = await res.json(); } catch {}
|
try { data = await res.json(); } catch { /* non-JSON error body */ }
|
||||||
throw new Error(data.error || `HTTP ${res.status}`);
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -319,7 +324,7 @@ export const api = {
|
||||||
subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
|
subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
|
||||||
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||||
createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data),
|
createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data),
|
||||||
declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', {
|
declineRecommendation: (recommendation: Recommendation) => post('/subscriptions/recommendations/decline', {
|
||||||
decline_key: recommendation?.decline_key || recommendation,
|
decline_key: recommendation?.decline_key || recommendation,
|
||||||
catalog_id: recommendation?.catalog_match?.id,
|
catalog_id: recommendation?.catalog_match?.id,
|
||||||
merchant: recommendation?.merchant,
|
merchant: recommendation?.merchant,
|
||||||
|
|
@ -348,7 +353,7 @@ export const api = {
|
||||||
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
|
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
|
||||||
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
|
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
|
||||||
snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
|
snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
|
||||||
snowballPlans: () => get('/snowball/plans'),
|
snowballPlans: () => get<{ plans?: unknown[] }>('/snowball/plans'),
|
||||||
snowballActivePlan: () => get('/snowball/plans/active'),
|
snowballActivePlan: () => get('/snowball/plans/active'),
|
||||||
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
|
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
|
||||||
updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d),
|
updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d),
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ export function useSnowballActivePlan() {
|
||||||
export function useSnowballPlans() {
|
export function useSnowballPlans() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['snowball-plans'],
|
queryKey: ['snowball-plans'],
|
||||||
queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []),
|
queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
|
||||||
staleTime: 1000 * 60 * 2,
|
staleTime: 1000 * 60 * 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,8 @@ export default function SpendingPage() {
|
||||||
}, [txPage, queryClient]);
|
}, [txPage, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// best-effort: the page-options are cosmetic; on a load failure we fall back
|
||||||
|
// to defaults rather than block the page or nag with a mount-time toast.
|
||||||
api.settings().then(s => setSpendingSettings((s as Record<string, unknown>) || {})).catch(() => {});
|
api.settings().then(s => setSpendingSettings((s as Record<string, unknown>) || {})).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue