Compare commits

..

10 commits

Author SHA1 Message Date
844721c6fd feat: near-term UX batch -- URL normalization, currency preference, async search/SSE
Some checks failed
CI / Python tests (push) Waiting to run
CI / Frontend typecheck + tests (push) Waiting to run
Release / release (push) Has been cancelled
Mirror / mirror (push) Has been cancelled
2026-04-20 11:04:52 -07:00
dca3c3f50b feat(prefs): display.currency preference with live exchange rate conversion
- Backend: validate display.currency against 10 supported ISO 4217 codes
  (USD, GBP, EUR, CAD, AUD, JPY, CHF, MXN, BRL, INR); return 400 on
  unsupported code with a clear message listing accepted values
- Frontend: useCurrency composable fetches rates from open.er-api.com
  with 1-hour module-level cache and in-flight deduplication; falls back
  to USD display on network failure
- Preferences store: adds display.currency with localStorage fallback for
  anonymous users and localStorage-to-DB migration for newly logged-in users
- ListingCard: price and market price now convert from USD using live rates,
  showing USD synchronously while rates load then updating reactively
- Settings UI: currency selector dropdown in Appearance section using
  theme-aware CSS classes; available to all users (anon via localStorage,
  logged-in via DB preference)
- Tests: 6 Python tests for the PATCH /api/preferences currency endpoint
  (including ordering-safe fixture using patch.object on _LOCAL_SNIPE_DB);
  14 Vitest tests for convertFromUSD, formatPrice, and formatPriceUSD
2026-04-20 11:02:59 -07:00
d5912080fb feat(search): async endpoint + SSE streaming for initial results
Add GET /api/search/async that returns HTTP 202 immediately and streams
scrape results via SSE to avoid nginx 120s timeouts on slow eBay searches.

Backend:
- New GET /api/search/async endpoint submits scraping to ThreadPoolExecutor
  and returns {session_id, status: "queued"} before scrape begins
- Background worker runs same pipeline as synchronous search, pushing
  typed SSE events: "listings" (initial batch), "update" (enrichment),
  "market_price", and None sentinel
- Existing GET /api/updates/{session_id} passes new event types through
  as-is (already a generic pass-through); deadline extended to 150s
- Module-level _search_executor (max_workers=4) caps concurrent scrape sessions

Frontend (search.ts):
- search() now calls /api/search/async instead of /api/search
- loading stays true until first "listings" SSE event arrives
- _openUpdates() handles new typed events: "listings", "market_price",
  "update"; legacy untyped enrichment events still handled
- cancelSearch() now also closes any open SSE stream

Tests: tests/test_async_search.py (6 tests) covering 202 response,
session_id registration in _update_queues, empty query path, UUID format,
and no-Chromium guarantee. All 159 pre-existing tests still pass.

Closes #49. Also closes Forgejo issue #1 (SSE enrichment streaming, already
implemented; async search completes the picture).
2026-04-20 10:57:32 -07:00
2e0a49bc12 docs(config): add cf_text trunk service backend to llm.yaml.example
Documents the cf-orch allocation pattern (cf_text openai_compat backend
with cf_orch block). Snipe's trust query builder can route through
cf-text when CF_ORCH_URL is set, rather than hitting ollama directly.
2026-04-20 10:56:23 -07:00
df4610c57b feat(search): normalize eBay listing + checkout URLs as item lookup
When the user pastes an eBay listing URL (www.ebay.com/itm/...) or an
eBay checkout URL (pay.ebay.com/rxo?itemId=...) into the search field,
extract the numeric item ID and use it as the search query.

Supported URL patterns:
- https://www.ebay.com/itm/Title-Slug/123456789012
- https://www.ebay.com/itm/123456789012
- https://ebay.com/itm/123456789012
- https://pay.ebay.com/rxo?action=view&sessionid=...&itemId=123456789012
- https://pay.ebay.com/rxo/view?itemId=123456789012

Closes #42
2026-04-20 10:49:17 -07:00
349cff8c50 chore: ignore .worktrees/ directory 2026-04-20 10:45:39 -07:00
90f72d6e53 feat(config): add CF_APP_NAME for cf-orch analytics attribution 2026-04-20 07:03:18 -07:00
e539427bec fix: catch sqlite3.OperationalError in search post-processing
Under high concurrency (100+ users), shared_db write contention causes
database is locked errors in the unguarded post-scrape block. These were
surfacing as 500s because there was no exception handler after line 663.

Now catches OperationalError and returns raw listings with empty trust
scores/sellers (degraded mode) instead of crashing. The SSE queue entry
is cleaned up on this path so no orphaned queue accumulates.

Root cause: shared_db (sellers, market_comps) is SQLite; at 100 concurrent
writers the WAL write queue exceeds the 30s busy timeout. Long-term fix
is migrating shared state to Postgres (see snipe#NN).

Refs: infra#12 load test Phase 2 spike findings
2026-04-19 21:26:20 -07:00
ed6d509a26 fix: authenticate eBay public key fetch + add webhook health endpoint
Fixes recurring `400 Missing access token` errors in Snipe logs.
`_fetch_public_key()` was making unauthenticated GET requests to
eBay's Notification API (`/commerce/notification/v1/public_key/{kid}`),
which requires an app-level Bearer token (client_credentials grant).

Wires in the existing `EbayTokenManager` as a lazy module-level
singleton so every public key fetch carries a valid OAuth token.

Also adds `GET /api/ebay/webhook-health` for Uptime Kuma compliance
monitoring — returns 200 + status dict when all five required env vars
are present, 500 with missing var names otherwise.

Runbook: circuitforge-plans/snipe/ebay-webhook-compliance-runbook.md
Kuma monitor: id=19 on heimdall status page (Snipe group)
2026-04-18 22:20:29 -07:00
16cd32b0db fix: body background follows theme tokens + Plausible analytics
- theme.css: add background: var(--color-surface) to body so it responds
  to theme changes (was hardcoded #0d1117 via FOFT guard in index.html,
  causing mixed dark/light on light theme)
- index.html: add Plausible analytics snippet (cookie-free, self-hosted,
  skips localhost; reports to hostname + circuitforge.tech rollup)
- index.html: clarify FOFT guard comment — bundle overrides both html
  and body once loaded
2026-04-17 03:00:16 -07:00
16 changed files with 1759 additions and 148 deletions

View file

@ -19,6 +19,25 @@ EBAY_SANDBOX_CERT_ID=
# production | sandbox # production | sandbox
EBAY_ENV=production EBAY_ENV=production
# ── eBay OAuth — Authorization Code (user account connection) ─────────────────
# Enables paid-tier users to connect their personal eBay account for instant
# trust scoring via Trading API GetUser (account age + per-category feedback).
# Without this, Snipe falls back to Shopping API + Playwright scraping.
#
# Setup steps:
# 1. Go to https://developer.ebay.com/my/keys → select your Production app
# 2. Under "Auth Accepted URL / RuName", create a new entry:
# - Callback URL: https://your-domain/api/ebay/callback
# (e.g. https://menagerie.circuitforge.tech/snipe/api/ebay/callback)
# - Snipe generates the redirect automatically — just register the URL above
# 3. Copy the RuName value (looks like "YourName-AppName-PRD-xxx-yyy")
# and paste it as EBAY_RUNAME below.
# 4. Set EBAY_OAUTH_REDIRECT_URI to the same HTTPS callback URL.
#
# Self-hosted: your callback URL must be HTTPS and publicly reachable.
# EBAY_RUNAME=YourName-AppName-PRD-xxxxxxxx-xxxxxxxx
# EBAY_OAUTH_REDIRECT_URI=https://your-domain/api/ebay/callback
# ── eBay Account Deletion Webhook ────────────────────────────────────────────── # ── eBay Account Deletion Webhook ──────────────────────────────────────────────
# Register endpoint at https://developer.ebay.com/my/notification — required for # Register endpoint at https://developer.ebay.com/my/notification — required for
# production key activation. Set EBAY_NOTIFICATION_ENDPOINT to the public HTTPS # production key activation. Set EBAY_NOTIFICATION_ENDPOINT to the public HTTPS
@ -32,6 +51,9 @@ EBAY_WEBHOOK_VERIFY_SIGNATURES=true
# ── Database ─────────────────────────────────────────────────────────────────── # ── Database ───────────────────────────────────────────────────────────────────
SNIPE_DB=data/snipe.db SNIPE_DB=data/snipe.db
# Product identifier reported in cf-orch coordinator analytics for per-app breakdown
CF_APP_NAME=snipe
# ── Cloud mode (managed / menagerie instance only) ───────────────────────────── # ── Cloud mode (managed / menagerie instance only) ─────────────────────────────
# Leave unset for self-hosted / local use. When set, per-user DB isolation # Leave unset for self-hosted / local use. When set, per-user DB isolation
# and Heimdall licensing are enabled. compose.cloud.yml sets CLOUD_MODE=true # and Heimdall licensing are enabled. compose.cloud.yml sets CLOUD_MODE=true

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ data/
web/node_modules/ web/node_modules/
web/dist/ web/dist/
config/llm.yaml config/llm.yaml
.worktrees/

View file

@ -33,6 +33,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key
from fastapi import APIRouter, Header, HTTPException, Request from fastapi import APIRouter, Header, HTTPException, Request
from app.db.store import Store from app.db.store import Store
from app.platforms.ebay.auth import EbayTokenManager
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -40,6 +41,24 @@ router = APIRouter()
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db")) _DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
# ── App-level token manager ───────────────────────────────────────────────────
# Lazily initialized from env vars; shared across all webhook requests.
# The Notification public_key endpoint requires a Bearer app token.
_app_token_manager: EbayTokenManager | None = None
def _get_app_token() -> str | None:
"""Return a valid eBay app-level Bearer token, or None if creds are absent."""
global _app_token_manager
client_id = (os.environ.get("EBAY_APP_ID") or os.environ.get("EBAY_CLIENT_ID", "")).strip()
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
if not client_id or not client_secret:
return None
if _app_token_manager is None:
_app_token_manager = EbayTokenManager(client_id, client_secret)
return _app_token_manager.get_token()
# ── Public-key cache ────────────────────────────────────────────────────────── # ── Public-key cache ──────────────────────────────────────────────────────────
# eBay key rotation is rare; 1-hour TTL is appropriate. # eBay key rotation is rare; 1-hour TTL is appropriate.
_KEY_CACHE_TTL = 3600 _KEY_CACHE_TTL = 3600
@ -58,7 +77,14 @@ def _fetch_public_key(kid: str) -> bytes:
return cached[0] return cached[0]
key_url = _EBAY_KEY_URL.format(kid=kid) key_url = _EBAY_KEY_URL.format(kid=kid)
resp = requests.get(key_url, timeout=10) headers: dict[str, str] = {}
app_token = _get_app_token()
if app_token:
headers["Authorization"] = f"Bearer {app_token}"
else:
log.warning("public_key fetch: no app credentials — request will likely fail")
resp = requests.get(key_url, headers=headers, timeout=10)
if not resp.ok: if not resp.ok:
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500]) log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
resp.raise_for_status() resp.raise_for_status()
@ -68,6 +94,42 @@ def _fetch_public_key(kid: str) -> bytes:
return pem_bytes return pem_bytes
# ── GET — webhook health check ───────────────────────────────────────────────
@router.get("/api/ebay/webhook-health")
def ebay_webhook_health() -> dict:
"""Lightweight health check for eBay webhook compliance monitoring.
Returns 200 + status dict when the webhook is fully configured.
Returns 500 when required env vars are missing.
Intended for Uptime Kuma or similar uptime monitors.
"""
token = os.environ.get("EBAY_NOTIFICATION_TOKEN", "")
endpoint = os.environ.get("EBAY_NOTIFICATION_ENDPOINT", "")
client_id = (os.environ.get("EBAY_APP_ID") or os.environ.get("EBAY_CLIENT_ID", "")).strip()
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
missing = [
name for name, val in [
("EBAY_NOTIFICATION_TOKEN", token),
("EBAY_NOTIFICATION_ENDPOINT", endpoint),
("EBAY_APP_ID / EBAY_CLIENT_ID", client_id),
("EBAY_CERT_ID / EBAY_CLIENT_SECRET", client_secret),
] if not val
]
if missing:
log.error("ebay_webhook_health: missing config: %s", missing)
raise HTTPException(
status_code=500,
detail=f"Webhook misconfigured — missing: {missing}",
)
return {
"status": "ok",
"endpoint": endpoint,
"signature_verification": os.environ.get("EBAY_WEBHOOK_VERIFY_SIGNATURES", "true"),
}
# ── GET — challenge verification ────────────────────────────────────────────── # ── GET — challenge verification ──────────────────────────────────────────────
@router.get("/api/ebay/account-deletion") @router.get("/api/ebay/account-deletion")

