diff --git a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts index fbaaf8f..b85d579 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts @@ -93,4 +93,28 @@ describe('OrgInventoryController', () => { new BadRequestException('location_id must be an integer'), ); }); + + it('throws a bad request for negative quantity filters', async () => { + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + minQuantity: '-0.25', + }), + ).rejects.toThrow( + new BadRequestException( + 'min_quantity must be greater than or equal to 0', + ), + ); + + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + maxQuantity: '-1', + }), + ).rejects.toThrow( + new BadRequestException( + 'max_quantity must be greater than or equal to 0', + ), + ); + }); }); diff --git a/backend/src/modules/org-inventory/org-inventory.controller.ts b/backend/src/modules/org-inventory/org-inventory.controller.ts index 6ac23e2..0f2eb68 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.ts @@ -158,11 +158,17 @@ export class OrgInventoryController { query, ['min_quantity', 'minQuantity'], 'min_quantity', + { + min: 0, + }, ), maxQuantity: this.readOptionalNumber( query, ['max_quantity', 'maxQuantity'], 'max_quantity', + { + min: 0, + }, ), }; diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index bbcd1ed..26cb996 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -59,6 +59,7 @@ import { OrgPermission, permissionsService } from '../services/permissions.servi type InventoryRecord = InventoryItem | OrgInventoryItem; type ActionMode = 'edit' | 'split' | 'share' | 'delete' | null; +type InlineDraft = { locationId: number | ''; quantity: number | '' }; const GAME_ID = 1; const EDITOR_MODE_QUANTITY_MAX = 100000; @@ -155,9 +156,9 @@ const InventoryPage = () => { const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(25); const [density, setDensity] = useState<'standard' | 'compact'>(() => readStoredDensity()); - const [inlineDrafts, setInlineDrafts] = useState< - Record - >({}); + const [inlineDrafts, setInlineDrafts] = useState>( + {}, + ); const [inlineSaving, setInlineSaving] = useState>(new Set()); const [inlineSaved, setInlineSaved] = useState>(new Set()); const [inlineError, setInlineError] = useState>({}); @@ -226,6 +227,18 @@ const InventoryPage = () => { } } }, [orgOptions, selectedOrgId, viewMode]); + + const inlineDraftFallbacks = useRef>(new Map()); + + useEffect(() => { + const itemIds = new Set(items.map((item) => item.id.toString())); + inlineDraftFallbacks.current.forEach((_, key) => { + if (!itemIds.has(key)) { + inlineDraftFallbacks.current.delete(key); + } + }); + }, [items]); + const debouncedNewItemSearch = useDebounce(newRowItemInput, 300); const debouncedNewLocationSearch = useDebounce(newRowLocationInput, 200); const getRowOrder = useCallback( @@ -1236,11 +1249,40 @@ const InventoryPage = () => { }; }, [debouncedNewItemSearch, isEditorMode]); + const getInlineDraft = useCallback( + (item: InventoryRecord): InlineDraft => { + const existingDraft = inlineDrafts[item.id]; + if (existingDraft) { + return existingDraft; + } + + const rowKey = item.id.toString(); + const nextFallback: InlineDraft = { + locationId: Number(item.locationId) || '', + quantity: Number(item.quantity) || 0, + }; + const cachedFallback = inlineDraftFallbacks.current.get(rowKey); + + if ( + cachedFallback && + cachedFallback.locationId === nextFallback.locationId && + cachedFallback.quantity === nextFallback.quantity + ) { + return cachedFallback; + } + + // Writing to a ref during render is intentional here. nextFallback is + // derived deterministically from item data, so concurrent/aborted renders + // always write the same value for a given key — no stale state is possible. + // Stale entries for removed items are pruned by the [items] effect above. + inlineDraftFallbacks.current.set(rowKey, nextFallback); + return nextFallback; + }, + [inlineDrafts], + ); + const handleInlineSave = useCallback(async (item: InventoryRecord) => { - const draft = inlineDrafts[item.id] ?? { - locationId: item.locationId ?? '', - quantity: Number(item.quantity) || 0, - }; + const draft = getInlineDraft(item); const parsedLocationId = draft.locationId === '' ? NaN : Number(draft.locationId); const parsedQuantity = Number(draft.quantity); @@ -1327,7 +1369,7 @@ const InventoryPage = () => { } }, [ focusController, - inlineDrafts, + getInlineDraft, inlineSaving, items, locationNameById, @@ -1785,10 +1827,7 @@ const InventoryPage = () => { const showEmptyState = filteredItems.length === 0 && !refreshing; const renderInlineRow = (item: InventoryRecord) => { const rowKey = item.id.toString(); - const draft = inlineDrafts[item.id] ?? { - locationId: Number(item.locationId) || '', - quantity: Number(item.quantity) || 0, - }; + const draft = getInlineDraft(item); const originalLocationId = Number(item.locationId) || ''; const draftLocationId = normalizeDraftLocationId(draft.locationId); const originalQuantity = Number(item.quantity) || 0;