Skip to content

feat(inventory): in-app inventory unfreeze (staff-managed Firestore + House Orders sync + staff UI)#200

Merged
arigatoexpress merged 10 commits into
mainfrom
feat/in-app-inventory
Jun 16, 2026
Merged

feat(inventory): in-app inventory unfreeze (staff-managed Firestore + House Orders sync + staff UI)#200
arigatoexpress merged 10 commits into
mainfrom
feat/in-app-inventory

Conversation

@arigatoexpress

Copy link
Copy Markdown
Owner

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)

  • seed — idempotent snapshot→Firestore seeder (tools/inventory_seed.py) + firestore_client.upsert/delete_inventory.
  • read-flipINVENTORY_SOURCE env flag (legacy|firestore|auto, default legacy = ZERO behavior change on merge); snapshot stays as safety-net fallback; instant revert via =legacy (no redeploy).
  • admin CRUDPOST/PUT/DELETE /api/inventory (require_admin, audited); DELETE soft-retires.
  • House Orders synctools/house_orders_sync.py: the true source (dealer's daily House Orders.xlsx). PII/cost-safe — reads Customer only to set status, never persists the name; never writes cost/MSRP (site stays Call-for-Price).
  • GCS loader — enables a scheduled refresh from a bucket copy of the sheet.
  • cost-fence hardening — never surface or persist dealer COST.
  • dims/beds-baths parsing hardening.
  • staff Inventory Manager UIfrontend/src/pages/InventoryManager.jsx wired into the admin area (manage-inventory page): list/add/edit/retire homes, cost fields never shown/sent.

Safety / risk

  • Merging is a no-op for the public site by defaultINVENTORY_SOURCE defaults to legacy. The unfreeze activates only when an operator seeds Firestore and flips the env. Fully reversible (=legacy).
  • Cost-fence enforced on both the sync (never writes cost) and the UI (never shows/sends it).

Tests

  • ruff check . clean.
  • 132 inventory-related backend tests pass (1 skipped); frontend builds clean (InventoryManager chunk 7.70 kB).
  • Rebased onto current origin/main (cb93a0a), conflict-free.

Activation runbook (operator-gated, AFTER merge+deploy)

  1. Merge → CI test job runs → deploy to Cloud Run (gated).
  2. Verify a new gcloud run revisions list entry + /healthz/ SHA == merge SHA.
  3. Seed: python -m tools.house_orders_sync --apply (or inventory_seed --apply) against prod Firestore (project tho-ai-agent), dry-run first.
  4. Flip: set Cloud Run env INVENTORY_SOURCE=firestore (or auto) via --update-env-vars. Revert instantly with =legacy.

🤖 Generated with Claude Code

arigatoexpress and others added 8 commits June 16, 2026 13:01
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +63 to +66
setForm({
...EMPTY, ...home,
bedrooms: home.beds ?? home.bedrooms ?? '', bathrooms: home.baths ?? home.bathrooms ?? '',
sqft: home.sqft ?? '', features: (home.features || []).join(', '),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread main.py
Comment on lines +4450 to +4454
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()
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

arigatoexpress and others added 2 commits June 16, 2026 13:20
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>
@arigatoexpress arigatoexpress merged commit cc080cf into main Jun 16, 2026
2 checks passed
@arigatoexpress arigatoexpress deleted the feat/in-app-inventory branch June 16, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant