diff --git a/README.md b/README.md index f17d4c8..38a34c4 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,12 @@ RMC, construction materials, …) are browsable with search, manufacturer / type size filters, pagination, and a detail view per item. Users can optionally create an account and build a **personal inventory**: items -from the catalog are added to their own list with a quantity and notes. The -catalog itself stays immutable read-only reference data; personal inventories -live in PostgreSQL and reference catalog items by `class_name` only, so the -dataset can be replaced wholesale without touching user data. +from the catalog are added to their own list with a quantity and notes. Logged-in +users can also place **orders** for any catalog item; orders go onto a public +board that every visitor can see. The catalog itself stays immutable read-only +reference data; personal inventories and orders live in PostgreSQL and reference +catalog items by `class_name` only, so the dataset can be replaced wholesale +without touching user data. ## Stack @@ -144,8 +146,18 @@ item's catalog details, quantity, quality (when applicable), 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. +The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in +user clicks any item to open its detail view, then fills in the **Request this +item** form there with a quantity, optional notes, and — for mineable +commodities — an optional quality. Setting up the order publishes the request +on the public board. +Orders show the item, quantity, quality, notes, requesting username, status +(`open`/`fulfilled`) and last update. Notes on orders are public by design; +inventory notes remain private. Only the owner can edit, mark fulfilled/reopen, +or remove their own order. + +Clicking an item name in **My Inventory**, **Available** or **Orders** opens the +same catalog detail view used by the catalog grid. ### Security: read before exposing this publicly @@ -186,7 +198,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 # 65 tests, no database needed +.venv/bin/python -m pytest tests/ -q # 83 tests, no database needed DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 ``` @@ -229,12 +241,19 @@ PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, qual DELETE /api/v1/me/inventory/{class_name} GET /api/v1/available?category=&search=&sort=&page=&page_size= + +GET /api/v1/orders?category=&search=&status=&sort=&page=&page_size= +POST /api/v1/orders {class_name, quantity?, notes?, quality?} +PATCH /api/v1/orders/{id} {quantity?, notes?, status?, quality?} +DELETE /api/v1/orders/{id} ``` `/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`). Catalog `category` values are `ships`, `ship-items`, -`fps-items` and `commodities`. +`/api/v1/available` and `GET /api/v1/orders` are public and read-only. Both +accept `sort=name|category|quantity|updated` (default `updated`); orders also +accept `status=open|fulfilled`. Creating or changing an order requires a +session; only its owner may update or delete it. Catalog `category` values are +`ships`, `ship-items`, `fps-items` and `commodities`. ### Configuration diff --git a/trade-hub/backend/app/api.py b/trade-hub/backend/app/api.py index 93495bc..6996175 100644 --- a/trade-hub/backend/app/api.py +++ b/trade-hub/backend/app/api.py @@ -15,6 +15,7 @@ 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 +from app.orders import router as orders_router @asynccontextmanager @@ -47,6 +48,7 @@ if config.CORS_ORIGINS: app.include_router(auth_router) app.include_router(inventory_router) app.include_router(available_router) +app.include_router(orders_router) VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"} diff --git a/trade-hub/backend/app/models.py b/trade-hub/backend/app/models.py index e26fae0..ace7ebf 100644 --- a/trade-hub/backend/app/models.py +++ b/trade-hub/backend/app/models.py @@ -49,6 +49,9 @@ class User(Base): inventory: Mapped[list["InventoryEntry"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) + orders: Mapped[list["Order"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) class UserSession(Base): @@ -109,3 +112,46 @@ class InventoryEntry(Base): ) user: Mapped[User] = relationship(back_populates="inventory") + + +class Order(Base): + """A public request for a catalog item placed by a logged-in user. + + Orders are intentionally visible to everyone (see app/orders.py). They + reference catalog items by `class_name` only, exactly like inventory rows, + so a catalog refresh cannot break them. + """ + + __tablename__ = "orders" + __table_args__ = ( + CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"), + CheckConstraint( + "quality IS NULL OR (quality >= 1 AND quality <= 1000)", + name="ck_orders_quality_range", + ), + CheckConstraint( + "status IN ('open', 'fulfilled')", + name="ck_orders_status", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + class_name: Mapped[str] = mapped_column(String(255), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + # Mining/refining quality (1-1000); only valid for quality-applicable ores. + quality: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str] = mapped_column(Text, nullable=False, default="") + status: Mapped[str] = mapped_column( + String(16), nullable=False, default="open", server_default="open" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + user: Mapped[User] = relationship(back_populates="orders") diff --git a/trade-hub/backend/app/schemas.py b/trade-hub/backend/app/schemas.py index e7234af..905e16d 100644 --- a/trade-hub/backend/app/schemas.py +++ b/trade-hub/backend/app/schemas.py @@ -109,3 +109,48 @@ class AvailablePage(BaseModel): class InventoryAddResponse(BaseModel): entry: InventoryEntryOut created: bool + + +class OrderCreateRequest(BaseModel): + class_name: str = Field(min_length=1, max_length=255) + quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY) + notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH) + quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY) + + +class OrderPatchRequest(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) + status: Literal["open", "fulfilled"] | None = None + # Explicit null clears the quality; omitting the field leaves it unchanged. + quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY) + + +class OrderOut(BaseModel): + id: int + class_name: str + name: str + category: str | None + type: str | None + size: int | None + grade: Any | None = None + manufacturer: dict | None + quantity: int + notes: str + quality: int | None = None + quality_applicable: bool = False + # Public listing: the requesting user's display name. + username: str + status: Literal["open", "fulfilled"] + # False when the catalog no longer contains the referenced item. + available: bool + created_at: datetime + updated_at: datetime + + +class OrderPage(BaseModel): + total: int + page: int + page_size: int + total_pages: int + items: list[OrderOut] diff --git a/trade-hub/frontend/src/app.js b/trade-hub/frontend/src/app.js index 952959a..15b0cc2 100644 --- a/trade-hub/frontend/src/app.js +++ b/trade-hub/frontend/src/app.js @@ -19,10 +19,12 @@ const state = { user: null, inventory: { search: "", category: "", sort: "name" }, available: { search: "", category: "", sort: "updated" }, + orders: { search: "", category: "", status: "", sort: "updated" }, }; let authMode = "login"; let pendingView = null; +let currentDetailClass = null; const els = {}; @@ -38,6 +40,8 @@ function cacheElements() { "available-view", "avail-search", "avail-category", "avail-sort", "avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody", "tab-available", + "orders-view", "order-search", "order-category", "order-status", "order-sort", + "order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders", "auth-backdrop", "auth-close", "auth-title", "auth-form", "auth-username", "auth-password", "auth-error", "auth-submit", "auth-toggle", "toast", @@ -198,6 +202,11 @@ async function submitAuth(event) { pendingView = null; switchToMine(); } + // The detail view was opened while logged out: re-render it so the + // order form becomes available in place. + if (currentDetailClass && !els["modal-backdrop"].classList.contains("hidden")) { + openModal(currentDetailClass); + } } catch (err) { els["auth-error"].textContent = err.message; } finally { @@ -222,6 +231,8 @@ async function logout() { state.category = ""; loadItems(); } + // Orders stay public, but the owner-only action buttons must disappear. + if (state.view === "orders") loadOrders(); } /* ---------- catalog list ---------- */ @@ -338,6 +349,7 @@ function renderPorts(ports) { } async function openModal(class_name) { + currentDetailClass = class_name; els["modal-body"].innerHTML = '