View file

@ -10,11 +10,13 @@ import json as _json
import logging import logging
import os import os
import queue as _queue import queue as _queue
import re
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import parse_qs, urlparse
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
from circuitforge_core.api import make_corrections_router as _make_corrections_router from circuitforge_core.api import make_corrections_router as _make_corrections_router
@ -147,8 +149,58 @@ async def _lifespan(app: FastAPI):
except Exception: except Exception:
log.warning("LLM query builder init failed.", exc_info=True) log.warning("LLM query builder init failed.", exc_info=True)
# Background monitor polling loop — checks every 60s for due saved-search monitors.
import asyncio
async def _monitor_loop(db: Path) -> None:
import asyncio as _asyncio
from app.tasks.monitor import run_monitor_search
while True:
try:
await _asyncio.sleep(60)
sched_store = Store(db)
due = sched_store.list_due_active_monitors()
for user_db_path, saved_search_id, _ in due:
user_db = Path(user_db_path)
if not user_db.exists():
log.warning("Monitor: user_db not found, skipping: %s", user_db_path)
sched_store.remove_active_monitor(user_db_path, saved_search_id)
continue
user_store = Store(user_db)
searches = [s for s in user_store.list_monitored_searches() if s.id == saved_search_id]
if not searches:
# Search was deleted or monitoring disabled — clean up registry.
sched_store.remove_active_monitor(user_db_path, saved_search_id)
continue
try:
await asyncio.to_thread(
run_monitor_search, searches[0],
user_db=user_db, shared_db=db,
)
sched_store.mark_active_monitor_checked(user_db_path, saved_search_id)
except Exception:
log.exception(
"Monitor: run failed for search %d (user_db=%s)",
saved_search_id, user_db_path,
)
except asyncio.CancelledError:
break
except Exception:
log.exception("Monitor: polling tick error")
_monitor_task = asyncio.create_task(_monitor_loop(sched_db))
log.info("Snipe monitor polling loop started.")
yield yield
_monitor_task.cancel()
try:
await _monitor_task
except Exception:
pass
log.info("Snipe monitor polling loop stopped.")
get_scheduler(sched_db).shutdown(timeout=10.0) get_scheduler(sched_db).shutdown(timeout=10.0)
reset_scheduler() reset_scheduler()
log.info("Snipe task scheduler stopped.") log.info("Snipe task scheduler stopped.")
@ -160,6 +212,52 @@ async def _lifespan(app: FastAPI):
_community_store = None _community_store = None
_EBAY_ITM_RE = re.compile(r"/itm/(?:[^/]+/)?(\d{8,13})(?:[/?#]|$)")
_EBAY_ITEM_ID_DIGITS = re.compile(r"^\d{8,13}$")
def _extract_ebay_item_id(q: str) -> str | None:
"""Extract a numeric eBay item ID from a URL, or return None if *q* is not an eBay URL.
Supported formats:
- https://www.ebay.com/itm/Title-String/123456789012
- https://www.ebay.com/itm/123456789012
- https://ebay.com/itm/123456789012
- https://pay.ebay.com/rxo?action=view&sessionid=...&itemId=123456789012
- https://pay.ebay.com/rxo/view?itemId=123456789012
"""
q = q.strip()
# Must look like a URL — require http/https scheme or an ebay.com hostname.
if not (q.startswith("http://") or q.startswith("https://")):
return None
try:
parsed = urlparse(q)
except Exception:
return None
host = parsed.hostname or ""
if not (host == "ebay.com" or host.endswith(".ebay.com")):
return None
# pay.ebay.com checkout URLs — item ID is in the itemId query param.
if host == "pay.ebay.com":
params = parse_qs(parsed.query)
item_id_list = params.get("itemId") or params.get("itemid")
if item_id_list:
candidate = item_id_list[0]
if _EBAY_ITEM_ID_DIGITS.match(candidate):
return candidate
return None
# Standard listing URLs — item ID appears after /itm/.
m = _EBAY_ITM_RE.search(parsed.path)
if m:
return m.group(1)
return None
def _ebay_creds() -> tuple[str, str, str]: def _ebay_creds() -> tuple[str, str, str]:
"""Return (client_id, client_secret, env) from env vars. """Return (client_id, client_secret, env) from env vars.
@ -266,6 +364,7 @@ def _trigger_scraper_enrichment(
user_db: Path | None = None, user_db: Path | None = None,
query: str = "", query: str = "",
session_id: str | None = None, session_id: str | None = None,
skip_seller_ids: "set[str] | None" = None,
) -> None: ) -> None:
"""Fire-and-forget background enrichment for missing seller signals. """Fire-and-forget background enrichment for missing seller signals.
@ -281,9 +380,11 @@ def _trigger_scraper_enrichment(
user_db: path to per-user listings/trust_scores DB (same as shared_db in local mode). user_db: path to per-user listings/trust_scores DB (same as shared_db in local mode).
query: original search query used for market comp lookup during re-score. query: original search query used for market comp lookup during re-score.
session_id: SSE session key; if set, updates are pushed to _update_queues[session_id]. session_id: SSE session key; if set, updates are pushed to _update_queues[session_id].
skip_seller_ids: seller IDs already enriched via Trading API skip BTF for these.
""" """
_BTF_MAX_PER_SEARCH = 3 _BTF_MAX_PER_SEARCH = 3
_CAT_MAX_PER_SEARCH = 3 _CAT_MAX_PER_SEARCH = 3
_skip = skip_seller_ids or set()
needs_btf: dict[str, str] = {} needs_btf: dict[str, str] = {}
needs_categories: list[str] = [] needs_categories: list[str] = []
@ -301,6 +402,7 @@ def _trigger_scraper_enrichment(
seller_listing_map.setdefault(sid, []).append(listing) seller_listing_map.setdefault(sid, []).append(listing)
if ((seller.account_age_days is None or seller.feedback_count == 0) if ((seller.account_age_days is None or seller.feedback_count == 0)
and sid not in needs_btf and sid not in needs_btf
and sid not in _skip
and len(needs_btf) < _BTF_MAX_PER_SEARCH): and len(needs_btf) < _BTF_MAX_PER_SEARCH):
needs_btf[sid] = listing.platform_listing_id needs_btf[sid] = listing.platform_listing_id
if (seller.category_history_json in ("{}", "", None) if (seller.category_history_json in ("{}", "", None)
@ -428,6 +530,61 @@ def _parse_terms(raw: str) -> list[str]:
return [t.strip() for t in raw.split(",") if t.strip()] return [t.strip() for t in raw.split(",") if t.strip()]
def _try_trading_api_enrichment(
adapter: "EbayAdapter",
seller_ids: list[str],
user_db: Path,
) -> set[str]:
"""Enrich sellers via Trading API GetUser if the user has a stored access token.
Returns the set of seller_ids successfully enriched so the caller can skip
those sellers in the slower BTF background pass.
Silently skips if:
- No tokens stored (user hasn't connected eBay account)
- Access token is expired and refresh fails
- Adapter is ScrapedEbayAdapter (no Trading API method)
"""
import time
if not hasattr(adapter, "enrich_seller_trading_api"):
return set()
tokens = _get_ebay_tokens(user_db)
if not tokens:
return set()
access_token = tokens["access_token"]
# Refresh if within 60s of expiry
if tokens["expires_at"] < time.time() + 60:
manager = _ebay_oauth_manager()
if manager is None:
return set()
try:
new_tokens = manager.refresh(tokens["refresh_token"])
_save_ebay_tokens(user_db, new_tokens)
access_token = new_tokens.access_token
log.debug("eBay access token refreshed for Trading API enrichment")
except Exception as exc:
log.debug("eBay token refresh failed — skipping Trading API enrichment: %s", exc)
return set()
enriched: set[str] = set()
for seller_id in seller_ids:
try:
ok = adapter.enrich_seller_trading_api(seller_id, access_token)
if ok:
enriched.add(seller_id)
except Exception as exc:
log.debug("Trading API enrichment failed for %s: %s", seller_id, exc)
if enriched:
log.info("Trading API: enriched %d/%d sellers inline", len(enriched), len(seller_ids))
return enriched
def _make_adapter(shared_store: Store, force: str = "auto"): def _make_adapter(shared_store: Store, force: str = "auto"):
"""Return the appropriate adapter. """Return the appropriate adapter.
@ -478,6 +635,13 @@ def search(
adapter: str = "auto", # "auto" | "api" | "scraper" — override adapter selection adapter: str = "auto", # "auto" | "api" | "scraper" — override adapter selection
session: CloudUser = Depends(get_session), session: CloudUser = Depends(get_session),
): ):
# If the user pasted an eBay listing or checkout URL, extract the item ID
# and use it as the search query so the exact item surfaces in results.
ebay_item_id = _extract_ebay_item_id(q)
if ebay_item_id:
log.info("search: eBay URL detected, extracted item_id=%s", ebay_item_id)
q = ebay_item_id
if not q.strip(): if not q.strip():
return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None, "adapter_used": _adapter_name(adapter)} return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None, "adapter_used": _adapter_name(adapter)}
@ -559,6 +723,15 @@ def search(
pages, len(ebay_queries), len(listings), q, pages, len(ebay_queries), len(listings), q,
) )
import sqlite3 as _sqlite3
affiliate_active = bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip())
# Pre-register SSE queue so session_id is available regardless of DB outcome.
session_id = str(uuid.uuid4())
_update_queues[session_id] = _queue.SimpleQueue()
try:
# Main-thread stores — fresh connections, same thread. # Main-thread stores — fresh connections, same thread.
# shared_store: sellers, market_comps (all users share this data) # shared_store: sellers, market_comps (all users share this data)
# user_store: listings, saved_searches (per-user in cloud mode, same file in local mode) # user_store: listings, saved_searches (per-user in cloud mode, same file in local mode)
@ -580,15 +753,32 @@ def search(
staged = user_store.get_listings_staged("ebay", [l.platform_listing_id for l in listings]) staged = user_store.get_listings_staged("ebay", [l.platform_listing_id for l in listings])
listings = [staged.get(l.platform_listing_id, l) for l in listings] listings = [staged.get(l.platform_listing_id, l) for l in listings]
# BTF enrichment: scrape /itm/ pages for sellers missing account_age_days. # Trading API enrichment: if the user has connected their eBay account, use
# Runs in the background so it doesn't delay the response. A session_id is # Trading API GetUser to instantly fill account_age_days for sellers missing it.
# generated so the frontend can open an SSE stream and receive live score # This is synchronous (~200ms per seller) but only runs for sellers that need
# updates as enrichment completes. # enrichment — typically a small subset. Sellers resolved here are excluded from
session_id = str(uuid.uuid4()) # the slower BTF Playwright background pass.
_update_queues[session_id] = _queue.SimpleQueue() _main_adapter = _make_adapter(shared_store, adapter)
sellers_needing_age = [
l.seller_platform_id for l in listings
if l.seller_platform_id
and shared_store.get_seller("ebay", l.seller_platform_id) is not None
and shared_store.get_seller("ebay", l.seller_platform_id).account_age_days is None
]
# Deduplicate while preserving order
seen: set[str] = set()
sellers_needing_age = [s for s in sellers_needing_age if not (s in seen or seen.add(s))] # type: ignore[func-returns-value]
trading_api_enriched = _try_trading_api_enrichment(
_main_adapter, sellers_needing_age, user_db
)
# BTF enrichment: scrape /itm/ pages for sellers still missing account_age_days
# after the Trading API pass. Runs in the background so it doesn't delay the
# response. Live score updates are pushed to the pre-registered SSE queue.
_trigger_scraper_enrichment( _trigger_scraper_enrichment(
listings, shared_store, shared_db, listings, shared_store, shared_db,
user_db=user_db, query=comp_query, session_id=session_id, user_db=user_db, query=comp_query, session_id=session_id,
skip_seller_ids=trading_api_enriched,
) )
scorer = TrustScorer(shared_store) scorer = TrustScorer(shared_store)
@ -645,10 +835,286 @@ def search(
"sellers": seller_map, "sellers": seller_map,
"market_price": market_price, "market_price": market_price,
"adapter_used": adapter_used, "adapter_used": adapter_used,
"affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()), "affiliate_active": affiliate_active,
"session_id": session_id, "session_id": session_id,
} }
except _sqlite3.OperationalError as e:
# shared_db write contention under high concurrency — return raw listings
# without trust scores rather than a 500. The frontend handles missing trust_scores.
log.warning("search DB contention, returning raw listings (no trust scores): %s", e)
_update_queues.pop(session_id, None)
return {
"listings": [dataclasses.asdict(l) for l in listings],
"trust_scores": {},
"sellers": {},
"market_price": None,
"adapter_used": adapter_used,
"affiliate_active": affiliate_active,
"session_id": None,
}
# ── Async search (fire-and-forget + SSE streaming) ───────────────────────────
# Module-level executor shared across all async search requests.
# max_workers=4 caps concurrent Playwright/scraper sessions to avoid OOM.
_search_executor = ThreadPoolExecutor(max_workers=4)
@app.get("/api/search/async", status_code=202)
def search_async(
q: str = "",
max_price: Optional[float] = None,
min_price: Optional[float] = None,
pages: int = 1,
must_include: str = "",
must_include_mode: str = "all",
must_exclude: str = "",
category_id: str = "",
adapter: str = "auto",
session: CloudUser = Depends(get_session),
):
"""Async variant of GET /api/search.
Returns HTTP 202 immediately with a session_id, then streams scrape results
and trust scores via GET /api/updates/{session_id} as they become available.
SSE event types pushed to the queue:
{"type": "listings", "listings": [...], "trust_scores": {...}, "sellers": {...},
"market_price": ..., "adapter_used": ..., "affiliate_active": ...}
{"type": "market_price", "market_price": 123.45} (if comp resolves after listings)
{"type": "update", "platform_listing_id": "...", "trust_score": {...},
"seller": {...}, "market_price": ...} (enrichment updates)
None (sentinel stream finished)
"""
# Validate / normalise params — same logic as synchronous endpoint.
ebay_item_id = _extract_ebay_item_id(q)
if ebay_item_id:
q = ebay_item_id
if not q.strip():
# Return a completed (empty) session so the client can open the SSE
# stream and immediately receive a done event.
empty_id = str(uuid.uuid4())
_update_queues[empty_id] = _queue.SimpleQueue()
_update_queues[empty_id].put({
"type": "listings",
"listings": [],
"trust_scores": {},
"sellers": {},
"market_price": None,
"adapter_used": _adapter_name(adapter),
"affiliate_active": bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip()),
})
_update_queues[empty_id].put(None)
return {"session_id": empty_id, "status": "queued"}
features = compute_features(session.tier)
pages = min(max(1, pages), features.max_pages)
session_id = str(uuid.uuid4())
_update_queues[session_id] = _queue.SimpleQueue()
# Capture everything the background worker needs — don't pass session object
# (it may not be safe to use across threads).
_shared_db = session.shared_db
_user_db = session.user_db
_tier = session.tier
_user_id = session.user_id
_affiliate_active = bool(os.environ.get("EBAY_AFFILIATE_CAMPAIGN_ID", "").strip())
def _background_search() -> None:
"""Run the full search pipeline and push SSE events to the queue."""
import hashlib as _hashlib
import sqlite3 as _sqlite3
q_norm = q # captured from outer scope
must_exclude_terms = _parse_terms(must_exclude)
if must_include_mode == "groups" and must_include.strip():
or_groups = parse_groups(must_include)
ebay_queries = expand_queries(q_norm, or_groups)
else:
ebay_queries = [q_norm]
if must_include_mode == "groups" and len(ebay_queries) > 0:
comp_query = ebay_queries[0]
elif must_include_mode == "all" and must_include.strip():
extra = " ".join(_parse_terms(must_include))
comp_query = f"{q_norm} {extra}".strip()
else:
comp_query = q_norm
base_filters = SearchFilters(
max_price=max_price if max_price and max_price > 0 else None,
min_price=min_price if min_price and min_price > 0 else None,
pages=pages,
must_exclude=must_exclude_terms,
category_id=category_id.strip() or None,
)
adapter_used = _adapter_name(adapter)
q_ref = _update_queues.get(session_id)
if q_ref is None:
return # client disconnected before we even started
def _push(event: dict | None) -> None:
"""Push an event to the queue; silently drop if session no longer exists."""
sq = _update_queues.get(session_id)
if sq is not None:
sq.put(event)
try:
def _run_search(ebay_query: str) -> list:
return _make_adapter(Store(_shared_db), adapter).search(ebay_query, base_filters)
def _run_comps() -> None:
try:
_make_adapter(Store(_shared_db), adapter).get_completed_sales(comp_query, pages)
except Exception:
log.warning("async comps: unhandled exception for %r", comp_query, exc_info=True)
max_workers_inner = min(len(ebay_queries) + 1, 5)
with ThreadPoolExecutor(max_workers=max_workers_inner) as ex:
comps_future = ex.submit(_run_comps)
search_futures = [ex.submit(_run_search, eq) for eq in ebay_queries]
seen_ids: set[str] = set()
listings: list = []
for fut in search_futures:
for listing in fut.result():
if listing.platform_listing_id not in seen_ids:
seen_ids.add(listing.platform_listing_id)
listings.append(listing)
comps_future.result()
log.info(
"async_search auth=%s tier=%s adapter=%s pages=%d listings=%d q=%r",
_auth_label(_user_id), _tier, adapter_used, pages, len(listings), q_norm,
)
shared_store = Store(_shared_db)
user_store = Store(_user_db)
user_store.save_listings(listings)
seller_ids = list({l.seller_platform_id for l in listings if l.seller_platform_id})
n_cat = shared_store.refresh_seller_categories("ebay", seller_ids, listing_store=user_store)
if n_cat:
log.info("async_search: category history derived for %d sellers", n_cat)
staged = user_store.get_listings_staged("ebay", [l.platform_listing_id for l in listings])
listings = [staged.get(l.platform_listing_id, l) for l in listings]
_main_adapter = _make_adapter(shared_store, adapter)
sellers_needing_age = [
l.seller_platform_id for l in listings
if l.seller_platform_id
and shared_store.get_seller("ebay", l.seller_platform_id) is not None
and shared_store.get_seller("ebay", l.seller_platform_id).account_age_days is None
]
seen_set: set[str] = set()
sellers_needing_age = [s for s in sellers_needing_age if not (s in seen_set or seen_set.add(s))] # type: ignore[func-returns-value]
# Use a temporary CloudUser-like object for Trading API enrichment
from api.cloud_session import CloudUser as _CloudUser
_session_stub = _CloudUser(
user_id=_user_id,
tier=_tier,
shared_db=_shared_db,
user_db=_user_db,
)
trading_api_enriched = _try_trading_api_enrichment(
_main_adapter, sellers_needing_age, _user_db
)
scorer = TrustScorer(shared_store)
trust_scores_list = scorer.score_batch(listings, q_norm)
user_store.save_trust_scores(trust_scores_list)
# Enqueue vision tasks for paid+ tiers
features_obj = compute_features(_tier)
if features_obj.photo_analysis:
_enqueue_vision_tasks(listings, trust_scores_list, _session_stub)
query_hash = _hashlib.md5(comp_query.encode()).hexdigest()
comp = shared_store.get_market_comp("ebay", query_hash)
market_price = comp.median_price if comp else None
trust_map = {
listing.platform_listing_id: dataclasses.asdict(ts)
for listing, ts in zip(listings, trust_scores_list)
if ts is not None
}
seller_map = {
listing.seller_platform_id: dataclasses.asdict(
shared_store.get_seller("ebay", listing.seller_platform_id)
)
for listing in listings
if listing.seller_platform_id
and shared_store.get_seller("ebay", listing.seller_platform_id)
}
_is_unauthed = _user_id == "anonymous" or _user_id.startswith("guest:")
_pref_store = None if _is_unauthed else user_store
def _get_pref(uid: Optional[str], path: str, default=None):
return _pref_store.get_user_preference(path, default=default) # type: ignore[union-attr]
def _serialize_listing(l: object) -> dict:
d = dataclasses.asdict(l)
d["url"] = _wrap_affiliate_url(
d["url"],
retailer="ebay",
user_id=None if _is_unauthed else _user_id,
get_preference=_get_pref if _pref_store is not None else None,
)
return d
# Push the initial listings batch
_push({
"type": "listings",
"listings": [_serialize_listing(l) for l in listings],
"trust_scores": trust_map,
"sellers": seller_map,
"market_price": market_price,
"adapter_used": adapter_used,
"affiliate_active": _affiliate_active,
"session_id": session_id,
})
# Kick off background enrichment — it pushes "update" events and the sentinel.
_trigger_scraper_enrichment(
listings, shared_store, _shared_db,
user_db=_user_db, query=comp_query, session_id=session_id,
skip_seller_ids=trading_api_enriched,
)
except _sqlite3.OperationalError as e:
log.warning("async_search DB contention: %s", e)
_push({
"type": "listings",
"listings": [],
"trust_scores": {},
"sellers": {},
"market_price": None,
"adapter_used": adapter_used,
"affiliate_active": _affiliate_active,
"session_id": session_id,
})
_push(None)
except Exception as e:
log.warning("async_search background scrape failed: %s", e)
_push({
"type": "error",
"message": str(e),
})
_push(None)
_search_executor.submit(_background_search)
return {"session_id": session_id, "status": "queued"}
# ── On-demand enrichment ────────────────────────────────────────────────────── # ── On-demand enrichment ──────────────────────────────────────────────────────
@ -750,21 +1216,33 @@ def enrich_seller(
async def stream_updates(session_id: str, request: Request): async def stream_updates(session_id: str, request: Request):
"""Server-Sent Events stream for live trust score updates. """Server-Sent Events stream for live trust score updates.
Opens after a search when any listings have score_is_partial=true. Used both by the synchronous search endpoint (enrichment-only updates) and
Streams re-scored trust score payloads as enrichment completes, then the async search endpoint (initial listings + enrichment updates).
sends a 'done' event and closes.
Each event payload: Event data formats:
Enrichment update (legacy / sync search):
{ platform_listing_id, trust_score, seller, market_price } { platform_listing_id, trust_score, seller, market_price }
Async search initial batch:
{ type: "listings", listings, trust_scores, sellers, market_price,
adapter_used, affiliate_active, session_id }
Async search market price resolved after listings:
{ type: "market_price", market_price }
Async search per-seller enrichment update:
{ type: "update", platform_listing_id, trust_score, seller, market_price }
Error:
{ type: "error", message }
Closes automatically after 90 seconds (worst-case Playwright enrichment). All events are serialised as plain `data:` lines (no named event type).
The client should also close on 'done' event. The stream ends with a named `event: done` line.
Closes automatically after 150 seconds (covers worst-case async scrape + enrichment).
The client should also close on the 'done' event.
""" """
if session_id not in _update_queues: if session_id not in _update_queues:
raise HTTPException(status_code=404, detail="Unknown session_id") raise HTTPException(status_code=404, detail="Unknown session_id")
q = _update_queues[session_id] q = _update_queues[session_id]
deadline = asyncio.get_event_loop().time() + 90.0 deadline = asyncio.get_event_loop().time() + 150.0
heartbeat_interval = 15.0 heartbeat_interval = 15.0
next_heartbeat = asyncio.get_event_loop().time() + heartbeat_interval next_heartbeat = asyncio.get_event_loop().time() + heartbeat_interval
@ -858,6 +1336,88 @@ def mark_saved_search_run(saved_id: int, session: CloudUser = Depends(get_sessio
return {"ok": True} return {"ok": True}
class MonitorSettingsUpdate(BaseModel):
monitor_enabled: bool
poll_interval_min: int = 60
min_trust_score: int = 60
@app.patch("/api/saved-searches/{saved_id}/monitor", status_code=200)
def update_monitor_settings(
saved_id: int,
body: MonitorSettingsUpdate,
session: CloudUser = Depends(get_session),
):
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
from app.tiers import can_use, get_limit
features = compute_features(session.tier)
if not features.background_monitoring:
raise HTTPException(status_code=403, detail="Background monitoring requires a paid plan.")
user_store = Store(session.user_db)
if body.monitor_enabled:
limit = get_limit("background_monitoring", session.tier)
if limit is not None:
active_count = user_store.count_active_monitors()
# Don't count the search being updated — it might already be enabled.
searches = user_store.list_saved_searches()
already_enabled = any(s.id == saved_id and s.monitor_enabled for s in searches)
if not already_enabled and active_count >= limit:
raise HTTPException(
status_code=403,
detail=f"Your plan allows up to {limit} active monitors. Disable one to add another.",
)
# Clamp values to sane bounds.
interval = max(15, min(body.poll_interval_min, 1440))
threshold = max(0, min(body.min_trust_score, 100))
user_store.update_monitor_settings(
saved_id,
monitor_enabled=body.monitor_enabled,
poll_interval_min=interval,
min_trust_score=threshold,
)
# Sync to the cross-user registry in sched_db.
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
sched_store = Store(sched_db)
if body.monitor_enabled:
sched_store.upsert_active_monitor(str(session.user_db), saved_id, interval)
else:
sched_store.remove_active_monitor(str(session.user_db), saved_id)
return {"ok": True, "monitor_enabled": body.monitor_enabled, "poll_interval_min": interval}
# ── Watch Alerts ──────────────────────────────────────────────────────────────
@app.get("/api/alerts")
def list_alerts(
include_dismissed: bool = False,
session: CloudUser = Depends(get_session),
):
user_store = Store(session.user_db)
alerts = user_store.list_alerts(include_dismissed=include_dismissed)
return {
"alerts": [dataclasses.asdict(a) for a in alerts],
"unread_count": user_store.count_undismissed_alerts(),
}
@app.post("/api/alerts/{alert_id}/dismiss", status_code=204)
def dismiss_alert(alert_id: int, session: CloudUser = Depends(get_session)):
Store(session.user_db).dismiss_alert(alert_id)
@app.post("/api/alerts/dismiss-all", status_code=200)
def dismiss_all_alerts(session: CloudUser = Depends(get_session)):
count = Store(session.user_db).dismiss_all_alerts()
return {"dismissed": count}
# ── Community Trust Signals ─────────────────────────────────────────────────── # ── Community Trust Signals ───────────────────────────────────────────────────
# Signals live in shared_db so feedback aggregates across all users. # Signals live in shared_db so feedback aggregates across all users.
@ -1048,6 +1608,11 @@ def get_preferences(session: CloudUser = Depends(get_session)) -> dict:
return store.get_all_preferences() return store.get_all_preferences()
_SUPPORTED_CURRENCIES = frozenset({
"USD", "GBP", "EUR", "CAD", "AUD", "JPY", "CHF", "MXN", "BRL", "INR",
})
@app.patch("/api/preferences") @app.patch("/api/preferences")
def patch_preference( def patch_preference(
body: PreferenceUpdate, body: PreferenceUpdate,
@ -1057,6 +1622,7 @@ def patch_preference(
- ``affiliate.opt_out`` available to all signed-in users. - ``affiliate.opt_out`` available to all signed-in users.
- ``affiliate.byok_ids.ebay`` Premium tier only. - ``affiliate.byok_ids.ebay`` Premium tier only.
- ``display.currency`` ISO 4217 code from the supported set.
Returns the full updated preferences dict. Returns the full updated preferences dict.
""" """
@ -1070,6 +1636,15 @@ def patch_preference(
status_code=403, status_code=403,
detail="Custom affiliate IDs (BYOK) require a Premium subscription.", detail="Custom affiliate IDs (BYOK) require a Premium subscription.",
) )
if body.path == "display.currency":
code = str(body.value or "").strip().upper()
if code not in _SUPPORTED_CURRENCIES:
supported = ", ".join(sorted(_SUPPORTED_CURRENCIES))
raise HTTPException(
status_code=400,
detail=f"Unsupported currency code '{body.value}'. Supported codes: {supported}",
)
body = PreferenceUpdate(path=body.path, value=code)
store = Store(session.user_db) store = Store(session.user_db)
store.set_user_preference(body.path, body.value) store.set_user_preference(body.path, body.value)
return store.get_all_preferences() return store.get_all_preferences()
@ -1134,3 +1709,164 @@ async def build_search_query(
} }
# ── eBay OAuth (Authorization Code) ───────────────────────────────────────────
# Allows paid-tier users to connect their eBay account for instant trust scores
# via Trading API GetUser (account age + per-category feedback) instead of
# Playwright scraping.
#
# Prerequisites:
# EBAY_RUNAME — RuName from eBay developer console (OAuth redirect name)
# EBAY_OAUTH_REDIRECT_URI — Full HTTPS callback URL registered with that RuName
# e.g. https://menagerie.circuitforge.tech/snipe/api/ebay/callback
#
# Flow: /api/ebay/connect → eBay → /api/ebay/callback → stored tokens → instant enrichment
def _ebay_oauth_manager() -> "EbayUserTokenManager | None":
"""Return a configured EbayUserTokenManager, or None if EBAY_RUNAME not set."""
from circuitforge_core.platforms.ebay.oauth import EbayUserTokenManager
runame = os.environ.get("EBAY_RUNAME", "").strip()
redirect_uri = os.environ.get("EBAY_OAUTH_REDIRECT_URI", "").strip()
if not runame or not redirect_uri:
return None
client_id, client_secret, env = _ebay_creds()
if not client_id or not client_secret:
return None
return EbayUserTokenManager(
client_id=client_id,
client_secret=client_secret,
runame=runame,
redirect_uri=redirect_uri,
env=env,
)
def _get_ebay_tokens(user_db: Path) -> "dict | None":
"""Load stored eBay user tokens from the per-user DB. Returns None if not connected."""
import sqlite3
try:
conn = sqlite3.connect(user_db)
row = conn.execute(
"SELECT access_token, refresh_token, expires_at, scopes FROM ebay_user_tokens LIMIT 1"
).fetchone()
conn.close()
if row:
return {"access_token": row[0], "refresh_token": row[1], "expires_at": row[2], "scopes": row[3]}
except Exception:
pass
return None
def _save_ebay_tokens(user_db: Path, tokens: "EbayUserTokens") -> None:
"""Persist eBay tokens into the per-user DB (single-row table — delete + insert)."""
import sqlite3
scopes_str = " ".join(tokens.scopes) if isinstance(tokens.scopes, list) else (tokens.scopes or "")
conn = sqlite3.connect(user_db)
try:
conn.execute("DELETE FROM ebay_user_tokens")
conn.execute(
"INSERT INTO ebay_user_tokens (access_token, refresh_token, expires_at, scopes, last_refreshed) VALUES (?, ?, ?, ?, datetime('now'))",
(tokens.access_token, tokens.refresh_token, tokens.expires_at, scopes_str),
)
conn.commit()
finally:
conn.close()
@app.get("/api/ebay/connect")
def ebay_oauth_connect(session: CloudUser = Depends(get_session)):
"""Redirect the user to eBay OAuth authorization.
Requires Paid tier or local mode. Returns a redirect URL for the frontend
to navigate to (frontend opens in same tab or popup).
"""
from fastapi.responses import JSONResponse
import secrets
features = compute_features(session.tier)
if not features.photo_analysis and session.tier != "local":
# Reuse photo_analysis flag as proxy for paid+ — both require paid tier
raise HTTPException(status_code=402, detail="eBay account connection requires Paid tier.")
manager = _ebay_oauth_manager()
if manager is None:
raise HTTPException(status_code=503, detail="eBay OAuth not configured (EBAY_RUNAME missing).")
state = secrets.token_urlsafe(16)
auth_url = manager.get_authorization_url(state=state)
return JSONResponse({"auth_url": auth_url, "state": state})
@app.get("/api/ebay/callback")
def ebay_oauth_callback(
code: str = "",
state: str = "",
error: str = "",
error_description: str = "",
session: CloudUser = Depends(get_session),
):
"""Handle eBay OAuth callback. Exchanges auth code for tokens and stores them.
eBay redirects here after the user authorizes (or denies) the connection.
On success, tokens are persisted to the per-user DB and the user is
redirected to the settings page.
"""
from fastapi.responses import RedirectResponse
base = os.environ.get("VITE_BASE_URL", "").rstrip("/") or ""
if error:
log.warning("eBay OAuth error: %s%s", error, error_description)
return RedirectResponse(f"{base}/settings?ebay_error={error}")
if not code:
raise HTTPException(status_code=400, detail="Missing authorization code.")
manager = _ebay_oauth_manager()
if manager is None:
raise HTTPException(status_code=503, detail="eBay OAuth not configured.")
try:
tokens = manager.exchange_code(code)
except Exception as exc:
log.error("eBay token exchange failed: %s", exc)
raise HTTPException(status_code=502, detail=f"Token exchange failed: {exc}")
_save_ebay_tokens(session.user_db, tokens)
log.info("eBay OAuth: tokens stored for user %s", session.user_id)
return RedirectResponse(f"{base}/settings?ebay_connected=1")
@app.get("/api/ebay/status")
def ebay_oauth_status(session: CloudUser = Depends(get_session)):
"""Return eBay connection status for the current user."""
import time
tokens = _get_ebay_tokens(session.user_db)
oauth_configured = _ebay_oauth_manager() is not None
if not tokens:
return {"connected": False, "oauth_available": oauth_configured}
expired = tokens["expires_at"] < time.time()
return {
"connected": True,
"oauth_available": oauth_configured,
"access_token_expired": expired,
"scopes": tokens["scopes"].split() if tokens["scopes"] else [],
}
@app.delete("/api/ebay/disconnect", status_code=204)
def ebay_oauth_disconnect(session: CloudUser = Depends(get_session)):
"""Remove stored eBay tokens for the current user."""
import sqlite3
try:
conn = sqlite3.connect(session.user_db)
conn.execute("DELETE FROM ebay_user_tokens")
conn.commit()
conn.close()
log.info("eBay OAuth: tokens removed for user %s", session.user_id)
except Exception as exc:
log.warning("eBay disconnect failed: %s", exc)

View file

@ -39,6 +39,21 @@ backends:
# service: ollama # service: ollama
# ttl_s: 300 # ttl_s: 300
# ── cf-orch trunk services ─────────────────────────────────────────────────
# Allocate via cf-orch; the router calls the allocated service directly.
# Set CF_ORCH_URL (env) or url below to activate.
cf_text:
type: openai_compat
enabled: false
base_url: http://localhost:8008/v1
model: __auto__
api_key: any
supports_images: false
cf_orch:
service: cf-text
model_candidates: []
ttl_s: 3600
fallback_order: fallback_order:
- anthropic - anthropic
- openai - openai

View file

@ -1,5 +1,6 @@
import pytest import pytest
from api.main import _extract_ebay_item_id
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
@ -56,3 +57,48 @@ def test_normalise_seller_maps_fields():
assert seller.feedback_count == 300 assert seller.feedback_count == 300
assert seller.feedback_ratio == pytest.approx(0.991, abs=0.001) assert seller.feedback_ratio == pytest.approx(0.991, abs=0.001)
assert seller.account_age_days > 0 assert seller.account_age_days > 0
# ── _extract_ebay_item_id ─────────────────────────────────────────────────────
class TestExtractEbayItemId:
"""Unit tests for the URL-to-item-ID normaliser."""
def test_itm_url_with_title_slug(self):
url = "https://www.ebay.com/itm/Sony-WH-1000XM5-Headphones/123456789012"
assert _extract_ebay_item_id(url) == "123456789012"
def test_itm_url_without_title_slug(self):
url = "https://www.ebay.com/itm/123456789012"
assert _extract_ebay_item_id(url) == "123456789012"
def test_itm_url_no_www(self):
url = "https://ebay.com/itm/123456789012"
assert _extract_ebay_item_id(url) == "123456789012"
def test_itm_url_with_query_params(self):
url = "https://www.ebay.com/itm/123456789012?hash=item1234abcd"
assert _extract_ebay_item_id(url) == "123456789012"
def test_pay_ebay_rxo_with_itemId_query_param(self):
url = "https://pay.ebay.com/rxo?action=view&sessionid=abc123&itemId=123456789012"
assert _extract_ebay_item_id(url) == "123456789012"
def test_pay_ebay_rxo_path_with_itemId(self):
url = "https://pay.ebay.com/rxo/view?itemId=123456789012"
assert _extract_ebay_item_id(url) == "123456789012"
def test_non_ebay_url_returns_none(self):
assert _extract_ebay_item_id("https://amazon.com/dp/B08N5WRWNW") is None
def test_plain_keyword_returns_none(self):
assert _extract_ebay_item_id("rtx 4090 gpu") is None
def test_empty_string_returns_none(self):
assert _extract_ebay_item_id("") is None
def test_ebay_url_no_item_id_returns_none(self):
assert _extract_ebay_item_id("https://www.ebay.com/sch/i.html?_nkw=gpu") is None
def test_pay_ebay_no_item_id_returns_none(self):
assert _extract_ebay_item_id("https://pay.ebay.com/rxo?action=view&sessionid=abc") is None

231
tests/test_async_search.py Normal file
View file

@ -0,0 +1,231 @@
"""Tests for GET /api/search/async (fire-and-forget search + SSE streaming).
Verifies:
- Returns HTTP 202 with session_id and status: "queued"
- session_id is registered in _update_queues immediately
- Actual scraping is not performed (mocked out)
- Empty query path returns a completed session with done event
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def client(tmp_path):
"""TestClient with a fresh tmp DB. Must set SNIPE_DB *before* importing app."""
os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db")
from api.main import app
return TestClient(app, raise_server_exceptions=False)
def _make_mock_listing():
"""Return a minimal mock listing object that satisfies the search pipeline."""
m = MagicMock()
m.platform_listing_id = "123456789"
m.seller_platform_id = "test_seller"
m.title = "Test GPU"
m.price = 100.0
m.currency = "USD"
m.condition = "Used"
m.url = "https://www.ebay.com/itm/123456789"
m.photo_urls = []
m.listing_age_days = 5
m.buying_format = "fixed_price"
m.ends_at = None
m.fetched_at = None
m.trust_score_id = None
m.id = 1
m.category_name = None
return m
# ── Core contract tests ───────────────────────────────────────────────────────
def test_async_search_returns_202(client):
"""GET /api/search/async?q=... returns HTTP 202 with session_id and status."""
with (
patch("api.main._make_adapter") as mock_adapter_factory,
patch("api.main._trigger_scraper_enrichment"),
patch("api.main.TrustScorer") as mock_scorer_cls,
):
mock_adapter = MagicMock()
mock_adapter.search.return_value = []
mock_adapter.get_completed_sales.return_value = None
mock_adapter_factory.return_value = mock_adapter
mock_scorer = MagicMock()
mock_scorer.score_batch.return_value = []
mock_scorer_cls.return_value = mock_scorer
resp = client.get("/api/search/async?q=test+gpu")
assert resp.status_code == 202
data = resp.json()
assert "session_id" in data
assert data["status"] == "queued"
assert isinstance(data["session_id"], str)
assert len(data["session_id"]) > 0
def test_async_search_registers_session_id(client):
"""session_id returned by 202 response must appear in _update_queues immediately."""
with (
patch("api.main._make_adapter") as mock_adapter_factory,
patch("api.main._trigger_scraper_enrichment"),
patch("api.main.TrustScorer") as mock_scorer_cls,
):
mock_adapter = MagicMock()
mock_adapter.search.return_value = []
mock_adapter.get_completed_sales.return_value = None
mock_adapter_factory.return_value = mock_adapter
mock_scorer = MagicMock()
mock_scorer.score_batch.return_value = []
mock_scorer_cls.return_value = mock_scorer
resp = client.get("/api/search/async?q=test+gpu")
assert resp.status_code == 202
session_id = resp.json()["session_id"]
# The queue must be registered so the SSE endpoint can open it.
from api.main import _update_queues
assert session_id in _update_queues
def test_async_search_empty_query(client):
"""Empty query returns 202 with a pre-loaded done sentinel, no scraping needed."""
resp = client.get("/api/search/async?q=")
assert resp.status_code == 202
data = resp.json()
assert data["status"] == "queued"
assert "session_id" in data
from api.main import _update_queues
import queue as _queue
sid = data["session_id"]
assert sid in _update_queues
q = _update_queues[sid]
# First item should be the empty listings event
first = q.get_nowait()
assert first is not None
assert first["type"] == "listings"
assert first["listings"] == []
# Second item should be the sentinel
sentinel = q.get_nowait()
assert sentinel is None
def test_async_search_no_real_chromium(client):
"""Async search endpoint must not launch real Chromium in tests.
Verifies that the background scraper is submitted to the executor but the
adapter factory is patched no real Playwright/Xvfb process is spawned.
Uses a broad patch on Store to avoid sqlite3 DB path issues in the thread pool.
"""
import threading
scrape_called = threading.Event()
def _fake_search(query, filters):
scrape_called.set()
return []
with (
patch("api.main._make_adapter") as mock_adapter_factory,
patch("api.main._trigger_scraper_enrichment"),
patch("api.main.TrustScorer") as mock_scorer_cls,
patch("api.main.Store") as mock_store_cls,
):
mock_adapter = MagicMock()
mock_adapter.search.side_effect = _fake_search
mock_adapter.get_completed_sales.return_value = None
mock_adapter_factory.return_value = mock_adapter
mock_scorer = MagicMock()
mock_scorer.score_batch.return_value = []
mock_scorer_cls.return_value = mock_scorer
mock_store = MagicMock()
mock_store.get_listings_staged.return_value = {}
mock_store.refresh_seller_categories.return_value = 0
mock_store.save_listings.return_value = None
mock_store.save_trust_scores.return_value = None
mock_store.get_market_comp.return_value = None
mock_store.get_seller.return_value = None
mock_store.get_user_preference.return_value = None
mock_store_cls.return_value = mock_store
resp = client.get("/api/search/async?q=rtx+3080")
assert resp.status_code == 202
# Give the background worker a moment to run (it's in a thread pool)
scrape_called.wait(timeout=5.0)
# If we get here without a real Playwright process, the test passes.
assert scrape_called.is_set(), "Background search worker never ran"
def test_async_search_query_params_forwarded(client):
"""All filter params accepted by /api/search are also accepted here."""
with (
patch("api.main._make_adapter") as mock_adapter_factory,
patch("api.main._trigger_scraper_enrichment"),
patch("api.main.TrustScorer") as mock_scorer_cls,
):
mock_adapter = MagicMock()
mock_adapter.search.return_value = []
mock_adapter.get_completed_sales.return_value = None
mock_adapter_factory.return_value = mock_adapter
mock_scorer = MagicMock()
mock_scorer.score_batch.return_value = []
mock_scorer_cls.return_value = mock_scorer
resp = client.get(
"/api/search/async"
"?q=rtx+3080"
"&max_price=400"
"&min_price=100"
"&pages=2"
"&must_include=rtx,3080"
"&must_include_mode=all"
"&must_exclude=mining"
"&category_id=27386"
"&adapter=auto"
)
assert resp.status_code == 202
def test_async_search_session_id_is_uuid(client):
"""session_id must be a valid UUID v4 string."""
import uuid as _uuid
with (
patch("api.main._make_adapter") as mock_adapter_factory,
patch("api.main._trigger_scraper_enrichment"),
patch("api.main.TrustScorer") as mock_scorer_cls,
):
mock_adapter = MagicMock()
mock_adapter.search.return_value = []
mock_adapter.get_completed_sales.return_value = None
mock_adapter_factory.return_value = mock_adapter
mock_scorer = MagicMock()
mock_scorer.score_batch.return_value = []
mock_scorer_cls.return_value = mock_scorer
resp = client.get("/api/search/async?q=test")
assert resp.status_code == 202
sid = resp.json()["session_id"]
# Should not raise if it's a valid UUID
parsed = _uuid.UUID(sid)
assert str(parsed) == sid

View file

@ -0,0 +1,76 @@
"""Tests for PATCH /api/preferences display.currency validation."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
"""TestClient with a patched local DB path.
api.cloud_session._LOCAL_SNIPE_DB is set at module import time, so we
cannot rely on setting SNIPE_DB before import when other tests have already
triggered the module load. Patch the module-level variable directly so
the session dependency points at our fresh tmp DB for the duration of this
fixture.
"""
db_path = tmp_path / "snipe.db"
# Ensure the DB is initialised so the Store can create its tables.
import api.cloud_session as _cs
from circuitforge_core.db import get_connection, run_migrations
conn = get_connection(db_path)
run_migrations(conn, Path("app/db/migrations"))
conn.close()
from api.main import app
with patch.object(_cs, "_LOCAL_SNIPE_DB", db_path):
yield TestClient(app, raise_server_exceptions=False)
def test_set_display_currency_valid(client):
"""Accepted ISO 4217 codes are stored and returned."""
for code in ("USD", "GBP", "EUR", "CAD", "AUD", "JPY", "CHF", "MXN", "BRL", "INR"):
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": code})
assert resp.status_code == 200, f"Expected 200 for {code}, got {resp.status_code}: {resp.text}"
data = resp.json()
assert data.get("display", {}).get("currency") == code
def test_set_display_currency_normalises_lowercase(client):
"""Lowercase code is accepted and normalised to uppercase."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "eur"})
assert resp.status_code == 200
assert resp.json()["display"]["currency"] == "EUR"
def test_set_display_currency_unsupported_returns_400(client):
"""Unsupported currency code returns 400 with a clear message."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "XYZ"})
assert resp.status_code == 400
detail = resp.json().get("detail", "")
assert "XYZ" in detail
assert "Supported" in detail or "supported" in detail
def test_set_display_currency_empty_string_returns_400(client):
"""Empty string is not a valid currency code."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": ""})
assert resp.status_code == 400
def test_set_display_currency_none_returns_400(client):
"""None is not a valid currency code."""
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": None})
assert resp.status_code == 400
def test_other_preference_paths_unaffected(client):
"""Unrelated preference paths still work normally after currency validation added."""
resp = client.patch("/api/preferences", json={"path": "affiliate.opt_out", "value": True})
assert resp.status_code == 200
assert resp.json().get("affiliate", {}).get("opt_out") is True

View file

@ -22,8 +22,9 @@
<meta name="twitter:description" content="Free eBay trust scorer. Catches scammers before you bid. No account required." /> <meta name="twitter:description" content="Free eBay trust scorer. Catches scammers before you bid. No account required." />
<meta name="twitter:image" content="https://menagerie.circuitforge.tech/snipe/og-image.png" /> <meta name="twitter:image" content="https://menagerie.circuitforge.tech/snipe/og-image.png" />
<link rel="canonical" href="https://menagerie.circuitforge.tech/snipe" /> <link rel="canonical" href="https://menagerie.circuitforge.tech/snipe" />
<!-- Inline background prevents blank flash before CSS bundle loads --> <!-- FOFT guard: prevents dark flash before CSS bundle loads.
<!-- Matches --color-surface dark tactical theme from theme.css --> theme.css overrides both html and body backgrounds via var(--color-surface)
once loaded, so this only applies for the brief pre-bundle window. -->
<style> <style>
html, body { margin: 0; background: #0d1117; min-height: 100vh; } html, body { margin: 0; background: #0d1117; min-height: 100vh; }
</style> </style>

View file

@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Reset module-level cache and fetch mock between tests
beforeEach(async () => {
vi.restoreAllMocks()
// Reset module-level cache so each test starts clean
const mod = await import('../composables/useCurrency')
mod._resetCacheForTest()
})
const MOCK_RATES: Record<string, number> = {
USD: 1,
GBP: 0.79,
EUR: 0.92,
JPY: 151.5,
CAD: 1.36,
}
function mockFetchSuccess(rates = MOCK_RATES) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rates }),
}))
}
function mockFetchFailure() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
}
describe('convertFromUSD', () => {
it('returns the same amount for USD (no conversion)', async () => {
mockFetchSuccess()
const { convertFromUSD } = await import('../composables/useCurrency')
const result = await convertFromUSD(100, 'USD')
expect(result).toBe(100)
// fetch should not be called for USD passthrough
expect(fetch).not.toHaveBeenCalled()
})
it('converts USD to GBP using fetched rates', async () => {
mockFetchSuccess()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(100, 'GBP')
expect(result).toBeCloseTo(79, 1)
})
it('converts USD to JPY using fetched rates', async () => {
mockFetchSuccess()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(10, 'JPY')
expect(result).toBeCloseTo(1515, 1)
})
it('returns the original amount when rates are unavailable (network failure)', async () => {
mockFetchFailure()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(100, 'EUR')
expect(result).toBe(100)
})
it('returns the original amount when the currency code is unknown', async () => {
mockFetchSuccess({ USD: 1, EUR: 0.92 }) // no XYZ rate
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await convertFromUSD(50, 'XYZ')
expect(result).toBe(50)
})
it('only calls fetch once when called concurrently (deduplication)', async () => {
mockFetchSuccess()
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
await Promise.all([
convertFromUSD(100, 'GBP'),
convertFromUSD(200, 'EUR'),
convertFromUSD(50, 'CAD'),
])
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
})
})
describe('formatPrice', () => {
it('formats USD amount with dollar sign', async () => {
mockFetchSuccess()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await formatPrice(99.99, 'USD')
expect(result).toMatch(/^\$99\.99$|^\$100$/) // Intl rounding may vary
expect(result).toContain('$')
})
it('formats GBP amount with correct symbol', async () => {
mockFetchSuccess()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await formatPrice(100, 'GBP')
// GBP 79 — expect pound sign or "GBP" prefix
expect(result).toMatch(/[£]|GBP/)
})
it('formats JPY without decimal places (Intl rounds to zero decimals)', async () => {
mockFetchSuccess()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
const result = await formatPrice(10, 'JPY')
// 10 * 151.5 = 1515 JPY — no decimal places for JPY
expect(result).toMatch(/¥1,515|JPY.*1,515|¥1515/)
})
it('falls back gracefully on network failure, showing USD', async () => {
mockFetchFailure()
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
_resetCacheForTest()
// With failed rates, conversion returns original amount and uses Intl with target currency
// This may throw if Intl doesn't know EUR — but the function should not throw
const result = await formatPrice(50, 'EUR')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
})
describe('formatPriceUSD', () => {
it('formats a USD amount synchronously', async () => {
const { formatPriceUSD } = await import('../composables/useCurrency')
const result = formatPriceUSD(1234.5)
// Intl output varies by runtime locale data; check structure not exact string
expect(result).toContain('$')
expect(result).toContain('1,234')
})
it('formats zero as a USD string', async () => {
const { formatPriceUSD } = await import('../composables/useCurrency')
const result = formatPriceUSD(0)
expect(result).toContain('$')
expect(result).toMatch(/\$0/)
})
})

View file

@ -2,6 +2,12 @@
Dark tactical theme: near-black surfaces, amber accent, trust-signal colours. Dark tactical theme: near-black surfaces, amber accent, trust-signal colours.
ALL color/font/spacing tokens live here nowhere else. ALL color/font/spacing tokens live here nowhere else.
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage). Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage).
Planned theme variants (add as [data-theme="<name>"] blocks using the same token set):
solarized-dark Ethan Schoonover's Solarized dark palette, amber accent
solarized-light Solarized light palette, amber accent
high-contrast WCAG AAA minimum contrast ratios, no mid-grey text
colorblind Deuteranopia-safe trust signal colours (blue/orange instead of green/red)
*/ */
/* Snipe dark tactical (default) /* Snipe dark tactical (default)
@ -212,7 +218,7 @@ html {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body { margin: 0; min-height: 100vh; } body { margin: 0; min-height: 100vh; background: var(--color-surface); }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display); font-family: var(--font-display);

View file

@ -189,15 +189,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import type { Listing, TrustScore, Seller } from '../stores/search' import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search' import { useSearchStore } from '../stores/search'
import { useBlocklistStore } from '../stores/blocklist' import { useBlocklistStore } from '../stores/blocklist'
import TrustFeedbackButtons from './TrustFeedbackButtons.vue' import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref' import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { formatPrice, formatPriceUSD } from '../composables/useCurrency'
import { usePreferencesStore } from '../stores/preferences'
const { enabled: trustSignalEnabled } = useTrustSignalPref() const { enabled: trustSignalEnabled } = useTrustSignalPref()
const prefsStore = usePreferencesStore()
const props = defineProps<{ const props = defineProps<{
listing: Listing listing: Listing
@ -379,15 +382,26 @@ const isSteal = computed(() => {
return props.listing.price < props.marketPrice * 0.8 return props.listing.price < props.marketPrice * 0.8
}) })
const formattedPrice = computed(() => { // Async price display show USD synchronously while rates load, then update
const sym = props.listing.currency === 'USD' ? '$' : props.listing.currency + ' ' const formattedPrice = ref(formatPriceUSD(props.listing.price))
return `${sym}${props.listing.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}` const formattedMarket = ref(props.marketPrice ? formatPriceUSD(props.marketPrice) : '')
})
const formattedMarket = computed(() => { async function _updatePrices() {
if (!props.marketPrice) return '' const currency = prefsStore.displayCurrency
return `$${props.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}` formattedPrice.value = await formatPrice(props.listing.price, currency)
}) if (props.marketPrice) {
formattedMarket.value = await formatPrice(props.marketPrice, currency)
} else {
formattedMarket.value = ''
}
}
// Update when the listing, marketPrice, or display currency changes
watch(
[() => props.listing.price, () => props.marketPrice, () => prefsStore.displayCurrency],
() => { _updatePrices() },
{ immediate: true },
)
</script> </script>
<style scoped> <style scoped>

View file

@ -0,0 +1,102 @@
/**
* useCurrency live exchange rate conversion from USD to a target display currency.
*
* Rates are fetched lazily on first use from open.er-api.com (free, no key required).
* A module-level cache with a 1-hour TTL prevents redundant network calls.
* On fetch failure the composable falls back silently to USD display.
*/
const ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
interface RateCache {
rates: Record<string, number>
fetchedAt: number
}
// Module-level cache shared across all composable instances
let _cache: RateCache | null = null
let _inflight: Promise<Record<string, number>> | null = null
async function _fetchRates(): Promise<Record<string, number>> {
const now = Date.now()
if (_cache && now - _cache.fetchedAt < CACHE_TTL_MS) {
return _cache.rates
}
// Deduplicate concurrent calls — reuse the same in-flight fetch
if (_inflight) {
return _inflight
}
_inflight = (async () => {
try {
const res = await fetch(ER_API_URL)
if (!res.ok) throw new Error(`ER-API responded ${res.status}`)
const data = await res.json()
const rates: Record<string, number> = data.rates ?? {}
_cache = { rates, fetchedAt: Date.now() }
return rates
} catch {
// Return cached stale data if available, otherwise empty object (USD passthrough)
return _cache?.rates ?? {}
} finally {
_inflight = null
}
})()
return _inflight
}
/**
* Convert an amount in USD to the target currency using the latest exchange rates.
* Returns the original amount unchanged if rates are unavailable or the currency is USD.
*/
export async function convertFromUSD(amountUSD: number, targetCurrency: string): Promise<number> {
if (targetCurrency === 'USD') return amountUSD
const rates = await _fetchRates()
const rate = rates[targetCurrency]
if (!rate) return amountUSD
return amountUSD * rate
}
/**
* Format a USD amount as a localized string in the target currency.
* Fetches exchange rates lazily. Falls back to USD display if rates are unavailable.
*
* Returns a plain USD string synchronously on first call while rates load;
* callers should use a ref that updates once the promise resolves.
*/
export async function formatPrice(amountUSD: number, currency: string): Promise<string> {
const converted = await convertFromUSD(amountUSD, currency)
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(converted)
} catch {
// Fallback if Intl doesn't know the currency code
return `${currency} ${converted.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
}
/**
* Synchronous USD-only formatter for use before rates have loaded.
*/
export function formatPriceUSD(amountUSD: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amountUSD)
}
// Exported for testing — allows resetting module-level cache between test cases
export function _resetCacheForTest(): void {
_cache = null
_inflight = null
}

View file

@ -12,8 +12,14 @@ export interface UserPreferences {
community?: { community?: {
blocklist_share?: boolean blocklist_share?: boolean
} }
display?: {
currency?: string
}
} }
const CURRENCY_LS_KEY = 'snipe:currency'
const DEFAULT_CURRENCY = 'USD'
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
export const usePreferencesStore = defineStore('preferences', () => { export const usePreferencesStore = defineStore('preferences', () => {
@ -26,14 +32,34 @@ export const usePreferencesStore = defineStore('preferences', () => {
const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '') const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '')
const communityBlocklistShare = computed(() => prefs.value.community?.blocklist_share ?? false) const communityBlocklistShare = computed(() => prefs.value.community?.blocklist_share ?? false)
// displayCurrency: DB preference for logged-in users, localStorage for anon users
const displayCurrency = computed((): string => {
return prefs.value.display?.currency ?? DEFAULT_CURRENCY
})
async function load() { async function load() {
if (!session.isLoggedIn) return if (!session.isLoggedIn) {
// Anonymous user: read currency from localStorage
const stored = localStorage.getItem(CURRENCY_LS_KEY)
if (stored) {
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: stored } }
}
return
}
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${apiBase}/api/preferences`) const res = await fetch(`${apiBase}/api/preferences`)
if (res.ok) { if (res.ok) {
prefs.value = await res.json() const data: UserPreferences = await res.json()
// Migration: if logged in but no DB preference, fall back to localStorage value
if (!data.display?.currency) {
const lsVal = localStorage.getItem(CURRENCY_LS_KEY)
if (lsVal) {
data.display = { ...data.display, currency: lsVal }
}
}
prefs.value = data
} }
} catch { } catch {
// Non-cloud deploy or network error — preferences unavailable // Non-cloud deploy or network error — preferences unavailable
@ -75,6 +101,18 @@ export const usePreferencesStore = defineStore('preferences', () => {
await setPref('community.blocklist_share', value) await setPref('community.blocklist_share', value)
} }
async function setDisplayCurrency(code: string) {
const upper = code.toUpperCase()
// Optimistic local update so the UI reacts immediately
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: upper } }
if (session.isLoggedIn) {
await setPref('display.currency', upper)
} else {
// Anonymous user: persist to localStorage only
localStorage.setItem(CURRENCY_LS_KEY, upper)
}
}
return { return {
prefs, prefs,
loading, loading,
@ -82,9 +120,11 @@ export const usePreferencesStore = defineStore('preferences', () => {
affiliateOptOut, affiliateOptOut,
affiliateByokId, affiliateByokId,
communityBlocklistShare, communityBlocklistShare,
displayCurrency,
load, load,
setAffiliateOptOut, setAffiliateOptOut,
setAffiliateByokId, setAffiliateByokId,
setCommunityBlocklistShare, setCommunityBlocklistShare,
setDisplayCurrency,
} }
}) })

View file

@ -145,6 +145,7 @@ export const useSearchStore = defineStore('search', () => {
_abort?.abort() _abort?.abort()
_abort = null _abort = null
loading.value = false loading.value = false
closeUpdates()
} }
async function search(q: string, filters: SearchFilters = {}) { async function search(q: string, filters: SearchFilters = {}) {
@ -158,8 +159,6 @@ export const useSearchStore = defineStore('search', () => {
error.value = null error.value = null
try { try {
// TODO: POST /api/search with { query: q, filters }
// API does not exist yet — stub returns empty results
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite) // VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
const params = new URLSearchParams({ q }) const params = new URLSearchParams({ q })
@ -174,51 +173,36 @@ export const useSearchStore = defineStore('search', () => {
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim()) if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim()) if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter) if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
const res = await fetch(`${apiBase}/api/search?${params}`, { signal })
// Use the async endpoint: returns 202 immediately with a session_id, then
// streams listings + trust scores via SSE as the scrape completes.
const res = await fetch(`${apiBase}/api/search/async?${params}`, { signal })
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`) if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
const data = await res.json() as { const data = await res.json() as {
listings: Listing[] session_id: string
trust_scores: Record<string, TrustScore> status: 'queued'
sellers: Record<string, Seller>
market_price: number | null
adapter_used: 'api' | 'scraper'
affiliate_active: boolean
session_id: string | null
} }
results.value = data.listings ?? [] // HTTP 202 received — scraping is underway in the background.
trustScores.value = new Map(Object.entries(data.trust_scores ?? {})) // Stay in loading state until the first "listings" SSE event arrives.
sellers.value = new Map(Object.entries(data.sellers ?? {})) // loading.value stays true; enriching tracks the SSE stream being open.
marketPrice.value = data.market_price ?? null enriching.value = true
adapterUsed.value = data.adapter_used ?? null
affiliateActive.value = data.affiliate_active ?? false
saveCache({
query: q,
results: results.value,
trustScores: data.trust_scores ?? {},
sellers: data.sellers ?? {},
marketPrice: marketPrice.value,
adapterUsed: adapterUsed.value,
})
// Open SSE stream if any scores are partial and a session_id was provided
const hasPartial = Object.values(data.trust_scores ?? {}).some(ts => ts.score_is_partial)
if (data.session_id && hasPartial) {
_openUpdates(data.session_id, apiBase) _openUpdates(data.session_id, apiBase)
}
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') { if (e instanceof DOMException && e.name === 'AbortError') {
// User cancelled — clear loading but don't surface as an error // User cancelled — clear loading but don't surface as an error
results.value = [] results.value = []
loading.value = false
} else { } else {
error.value = e instanceof Error ? e.message : 'Unknown error' error.value = e instanceof Error ? e.message : 'Unknown error'
results.value = [] results.value = []
}
} finally {
loading.value = false loading.value = false
}
_abort = null _abort = null
} }
// Note: loading.value is NOT set to false here — it stays true until the
// first "listings" SSE event arrives (see _openUpdates handler below).
} }
function closeUpdates() { function closeUpdates() {
@ -229,34 +213,115 @@ export const useSearchStore = defineStore('search', () => {
enriching.value = false enriching.value = false
} }
// Internal type for typed SSE events from the async search endpoint
type _AsyncListingsEvent = {
type: 'listings'
listings: Listing[]
trust_scores: Record<string, TrustScore>
sellers: Record<string, Seller>
market_price: number | null
adapter_used: 'api' | 'scraper'
affiliate_active: boolean
session_id: string
}
type _MarketPriceEvent = {
type: 'market_price'
market_price: number | null
}
type _UpdateEvent = {
type: 'update'
platform_listing_id: string
trust_score: TrustScore
seller: Seller
market_price: number | null
}
type _LegacyUpdateEvent = {
platform_listing_id: string
trust_score: TrustScore
seller: Record<string, unknown>
market_price: number | null
}
type _SSEEvent =
| _AsyncListingsEvent
| _MarketPriceEvent
| _UpdateEvent
| _LegacyUpdateEvent
function _openUpdates(sessionId: string, apiBase: string) { function _openUpdates(sessionId: string, apiBase: string) {
closeUpdates() // close any previous stream // Close any pre-existing stream but preserve enriching state — caller sets it.
enriching.value = true if (_sse) {
_sse.close()
_sse = null
}
const es = new EventSource(`${apiBase}/api/updates/${sessionId}`) const es = new EventSource(`${apiBase}/api/updates/${sessionId}`)
_sse = es _sse = es
es.onmessage = (e) => { es.onmessage = (e) => {
try { try {
const update = JSON.parse(e.data) as { const update = JSON.parse(e.data) as _SSEEvent
platform_listing_id: string
trust_score: TrustScore if ('type' in update) {
seller: Record<string, unknown> // Typed events from the async search endpoint
market_price: number | null if (update.type === 'listings') {
// First batch: hydrate store and transition out of loading state
results.value = update.listings ?? []
trustScores.value = new Map(Object.entries(update.trust_scores ?? {}))
sellers.value = new Map(Object.entries(update.sellers ?? {}))
marketPrice.value = update.market_price ?? null
adapterUsed.value = update.adapter_used ?? null
affiliateActive.value = update.affiliate_active ?? false
saveCache({
query: query.value,
results: results.value,
trustScores: update.trust_scores ?? {},
sellers: update.sellers ?? {},
marketPrice: marketPrice.value,
adapterUsed: adapterUsed.value,
})
// Scrape complete — turn off the initial loading spinner.
// enriching stays true while enrichment SSE is still open.
loading.value = false
} else if (update.type === 'market_price') {
if (update.market_price != null) {
marketPrice.value = update.market_price
} }
} else if (update.type === 'update') {
// Per-seller enrichment update (same as legacy format but typed)
if (update.platform_listing_id && update.trust_score) { if (update.platform_listing_id && update.trust_score) {
trustScores.value = new Map(trustScores.value) trustScores.value = new Map(trustScores.value)
trustScores.value.set(update.platform_listing_id, update.trust_score) trustScores.value.set(update.platform_listing_id, update.trust_score)
} }
if (update.seller) { if (update.seller?.platform_seller_id) {
const s = update.seller as Seller sellers.value = new Map(sellers.value)
sellers.value.set(update.seller.platform_seller_id, update.seller)
}
if (update.market_price != null) {
marketPrice.value = update.market_price
}
}
// type: "error" — no special handling; stream will close via 'done'
} else {
// Legacy enrichment update (no type field) from synchronous search path
const legacy = update as _LegacyUpdateEvent
if (legacy.platform_listing_id && legacy.trust_score) {
trustScores.value = new Map(trustScores.value)
trustScores.value.set(legacy.platform_listing_id, legacy.trust_score)
}
if (legacy.seller) {
const s = legacy.seller as Seller
if (s.platform_seller_id) { if (s.platform_seller_id) {
sellers.value = new Map(sellers.value) sellers.value = new Map(sellers.value)
sellers.value.set(s.platform_seller_id, s) sellers.value.set(s.platform_seller_id, s)
} }
} }
if (update.market_price != null) { if (legacy.market_price != null) {
marketPrice.value = update.market_price marketPrice.value = legacy.market_price
}
} }
} catch { } catch {
// malformed event — ignore // malformed event — ignore
@ -268,6 +333,8 @@ export const useSearchStore = defineStore('search', () => {
}) })
es.onerror = () => { es.onerror = () => {
// If loading is still true (never got a "listings" event), clear it
loading.value = false
closeUpdates() closeUpdates()
} }
} }

View file

@ -69,6 +69,28 @@
>{{ opt.label }}</button> >{{ opt.label }}</button>
</div> </div>
</div> </div>
<!-- Display currency -->
<div class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Display currency</span>
<span class="settings-toggle-desc">
Listing prices are converted from USD using live exchange rates.
Rates update hourly.
</span>
</div>
<select
id="display-currency"
class="settings-select"
:value="prefs.displayCurrency"
aria-label="Select display currency"
@change="prefs.setDisplayCurrency(($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
{{ opt.code }} {{ opt.label }}
</option>
</select>
</div>
</section> </section>
<!-- Affiliate Links only shown to signed-in cloud users --> <!-- Affiliate Links only shown to signed-in cloud users -->
@ -166,6 +188,18 @@ const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
{ value: 'dark', label: 'Dark' }, { value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' }, { value: 'light', label: 'Light' },
] ]
const currencyOptions: { code: string; label: string }[] = [
{ code: 'USD', label: 'US Dollar' },
{ code: 'EUR', label: 'Euro' },
{ code: 'GBP', label: 'British Pound' },
{ code: 'CAD', label: 'Canadian Dollar' },
{ code: 'AUD', label: 'Australian Dollar' },
{ code: 'JPY', label: 'Japanese Yen' },
{ code: 'CHF', label: 'Swiss Franc' },
{ code: 'MXN', label: 'Mexican Peso' },
{ code: 'BRL', label: 'Brazilian Real' },
{ code: 'INR', label: 'Indian Rupee' },
]
const session = useSessionStore() const session = useSessionStore()
const prefs = usePreferencesStore() const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder() const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
@ -346,6 +380,24 @@ function saveByokId() {
margin: 0; margin: 0;
} }
.settings-select {
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
outline: none;
flex-shrink: 0;
transition: border-color 0.15s ease;
}
.settings-select:focus {
border-color: var(--app-primary);
}
.theme-btn-group { .theme-btn-group {
display: flex; display: flex;
gap: 0; gap: 0;