fix(ui): handle missing store item catalog table gracefully

This commit is contained in:
Nico 2026-03-28 23:22:03 -07:00
parent 254d166e84
commit 15c3ea279c
4 changed files with 147 additions and 2 deletions

View File

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

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 [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>

View File

@ -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);