chore: harden reliability checks #2
@ -13,6 +13,10 @@ function parseBoolean(value) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function isCatalogTableMissing(error) {
|
||||
return error?.code === "42P01" && /household_store_available_items/i.test(error?.message || "");
|
||||
}
|
||||
|
||||
function parseClassificationInput(value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
@ -119,8 +123,15 @@ exports.getAvailableItems = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
|
||||
res.json({ items });
|
||||
res.json({ items, catalog_ready: true });
|
||||
} catch (error) {
|
||||
if (isCatalogTableMissing(error)) {
|
||||
return res.json({
|
||||
items: [],
|
||||
catalog_ready: false,
|
||||
message: "Store item catalog is unavailable until the latest database migration is applied.",
|
||||
});
|
||||
}
|
||||
logError(req, "availableItems.getAvailableItems", error);
|
||||
sendError(res, 500, "Failed to load available items");
|
||||
}
|
||||
@ -171,6 +182,13 @@ exports.createAvailableItem = async (req, res) => {
|
||||
item: refreshedItem,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCatalogTableMissing(error)) {
|
||||
return sendError(
|
||||
res,
|
||||
503,
|
||||
"Store item catalog is unavailable until the latest database migration is applied"
|
||||
);
|
||||
}
|
||||
logError(req, "availableItems.createAvailableItem", error);
|
||||
if (error.code === "23505") {
|
||||
return sendError(res, 400, "Available item already exists for this store");
|
||||
@ -234,6 +252,13 @@ exports.updateAvailableItem = async (req, res) => {
|
||||
item: refreshedItem,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCatalogTableMissing(error)) {
|
||||
return sendError(
|
||||
res,
|
||||
503,
|
||||
"Store item catalog is unavailable until the latest database migration is applied"
|
||||
);
|
||||
}
|
||||
logError(req, "availableItems.updateAvailableItem", error);
|
||||
if (error.code === "23505") {
|
||||
return sendError(res, 400, "Available item already exists for this store");
|
||||
@ -258,6 +283,13 @@ exports.deleteAvailableItem = async (req, res) => {
|
||||
|
||||
res.json({ message: "Available item removed" });
|
||||
} catch (error) {
|
||||
if (isCatalogTableMissing(error)) {
|
||||
return sendError(
|
||||
res,
|
||||
503,
|
||||
"Store item catalog is unavailable until the latest database migration is applied"
|
||||
);
|
||||
}
|
||||
logError(req, "availableItems.deleteAvailableItem", error);
|
||||
sendError(res, 500, "Failed to remove available item");
|
||||
}
|
||||
@ -273,6 +305,13 @@ exports.importCurrentItems = async (req, res) => {
|
||||
imported_count: importedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCatalogTableMissing(error)) {
|
||||
return sendError(
|
||||
res,
|
||||
503,
|
||||
"Store item catalog is unavailable until the latest database migration is applied"
|
||||
);
|
||||
}
|
||||
logError(req, "availableItems.importCurrentItems", error);
|
||||
sendError(res, 500, "Failed to import current list items");
|
||||
}
|
||||
|
||||
@ -134,4 +134,53 @@ describe("available-items.controller", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns an empty catalog payload when the available items table is missing", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
query: {},
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
AvailableItems.listAvailableItems.mockRejectedValueOnce({
|
||||
code: "42P01",
|
||||
message: 'relation "household_store_available_items" does not exist',
|
||||
});
|
||||
|
||||
await controller.getAvailableItems(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [],
|
||||
catalog_ready: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a setup error when creating while the available items table is missing", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
},
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
AvailableItems.createAvailableItem.mockRejectedValueOnce({
|
||||
code: "42P01",
|
||||
message: 'relation "household_store_available_items" does not exist',
|
||||
});
|
||||
|
||||
await controller.createAvailableItem(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringContaining("latest database migration"),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,6 +23,8 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
const toast = useActionToast();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [items, setItems] = useState([]);
|
||||
const [catalogReady, setCatalogReady] = useState(true);
|
||||
const [catalogMessage, setCatalogMessage] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editorItem, setEditorItem] = useState(null);
|
||||
@ -38,8 +40,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
try {
|
||||
const response = await getAvailableItems(householdId, store.id, search);
|
||||
setItems(response.data.items || []);
|
||||
setCatalogReady(response.data.catalog_ready !== false);
|
||||
setCatalogMessage(response.data.message || "");
|
||||
} catch (error) {
|
||||
console.error("Failed to load available items:", error);
|
||||
setCatalogReady(false);
|
||||
setCatalogMessage("Store item catalog is unavailable right now.");
|
||||
const message = getApiErrorMessage(error, "Failed to load available items");
|
||||
toast.error("Load store items failed", `Load store items failed: ${message}`);
|
||||
} finally {
|
||||
@ -56,6 +62,14 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
}, [expanded, query, loadItems]);
|
||||
|
||||
const handleCreate = async (payload) => {
|
||||
if (!catalogReady) {
|
||||
toast.info(
|
||||
"Store item catalog unavailable",
|
||||
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createAvailableItem(householdId, store.id, payload);
|
||||
toast.success("Added store item", `Added ${payload.itemName} to ${store.name}`);
|
||||
@ -70,6 +84,14 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
};
|
||||
|
||||
const handleUpdate = async (payload) => {
|
||||
if (!catalogReady) {
|
||||
toast.info(
|
||||
"Store item catalog unavailable",
|
||||
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
|
||||
toast.success("Updated store item", `Updated ${payload.itemName} for ${store.name}`);
|
||||
@ -84,6 +106,14 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
};
|
||||
|
||||
const handleDelete = async (item) => {
|
||||
if (!catalogReady) {
|
||||
toast.info(
|
||||
"Store item catalog unavailable",
|
||||
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Remove ${item.item_name} from ${store.name}'s available items?`)) {
|
||||
return;
|
||||
}
|
||||
@ -99,6 +129,14 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!catalogReady) {
|
||||
toast.info(
|
||||
"Store item catalog unavailable",
|
||||
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await importCurrentAvailableItems(householdId, store.id);
|
||||
const importedCount = response.data.imported_count || 0;
|
||||
@ -137,18 +175,25 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
|
||||
{expanded ? (
|
||||
<div className="store-available-items-panel">
|
||||
{!catalogReady ? (
|
||||
<p className="store-available-items-notice">
|
||||
{catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="store-available-items-toolbar">
|
||||
<input
|
||||
className="store-available-items-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search store items"
|
||||
disabled={!catalogReady}
|
||||
/>
|
||||
<div className="store-available-items-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
onClick={handleImport}
|
||||
disabled={!catalogReady}
|
||||
>
|
||||
Import Current List
|
||||
</button>
|
||||
@ -159,13 +204,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
setEditorItem(null);
|
||||
setShowEditor(true);
|
||||
}}
|
||||
disabled={!catalogReady}
|
||||
>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{!catalogReady ? (
|
||||
<p className="empty-message">Run the latest database migrations to enable this catalog.</p>
|
||||
) : loading ? (
|
||||
<p className="empty-message">Loading store items...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="empty-message">No available items saved for this store yet.</p>
|
||||
|
||||
@ -29,6 +29,15 @@
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.store-available-items-notice {
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: var(--border-width-thin) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.store-available-items-toolbar {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user