Loading…

'; els["modal-backdrop"].classList.remove("hidden"); try { @@ -369,6 +381,27 @@ async function openModal(class_name) { ${state.user ? "Add to my inventory" : "Log in to add"} +

Request this item

+ ${state.user ? ` +

Set up an order so every player can see what you are looking for.

+
+ + + ${item.quality_applicable ? ` + + + ` : ""} + + +
+

+ ` : ` +
+ Log in to set up an order other players can see. + +
+ `} `; document.getElementById("add-to-inventory").addEventListener("click", () => { const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); @@ -383,12 +416,27 @@ async function openModal(class_name) { } addToInventory(item.class_name, qty, quality); }); + + const createOrderButton = document.getElementById("create-order"); + if (createOrderButton) { + createOrderButton.addEventListener("click", () => + submitDetailOrder(item, createOrderButton) + ); + } else { + const loginButton = document.getElementById("order-login"); + if (loginButton) { + loginButton.addEventListener("click", () => + openAuth("login", "Log in to set up an order.") + ); + } + } } catch (err) { els["modal-body"].innerHTML = ``; } } function closeModal() { + currentDetailClass = null; els["modal-backdrop"].classList.add("hidden"); } @@ -424,6 +472,7 @@ async function switchToMine() { state.view = "mine"; els["catalog-view"].classList.add("hidden"); els["available-view"].classList.add("hidden"); + els["orders-view"].classList.add("hidden"); els["inventory-view"].classList.remove("hidden"); await loadInventory(); } @@ -432,6 +481,7 @@ function showCatalogView() { state.view = "catalog"; els["inventory-view"].classList.add("hidden"); els["available-view"].classList.add("hidden"); + els["orders-view"].classList.add("hidden"); els["catalog-view"].classList.remove("hidden"); } @@ -727,6 +777,7 @@ async function switchToAvailable() { state.view = "available"; els["catalog-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden"); + els["orders-view"].classList.add("hidden"); els["available-view"].classList.remove("hidden"); await loadAvailable(); } @@ -795,6 +846,190 @@ function renderAvailable(listing) { } } +/* ---------- orders (public board) ---------- */ + +async function switchToOrders() { + setActiveTab(els["tab-orders"]); + state.view = "orders"; + els["catalog-view"].classList.add("hidden"); + els["inventory-view"].classList.add("hidden"); + els["available-view"].classList.add("hidden"); + els["orders-view"].classList.remove("hidden"); + await loadOrders(); +} + +async function submitDetailOrder(item, button) { + const qtyInput = document.getElementById("order-qty"); + const qualityInput = document.getElementById("order-quality"); + const notesInput = document.getElementById("order-notes"); + const error = document.getElementById("order-error"); + + const quantity = Number(qtyInput.value); + if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000000) { + error.textContent = "Quantity must be between 1 and 1,000,000"; + return; + } + + let quality = null; + if (item.quality_applicable && qualityInput) { + const raw = qualityInput.value.trim(); + if (raw !== "") { + quality = Number(raw); + if (!Number.isInteger(quality) || quality < 1 || quality > 1000) { + error.textContent = "Quality must be between 1 and 1000"; + return; + } + } + } + + const body = { + class_name: item.class_name, + quantity, + notes: notesInput.value.trim(), + }; + if (quality !== null) body.quality = quality; + + button.disabled = true; + error.textContent = ""; + try { + await apiFetch("/api/v1/orders", { method: "POST", body }); + showToast("Order set up — visible to all pilots"); + if (state.view === "orders") loadOrders(); + } catch (err) { + if (!handleUnauthorized(err)) error.textContent = err.message; + } finally { + button.disabled = false; + } +} + +async function loadOrders() { + const params = new URLSearchParams(); + if (state.orders.search) params.set("search", state.orders.search); + if (state.orders.category) params.set("category", state.orders.category); + if (state.orders.status) params.set("status", state.orders.status); + params.set("sort", state.orders.sort); + params.set("page_size", String(INVENTORY_PAGE_SIZE)); + + els["order-summary"].textContent = "Loading…"; + try { + const listing = await apiFetch(`/api/v1/orders?${params}`); + renderOrders(listing); + } catch (err) { + els["order-summary"].textContent = ""; + showToast(err.message, true); + } +} + +function renderOrders(listing) { + const tbody = els["order-tbody"]; + tbody.innerHTML = ""; + els["order-summary"].textContent = + `${listing.total} order${listing.total === 1 ? "" : "s"}`; + els["order-empty"].classList.toggle("hidden", listing.total !== 0); + els["order-table-wrap"].classList.toggle("hidden", listing.total === 0); + + for (const entry of listing.items) { + const row = document.createElement("tr"); + + const nameCell = itemNameCell(entry); + + const categoryCell = document.createElement("td"); + categoryCell.innerHTML = entry.category + ? categoryBadge(entry.category) + : '—'; + + const qtyCell = document.createElement("td"); + qtyCell.textContent = String(entry.quantity); + + const notesCell = document.createElement("td"); + notesCell.className = "order-notes"; + notesCell.textContent = entry.notes || "—"; + + const userCell = document.createElement("td"); + userCell.className = "avail-user"; + userCell.textContent = entry.username; + + const statusCell = document.createElement("td"); + const status = document.createElement("span"); + status.className = `order-status ${entry.status}`; + status.textContent = entry.status === "fulfilled" ? "Fulfilled" : "Open"; + statusCell.appendChild(status); + + const updatedCell = document.createElement("td"); + updatedCell.className = "avail-updated"; + updatedCell.textContent = formatDate(entry.updated_at); + + const actionCell = document.createElement("td"); + actionCell.className = "order-actions"; + if (state.user && entry.username === state.user.username) { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "visibility-toggle"; + const next = entry.status === "fulfilled" ? "open" : "fulfilled"; + toggle.textContent = next === "fulfilled" ? "Fulfil" : "Reopen"; + toggle.dataset.status = next; + toggle.addEventListener("click", () => + patchOrder(entry, { status: next }, toggle) + ); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "inv-remove"; + remove.textContent = "Remove"; + remove.addEventListener("click", () => deleteOrder(entry)); + + actionCell.append(toggle, remove); + } + + row.append( + nameCell, + categoryCell, + qtyCell, + qualityCell(entry, false), + notesCell, + userCell, + statusCell, + updatedCell, + actionCell + ); + tbody.appendChild(row); + } +} + +async function patchOrder(entry, payload, button = null) { + if (button) button.disabled = true; + try { + const updated = await apiFetch(`/api/v1/orders/${entry.id}`, { + method: "PATCH", + body: payload, + }); + showToast( + updated.status === "fulfilled" ? "Order marked fulfilled" : "Order reopened" + ); + await loadOrders(); + } catch (err) { + if (!handleUnauthorized(err)) showToast(err.message, true); + await loadOrders(); + } finally { + if (button) button.disabled = false; + } +} + +async function deleteOrder(entry) { + const confirmed = await confirmAction( + `Remove your order for "${entry.name}"? It will disappear for everyone.`, + "Remove" + ); + if (!confirmed) return; + try { + await apiFetch(`/api/v1/orders/${entry.id}`, { method: "DELETE" }); + showToast("Order removed"); + await loadOrders(); + } catch (err) { + if (!handleUnauthorized(err)) showToast(err.message, true); + } +} + /* ---------- events ---------- */ function debounce(fn, wait) { @@ -827,6 +1062,10 @@ function bindEvents() { switchToAvailable(); return; } + if (tab.dataset.view === "orders") { + switchToOrders(); + return; + } setActiveTab(tab); showCatalogView(); state.category = tab.dataset.category; @@ -949,6 +1188,28 @@ function bindAvailableEvents() { }); } +function bindOrderEvents() { + els["order-search"].addEventListener( + "input", + debounce(() => { + state.orders.search = els["order-search"].value.trim(); + loadOrders(); + }, 300) + ); + els["order-category"].addEventListener("change", () => { + state.orders.category = els["order-category"].value; + loadOrders(); + }); + els["order-status"].addEventListener("change", () => { + state.orders.status = els["order-status"].value; + loadOrders(); + }); + els["order-sort"].addEventListener("change", () => { + state.orders.sort = els["order-sort"].value; + loadOrders(); + }); +} + /* ---------- boot ---------- */ document.addEventListener("DOMContentLoaded", async () => { @@ -957,6 +1218,7 @@ document.addEventListener("DOMContentLoaded", async () => { bindAuthEvents(); bindInventoryEvents(); bindAvailableEvents(); + bindOrderEvents(); initHeader(); await initAuth(); loadItems(); diff --git a/trade-hub/frontend/src/index.html b/trade-hub/frontend/src/index.html index 06287ce..d6c9550 100644 --- a/trade-hub/frontend/src/index.html +++ b/trade-hub/frontend/src/index.html @@ -31,6 +31,7 @@ +
@@ -140,6 +141,53 @@
+ +