chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
4 changed files with 147 additions and 2 deletions
Showing only changes of commit 15c3ea279c - Show all commits

View File

@ -13,6 +13,10 @@ function parseBoolean(value) {
return value === true || value === "true" || value === "1"; 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) { function parseClassificationInput(value) {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
@ -119,8 +123,15 @@ exports.getAvailableItems = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId, storeId } = req.params;
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || ""); const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
res.json({ items }); res.json({ items, catalog_ready: true });
} catch (error) { } 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); logError(req, "availableItems.getAvailableItems", error);
sendError(res, 500, "Failed to load available items"); sendError(res, 500, "Failed to load available items");
} }
@ -171,6 +182,13 @@ exports.createAvailableItem = async (req, res) => {
item: refreshedItem, item: refreshedItem,
}); });
} catch (error) { } 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); logError(req, "availableItems.createAvailableItem", error);
if (error.code === "23505") { if (error.code === "23505") {
return sendError(res, 400, "Available item already exists for this store"); return sendError(res, 400, "Available item already exists for this store");
@ -234,6 +252,13 @@ exports.updateAvailableItem = async (req, res) => {
item: refreshedItem, item: refreshedItem,
}); });
} catch (error) { } 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); logError(req, "availableItems.updateAvailableItem", error);
if (error.code === "23505") { if (error.code === "23505") {
return sendError(res, 400, "Available item already exists for this store"); 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" }); res.json({ message: "Available item removed" });
} catch (error) { } 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); logError(req, "availableItems.deleteAvailableItem", error);
sendError(res, 500, "Failed to remove available item"); sendError(res, 500, "Failed to remove available item");
} }
@ -273,6 +305,13 @@ exports.importCurrentItems = async (req, res) => {
imported_count: importedCount, imported_count: importedCount,
}); });
} catch (error) { } 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); logError(req, "availableItems.importCurrentItems", error);
sendError(res, 500, "Failed to import current list items"); sendError(res, 500, "Failed to import current list items");
} }

View File

@ -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"),
}),
})
);
});
}); });

View File

@ -23,6 +23,8 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const toast = useActionToast(); const toast = useActionToast();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null); const [editorItem, setEditorItem] = useState(null);
@ -38,8 +40,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
try { try {
const response = await getAvailableItems(householdId, store.id, search); const response = await getAvailableItems(householdId, store.id, search);
setItems(response.data.items || []); setItems(response.data.items || []);
setCatalogReady(response.data.catalog_ready !== false);
setCatalogMessage(response.data.message || "");
} catch (error) { } catch (error) {
console.error("Failed to load available items:", 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"); const message = getApiErrorMessage(error, "Failed to load available items");
toast.error("Load store items failed", `Load store items failed: ${message}`); toast.error("Load store items failed", `Load store items failed: ${message}`);
} finally { } finally {
@ -56,6 +62,14 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}, [expanded, query, loadItems]); }, [expanded, query, loadItems]);
const handleCreate = async (payload) => { 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 { try {
await createAvailableItem(householdId, store.id, payload); await createAvailableItem(householdId, store.id, payload);
toast.success("Added store item", `Added ${payload.itemName} to ${store.name}`); 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) => { 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 { try {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${payload.itemName} for ${store.name}`); 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) => { 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?`)) { if (!confirm(`Remove ${item.item_name} from ${store.name}'s available items?`)) {
return; return;
} }
@ -99,6 +129,14 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}; };
const handleImport = async () => { 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 { try {
const response = await importCurrentAvailableItems(householdId, store.id); const response = await importCurrentAvailableItems(householdId, store.id);
const importedCount = response.data.imported_count || 0; const importedCount = response.data.imported_count || 0;
@ -137,18 +175,25 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
{expanded ? ( {expanded ? (
<div className="store-available-items-panel"> <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"> <div className="store-available-items-toolbar">
<input <input
className="store-available-items-search" className="store-available-items-search"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Search store items" placeholder="Search store items"
disabled={!catalogReady}
/> />
<div className="store-available-items-toolbar-actions"> <div className="store-available-items-toolbar-actions">
<button <button
type="button" type="button"
className="btn-secondary btn-small" className="btn-secondary btn-small"
onClick={handleImport} onClick={handleImport}
disabled={!catalogReady}
> >
Import Current List Import Current List
</button> </button>
@ -159,13 +204,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setEditorItem(null); setEditorItem(null);
setShowEditor(true); setShowEditor(true);
}} }}
disabled={!catalogReady}
> >
Add Item Add Item
</button> </button>
</div> </div>
</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> <p className="empty-message">Loading store items...</p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="empty-message">No available items saved for this store yet.</p> <p className="empty-message">No available items saved for this store yet.</p>

View File

@ -29,6 +29,15 @@
gap: var(--spacing-md); 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 { .store-available-items-toolbar {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);