chore: harden reliability checks #2
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user