Skip to content

Commit 241f88b

Browse files
(SP: 1) [Shop] Harden cart rehydrate: typed API error parsing + safe self-heal on PRICE_CONFIG_ERROR (no crash, no console)
1 parent 813331b commit 241f88b

3 files changed

Lines changed: 193 additions & 43 deletions

File tree

frontend/app/api/shop/cart/rehydrate/route.ts

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,34 @@ function normalizeCartPayload(body: unknown) {
2929
};
3030
}
3131

32+
function jsonError(
33+
status: number,
34+
code: string,
35+
message: string,
36+
details?: unknown
37+
) {
38+
return NextResponse.json(
39+
{ error: { code, message, ...(details ? { details } : {}) } },
40+
{ status }
41+
);
42+
}
43+
3244
export async function POST(request: NextRequest) {
3345
let body: unknown;
3446

3547
try {
3648
body = await request.json();
3749
} catch {
38-
return NextResponse.json(
39-
{ error: 'Unable to process cart data.' },
40-
{ status: 400 }
41-
);
50+
return jsonError(400, 'INVALID_PAYLOAD', 'Unable to process cart data.');
4251
}
4352

4453
const normalizedBody = normalizeCartPayload(body);
4554
const parsedPayload = cartRehydratePayloadSchema.safeParse(normalizedBody);
4655

4756
if (!parsedPayload.success) {
48-
return NextResponse.json(
49-
{ error: 'Invalid cart payload', details: parsedPayload.error.format() },
50-
{ status: 400 }
51-
);
57+
return jsonError(400, 'INVALID_PAYLOAD', 'Invalid cart payload', {
58+
issues: parsedPayload.error.format(),
59+
});
5260
}
5361

5462
const { currency } = resolveLocaleAndCurrency(request);
@@ -60,41 +68,33 @@ export async function POST(request: NextRequest) {
6068
} catch (error) {
6169
logError('cart_rehydrate_failed', error);
6270

71+
// Missing price for locale currency is a CONTRACT error, not a 422.
6372
if (error instanceof PriceConfigError) {
64-
return NextResponse.json(
65-
{
66-
code: error.code,
67-
message: error.message,
68-
details: { productId: error.productId, currency: error.currency },
69-
},
70-
{ status: 422 }
71-
);
73+
return jsonError(400, error.code, error.message, {
74+
productId: error.productId,
75+
currency: error.currency,
76+
});
7277
}
78+
79+
// DB misconfiguration / invalid stored money: treat as 500 (server fault),
80+
// but keep stable code for diagnostics.
7381
if (error instanceof MoneyValueError) {
74-
return NextResponse.json(
82+
return jsonError(
83+
500,
84+
'PRICE_CONFIG_ERROR',
85+
'Invalid price configuration for one or more products.',
7586
{
76-
code: 'PRICE_CONFIG_ERROR',
77-
message: 'Invalid price configuration for one or more products.',
78-
details: {
79-
productId: error.productId,
80-
field: error.field,
81-
rawValue: error.rawValue,
82-
},
83-
},
84-
{ status: 500 }
87+
productId: error.productId,
88+
field: error.field,
89+
rawValue: error.rawValue,
90+
}
8591
);
8692
}
8793

8894
if (error instanceof InvalidPayloadError) {
89-
return NextResponse.json(
90-
{ code: error.code, message: error.message },
91-
{ status: 400 }
92-
);
95+
return jsonError(400, error.code, error.message);
9396
}
9497

95-
return NextResponse.json(
96-
{ error: 'Unable to rehydrate cart.' },
97-
{ status: 500 }
98-
);
98+
return jsonError(500, 'INTERNAL_ERROR', 'Unable to rehydrate cart.');
9999
}
100100
}

