diff --git a/README.md b/README.md index c98718d..5423b42 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,22 @@ The inventory view can be filtered by category and searched by name/class name, and sorted by `name` (default), `category`, `quantity` (high to low) or `updated` (most recently changed first). -Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item -that is already present increments its quantity; quantity and notes can then be -edited or the row removed. Responses are enriched live from the catalog. If a -catalog item disappears after a dataset refresh, the row is reported with -`available: false` instead of being silently deleted. +Inventory rows store `(user_id, class_name, quantity, notes, visibility)`. +Adding an item that is already present increments its quantity; quantity, notes +and visibility can then be edited or the row removed. Every added item starts +`private`; the Visibility toggle switches it to `available` and back. Responses +are enriched live from the catalog. If a catalog item disappears after a +dataset refresh, the row is reported with `available: false` instead of being +silently deleted (`available` is about the catalog, `visibility` is the owner's +classification). + +The **Available** tab (backed by `GET /api/v1/available`) is public, like the +catalog: it lists every row marked `available` across all accounts with the +item's catalog details, quantity, owner username and last update. Private rows +never appear there and inventory notes are never exposed. + +Clicking an item name in either **My Inventory** or **Available** opens the same +catalog detail view used by the catalog grid. ### Security: read before exposing this publicly @@ -161,7 +172,7 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory cd trade-hub/backend python3 -m venv .venv .venv/bin/pip install -r requirements.txt -r requirements-dev.txt -.venv/bin/python -m pytest tests/ -q # 50 tests, no database needed +.venv/bin/python -m pytest tests/ -q # 59 tests, no database needed DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 ``` @@ -173,8 +184,9 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL. Tables are created on startup with an idempotent `create_all` and a connection retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does -not crash the backend. This does not migrate changed columns — introduce Alembic -when the schema starts evolving in place. +not crash the backend. Additive columns for existing deployments (currently +`inventory_entries.visibility`) are applied automatically on startup; changing +or dropping a column still requires Alembic. ## API @@ -193,11 +205,15 @@ GET /api/v1/auth/me -> {user} GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size= GET /api/v1/me/inventory/summary POST /api/v1/me/inventory {class_name, quantity?, notes?} -PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?} +PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?} DELETE /api/v1/me/inventory/{class_name} + +GET /api/v1/available?category=&search=&sort=&page=&page_size= ``` `/api/v1/me/*` requires a valid session cookie and returns `401` otherwise. +`/api/v1/available` is public and read-only; it accepts `sort=name|category|quantity|updated` +(default `updated`). ### Configuration diff --git a/trade-hub/backend/app/api.py b/trade-hub/backend/app/api.py index 53df03f..e0a4726 100644 --- a/trade-hub/backend/app/api.py +++ b/trade-hub/backend/app/api.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from app import config from app.auth import router as auth_router +from app.available import router as available_router from app.catalog import store from app.db import db_healthy, init_db from app.inventory import router as inventory_router @@ -45,6 +46,7 @@ if config.CORS_ORIGINS: app.include_router(auth_router) app.include_router(inventory_router) +app.include_router(available_router) VALID_CATEGORIES = {"ships", "ship-items", "fps-items"} diff --git a/trade-hub/backend/app/db.py b/trade-hub/backend/app/db.py index 9c8baa4..d5bf787 100644 --- a/trade-hub/backend/app/db.py +++ b/trade-hub/backend/app/db.py @@ -51,12 +51,46 @@ def get_db(): db.close() +def _upgrade_schema(engine) -> None: + """Add columns that `create_all` cannot add to an existing table. + + PostgreSQL only: the SQLite database used by the tests is created fresh + from the models on every run, so it already has every column. + """ + if engine.dialect.name != "postgresql": + return + with engine.begin() as conn: + conn.execute( + text( + "ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS " + "visibility VARCHAR(16) NOT NULL DEFAULT 'private'" + ) + ) + conn.execute( + text( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ck_inventory_visibility' + ) THEN + ALTER TABLE inventory_entries + ADD CONSTRAINT ck_inventory_visibility + CHECK (visibility IN ('private', 'available')); + END IF; + END $$ + """ + ) + ) + + def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: """Create missing tables, retrying while PostgreSQL finishes booting. `create_all(checkfirst=True)` is idempotent and safe to run on every start. - It does not migrate changed columns; introduce Alembic when the schema - starts evolving in place. + Additive columns for existing deployments are applied afterwards; changing + or dropping an existing column still requires Alembic. """ deadline = time.monotonic() + max(wait_seconds, 0) while True: @@ -65,6 +99,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: with engine.connect() as conn: conn.execute(text("SELECT 1")) Base.metadata.create_all(bind=engine, checkfirst=True) + _upgrade_schema(engine) return except (OperationalError, IntegrityError, ProgrammingError) as exc: # A concurrent startup may have created the tables first. diff --git a/trade-hub/backend/app/inventory.py b/trade-hub/backend/app/inventory.py index 863ccd2..df500d9 100644 --- a/trade-hub/backend/app/inventory.py +++ b/trade-hub/backend/app/inventory.py @@ -33,7 +33,7 @@ router = APIRouter( MAX_PAGE_SIZE = 100 -def _enrich(entry: InventoryEntry) -> dict: +def enrich_entry(entry: InventoryEntry) -> dict: item = catalog.store.by_class_name.get(entry.class_name) if item is None: return { @@ -46,6 +46,7 @@ def _enrich(entry: InventoryEntry) -> dict: "manufacturer": None, "quantity": entry.quantity, "notes": entry.notes, + "visibility": entry.visibility, "available": False, "created_at": entry.created_at, "updated_at": entry.updated_at, @@ -60,6 +61,7 @@ def _enrich(entry: InventoryEntry) -> dict: "manufacturer": item["manufacturer"], "quantity": entry.quantity, "notes": entry.notes, + "visibility": entry.visibility, "available": True, "created_at": entry.created_at, "updated_at": entry.updated_at, @@ -91,7 +93,7 @@ def _sort_key_updated(item): return (-item["updated_at"].timestamp(), item["name"].lower()) -_SORT_KEYS = { +SORT_KEYS = { "name": _sort_key_name, "category": _sort_key_category, "quantity": _sort_key_quantity, @@ -114,7 +116,7 @@ def list_inventory( select(InventoryEntry).where(InventoryEntry.user_id == user.id) ).scalars().all() - items = [_enrich(row) for row in rows] + items = [enrich_entry(row) for row in rows] if category is not None and category.strip(): wanted = category.strip() @@ -127,7 +129,7 @@ def list_inventory( if needle in i["name"].lower() or needle in i["class_name"].lower() ] - items.sort(key=_SORT_KEYS[sort]) + items.sort(key=SORT_KEYS[sort]) total = len(items) total_pages = max(1, math.ceil(total / page_size)) start = (page - 1) * page_size @@ -213,7 +215,7 @@ def add_to_inventory( db.refresh(entry) response.status_code = 201 if created else 200 - return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created} + return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created} @router.patch("/{class_name}", response_model=InventoryEntryOut) @@ -231,10 +233,12 @@ def update_inventory_entry( entry.quantity = payload.quantity if payload.notes is not None: entry.notes = payload.notes + if payload.visibility is not None: + entry.visibility = payload.visibility db.commit() db.refresh(entry) - return InventoryEntryOut.model_validate(_enrich(entry)) + return InventoryEntryOut.model_validate(enrich_entry(entry)) @router.delete("/{class_name}", status_code=204) diff --git a/trade-hub/backend/app/models.py b/trade-hub/backend/app/models.py index d9b0297..7b4a2b2 100644 --- a/trade-hub/backend/app/models.py +++ b/trade-hub/backend/app/models.py @@ -74,6 +74,10 @@ class InventoryEntry(Base): __table_args__ = ( UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"), CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"), + CheckConstraint( + "visibility IN ('private', 'available')", + name="ck_inventory_visibility", + ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) @@ -83,6 +87,12 @@ class InventoryEntry(Base): class_name: Mapped[str] = mapped_column(String(255), nullable=False) quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) notes: Mapped[str] = mapped_column(Text, nullable=False, default="") + # "private" (default): only the owner sees the row. "available": the owner + # is offering the item. Unrelated to the response flag `available`, which + # only reports whether the catalog still contains the referenced item. + visibility: Mapped[str] = mapped_column( + String(16), nullable=False, default="private", server_default="private" + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) diff --git a/trade-hub/backend/app/schemas.py b/trade-hub/backend/app/schemas.py index 7969c0e..e347af5 100644 --- a/trade-hub/backend/app/schemas.py +++ b/trade-hub/backend/app/schemas.py @@ -1,6 +1,6 @@ """Pydantic request/response models for auth and personal inventory.""" from datetime import datetime -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -38,6 +38,7 @@ class InventoryAddRequest(BaseModel): class InventoryPatchRequest(BaseModel): quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY) notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH) + visibility: Literal["private", "available"] | None = None class InventoryEntryOut(BaseModel): @@ -50,6 +51,8 @@ class InventoryEntryOut(BaseModel): manufacturer: dict | None quantity: int notes: str + # Owner-chosen classification. Unrelated to `available` below. + visibility: Literal["private", "available"] # False when the catalog no longer contains the referenced item (e.g. after # a dataset refresh). The row is preserved rather than silently deleted. available: bool @@ -72,6 +75,28 @@ class InventorySummary(BaseModel): unavailable: int +class AvailableItemOut(BaseModel): + class_name: str + name: str + category: str | None + type: str | None + size: int | None + grade: Any | None = None + manufacturer: dict | None + quantity: int + # Public listing: only the owner's display name, never notes. + username: str + updated_at: datetime + + +class AvailablePage(BaseModel): + total: int + page: int + page_size: int + total_pages: int + items: list[AvailableItemOut] + + class InventoryAddResponse(BaseModel): entry: InventoryEntryOut created: bool diff --git a/trade-hub/backend/tests/test_inventory.py b/trade-hub/backend/tests/test_inventory.py index d8552c2..5ced3d1 100644 --- a/trade-hub/backend/tests/test_inventory.py +++ b/trade-hub/backend/tests/test_inventory.py @@ -60,6 +60,7 @@ def test_add_creates_enriched_entry(): assert entry["name"] == "Anvil Carrack" assert entry["category"] == "ships" assert entry["quantity"] == 1 + assert entry["visibility"] == "private" assert entry["available"] is True assert entry["manufacturer"]["code"] == "ANVL" @@ -105,6 +106,43 @@ def test_patch_updates_quantity_and_notes(): assert body["notes"] == "hangar 3" +def test_visibility_toggles_between_private_and_available(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + + resp = client.patch( + f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"} + ) + assert resp.status_code == 200 + assert resp.json()["visibility"] == "available" + listed = client.get("/api/v1/me/inventory").json()["items"] + assert listed[0]["visibility"] == "available" + + back = client.patch( + f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"} + ) + assert back.json()["visibility"] == "private" + + +def test_patch_rejects_unknown_visibility(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "public"}) + assert resp.status_code == 422 + + +def test_visibility_is_kept_when_quantity_changes_and_item_is_readded(): + client = authed_client() + add(client, CARROCK) + client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"}) + + client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 3}) + assert add(client, CARROCK, quantity=1).status_code == 200 + entry = client.get("/api/v1/me/inventory").json()["items"][0] + assert entry["quantity"] == 4 + assert entry["visibility"] == "available" + + def test_patch_unknown_entry_is_not_found(): client = authed_client() assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404 diff --git a/trade-hub/frontend/src/app.js b/trade-hub/frontend/src/app.js index 2d9f78e..79952a8 100644 --- a/trade-hub/frontend/src/app.js +++ b/trade-hub/frontend/src/app.js @@ -18,6 +18,7 @@ const state = { view: "catalog", user: null, inventory: { search: "", category: "", sort: "name" }, + available: { search: "", category: "", sort: "updated" }, }; let authMode = "login"; @@ -34,9 +35,13 @@ function cacheElements() { "modal-backdrop", "modal-close", "modal-title", "modal-body", "catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort", "inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine", + "available-view", "avail-search", "avail-category", "avail-sort", + "avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody", + "tab-available", "auth-backdrop", "auth-close", "auth-title", "auth-form", "auth-username", "auth-password", "auth-error", "auth-submit", "auth-toggle", "toast", + "confirm-backdrop", "confirm-message", "confirm-ok", "confirm-cancel", ]; for (const id of ids) els[id] = document.getElementById(id); } @@ -369,6 +374,26 @@ function closeModal() { els["modal-backdrop"].classList.add("hidden"); } +let confirmResolve = null; + +function confirmAction(message, label = "Remove") { + if (confirmResolve) closeConfirm(false); + els["confirm-message"].textContent = message; + els["confirm-ok"].textContent = label; + els["confirm-backdrop"].classList.remove("hidden"); + els["confirm-ok"].focus(); + return new Promise((resolve) => { + confirmResolve = resolve; + }); +} + +function closeConfirm(result) { + els["confirm-backdrop"].classList.add("hidden"); + const resolve = confirmResolve; + confirmResolve = null; + if (resolve) resolve(result); +} + /* ---------- personal inventory ---------- */ async function switchToMine() { @@ -380,6 +405,7 @@ async function switchToMine() { setActiveTab(els["tab-mine"]); state.view = "mine"; els["catalog-view"].classList.add("hidden"); + els["available-view"].classList.add("hidden"); els["inventory-view"].classList.remove("hidden"); await loadInventory(); } @@ -387,6 +413,7 @@ async function switchToMine() { function showCatalogView() { state.view = "catalog"; els["inventory-view"].classList.add("hidden"); + els["available-view"].classList.add("hidden"); els["catalog-view"].classList.remove("hidden"); } @@ -443,6 +470,29 @@ function renderInventorySummary(summary) { (parts ? ` · ${parts}` : "") + unavailable; } +function itemNameCell(entry) { + const cell = document.createElement("td"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "inv-item-link"; + button.innerHTML = + `