feat(inventory): in-app inventory unfreeze (staff-managed Firestore + House Orders sync + staff UI)#200
Conversation
…shot Increment 1 of moving THO inventory in-app. The 2026 domain cutover repointed the legacy scrape URL to the new app, freezing public inventory at the 2026-05-11 snapshot (see #192). This adds an idempotent seeder that maps snapshot homes into the Firestore `inventory` collection THODatabase already reads, so staff inherit the current homes to manage instead of starting empty. - tools/inventory_seed.py: snapshot_home_to_inventory_doc() — the inverse of inventory_tools._load_inventory_from_firestore, so a seeded home renders identically through the public Firestore read path (display-preserving). Idempotent seeder (legacy id = Firestore doc id) + dry-run-default CLI. - database/firestore_client.py: upsert_inventory() (set merge=True, preserves staff edits) + delete_inventory(). - tests/test_inventory_seed.py: mapper shape, half-baths, pre-owned, dry-run, idempotency, no-id skip (6 tests, ruff clean). Purely additive — no serving change yet. Next: read-flip /api/marketing/inventory-context to prefer Firestore (legacy snapshot as fallback), then admin CRUD endpoints + a staff Inventory Manager page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Y_SOURCE Increment 2: read-flip for /api/marketing/inventory-context. Extracts the legacy-snapshot and Firestore branches into _legacy_inventory_context() and _firestore_inventory_context(), and adds INVENTORY_SOURCE (legacy|firestore| auto; default legacy) to choose between them. Default is legacy -> ZERO behavior change on merge. Set INVENTORY_SOURCE= firestore (or auto) AFTER seeding to serve in-app inventory, with the legacy snapshot as the safety-net fallback so the page is never empty. Instant revert via INVENTORY_SOURCE=legacy (no redeploy). Health-checks the RAW Firestore home count (not the post-fallback website-asset homes), so auto never regresses to the small asset catalog when Firestore is empty. Tests: 4 precedence tests + existing inventory-context tests unchanged. 72 green across api_v1 + inventory suites; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Increment 3: staff can create/update/retire homes in the Firestore inventory
store (served by increment 2 when INVENTORY_SOURCE=firestore/auto).
- POST /api/inventory create (require_admin; model_name required)
- PUT /api/inventory/{id} partial-merge update
- DELETE /api/inventory/{id} soft-retire (status=RETIRED); ?hard=true deletes
- database/models.py: InventoryWrite — permissive (extra=allow) write schema
normalizing the core typed fields while passing staff extras through.
- All writes audited via log_admin_action.
Tests: 6 endpoint tests (auth, create, missing-name, merge-update, soft-retire,
hard-delete) + FakeTHODatabase write methods + exposed InventoryWrite on the
harness's synthetic database.models mock. 61 green; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cost-safe) Integration increment 1 (the live unfreeze). House Orders.xlsx (the operator's daily-maintained source of truth) -> Firestore inventory, so the public site reflects real stock instead of the frozen 2026-05-11 scrape. - tools/house_orders_sync.py: parse_house_orders() (sheet 'Everybody Else', header=1) + a pure, allow-listed mapper. PII contract: the Customer column is read ONLY to set availability (blank=AVAILABLE, filled=SOLD) and the buyer NAME is never persisted; invoice cost / MSRP / price are never written (site keeps Call-for-Price); records are emitted by an ALLOW-LIST so no new sheet column can leak. Upserts via upsert_inventory keyed on serial# = doc id (idempotent). Dry-run default; --apply writes. - tests/test_house_orders_sync.py: 11 tests led by the PII guards (name never persisted, cost/price never in the doc, emitted keys subset of the allow-list) + mapping, header/section/total filtering, idempotency. ruff clean. Column names verified against the live sheet header + convert_inventory.py. Activation (gated): place House Orders.xlsx in data/, dry-run to eyeball, --apply once, then INVENTORY_SOURCE=firestore. Automated daily Drive pull deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
load_house_orders_from_gcs() downloads House Orders.xlsx from a GCS bucket to a temp file, parses it (PII/cost-safe path), and cleans up — so a daily Cloud Scheduler job or a staff 'refresh' action can keep inventory current from the operator's sheet without a manual local run. +1 test (mocked storage). ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ening)
All-hands audit flagged a latent dealer-cost path the in-app branch makes
reachable (no default-config leak, but a flag-flip + staff write or the agent's
search_inventory() full-dict return could expose invoice_amount).
- tools/inventory_tools.py: stop copying invoice_amount into the public read /
agent pricing dict (the leak at the source).
- main.py POST/PUT /api/inventory: strip {invoice_amount, invoice_date, cost}
before upsert, so the permissive InventoryWrite(extra=allow) can't persist
dealer cost into the public-served Firestore collection.
- tests: assert cost POSTed to /api/inventory is not stored; firestore loader
test no longer expects invoice_amount.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All-hands audit found _parse_model_specs misparsed dates as beds/baths
('Model 6/12/2024 28x56' -> beds=6), accepted 3-digit widths as 0, and took
transposed 'LxW' dims verbatim. Now: dims anchored to non-digits + sanity-bounded
to 8-40 + de-transposed; beds/baths require single digits not inside a longer run
(dates ignored). +4 assertions. ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the in-app inventory management page (create/edit/retire homes in the Firestore store the public site serves when INVENTORY_SOURCE=firestore) and wires it into App.jsx state routing as admin page 'manage-inventory': lazy import, ADMIN_PAGE_KEYS, NavBar admin item, pageFromPath, render block. Cost fields are never shown or sent (public read path hides dealer cost). Completes the in-app inventory unfreeze end-to-end (consumes the admin CRUD endpoints POST/PUT/DELETE /api/inventory already on this branch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1433d2f70b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| setForm({ | ||
| ...EMPTY, ...home, | ||
| bedrooms: home.beds ?? home.bedrooms ?? '', bathrooms: home.baths ?? home.bathrooms ?? '', | ||
| sqft: home.sqft ?? '', features: (home.features || []).join(', '), |
There was a problem hiding this comment.
Preserve classification when editing homes
When editing an existing home returned by /api/inventory, this form seeds from EMPTY, but that endpoint does not include classification, and toPayload() always posts the current classification. As a result, editing a Double Wide or other non-default record silently sends classification: 'Single Wide' even if staff only changed a different field, corrupting public filters/listing metadata. Include the classification in the list response or avoid sending this default on unchanged edits.
Useful? React with 👍 / 👎.
| raw = get_inventory_for_ads(limit=100) | ||
| raw_homes = raw.get("homes") or [] | ||
| if prefer == "firestore" or ( | ||
| raw.get("success") and len(raw_homes) >= _inventory_firestore_min_homes() | ||
| ): |
There was a problem hiding this comment.
Use a Firestore-only probe for auto mode
In INVENTORY_SOURCE=auto, this gate treats get_inventory_for_ads() as proof that Firestore has staff-managed homes, but that helper goes through tools.inventory_tools._load_inventory(), which falls back to local JSON/sample data and website catalog entries when Firestore is empty. Therefore len(raw_homes) >= INVENTORY_FIRESTORE_MIN_HOMES can be true with zero Firestore docs, causing auto mode to bypass the legacy snapshot and serve stale fallback data. Gate on a Firestore-only count/query before returning _firestore_inventory_context.
Useful? React with 👍 / 👎.
The loader does `from google.cloud import storage`; faking it via sys.modules is bypassed once the real submodule is imported earlier in the full suite, so CI's credential-free job hit DefaultCredentialsError. Patch Client on the real module instead — order-independent, no GCS creds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why
The public site has served a frozen 2026-05-11 inventory snapshot since the DNS cutover repointed the legacy scrape URL to the new SPA. Any home added/changed after May 11 can never appear, and nothing flags it. This branch moves inventory in-app (staff-managed Firestore) and retires the dead legacy scrape.
What (8 commits)
tools/inventory_seed.py) +firestore_client.upsert/delete_inventory.INVENTORY_SOURCEenv flag (legacy|firestore|auto, defaultlegacy= ZERO behavior change on merge); snapshot stays as safety-net fallback; instant revert via=legacy(no redeploy).POST/PUT/DELETE /api/inventory(require_admin, audited); DELETE soft-retires.tools/house_orders_sync.py: the true source (dealer's dailyHouse Orders.xlsx). PII/cost-safe — reads Customer only to set status, never persists the name; never writes cost/MSRP (site stays Call-for-Price).frontend/src/pages/InventoryManager.jsxwired into the admin area (manage-inventorypage): list/add/edit/retire homes, cost fields never shown/sent.Safety / risk
INVENTORY_SOURCEdefaults tolegacy. The unfreeze activates only when an operator seeds Firestore and flips the env. Fully reversible (=legacy).Tests
ruff check .clean.InventoryManagerchunk 7.70 kB).origin/main(cb93a0a), conflict-free.Activation runbook (operator-gated, AFTER merge+deploy)
gcloud run revisions listentry +/healthz/SHA == merge SHA.python -m tools.house_orders_sync --apply(orinventory_seed --apply) against prod Firestore (projecttho-ai-agent), dry-run first.INVENTORY_SOURCE=firestore(orauto) via--update-env-vars. Revert instantly with=legacy.🤖 Generated with Claude Code