Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';

import { CATEGORIES, COLORS, PRODUCT_TYPES, SIZES } from '@/lib/config/catalog';
import type { ProductAdminInput } from '@/lib/validation/shop';
import { logError } from '@/lib/logging';

const localSlugify = (input: string): string => {
return input
Expand Down Expand Up @@ -344,10 +345,11 @@ export function ProductForm({
const destinationSlug = data.product?.slug ?? slugValue;
router.push(`/shop/products/${destinationSlug}`);
} catch (err) {
console.error(
`Failed to ${mode === 'create' ? 'create' : 'update'} product`,
err
);
logError('admin_product_form_failed', err, {
mode,
productId: productId ?? null,
slug: slugValue,
});
setError(
`Unexpected error while ${
mode === 'create' ? 'creating' : 'updating'
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/[locale]/shop/admin/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { db } from '@/db';
import { products, productPrices } from '@/db/schema';
import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency';
import { fromDbMoney } from '@/lib/shop/money';
import { logWarn } from '@/lib/logging';

function formatDate(value: Date | null, locale: string) {
if (!value) return '-';
Expand All @@ -22,7 +23,7 @@ function safeFromDbMoney(
try {
return fromDbMoney(value);
} catch (err) {
console.warn('[admin products] fromDbMoney failed', {
logWarn('admin_products_from_db_money_failed', {
...ctx,
valueType: typeof value,
value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
resolveCurrencyFromLocale,
type CurrencyCode,
} from '@/lib/shop/currency';
import { logError } from '@/lib/logging';

type PaymentFormProps = {
orderId: string;
Expand Down Expand Up @@ -111,7 +112,7 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
});
router.push(next);
} catch (error) {
console.error('Payment confirmation failed', error);
logError('stripe_payment_confirm_failed', error, { orderId });
setErrorMessage('We couldn’t confirm your payment. Please try again.');
router.push(`/shop/checkout/error?orderId=${orderId}`);
} finally {
Expand Down
15 changes: 6 additions & 9 deletions frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { orderIdParamSchema } from '@/lib/validation/shop';
import { getStripeEnv } from '@/lib/env/stripe';
import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe';
import { setOrderPaymentIntent } from '@/lib/services/orders';
import { logError } from '@/lib/logging';

function getOrderId(params: { orderId?: string }) {
const parsed = orderIdParamSchema.safeParse({ id: params.orderId ?? '' });
Expand Down Expand Up @@ -169,15 +170,11 @@ export default async function PaymentPage(props: PaymentPageProps) {
clientSecret = created.clientSecret;
}
} catch (error) {
console.error(
'Failed to initialize Stripe payment intent',
{
orderId: order.id,
existingPi,
phase,
},
error
);
logError('payment_page_failed', error, {
orderId: order.id,
existingPi,
phase,
});

// Leave clientSecret empty -> UI shows "Payment cannot be initialized"
}
Expand Down
92 changes: 46 additions & 46 deletions frontend/app/api/shop/cart/rehydrate/route.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest, NextResponse } from 'next/server';

import { MoneyValueError } from "@/db/queries/shop/orders";
import { resolveLocaleAndCurrency } from "@/lib/shop/request-locale";
import { MoneyValueError } from '@/db/queries/shop/orders';
import { resolveLocaleAndCurrency } from '@/lib/shop/request-locale';

import { rehydrateCartItems } from "@/lib/services/products";
import { cartRehydratePayloadSchema } from "@/lib/validation/shop";
import { InvalidPayloadError, PriceConfigError } from "@/lib/services/errors";
import { rehydrateCartItems } from '@/lib/services/products';
import { cartRehydratePayloadSchema } from '@/lib/validation/shop';
import { InvalidPayloadError, PriceConfigError } from '@/lib/services/errors';
import { logError } from '@/lib/logging';

function normalizeCartPayload(body: unknown) {
if (!body || typeof body !== "object") return body;
if (!body || typeof body !== 'object') return body;
const { items, ...rest } = body as { items?: unknown };

if (!Array.isArray(items)) return body;

return {
...rest,
items: items.map((item) => {
if (!item || typeof item !== "object") return item;
items: items.map(item => {
if (!item || typeof item !== 'object') return item;
const { quantity, ...itemRest } = item as { quantity?: unknown };
const normalizedQuantity =
typeof quantity === "string" && quantity.trim().length > 0
typeof quantity === 'string' && quantity.trim().length > 0
? Number(quantity)
: quantity;

Expand All @@ -28,73 +29,72 @@ function normalizeCartPayload(body: unknown) {
};
}

function jsonError(
status: number,
code: string,
message: string,
details?: unknown
) {
return NextResponse.json(
{ error: { code, message, ...(details ? { details } : {}) } },
{ status }
);
}

export async function POST(request: NextRequest) {
let body: unknown;

try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Unable to process cart data." },
{ status: 400 }
);
return jsonError(400, 'INVALID_PAYLOAD', 'Unable to process cart data.');
}

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

if (!parsedPayload.success) {
return NextResponse.json(
{ error: "Invalid cart payload", details: parsedPayload.error.format() },
{ status: 400 }
);
return jsonError(400, 'INVALID_PAYLOAD', 'Invalid cart payload', {
issues: parsedPayload.error.format(),
});
}

const { currency } = resolveLocaleAndCurrency(request);


try {
const { items } = parsedPayload.data;
const parsedResult = await rehydrateCartItems(items, currency);
return NextResponse.json(parsedResult);
} catch (error) {
console.error("Cart rehydrate failed", error);
logError('cart_rehydrate_failed', error);

// Missing price for locale currency is a CONTRACT error, not a 422.
if (error instanceof PriceConfigError) {
return NextResponse.json(
{
code: error.code,
message: error.message,
details: { productId: error.productId, currency: error.currency },
},
{ status: 422 }
);
return jsonError(400, error.code, error.message, {
productId: error.productId,
currency: error.currency,
});
}

// DB misconfiguration / invalid stored money: treat as 500 (server fault),
// but keep stable code for diagnostics.
if (error instanceof MoneyValueError) {
return NextResponse.json(
return jsonError(
500,
'PRICE_CONFIG_ERROR',
'Invalid price configuration for one or more products.',
{
code: "PRICE_CONFIG_ERROR",
message: "Invalid price configuration for one or more products.",
details: {
productId: error.productId,
field: error.field,
rawValue: error.rawValue,
},
},
{ status: 500 }
productId: error.productId,
field: error.field,
rawValue: error.rawValue,
}
);
}

if (error instanceof InvalidPayloadError) {
return NextResponse.json(
{ code: error.code, message: error.message },
{ status: 400 }
);
return jsonError(400, error.code, error.message);
}

return NextResponse.json(
{ error: "Unable to rehydrate cart." },
{ status: 500 }
);
return jsonError(500, 'INTERNAL_ERROR', 'Unable to rehydrate cart.');
}
}
12 changes: 12 additions & 0 deletions frontend/app/api/shop/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe';
import {
InsufficientStockError,
InvalidPayloadError,
InvalidVariantError,
PriceConfigError,
OrderStateInvalidError,
} from '@/lib/services/errors';
Expand All @@ -28,6 +29,7 @@ import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments';
const EXPECTED_BUSINESS_ERROR_CODES = new Set([
'IDEMPOTENCY_CONFLICT',
'INVALID_PAYLOAD',
'INVALID_VARIANT',
'INSUFFICIENT_STOCK',
'PRICE_CONFIG_ERROR',
]);
Expand All @@ -47,6 +49,7 @@ function isExpectedBusinessError(err: unknown): boolean {
if (err instanceof InvalidPayloadError) return true;
if (err instanceof InsufficientStockError) return true;
if (err instanceof PriceConfigError) return true;
if (err instanceof InvalidVariantError) return true;

return false;
}
Expand Down Expand Up @@ -461,6 +464,15 @@ export async function POST(request: NextRequest) {
);
}

if (error instanceof InvalidVariantError) {
return errorResponse(error.code, error.message, 400, {
productId: error.productId,
field: error.field,
value: error.value,
allowed: error.allowed,
});
}

if (error instanceof IdempotencyConflictError) {
return errorResponse(error.code, error.message, 409, error.details);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// C:\Users\milka\devlovers.net\frontend\app\api\shop\internal\orders\restock-stale\route.ts
//app\api\shop\internal\orders\restock-stale\route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { sql } from 'drizzle-orm';
Expand All @@ -10,6 +10,7 @@ import {
} from '@/lib/services/orders';

import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor';
import { logError } from '@/lib/logging';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -422,7 +423,7 @@ export async function POST(request: NextRequest) {
minIntervalSeconds,
});
} catch (e) {
console.error('restock-stale failed', { runId, error: e });
logError('restock_stale_failed', e, { runId });
return NextResponse.json(
{ success: false, code: 'INTERNAL_ERROR' },
{ status: 500 }
Expand Down
Loading