Compare commits
10 commits
dbe9aaa00b
...
844721c6fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 844721c6fd | |||
| dca3c3f50b | |||
| d5912080fb | |||
| 2e0a49bc12 | |||
| df4610c57b | |||
| 349cff8c50 | |||
| 90f72d6e53 | |||
| e539427bec | |||
| ed6d509a26 | |||
| 16cd32b0db |
16 changed files with 1759 additions and 148 deletions
22
.env.example
22
.env.example
|
|
@ -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
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ data/
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
config/llm.yaml
|
config/llm.yaml
|
||||||
|
.worktrees/
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
764
api/main.py
764
api/main.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
231
tests/test_async_search.py
Normal 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
|
||||||
76
tests/test_preferences_currency.py
Normal file
76
tests/test_preferences_currency.py
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
140
web/src/__tests__/useCurrency.test.ts
Normal file
140
web/src/__tests__/useCurrency.test.ts
Normal 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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
102
web/src/composables/useCurrency.ts
Normal file
102
web/src/composables/useCurrency.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,13 @@ 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) ?? ''
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue