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
7 changes: 7 additions & 0 deletions cod-client-astro/locales/ar/products.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"error_delete_has_orders": "لا يمكن حذف منتج لديه سجل طلبات",
"error_duplicate_sku": "هذا الرمز مستخدم بالفعل",
"error_insufficient_stock": "لا يمكن أن يقل المخزون عن الصفر",
"error_validation": "بعض الحقول غير صالحة",
"error_unexpected_id": "حدث خطأ غير متوقع. حاول مرة أخرى — وإذا استمر، أذكر الرقم {id} للدعم.",
"error_duplicate_sku_named": "رمز SKU {sku} مستخدم بالفعل في متغير آخر",
"search_placeholder": "بحث بالاسم أو الرمز...",
"table": {
"name": "المنتج",
Expand Down Expand Up @@ -118,6 +121,10 @@
"error_not_found": "المنتج غير موجود",
"error_load_failed": "فشل تحميل المنتج",
"error_name_price_required": "الاسم والسعر حقلان مطلوبان",
"error_price_integer": "يجب أن يكون السعر رقمًا صحيحًا",
"error_duplicate_variant_sku": "صفّان من المتغيرات يستخدمان نفس SKU",
"error_variant_row": "تعذر حفظ المتغير {variant}",
"partial_save_redirect": "تم حفظ المنتج، لكن فشل أحد المتغيرات: {message}. جارٍ فتح المحرر لتصحيحه.",
"error_save_failed": "فشل حفظ المنتج",
"error_required_variant_sku": "كل متغير يتطلب رمزاً",
"base_price_hint": "السعر الأساسي — يمكن لكل متغير تجاوزه أدناه",
Expand Down
7 changes: 7 additions & 0 deletions cod-client-astro/locales/en/products.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"error_delete_has_orders": "Cannot delete a product that has order history",
"error_duplicate_sku": "This SKU is already in use",
"error_insufficient_stock": "Stock cannot go below zero",
"error_validation": "Some fields are invalid",
"error_unexpected_id": "An unexpected error occurred. Please try again — if it persists, quote reference {id} to support.",
"error_duplicate_sku_named": "SKU {sku} is already used by another variant",
"search_placeholder": "Search by name, SKU...",
"table": {
"name": "Product",
Expand Down Expand Up @@ -118,6 +121,10 @@
"error_not_found": "Product not found",
"error_load_failed": "Failed to load product",
"error_name_price_required": "Name and price are required",
"error_price_integer": "Price must be a whole number",
"error_duplicate_variant_sku": "Two variant rows use the same SKU",
"error_variant_row": "Variant {variant} could not be saved",
"partial_save_redirect": "Product saved, but a variant failed: {message}. Opening the editor so you can fix it.",
"error_save_failed": "Failed to save product",
"error_required_variant_sku": "Every variant needs a SKU",
"base_price_hint": "Base price — each variant can override this below",
Expand Down
7 changes: 7 additions & 0 deletions cod-client-astro/locales/fr/products.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"error_delete_has_orders": "Impossible de supprimer un produit ayant un historique de commandes",
"error_duplicate_sku": "Ce SKU est déjà utilisé",
"error_insufficient_stock": "Le stock ne peut pas passer sous zéro",
"error_validation": "Certains champs sont invalides",
"error_unexpected_id": "Une erreur inattendue s'est produite. Réessayez — si elle persiste, communiquez la référence {id} au support.",
"error_duplicate_sku_named": "Le SKU {sku} est déjà utilisé par une autre variante",
"search_placeholder": "Rechercher par nom, SKU...",
"table": {
"name": "Produit",
Expand Down Expand Up @@ -118,6 +121,10 @@
"error_not_found": "Produit non trouvé",
"error_load_failed": "Échec du chargement du produit",
"error_name_price_required": "Le nom et le prix sont requis",
"error_price_integer": "Le prix doit être un nombre entier",
"error_duplicate_variant_sku": "Deux lignes de variante utilisent le même SKU",
"error_variant_row": "La variante {variant} n'a pas pu être enregistrée",
"partial_save_redirect": "Produit enregistré, mais une variante a échoué : {message}. Ouverture de l'éditeur pour corriger.",
"error_save_failed": "Échec de l'enregistrement du produit",
"error_required_variant_sku": "Chaque variante nécessite un SKU",
"base_price_hint": "Prix de base — chaque variante peut l'écraser ci-dessous",
Expand Down
127 changes: 84 additions & 43 deletions cod-client-astro/src/features/products/components/ProductForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
generateCombinations,
productErrorMessage,
toSlug,
variantLabel,
} from "@/features/products/model";
import type {
ProductImage,
Expand Down Expand Up @@ -232,8 +233,17 @@ export function ProductForm({ productId }: { productId?: string }) {
if (!name.trim()) next.name = t("form.error_name_price_required");
if (!price || Number(price) < 0)
next.price = t("form.error_name_price_required");
else if (!Number.isInteger(Number(price)))
next.price = t("form.error_price_integer");
if (hasVariantsSwitch && variantRows.some((row) => !row.sku.trim()))
next.variantRowsSku = t("form.error_required_variant_sku");
if (hasVariantsSwitch) {
const skus = variantRows
.map((row) => row.sku.trim())
.filter(Boolean);
if (new Set(skus).size !== skus.length)
next.variantRowsSkuDuplicate = t("form.error_duplicate_variant_sku");
}
setErrors(next);
return Object.keys(next).length === 0;
}
Expand All @@ -242,7 +252,7 @@ export function ProductForm({ productId }: { productId?: string }) {
if (!validate()) return;
setBusy(true);
setMessage(null);
try {
{
const apiOptions = apiVariantOptions(variantOptions);
const hasVariants = apiOptions.length > 0 && variantRows.length > 0;
const variantsToDelete: string[] = [];
Expand Down Expand Up @@ -302,51 +312,82 @@ export function ProductForm({ productId }: { productId?: string }) {
variantOptions: hasVariants ? apiOptions : null,
};

let savedId: string;
if (editing && productId) {
await updateProduct(productId, data as never);
savedId = productId;
} else {
savedId = (await createProduct(data as never)).data.id;
}
// Track how far the multi-step save got so the failure message can say
// WHAT failed and the retry can never duplicate the product.
let savedId: string | null = null;
let failedStep: "variants" | "images" | null = null;
let failedVariantLabel: string | null = null;
try {
if (editing && productId) {
await updateProduct(productId, data as never);
savedId = productId;
} else {
savedId = (await createProduct(data as never)).data.id;
}

if (hasVariants) {
for (const row of variantRows) {
if (row.existingId && orphanedIds.has(row.existingId)) continue;
const variantData = {
variations: row.variations,
price: Math.round(Number(row.price) || 0),
sku: row.sku.trim(),
inventory: Number(row.inventory) || 0,
lowStockThreshold: Number(row.lowStockThreshold) || 5,
active: row.active,
imageId: row.imageId ?? null,
};
if (row.existingId)
await updateVariant(savedId, row.existingId, variantData as never);
else await createVariant(savedId, variantData as never);
if (hasVariants) {
failedStep = "variants";
for (const row of variantRows) {
if (row.existingId && orphanedIds.has(row.existingId)) continue;
const variantData = {
variations: row.variations,
price: Math.round(Number(row.price) || 0),
sku: row.sku.trim(),
inventory: Number(row.inventory) || 0,
lowStockThreshold: Number(row.lowStockThreshold) || 5,
active: row.active,
imageId: row.imageId ?? null,
};
failedVariantLabel = variantLabel(row.variations) || row.sku.trim();
if (row.existingId)
await updateVariant(savedId, row.existingId, variantData as never);
else await createVariant(savedId, variantData as never);
}
failedVariantLabel = null;
}
failedStep = "images";
for (const variantId of variantsToDelete) {
await deleteVariant(savedId, variantId);
}
for (const imageId of deletedImageIds) {
await deleteProductImage(savedId, imageId);
}
for (let i = 0; i < pendingImages.length; i++) {
await saveProductImage(savedId, {
key: pendingImages[i].key,
src: pendingImages[i].url,
position: existingImages.length - deletedImageIds.length + i + 1,
});
}
failedStep = null;
notify.flashSuccess(t(editing ? "form.success_edit" : "form.success_add"));
window.location.assign("/products");
return;
} catch (cause) {
const detail = productErrorMessage(cause, t);
let message = detail;
if (failedStep === "variants" && failedVariantLabel) {
message = `${t("form.error_variant_row")
.replace("{variant}", failedVariantLabel)} — ${detail}`;
}

// Partial-save trap: the product row EXISTS (created or updated) but
// a later step failed. Retrying "create" would duplicate the product —
// send the merchant to the editor instead, where retry = update.
if (savedId && !editing) {
notify.error(
t("form.partial_save_redirect").replace("{message}", detail),
);
window.location.assign(
`/products/${encodeURIComponent(savedId)}/edit`,
);
return;
}

setMessage(message);
notify.error(message);
setBusy(false);
}
for (const variantId of variantsToDelete) {
await deleteVariant(savedId, variantId);
}
for (const imageId of deletedImageIds) {
await deleteProductImage(savedId, imageId);
}
for (let i = 0; i < pendingImages.length; i++) {
await saveProductImage(savedId, {
key: pendingImages[i].key,
src: pendingImages[i].url,
position: existingImages.length - deletedImageIds.length + i + 1,
});
}
notify.flashSuccess(t(editing ? "form.success_edit" : "form.success_add"));
window.location.assign("/products");
} catch (cause) {
const message = productErrorMessage(cause, t);
setMessage(message);
notify.error(message);
setBusy(false);
}
}

Expand Down
47 changes: 45 additions & 2 deletions cod-client-astro/src/features/products/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,55 @@ export function parseProductRoute(pathname: string): ProductRoute {
}
}

interface ApiLikeError {
code?: string;
status?: number;
context?: Record<string, unknown>;
}

function asApiError(cause: unknown): ApiLikeError | null {
if (!cause || typeof cause !== "object") return null;
const candidate = cause as ApiLikeError;
return typeof candidate.code === "string" ? candidate : null;
}

export function productErrorMessage(cause: unknown, t: (key: string) => string) {
const code = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
const api = asApiError(cause);
const code = api?.code ?? "";
const context = api?.context;

if (code === "PRODUCT_NOT_FOUND") return t("error_not_found");
if (code === "PRODUCT_HAS_ORDERS") return t("error_delete_has_orders");
if (code === "DUPLICATE_SKU") return t("error_duplicate_sku");
if (code === "INSUFFICIENT_STOCK") return t("error_insufficient_stock");

if (code === "DUPLICATE_SKU") {
const sku = typeof context?.sku === "string" ? context.sku : null;
return sku
? t("error_duplicate_sku_named").replace("{sku}", sku)
: t("error_duplicate_sku");
}

if (code === "VALIDATION_FAILED") {
const fields = Array.isArray(context?.fields) ? context!.fields : [];
const lines = fields
.map((field) => {
const entry = field as { path?: string; message?: string };
return entry.path ? `${entry.path}: ${entry.message ?? ""}` : entry.message ?? "";
})
.filter(Boolean);
return lines.length
? `${t("error_validation")} — ${lines.join(" · ")}`
: t("error_validation");
}

if (code === "INTERNAL_SERVER_ERROR" || (api?.status ?? 0) >= 500) {
const requestId =
typeof context?.requestId === "string" ? context.requestId : null;
return requestId
? t("error_unexpected_id").replace("{id}", requestId)
: t("error_generic");
}

return t("error_generic");
}

Expand Down
2 changes: 1 addition & 1 deletion cod-server/src/endpoints/variants/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Terms owned by neighboring contexts — use them, don't redefine them here:

**No blueprint police**: Creating `{"Material": "Gold"}` on a product whose declared options only list Color succeeds silently — drift between options and real variants is possible.

**SKU collisions crash late**: Duplicate SKUs surface as raw database constraint errors rather than friendly conflicts, because uniqueness is delegated entirely to the schema.
**SKU collisions fail friendly**: Duplicate SKUs are caught by a pre-check (and a defensive constraint mapping for races) and rejected as a 409 Conflict carrying the offending SKU — the same treatment product creation gets.

**Parent existence is assumed**: Creating a variant against a nonexistent product fails at the foreign-key level, not with a clean not-found message.

Expand Down
75 changes: 75 additions & 0 deletions cod-server/src/endpoints/variants/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,80 @@
/**
* Re-exported from cod-shared/queries/variants so the dashboard can consume
* the same read functions directly from D1.
*
* createVariant/updateVariant wrappers stay here because they raise
* BusinessLogicError: the SKU uniqueness pre-check turns a raw DB constraint
* crash (HTTP 500) into a friendly 409 DUPLICATE_SKU carrying the SKU.
*/
import { eq, and, ne } from "drizzle-orm";
import { productVariants } from "@/db/schema";
import type { AppDb } from "@/db";
import { ConflictError } from "@/lib/errors/classes";
import { ERROR_CODES } from "../../../../cod-shared/errors/codes";
import * as shared from "../../../../cod-shared/queries/variants";

export * from "../../../../cod-shared/queries/variants";

type CreateVariantData = Parameters<typeof shared.createVariant>[2];
type UpdateVariantData = Parameters<typeof shared.updateVariant>[2];

function isSkuUniqueViolation(err: unknown): boolean {
return (
err instanceof Error &&
/UNIQUE constraint failed: product_variants\.sku/i.test(err.message)
);
}

function duplicateSkuError(sku: string) {
return new ConflictError(
`SKU "${sku}" is already used by another variant`,
ERROR_CODES.DUPLICATE_SKU,
{ sku },
);
}

async function assertSkuAvailable(
db: AppDb,
sku: string,
excludeVariantId: string | null,
) {
const clash = await db
.select({ id: productVariants.id, productId: productVariants.productId })
.from(productVariants)
.where(
excludeVariantId
? and(eq(productVariants.sku, sku), ne(productVariants.id, excludeVariantId))
: eq(productVariants.sku, sku),
)
.get();
if (clash) throw duplicateSkuError(sku);
}

export async function createVariant(
db: AppDb,
productId: string,
data: CreateVariantData,
) {
if (data.sku) await assertSkuAvailable(db, data.sku, null);
try {
return await shared.createVariant(db, productId, data);
} catch (err) {
// Race: a concurrent writer took the SKU between check and insert.
if (isSkuUniqueViolation(err)) throw duplicateSkuError(data.sku);
throw err;
}
}

export async function updateVariant(
db: AppDb,
variantId: string,
data: UpdateVariantData,
) {
if (data.sku) await assertSkuAvailable(db, data.sku, variantId);
try {
return await shared.updateVariant(db, variantId, data);
} catch (err) {
if (data.sku && isSkuUniqueViolation(err)) throw duplicateSkuError(data.sku);
throw err;
}
}
Loading