frontend/components/shop/cart-provider.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import {
1414
type Cart,
1515
type CartClientItem,
16+
type CartRehydrateError,
1617
createCartItemKey,
1718
capQuantityByStock,
1819
getStoredCartItems,
@@ -22,6 +23,7 @@ import {
2223
emptyCart,
2324
} from '@/lib/cart';
2425
import type { ShopProduct } from '@/lib/shop/data';
26+
import { logWarn } from '@/lib/logging';
2527

2628
interface CartContextType {
2729
cart: Cart;
@@ -53,16 +55,89 @@ const CartContext = createContext<CartContextType>({
5355
clearCart: () => {},
5456
});
5557

58+
function getErrorInfo(error: unknown): {
59+
code: string;
60+
message: string;
61+
details?: unknown;
62+
} {
63+
const e = error as Partial<CartRehydrateError> & {
64+
code?: unknown;
65+
details?: unknown;
66+
message?: unknown;
67+
};
68+
69+
return {
70+
code: typeof e?.code === 'string' ? e.code : 'UNKNOWN_ERROR',
71+
message: typeof e?.message === 'string' ? e.message : 'Cart rehydrate failed',
72+
details: e?.details,
73+
};
74+
}
75+
5676
export function CartProvider({ children }: { children: React.ReactNode }) {
5777
const [cart, setCart] = useState<Cart>(emptyCart);
5878

5979
const syncCartWithServer = useCallback(async (items: CartClientItem[]) => {
6080
persistCartItems(items);
81+
6182
try {
6283
const nextCart = await rehydrateCart(items);
6384
setCart(nextCart);
85+
return;
6486
} catch (error) {
65-
console.error('Failed to rehydrate cart', error);
87+
const info = getErrorInfo(error);
88+
89+
// Self-heal: missing price for locale currency (e.g., uk => UAH) should not crash UI.
90+
if (info.code === 'PRICE_CONFIG_ERROR') {
91+
const productId =
92+
typeof (info.details as any)?.productId === 'string'
93+
? String((info.details as any).productId)
94+
: '';
95+
96+
// Best-effort: remove only the problematic product (if identified), retry once.
97+
if (productId) {
98+
const filtered = items.filter(i => i.productId !== productId);
99+
100+
if (filtered.length !== items.length) {
101+
persistCartItems(filtered);
102+
103+
try {
104+
const retriedCart = await rehydrateCart(filtered);
105+
setCart(retriedCart);
106+
107+
logWarn('cart_rehydrate_recovered_by_removing_item', {
108+
code: info.code,
109+
removedProductId: productId,
110+
});
111+
112+
return;
113+
} catch (retryError) {
114+
const retryInfo = getErrorInfo(retryError);
115+
logWarn('cart_rehydrate_retry_failed', {
116+
code: retryInfo.code,
117+
message: retryInfo.message,
118+
});
119+
}
120+
}
121+
}
122+
123+
// Fallback: clear cart to unblock the user.
124+
clearStoredCart();
125+
setCart(emptyCart);
126+
127+
logWarn('cart_cleared_due_to_rehydrate_error', {
128+
code: info.code,
129+
message: info.message,
130+
details: info.details,
131+
});
132+
133+
return;
134+
}
135+
136+
// Non-blocking: keep current cart state (avoid crashing the page).
137+
logWarn('cart_rehydrate_failed_client', {
138+
code: info.code,
139+
message: info.message,
140+
});
66141
}
67142
}, []);
68143

frontend/lib/cart.ts

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,7 @@ export function persistCartItems(items: CartClientItem[]): void {
106106
}
107107
}
108108

109-
function normalizeItemsForStorage(
110-
items: CartRehydrateItem[]
111-
): CartClientItem[] {
109+
function normalizeItemsForStorage(items: CartRehydrateItem[]): CartClientItem[] {
112110
return items.map(item => ({
113111
productId: item.productId,
114112
quantity: item.quantity,
@@ -121,9 +119,7 @@ function normalizeItemsForStorage(
121119
* IMPORTANT:
122120
* Cart money fields are MINOR UNITS (integers).
123121
*/
124-
export function computeSummaryFromItems(
125-
items: CartRehydrateItem[]
126-
): CartSummary {
122+
export function computeSummaryFromItems(items: CartRehydrateItem[]): CartSummary {
127123
if (!items.length) {
128124
return {
129125
totalAmountMinor: 0,
@@ -158,6 +154,79 @@ export function computeSummaryFromItems(
158154
};
159155
}
160156

157+
function isRecord(value: unknown): value is Record<string, unknown> {
158+
return !!value && typeof value === 'object' && !Array.isArray(value);
159+
}
160+
161+
function extractApiError(data: unknown): {
162+
code: string;
163+
message: string;
164+
details?: unknown;
165+
} {
166+
// legacy: { error: "..." }
167+
if (isRecord(data) && typeof data.error === 'string') {
168+
return { code: 'UNKNOWN_ERROR', message: data.error };
169+
}
170+
171+
// preferred: { error: { code, message, details } }
172+
if (isRecord(data) && isRecord(data.error)) {
173+
const errObj = data.error;
174+
const code =
175+
typeof errObj.code === 'string' && errObj.code.trim().length > 0
176+
? errObj.code
177+
: 'UNKNOWN_ERROR';
178+
const message =
179+
typeof errObj.message === 'string' && errObj.message.trim().length > 0
180+
? errObj.message
181+
: 'Request failed';
182+
return { code, message, details: errObj.details };
183+
}
184+
185+
// fallback: { code, message, details }
186+
if (isRecord(data)) {
187+
const code =
188+
typeof data.code === 'string' && data.code.trim().length > 0
189+
? data.code
190+
: 'UNKNOWN_ERROR';
191+
const message =
192+
typeof data.message === 'string' && data.message.trim().length > 0
193+
? data.message
194+
: 'Request failed';
195+
if (typeof data.code === 'string' || typeof data.message === 'string') {
196+
return { code, message, details: data.details };
197+
}
198+
}
199+
200+
return { code: 'UNKNOWN_ERROR', message: 'Unable to rehydrate cart' };
201+
}
202+
203+
export class CartRehydrateError extends Error {
204+
public readonly code: string;
205+
public readonly status: number;
206+
public readonly details?: unknown;
207+
208+
constructor(args: {
209+
code: string;
210+
message: string;
211+
status: number;
212+
details?: unknown;
213+
}) {
214+
super(args.message);
215+
this.name = 'CartRehydrateError';
216+
this.code = args.code;
217+
this.status = args.status;
218+
this.details = args.details;
219+
}
220+
}
221+
222+
async function readJsonSafe(response: Response): Promise<unknown> {
223+
try {
224+
return await response.json();
225+
} catch {
226+
return null;
227+
}
228+
}
229+
161230
export async function rehydrateCart(items: CartClientItem[]): Promise<Cart> {
162231
if (!items.length) {
163232
persistCartItems([]);
@@ -170,10 +239,16 @@ export async function rehydrateCart(items: CartClientItem[]): Promise<Cart> {
170239
body: JSON.stringify({ items }),
171240
});
172241

173-
const data = await response.json();
242+
const data = await readJsonSafe(response);
174243

175244
if (!response.ok) {
176-
throw new Error(data?.error ?? 'Unable to rehydrate cart');
245+
const apiErr = extractApiError(data);
246+
throw new CartRehydrateError({
247+
code: apiErr.code,
248+
message: apiErr.message || `Unable to rehydrate cart (HTTP ${response.status})`,
249+
status: response.status,
250+
details: apiErr.details,
251+
});
177252
}
178253

179254
const parsed = cartRehydrateResultSchema.parse(data);

0 commit comments

Comments
 (0)