feat(monitor): background saved-search monitoring with watch alerts (#12)
Some checks failed
CI / Python tests (push) Has been cancelled
CI / Frontend typecheck + tests (push) Has been cancelled
Mirror / mirror (push) Has been cancelled
Release / release (push) Has been cancelled

Backend:
- Migrations 013-015: eBay user tokens, monitor settings on saved_searches
  (monitor_enabled, poll_interval_min, min_trust_score, last_checked_at),
  watch_alerts table with UNIQUE dedup on (saved_search_id, platform_listing_id),
  active_monitors registry for cross-user polling
- WatchAlert model + store methods: upsert_alert, list_alerts, dismiss_alert,
  count_undismissed_alerts, dismiss_all_alerts, list_active_monitors
- monitor.py: run_monitor_search() using TrustScorer.score_batch(); should_alert()
  with BIN/auction/partial-score logic (auction window = 24h, partial +10 buffer)
- PATCH /api/saved-searches/{id}/monitor, GET /api/alerts, POST /api/alerts/*/dismiss
- Background polling loop at startup (asyncio.to_thread every 60s check cycle)
- ebay/adapter.py: enrich_seller_trading_api() via Trading API GetUser (OAuth token)
- nginx: raise proxy_read_timeout to 120s for slow eBay search responses

Frontend:
- AlertBell component: bell button + unread badge + panel with dismiss/clear-all;
  polls /api/alerts every 2 minutes; aria-live announcement on count change
- alerts.ts Pinia store: fetchAlerts, dismiss, dismissAll
- SavedSearchesView: monitor toggle + poll interval + min trust score controls
- SettingsView: eBay OAuth connect/disconnect section
- AppNav: AlertBell wired for logged-in and local-tier users

Tests: 24 monitor tests (should_alert branches, store alert CRUD, run_monitor_search
with mocked adapter); fix browser_pool test assertions for new wait_for_* params.
This commit is contained in:
pyr0ball 2026-05-04 08:24:56 -07:00
parent ac5e6166c9
commit 89d3862f62
15 changed files with 1897 additions and 36 deletions

View file

@ -0,0 +1,20 @@
-- Migration 013: eBay user OAuth tokens
--
-- Stores per-user eBay Authorization Code tokens so the app can call
-- Trading API GetUser for instant account_age_days + category feedback
-- without Playwright scraping.
--
-- Stored in the per-user DB (user.db), never the shared DB.
-- access_token is short-lived (2h); refresh_token is valid 18 months.
-- The API layer refreshes access_token automatically before expiry.
CREATE TABLE IF NOT EXISTS ebay_user_tokens (
id INTEGER PRIMARY KEY,
-- Single row per user DB — upsert on reconnect
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expires_at REAL NOT NULL, -- epoch seconds; access token expiry
scopes TEXT NOT NULL DEFAULT '',
connected_at TEXT NOT NULL DEFAULT (datetime('now')),
last_refreshed TEXT
);

View file

@ -0,0 +1,24 @@
-- Migration 014: background monitor settings on saved_searches + watch_alerts table
ALTER TABLE saved_searches ADD COLUMN monitor_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE saved_searches ADD COLUMN poll_interval_min INTEGER NOT NULL DEFAULT 60;
ALTER TABLE saved_searches ADD COLUMN min_trust_score INTEGER NOT NULL DEFAULT 60;
ALTER TABLE saved_searches ADD COLUMN last_checked_at TEXT;
CREATE TABLE IF NOT EXISTS watch_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
platform_listing_id TEXT NOT NULL,
title TEXT NOT NULL,
price REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
trust_score INTEGER NOT NULL,
url TEXT,
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dismissed_at TEXT,
UNIQUE(saved_search_id, platform_listing_id)
);
CREATE INDEX IF NOT EXISTS idx_watch_alerts_undismissed
ON watch_alerts(saved_search_id)
WHERE dismissed_at IS NULL;

View file

@ -0,0 +1,20 @@
-- Migration 015: cross-user monitor registry for the background polling loop
--
-- In cloud mode this table lives in shared.db — the polling loop queries it
-- to find all due monitors without scanning per-user DB files.
-- In local mode it lives in the single local DB (same result, one user).
--
-- user_db_path references the per-user snipe user.db so the poller knows
-- which DB to open for the full SavedSearch config and to write alerts.
CREATE TABLE IF NOT EXISTS active_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_db_path TEXT NOT NULL,
saved_search_id INTEGER NOT NULL,
poll_interval_min INTEGER NOT NULL DEFAULT 60,
last_checked_at TEXT,
UNIQUE(user_db_path, saved_search_id)
);
CREATE INDEX IF NOT EXISTS idx_active_monitors_due
ON active_monitors(last_checked_at);

View file

@ -81,6 +81,26 @@ class SavedSearch:
id: Optional[int] = None
created_at: Optional[str] = None
last_run_at: Optional[str] = None
# Monitor settings (migration 014)
monitor_enabled: bool = False
poll_interval_min: int = 60
min_trust_score: int = 60
last_checked_at: Optional[str] = None
@dataclass
class WatchAlert:
"""A new listing surfaced by the background monitor for a saved search."""
saved_search_id: int
platform_listing_id: str
title: str
price: float
trust_score: int
currency: str = "USD"
url: Optional[str] = None
id: Optional[int] = None
first_alerted_at: Optional[str] = None
dismissed_at: Optional[str] = None
@dataclass

View file

@ -8,7 +8,7 @@ from typing import Optional
from circuitforge_core.db import get_connection, run_migrations
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore, WatchAlert
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
@ -310,15 +310,66 @@ class Store:
def list_saved_searches(self) -> list[SavedSearch]:
rows = self._conn.execute(
"SELECT name, query, platform, filters_json, id, created_at, last_run_at "
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
"monitor_enabled, poll_interval_min, min_trust_score, last_checked_at "
"FROM saved_searches ORDER BY created_at DESC"
).fetchall()
return [
SavedSearch(name=r[0], query=r[1], platform=r[2], filters_json=r[3],
id=r[4], created_at=r[5], last_run_at=r[6])
SavedSearch(
name=r[0], query=r[1], platform=r[2], filters_json=r[3],
id=r[4], created_at=r[5], last_run_at=r[6],
monitor_enabled=bool(r[7]), poll_interval_min=r[8],
min_trust_score=r[9], last_checked_at=r[10],
)
for r in rows
]
def update_monitor_settings(
self,
saved_id: int,
*,
monitor_enabled: bool,
poll_interval_min: int,
min_trust_score: int,
) -> None:
self._conn.execute(
"UPDATE saved_searches "
"SET monitor_enabled=?, poll_interval_min=?, min_trust_score=? "
"WHERE id=?",
(int(monitor_enabled), poll_interval_min, min_trust_score, saved_id),
)
self._conn.commit()
def list_monitored_searches(self) -> list[SavedSearch]:
"""Return all saved searches with monitoring enabled (used by background poller)."""
rows = self._conn.execute(
"SELECT name, query, platform, filters_json, id, created_at, last_run_at, "
"monitor_enabled, poll_interval_min, min_trust_score, last_checked_at "
"FROM saved_searches WHERE monitor_enabled=1"
).fetchall()
return [
SavedSearch(
name=r[0], query=r[1], platform=r[2], filters_json=r[3],
id=r[4], created_at=r[5], last_run_at=r[6],
monitor_enabled=True, poll_interval_min=r[8],
min_trust_score=r[9], last_checked_at=r[10],
)
for r in rows
]
def mark_search_checked(self, saved_id: int) -> None:
self._conn.execute(
"UPDATE saved_searches SET last_checked_at=? WHERE id=?",
(datetime.now(timezone.utc).isoformat(), saved_id),
)
self._conn.commit()
def count_active_monitors(self) -> int:
row = self._conn.execute(
"SELECT COUNT(*) FROM saved_searches WHERE monitor_enabled=1"
).fetchone()
return row[0] if row else 0
def delete_saved_search(self, saved_id: int) -> None:
self._conn.execute("DELETE FROM saved_searches WHERE id=?", (saved_id,))
self._conn.commit()
@ -330,6 +381,112 @@ class Store:
)
self._conn.commit()
# --- WatchAlerts ---
def upsert_alert(self, alert: WatchAlert) -> tuple[int, bool]:
"""Insert alert if not already present. Returns (id, is_new)."""
existing = self._conn.execute(
"SELECT id FROM watch_alerts WHERE saved_search_id=? AND platform_listing_id=?",
(alert.saved_search_id, alert.platform_listing_id),
).fetchone()
if existing:
return existing[0], False
cur = self._conn.execute(
"INSERT INTO watch_alerts "
"(saved_search_id, platform_listing_id, title, price, currency, trust_score, url) "
"VALUES (?,?,?,?,?,?,?)",
(alert.saved_search_id, alert.platform_listing_id, alert.title,
alert.price, alert.currency, alert.trust_score, alert.url),
)
self._conn.commit()
return cur.lastrowid, True
def list_alerts(self, *, include_dismissed: bool = False) -> list[WatchAlert]:
where = "" if include_dismissed else "WHERE dismissed_at IS NULL"
rows = self._conn.execute(
f"SELECT id, saved_search_id, platform_listing_id, title, price, currency, "
f"trust_score, url, first_alerted_at, dismissed_at "
f"FROM watch_alerts {where} ORDER BY first_alerted_at DESC"
).fetchall()
return [
WatchAlert(
id=r[0], saved_search_id=r[1], platform_listing_id=r[2],
title=r[3], price=r[4], currency=r[5], trust_score=r[6],
url=r[7], first_alerted_at=r[8], dismissed_at=r[9],
)
for r in rows
]
def count_undismissed_alerts(self) -> int:
row = self._conn.execute(
"SELECT COUNT(*) FROM watch_alerts WHERE dismissed_at IS NULL"
).fetchone()
return row[0] if row else 0
def dismiss_alert(self, alert_id: int) -> None:
self._conn.execute(
"UPDATE watch_alerts SET dismissed_at=? WHERE id=?",
(datetime.now(timezone.utc).isoformat(), alert_id),
)
self._conn.commit()
def dismiss_all_alerts(self) -> int:
"""Dismiss all undismissed alerts. Returns count dismissed."""
cur = self._conn.execute(
"UPDATE watch_alerts SET dismissed_at=? WHERE dismissed_at IS NULL",
(datetime.now(timezone.utc).isoformat(),),
)
self._conn.commit()
return cur.rowcount
# --- ActiveMonitors (sched_db / shared_db) ---
def upsert_active_monitor(
self,
user_db_path: str,
saved_search_id: int,
poll_interval_min: int,
) -> None:
"""Register or update a monitor in the cross-user registry (sched_db)."""
self._conn.execute(
"INSERT INTO active_monitors (user_db_path, saved_search_id, poll_interval_min) "
"VALUES (?,?,?) "
"ON CONFLICT(user_db_path, saved_search_id) DO UPDATE SET "
" poll_interval_min=excluded.poll_interval_min",
(user_db_path, saved_search_id, poll_interval_min),
)
self._conn.commit()
def remove_active_monitor(self, user_db_path: str, saved_search_id: int) -> None:
self._conn.execute(
"DELETE FROM active_monitors WHERE user_db_path=? AND saved_search_id=?",
(user_db_path, saved_search_id),
)
self._conn.commit()
def list_due_active_monitors(self) -> list[tuple[str, int, int]]:
"""Return (user_db_path, saved_search_id, poll_interval_min) for monitors that are due.
Due = never checked OR last_checked_at is old enough given poll_interval_min.
Uses SQLite's strftime('%s') for epoch arithmetic without Python datetime overhead.
"""
rows = self._conn.execute(
"SELECT user_db_path, saved_search_id, poll_interval_min "
"FROM active_monitors "
"WHERE last_checked_at IS NULL "
" OR (strftime('%s','now') - strftime('%s', last_checked_at)) "
" >= poll_interval_min * 60"
).fetchall()
return [(r[0], r[1], r[2]) for r in rows]
def mark_active_monitor_checked(self, user_db_path: str, saved_search_id: int) -> None:
self._conn.execute(
"UPDATE active_monitors SET last_checked_at=? "
"WHERE user_db_path=? AND saved_search_id=?",
(datetime.now(timezone.utc).isoformat(), user_db_path, saved_search_id),
)
self._conn.commit()
# --- ScammerBlocklist ---
def add_to_blocklist(self, entry: ScammerEntry) -> ScammerEntry:

View file

@ -1,8 +1,9 @@
"""eBay Browse API adapter."""
"""eBay Browse + Trading API adapter."""
from __future__ import annotations
import hashlib
import logging
import xml.etree.ElementTree as ET
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from typing import Optional
@ -210,6 +211,70 @@ class EbayAdapter(PlatformAdapter):
except Exception as e:
log.debug("Shopping API enrich failed for %s: %s", username, e)
# ── Trading API GetUser (requires user OAuth token) ───────────────────────
_TRADING_API_URL = "https://api.ebay.com/ws/api.dll"
_TRADING_API_COMPATIBILITY = "1283"
def enrich_seller_trading_api(self, username: str, user_access_token: str) -> bool:
"""Enrich a seller's account_age_days using Trading API GetUser.
Uses the connected user's OAuth access token (Authorization Code flow),
which bypasses Shopping API rate limits and works even when the Shopping
API GetUserProfile call is throttled.
Unlike BTF scraping, this is a clean API call (~200ms, no Playwright).
Called from the search endpoint when the requesting user has connected
their eBay account.
Returns True if enrichment succeeded, False on any failure.
"""
xml_body = (
'<?xml version="1.0" encoding="utf-8"?>'
'<GetUserRequest xmlns="urn:ebay:apis:eBLBaseComponents">'
f'<UserID>{username}</UserID>'
'</GetUserRequest>'
)
try:
resp = requests.post(
self._TRADING_API_URL,
headers={
"X-EBAY-API-CALL-NAME": "GetUser",
"X-EBAY-API-SITEID": "0",
"X-EBAY-API-COMPATIBILITY-LEVEL": self._TRADING_API_COMPATIBILITY,
"X-EBAY-API-IAF-TOKEN": f"Bearer {user_access_token}",
"Content-Type": "text/xml",
},
data=xml_body.encode("utf-8"),
timeout=10,
)
resp.raise_for_status()
root = ET.fromstring(resp.text)
ns = {"e": "urn:ebay:apis:eBLBaseComponents"}
ack = root.findtext("e:Ack", namespaces=ns)
if ack not in ("Success", "Warning"):
errors = [e.findtext("e:LongMessage", namespaces=ns, default="")
for e in root.findall("e:Errors", namespaces=ns)]
log.debug("Trading API GetUser failed for %s: %s", username, errors)
return False
reg_date = root.findtext("e:User/e:RegistrationDate", namespaces=ns)
if not reg_date:
return False
dt = datetime.fromisoformat(reg_date.replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - dt).days
seller = self._store.get_seller("ebay", username)
if seller:
self._store.save_seller(replace(seller, account_age_days=age_days))
log.debug("Trading API GetUser: %s registered %d days ago", username, age_days)
return True
except Exception as exc:
log.debug("Trading API GetUser failed for %s: %s", username, exc)
return False
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
cached = self._store.get_seller("ebay", seller_platform_id)
if cached:

145
app/tasks/monitor.py Normal file
View file

@ -0,0 +1,145 @@
# app/tasks/monitor.py
"""Background saved-search monitor — polls eBay and writes WatchAlerts for new listings.
Design notes:
- Runs synchronously inside an asyncio.to_thread() call from the polling loop.
- Uses the same eBay adapter + trust scoring pipeline as the live search endpoint.
- Dedup via watch_alerts (saved_search_id, platform_listing_id) UNIQUE constraint.
- Never takes any transactional action alert only.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from app.db.models import SavedSearch, WatchAlert
from app.db.store import Store
log = logging.getLogger(__name__)
_AUCTION_ALERT_WINDOW_HOURS = 24 # alert on auctions ending within this window
def should_alert(
*,
trust_score: int,
score_is_partial: bool,
price: float,
buying_format: str,
min_trust_score: int,
ends_at: "str | None" = None,
) -> bool:
"""Return True if a listing qualifies for a watch alert.
BIN (fixed_price / best_offer): alert immediately these sell on a first-come
basis, so speed matters. Require a higher trust bar on partial scores to reduce
false positives while BTF scraping is still in flight.
Auction: only alert when the auction is within _AUCTION_ALERT_WINDOW_HOURS of
ending. Alerting on a 7-day auction 6 days early is noise the user can't act
usefully until the end window anyway. Bid scheduling (paid+) and sniping algo
(premium) are separate features built on top of this alert layer.
"""
from datetime import datetime, timezone
# Partial scores: apply a +10 buffer so we don't surface unreliable signals.
effective_min = min_trust_score + 10 if score_is_partial else min_trust_score
if trust_score < effective_min:
return False
if buying_format in ("fixed_price", "best_offer"):
# BIN: alert immediately — inventory can disappear any time.
return True
if buying_format == "auction":
if not ends_at:
# No end time recorded — alert anyway rather than silently skip.
return True
try:
end = datetime.fromisoformat(ends_at.replace("Z", "+00:00"))
hours_remaining = (end - datetime.now(timezone.utc)).total_seconds() / 3600
return 0 < hours_remaining <= _AUCTION_ALERT_WINDOW_HOURS
except (ValueError, TypeError):
log.debug("should_alert: could not parse ends_at=%r, alerting anyway", ends_at)
return True
# Unknown format — alert and let the user decide.
return True
def run_monitor_search(
search: SavedSearch,
*,
user_db: Path,
shared_db: Path,
) -> int:
"""Execute one background monitor run for a saved search.
Fetches current listings, scores them, writes new high-trust finds
to watch_alerts. Returns the count of new alerts written.
Called from the async polling loop via asyncio.to_thread().
"""
from app.platforms.ebay.adapter import EbayAdapter
from app.trust import TrustScorer
log.info("Monitor: checking saved search %d (%r)", search.id, search.name)
filters = json.loads(search.filters_json or "{}")
query = filters.pop("query_raw", search.query)
try:
adapter = EbayAdapter()
raw_listings = adapter.search(query, **filters)
except Exception as exc:
log.warning("Monitor: eBay search failed for search %d: %s", search.id, exc)
return 0
shared_store = Store(shared_db)
user_store = Store(user_db)
scorer = TrustScorer(shared_store)
try:
trust_scores = scorer.score_batch(raw_listings, query)
except Exception as exc:
log.warning("Monitor: trust scoring failed for search %d: %s", search.id, exc)
return 0
new_alert_count = 0
for listing, trust in zip(raw_listings, trust_scores):
qualifies = should_alert(
trust_score=trust.composite_score,
score_is_partial=trust.score_is_partial,
price=listing.price,
buying_format=listing.buying_format,
min_trust_score=search.min_trust_score,
ends_at=listing.ends_at,
)
if not qualifies:
continue
alert = WatchAlert(
saved_search_id=search.id,
platform_listing_id=listing.platform_listing_id,
title=listing.title,
price=listing.price,
currency=listing.currency,
trust_score=trust.composite_score,
url=listing.url,
)
_, is_new = user_store.upsert_alert(alert)
if is_new:
new_alert_count += 1
log.info(
"Monitor: new alert — search %d, listing %s, score=%d",
search.id, listing.platform_listing_id, trust.composite_score,
)
user_store.mark_search_checked(search.id)
log.info(
"Monitor: search %d done — %d new alerts from %d listings",
search.id, new_alert_count, len(raw_listings),
)
return new_alert_count

View file

@ -16,6 +16,10 @@ server {
# Forward the session header injected by Caddy from the cf_session cookie.
# Caddy adds: header_up X-CF-Session {http.request.cookie.cf_session}
proxy_set_header X-CF-Session $http_x_cf_session;
# eBay search + comps can take 60-90s (Marketplace Insights 404 → Browse fallback).
# Default 60s proxy_read_timeout drops slow searches with a NetworkError on the client.
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# index.html — never cache; ensures clients always get the latest entry point

View file

@ -153,7 +153,10 @@ class TestFetchHtmlPoolHit:
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=test", delay=0)
assert html == "<html>ok</html>"
mock_fetch.assert_called_once_with(slot, "https://www.ebay.com/sch/i.html?_nkw=test")
mock_fetch.assert_called_once_with(
slot, "https://www.ebay.com/sch/i.html?_nkw=test",
wait_for_selector=None, wait_for_timeout_ms=2000,
)
mock_replenish.assert_called_once_with(slot)
# Fresh slot returned to queue
assert pool._q.get_nowait() is fresh_slot
@ -197,7 +200,10 @@ class TestFetchHtmlFallback:
html = pool.fetch_html("https://www.ebay.com/sch/i.html?_nkw=widget", delay=0)
assert html == "<html>fresh</html>"
mock_fresh.assert_called_once_with("https://www.ebay.com/sch/i.html?_nkw=widget")
mock_fresh.assert_called_once_with(
"https://www.ebay.com/sch/i.html?_nkw=widget",
wait_for_selector=None, wait_for_timeout_ms=2000,
)
def test_falls_back_when_pooled_fetch_raises(self):
"""If _fetch_with_slot raises, the slot is closed and _fetch_fresh is used."""

View file

@ -0,0 +1,372 @@
"""Tests for the background monitor: should_alert logic, store alert methods, and run_monitor_search."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.tasks.monitor import _AUCTION_ALERT_WINDOW_HOURS, should_alert
# ---------------------------------------------------------------------------
# should_alert — pure function, no I/O
# ---------------------------------------------------------------------------
class TestShouldAlert:
def test_bin_above_threshold_alerts(self):
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="fixed_price",
min_trust_score=60,
) is True
def test_bin_below_threshold_no_alert(self):
assert should_alert(
trust_score=55, score_is_partial=False,
price=100.0, buying_format="fixed_price",
min_trust_score=60,
) is False
def test_partial_score_applies_buffer(self):
# Score 65 with min 60 passes normally but fails with the +10 partial buffer.
assert should_alert(
trust_score=65, score_is_partial=True,
price=100.0, buying_format="fixed_price",
min_trust_score=60,
) is False
def test_partial_score_above_buffered_threshold_alerts(self):
assert should_alert(
trust_score=75, score_is_partial=True,
price=100.0, buying_format="fixed_price",
min_trust_score=60,
) is True
def test_best_offer_treated_like_bin(self):
assert should_alert(
trust_score=80, score_is_partial=False,
price=200.0, buying_format="best_offer",
min_trust_score=60,
) is True
def test_auction_within_window_alerts(self):
soon = (datetime.now(timezone.utc) + timedelta(hours=12)).isoformat()
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="auction",
min_trust_score=60, ends_at=soon,
) is True
def test_auction_outside_window_no_alert(self):
far = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat()
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="auction",
min_trust_score=60, ends_at=far,
) is False
def test_auction_no_ends_at_alerts_anyway(self):
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="auction",
min_trust_score=60, ends_at=None,
) is True
def test_auction_bad_ends_at_alerts_anyway(self):
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="auction",
min_trust_score=60, ends_at="not-a-date",
) is True
def test_auction_expired_no_alert(self):
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="auction",
min_trust_score=60, ends_at=past,
) is False
def test_unknown_format_alerts(self):
# Fail-open: unknown buying_format should not silently suppress.
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="mystery_format",
min_trust_score=60,
) is True
def test_score_exactly_at_threshold_passes(self):
assert should_alert(
trust_score=60, score_is_partial=False,
price=100.0, buying_format="fixed_price",
min_trust_score=60,
) is True
def test_auction_exactly_at_window_boundary_alerts(self):
boundary = (datetime.now(timezone.utc) + timedelta(hours=_AUCTION_ALERT_WINDOW_HOURS - 0.1)).isoformat()
assert should_alert(
trust_score=70, score_is_partial=False,
price=100.0, buying_format="auction",
min_trust_score=60, ends_at=boundary,
) is True
# ---------------------------------------------------------------------------
# Store alert methods — integration against real SQLite
# ---------------------------------------------------------------------------
def _create_monitor_db(path: Path) -> None:
conn = sqlite3.connect(path)
conn.executescript("""
CREATE TABLE IF NOT EXISTS saved_searches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
query TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'ebay',
filters_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_run_at TEXT,
monitor_enabled INTEGER NOT NULL DEFAULT 0,
poll_interval_min INTEGER NOT NULL DEFAULT 60,
min_trust_score INTEGER NOT NULL DEFAULT 60,
last_checked_at TEXT
);
CREATE TABLE IF NOT EXISTS watch_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
saved_search_id INTEGER NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE,
platform_listing_id TEXT NOT NULL,
title TEXT NOT NULL,
price REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
trust_score INTEGER NOT NULL,
url TEXT,
first_alerted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dismissed_at TEXT,
UNIQUE(saved_search_id, platform_listing_id)
);
INSERT INTO saved_searches (name, query, monitor_enabled) VALUES ('RTX 4090', 'rtx 4090', 1);
""")
conn.commit()
conn.close()
@pytest.fixture
def monitor_db(tmp_path: Path) -> Path:
db = tmp_path / "snipe.db"
_create_monitor_db(db)
return db
class TestStoreAlertMethods:
def test_upsert_alert_new(self, monitor_db: Path):
from app.db.models import WatchAlert
from app.db.store import Store
store = Store(monitor_db)
alert = WatchAlert(
saved_search_id=1, platform_listing_id="ebay-001",
title="RTX 4090", price=750.0, trust_score=72, currency="USD",
url="https://ebay.com/itm/001",
)
alert_id, is_new = store.upsert_alert(alert)
assert is_new is True
assert alert_id > 0
def test_upsert_alert_dedup(self, monitor_db: Path):
from app.db.models import WatchAlert
from app.db.store import Store
store = Store(monitor_db)
alert = WatchAlert(
saved_search_id=1, platform_listing_id="ebay-002",
title="RTX 4090 FE", price=800.0, trust_score=68,
)
id1, new1 = store.upsert_alert(alert)
id2, new2 = store.upsert_alert(alert)
assert id1 == id2
assert new1 is True
assert new2 is False
def test_list_alerts_returns_undismissed(self, monitor_db: Path):
from app.db.models import WatchAlert
from app.db.store import Store
store = Store(monitor_db)
alert = WatchAlert(
saved_search_id=1, platform_listing_id="ebay-003",
title="Test listing", price=500.0, trust_score=75,
)
store.upsert_alert(alert)
alerts = store.list_alerts(include_dismissed=False)
assert len(alerts) == 1
assert alerts[0].platform_listing_id == "ebay-003"
def test_count_undismissed_alerts(self, monitor_db: Path):
from app.db.models import WatchAlert
from app.db.store import Store
store = Store(monitor_db)
for i in range(3):
store.upsert_alert(WatchAlert(
saved_search_id=1, platform_listing_id=f"ebay-{i:03d}",
title=f"Listing {i}", price=float(100 + i), trust_score=70,
))
assert store.count_undismissed_alerts() == 3
def test_dismiss_alert(self, monitor_db: Path):
from app.db.models import WatchAlert
from app.db.store import Store
store = Store(monitor_db)
alert = WatchAlert(
saved_search_id=1, platform_listing_id="ebay-dismiss",
title="To dismiss", price=400.0, trust_score=65,
)
alert_id, _ = store.upsert_alert(alert)
store.dismiss_alert(alert_id)
alerts = store.list_alerts(include_dismissed=False)
assert all(a.id != alert_id for a in alerts)
def test_dismiss_all_alerts(self, monitor_db: Path):
from app.db.models import WatchAlert
from app.db.store import Store
store = Store(monitor_db)
for i in range(3):
store.upsert_alert(WatchAlert(
saved_search_id=1, platform_listing_id=f"all-{i}",
title=f"All {i}", price=float(100 * i), trust_score=70,
))
count = store.dismiss_all_alerts()
assert count == 3
assert store.count_undismissed_alerts() == 0
def test_mark_search_checked_updates_timestamp(self, monitor_db: Path):
from app.db.store import Store
store = Store(monitor_db)
store.mark_search_checked(1)
searches = store.list_monitored_searches()
assert searches[0].last_checked_at is not None
# ---------------------------------------------------------------------------
# run_monitor_search — mocked adapter + trust aggregator
# ---------------------------------------------------------------------------
class TestRunMonitorSearch:
def test_new_qualifying_listing_creates_alert(self, monitor_db: Path):
from app.db.models import Listing, SavedSearch, TrustScore
from app.db.store import Store
from app.tasks.monitor import run_monitor_search
search = SavedSearch(
id=1, name="RTX 4090", query="rtx 4090",
platform="ebay", monitor_enabled=True,
min_trust_score=60,
)
mock_listing = Listing(
platform="ebay", platform_listing_id="ebay-new",
title="ASUS RTX 4090", price=750.0, currency="USD",
condition="used", url="https://ebay.com/itm/new",
buying_format="fixed_price", seller_platform_id="seller123",
)
mock_trust = TrustScore(
listing_id=0, composite_score=72, score_is_partial=False,
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
price_vs_market_score=0, category_history_score=0,
)
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
patch("app.trust.TrustScorer") as MockAgg:
MockAdapter.return_value.search.return_value = [mock_listing]
MockAgg.return_value.score_batch.return_value = [mock_trust]
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
assert count == 1
alerts = Store(monitor_db).list_alerts()
assert len(alerts) == 1
assert alerts[0].platform_listing_id == "ebay-new"
def test_below_threshold_listing_not_alerted(self, monitor_db: Path):
from app.db.models import Listing, SavedSearch, TrustScore
from app.tasks.monitor import run_monitor_search
search = SavedSearch(
id=1, name="RTX 4090", query="rtx 4090",
platform="ebay", monitor_enabled=True,
min_trust_score=70,
)
mock_listing = Listing(
platform="ebay", platform_listing_id="ebay-low",
title="Sketchy RTX 4090", price=500.0, currency="USD",
condition="used", url="https://ebay.com/itm/low",
buying_format="fixed_price", seller_platform_id="s1",
)
mock_trust = TrustScore(
listing_id=0, composite_score=55, score_is_partial=False,
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
price_vs_market_score=0, category_history_score=0,
)
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
patch("app.trust.TrustScorer") as MockAgg:
MockAdapter.return_value.search.return_value = [mock_listing]
MockAgg.return_value.score_batch.return_value = [mock_trust]
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
assert count == 0
def test_duplicate_listing_not_double_alerted(self, monitor_db: Path):
from app.db.models import Listing, SavedSearch, TrustScore
from app.tasks.monitor import run_monitor_search
search = SavedSearch(
id=1, name="RTX 4090", query="rtx 4090",
platform="ebay", monitor_enabled=True, min_trust_score=60,
)
mock_listing = Listing(
platform="ebay", platform_listing_id="ebay-dupe",
title="RTX 4090", price=700.0, currency="USD",
condition="used", url="https://ebay.com/itm/dupe",
buying_format="fixed_price", seller_platform_id="s1",
)
mock_trust = TrustScore(
listing_id=0, composite_score=75, score_is_partial=False,
account_age_score=0, feedback_count_score=0, feedback_ratio_score=0,
price_vs_market_score=0, category_history_score=0,
)
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter, \
patch("app.trust.TrustScorer") as MockAgg:
MockAdapter.return_value.search.return_value = [mock_listing]
MockAgg.return_value.score_batch.return_value = [mock_trust]
count1 = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
count2 = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
assert count1 == 1
assert count2 == 0 # deduped by UNIQUE constraint
def test_adapter_failure_returns_zero(self, monitor_db: Path):
from app.db.models import SavedSearch
from app.tasks.monitor import run_monitor_search
search = SavedSearch(
id=1, name="RTX 4090", query="rtx 4090",
platform="ebay", monitor_enabled=True, min_trust_score=60,
)
with patch("app.platforms.ebay.adapter.EbayAdapter") as MockAdapter:
MockAdapter.return_value.search.side_effect = RuntimeError("eBay down")
count = run_monitor_search(search, user_db=monitor_db, shared_db=monitor_db)
assert count == 0

View file

@ -0,0 +1,398 @@
<template>
<div class="alert-bell-wrap" ref="wrapRef">
<!-- Bell trigger button -->
<button
ref="bellRef"
class="alert-bell"
:class="{ 'alert-bell--active': panelOpen }"
:aria-label="unreadCount > 0 ? `${unreadCount} new watch alert${unreadCount === 1 ? '' : 's'}` : 'Watch alerts'"
:aria-expanded="panelOpen"
aria-haspopup="true"
@click="togglePanel"
>
<BellIcon class="alert-bell__icon" aria-hidden="true" />
<span
v-if="unreadCount > 0"
class="alert-badge"
aria-hidden="true"
>{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
<!-- Polite live region announces count changes without moving focus -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ liveAnnouncement }}
</div>
<!-- Alert panel -->
<Transition name="panel">
<div
v-if="panelOpen"
class="alert-panel"
role="dialog"
aria-label="Watch alerts"
aria-modal="false"
>
<div class="alert-panel__header">
<span class="alert-panel__title">Watch Alerts</span>
<button
v-if="store.alerts.length > 0"
class="alert-panel__clear"
@click="onDismissAll"
>
Clear all
</button>
<button
class="alert-panel__close"
aria-label="Close alerts panel"
@click="closePanel"
>
</button>
</div>
<div v-if="store.loading" class="alert-panel__state">
Loading
</div>
<div v-else-if="store.alerts.length === 0" class="alert-panel__state">
<span aria-hidden="true">🔔</span>
<p>No new alerts. Enable monitoring on a saved search to get notified.</p>
</div>
<ul v-else class="alert-list" role="list">
<li
v-for="alert in store.alerts"
:key="alert.id"
class="alert-card"
>
<div class="alert-card__body">
<p class="alert-card__title">{{ alert.title }}</p>
<div class="alert-card__meta">
<span class="alert-card__price">${{ alert.price.toFixed(2) }}</span>
<span class="alert-card__score" :class="scoreClass(alert.trust_score)">
Trust {{ alert.trust_score }}
</span>
</div>
</div>
<div class="alert-card__actions">
<a
v-if="alert.url"
:href="alert.url"
target="_blank"
rel="noopener noreferrer"
class="alert-card__view"
:aria-label="`View listing: ${alert.title}`"
>
View on eBay
</a>
<button
class="alert-card__dismiss"
:aria-label="`Dismiss alert: ${alert.title}`"
@click="onDismiss(alert.id)"
@keydown.delete.prevent="onDismiss(alert.id)"
>
</button>
</div>
</li>
</ul>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { BellIcon } from '@heroicons/vue/24/outline'
import { useAlertsStore } from '../stores/alerts'
const store = useAlertsStore()
const panelOpen = ref(false)
const bellRef = ref<HTMLButtonElement | null>(null)
const wrapRef = ref<HTMLDivElement | null>(null)
const liveAnnouncement = ref('')
const unreadCount = computed(() => store.unreadCount)
// Announce count changes to screen readers via the polite live region.
watch(unreadCount, (count, prev) => {
if (count > prev) {
liveAnnouncement.value = `${count} new watch alert${count === 1 ? '' : 's'}`
// Reset after announcement so repeat counts still fire.
setTimeout(() => { liveAnnouncement.value = '' }, 1500)
}
})
function togglePanel() {
panelOpen.value = !panelOpen.value
if (panelOpen.value) store.fetchAlerts()
}
function closePanel() {
panelOpen.value = false
bellRef.value?.focus()
}
async function onDismiss(id: number) {
await store.dismiss(id)
if (store.alerts.length === 0) {
// Return focus to bell when last alert is dismissed.
panelOpen.value = false
bellRef.value?.focus()
}
}
async function onDismissAll() {
await store.dismissAll()
panelOpen.value = false
bellRef.value?.focus()
}
function scoreClass(score: number) {
if (score >= 75) return 'score--high'
if (score >= 50) return 'score--medium'
return 'score--low'
}
// Close on outside click.
function handleOutsideClick(e: MouseEvent) {
if (wrapRef.value && !wrapRef.value.contains(e.target as Node)) {
panelOpen.value = false
}
}
onMounted(() => {
store.fetchAlerts()
// Poll for new alerts every 2 minutes while the app is open.
const interval = setInterval(() => store.fetchAlerts(), 120_000)
document.addEventListener('click', handleOutsideClick)
onBeforeUnmount(() => {
clearInterval(interval)
document.removeEventListener('click', handleOutsideClick)
})
})
</script>
<style scoped>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.alert-bell-wrap {
position: relative;
}
.alert-bell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
}
.alert-bell:hover,
.alert-bell--active {
border-color: var(--app-primary);
color: var(--app-primary);
background: var(--app-primary-light);
}
.alert-bell__icon {
width: 1.25rem;
height: 1.25rem;
}
.alert-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--color-error, #ef4444);
color: #fff;
font-size: 0.625rem;
font-weight: 700;
font-family: var(--font-mono);
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* Panel */
.alert-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(360px, 92vw);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 200;
overflow: hidden;
}
.alert-panel__header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.alert-panel__title {
flex: 1;
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text);
}
.alert-panel__clear {
font-size: 0.75rem;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: color 150ms ease;
}
.alert-panel__clear:hover { color: var(--color-error); }
.alert-panel__close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
line-height: 1;
transition: color 150ms ease;
min-width: 24px;
min-height: 24px;
}
.alert-panel__close:hover { color: var(--color-error); }
.alert-panel__state {
padding: var(--space-8) var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-size: 0.875rem;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
/* Alert list */
.alert-list {
list-style: none;
max-height: 360px;
overflow-y: auto;
}
.alert-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
transition: background 150ms ease;
}
.alert-card:hover { background: var(--color-surface); }
.alert-card:last-child { border-bottom: none; }
.alert-card__body { flex: 1; min-width: 0; }
.alert-card__title {
font-size: 0.8125rem;
color: var(--color-text);
margin: 0 0 var(--space-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.alert-card__meta {
display: flex;
gap: var(--space-2);
align-items: center;
}
.alert-card__price {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--app-primary);
}
.alert-card__score {
font-size: 0.6875rem;
font-weight: 600;
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
}
.score--high { background: rgba(34,197,94,0.15); color: #22c55e; }
.score--medium { background: rgba(234,179,8,0.15); color: #eab308; }
.score--low { background: rgba(239,68,68,0.15); color: #ef4444; }
.alert-card__actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.alert-card__view {
font-size: 0.75rem;
color: var(--app-primary);
text-decoration: none;
white-space: nowrap;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: background 150ms ease;
}
.alert-card__view:hover { background: var(--app-primary-light); }
.alert-card__dismiss {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.625rem;
cursor: pointer;
min-width: 24px;
min-height: 24px;
transition: border-color 150ms ease, color 150ms ease;
}
.alert-card__dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
/* Transition */
.panel-enter-active,
.panel-leave-active { transition: opacity 120ms ease, transform 120ms ease; }
.panel-enter-from,
.panel-leave-to { opacity: 0; transform: translateY(-6px); }
@media (prefers-reduced-motion: reduce) {
.panel-enter-active,
.panel-leave-active { transition: none; }
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<!-- Desktop: persistent sidebar (1024px) -->
<!-- Mobile: bottom tab bar (<1024px) -->
<nav class="app-sidebar" role="navigation" aria-label="Main navigation">
<nav class="app-sidebar" role="navigation" aria-label="Sidebar">
<!-- Brand -->
<div class="sidebar__brand">
<RouterLink to="/" class="sidebar__logo">
@ -32,17 +32,20 @@
</button>
</div>
<!-- Settings at bottom -->
<!-- Settings + alert bell at bottom -->
<div class="sidebar__footer">
<div class="sidebar__footer-row">
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">Settings</span>
</RouterLink>
<AlertBell v-if="session.isLoggedIn || session.tier === 'local'" class="sidebar__bell" />
</div>
</div>
</nav>
<!-- Mobile bottom tab bar -->
<nav class="app-tabbar" role="navigation" aria-label="Main navigation">
<nav class="app-tabbar" role="navigation" aria-label="Tab bar">
<ul class="tabbar__links" role="list">
<li v-for="link in mobileLinks" :key="link.to">
<RouterLink
@ -69,8 +72,11 @@ import {
ShieldExclamationIcon,
} from '@heroicons/vue/24/outline'
import { useSnipeMode } from '../composables/useSnipeMode'
import { useSessionStore } from '../stores/session'
import AlertBell from './AlertBell.vue'
const { active: isSnipeMode, deactivate } = useSnipeMode()
const session = useSessionStore()
const navLinks = computed(() => [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
@ -81,7 +87,7 @@ const navLinks = computed(() => [
const mobileLinks = [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
]
</script>
@ -202,6 +208,20 @@ const mobileLinks = [
border-top: 1px solid var(--color-border-light);
}
.sidebar__footer-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar__footer-row .sidebar__link {
flex: 1;
}
.sidebar__bell {
flex-shrink: 0;
}
/* ── Mobile tab bar (<1024px) ───────────────────────── */
.app-tabbar {
display: none;

63
web/src/stores/alerts.ts Normal file
View file

@ -0,0 +1,63 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface WatchAlert {
id: number
saved_search_id: number
platform_listing_id: string
title: string
price: number
currency: string
trust_score: number
url: string | null
first_alerted_at: string
dismissed_at: string | null
}
const BASE = import.meta.env.VITE_API_BASE ?? ''
export const useAlertsStore = defineStore('alerts', () => {
const alerts = ref<WatchAlert[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchAlerts(includeDismissed = false) {
loading.value = true
error.value = null
try {
const res = await fetch(
`${BASE}/api/alerts${includeDismissed ? '?include_dismissed=true' : ''}`,
{ credentials: 'include' },
)
if (!res.ok) throw new Error(`${res.status}`)
const data = await res.json()
alerts.value = data.alerts
unreadCount.value = data.unread_count
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load alerts'
} finally {
loading.value = false
}
}
async function dismiss(alertId: number) {
await fetch(`${BASE}/api/alerts/${alertId}/dismiss`, {
method: 'POST',
credentials: 'include',
})
alerts.value = alerts.value.filter((a) => a.id !== alertId)
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
async function dismissAll() {
await fetch(`${BASE}/api/alerts/dismiss-all`, {
method: 'POST',
credentials: 'include',
})
alerts.value = []
unreadCount.value = 0
}
return { alerts, unreadCount, loading, error, fetchAlerts, dismiss, dismissAll }
})

View file

@ -31,8 +31,63 @@
<span v-if="item.last_run_at">Last run {{ formatDate(item.last_run_at) }}</span>
<span v-else>Never run</span>
· Saved {{ formatDate(item.created_at) }}
<span v-if="item.last_checked_at" class="saved-card-checked">
· Monitored {{ formatDate(item.last_checked_at) }}
</span>
</p>
</div>
<div class="saved-card-right">
<!-- Monitor toggle only shown to paid+ users -->
<div v-if="session.isPaid || session.tier === 'local'" class="monitor-section">
<label class="monitor-toggle-label">
<input
type="checkbox"
class="monitor-toggle-input"
:checked="item.monitor_enabled"
:aria-label="`Monitor ${item.name}`"
@change="onToggleMonitor(item, ($event.target as HTMLInputElement).checked)"
/>
<span class="monitor-toggle-track" aria-hidden="true" />
<span class="monitor-toggle-text">Monitor</span>
</label>
<!-- Inline settings only when enabled -->
<Transition name="slide">
<div v-if="item.monitor_enabled" class="monitor-settings">
<label class="monitor-setting-label">
Check every
<input
type="number"
class="monitor-setting-input"
:value="item.poll_interval_min"
min="15"
max="1440"
step="15"
:aria-label="`Poll interval for ${item.name} in minutes`"
@change="onIntervalChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
/>
min
<span class="monitor-hint">Min 15. 60 = hourly.</span>
</label>
<label class="monitor-setting-label">
Trust
<input
type="number"
class="monitor-setting-input"
:value="item.min_trust_score"
min="0"
max="100"
step="5"
:aria-label="`Minimum trust score for ${item.name}`"
@change="onThresholdChange(item, ($event.target as HTMLInputElement).valueAsNumber)"
/>
<span class="monitor-hint">0100. 60 = medium confidence.</span>
</label>
</div>
</Transition>
</div>
<div class="saved-card-actions">
<button class="saved-run-btn" type="button" @click="onRun(item)">
Run
@ -41,31 +96,47 @@
class="saved-delete-btn"
type="button"
:aria-label="`Delete saved search: ${item.name}`"
@click="onDelete(item.id)"
@click="onDelete(item)"
>
</button>
</div>
</div>
</li>
</ul>
<!-- Undo toast for delete -->
<Transition name="toast">
<div v-if="pendingDelete" class="undo-toast" role="status" aria-live="polite">
<span>Deleted "{{ pendingDelete.name }}"</span>
<button class="undo-btn" @click="onUndoDelete">Undo</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useSavedSearchesStore } from '../stores/savedSearches'
import { useSessionStore } from '../stores/session'
import type { SavedSearch } from '../stores/savedSearches'
const store = useSavedSearchesStore()
const session = useSessionStore()
const router = useRouter()
const BASE = import.meta.env.VITE_API_BASE ?? ''
// Soft-delete state holds for 3 seconds before committing
const pendingDelete = ref<SavedSearch | null>(null)
let deleteTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => store.fetchAll())
function formatDate(iso: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
async function onRun(item: SavedSearch) {
@ -75,8 +146,65 @@ async function onRun(item: SavedSearch) {
router.push({ path: '/', query })
}
async function onDelete(id: number) {
await store.remove(id)
function onDelete(item: SavedSearch) {
// Soft-delete: show undo toast, commit after 3s.
if (deleteTimer) clearTimeout(deleteTimer)
pendingDelete.value = item
deleteTimer = setTimeout(async () => {
if (pendingDelete.value?.id === item.id) {
await store.remove(item.id)
pendingDelete.value = null
}
}, 3000)
}
function onUndoDelete() {
if (deleteTimer) clearTimeout(deleteTimer)
pendingDelete.value = null
}
async function onToggleMonitor(item: SavedSearch, enabled: boolean) {
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
monitor_enabled: enabled,
poll_interval_min: item.poll_interval_min,
min_trust_score: item.min_trust_score,
}),
})
await store.fetchAll()
}
async function onIntervalChange(item: SavedSearch, minutes: number) {
if (isNaN(minutes) || minutes < 15) return
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
monitor_enabled: item.monitor_enabled,
poll_interval_min: minutes,
min_trust_score: item.min_trust_score,
}),
})
await store.fetchAll()
}
async function onThresholdChange(item: SavedSearch, score: number) {
if (isNaN(score)) return
await fetch(`${BASE}/api/saved-searches/${item.id}/monitor`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
monitor_enabled: item.monitor_enabled,
poll_interval_min: item.poll_interval_min,
min_trust_score: score,
}),
})
await store.fetchAll()
}
</script>
@ -127,12 +255,12 @@ async function onDelete(id: number) {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 720px;
max-width: 800px;
}
.saved-card {
display: flex;
align-items: center;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
background: var(--color-surface-2);
@ -174,13 +302,131 @@ async function onDelete(id: number) {
margin: 0;
}
.saved-card-checked {
color: var(--app-primary);
}
/* Right column: monitor section + action buttons */
.saved-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-3);
flex-shrink: 0;
}
.saved-card-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Monitor toggle */
.monitor-section {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
}
.monitor-toggle-label {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
user-select: none;
}
/* Visually hide the native checkbox but keep it accessible */
.monitor-toggle-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.monitor-toggle-track {
display: inline-block;
width: 32px;
height: 18px;
border-radius: 9px;
background: var(--color-border);
position: relative;
transition: background 150ms ease;
flex-shrink: 0;
}
.monitor-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: transform 150ms ease;
}
.monitor-toggle-input:checked + .monitor-toggle-track {
background: var(--app-primary);
}
.monitor-toggle-input:checked + .monitor-toggle-track::after {
transform: translateX(14px);
}
/* Focus ring on the label when the hidden checkbox is focused */
.monitor-toggle-label:has(.monitor-toggle-input:focus-visible) .monitor-toggle-track {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.monitor-toggle-text {
font-size: 0.8125rem;
color: var(--color-text-muted);
white-space: nowrap;
}
/* Inline monitor settings */
.monitor-settings {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.monitor-setting-label {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.monitor-setting-input {
width: 60px;
padding: var(--space-1) var(--space-2);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.8125rem;
text-align: center;
}
.monitor-hint {
font-size: 0.6875rem;
color: var(--color-text-muted);
opacity: 0.75;
}
.saved-run-btn {
padding: var(--space-2) var(--space-4);
background: var(--app-primary);
@ -206,13 +452,65 @@ async function onDelete(id: number) {
cursor: pointer;
transition: border-color 150ms ease, color 150ms ease;
min-width: 28px;
min-height: 28px;
}
.saved-delete-btn:hover { border-color: var(--color-error); color: var(--color-error); }
/* Undo toast */
.undo-toast {
position: fixed;
bottom: calc(var(--space-6) + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
font-size: 0.875rem;
color: var(--color-text);
z-index: 300;
white-space: nowrap;
}
.undo-btn {
padding: var(--space-1) var(--space-3);
background: var(--app-primary);
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-inverse);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
/* Transitions */
.slide-enter-active,
.slide-leave-active { transition: opacity 150ms ease, max-height 200ms ease; max-height: 200px; overflow: hidden; }
.slide-enter-from,
.slide-leave-to { opacity: 0; max-height: 0; }
.toast-enter-active,
.toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
.toast-enter-from,
.toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
@media (prefers-reduced-motion: reduce) {
.slide-enter-active, .slide-leave-active,
.toast-enter-active, .toast-leave-active { transition: none; }
}
@media (max-width: 767px) {
.saved-header { padding: var(--space-4); }
.saved-list { padding: var(--space-4); }
.saved-card { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
.saved-card-right { width: 100%; align-items: flex-start; }
.saved-card-actions { width: 100%; justify-content: flex-end; }
.monitor-section { width: 100%; align-items: flex-start; }
.monitor-settings { width: 100%; }
}
</style>

View file

@ -93,6 +93,74 @@
</div>
</section>
<!-- eBay Account Connection paid+ only -->
<section v-if="ebay.oauth_available && session.isLoggedIn" class="settings-section">
<h2 class="settings-section-title">eBay Account</h2>
<!-- Connected state -->
<div v-if="ebay.connected" class="ebay-connected">
<div class="ebay-status-row">
<span class="ebay-status-dot ebay-status-dot--on" aria-hidden="true" />
<span class="settings-toggle-label">Connected</span>
</div>
<p class="settings-toggle-desc">
Snipe uses your eBay account to fetch seller registration dates instantly
via the Trading API, without Playwright scraping. This means faster, more
accurate trust scores on every search.
<span v-if="ebay.access_token_expired" class="ebay-warn">
Your access token has expired reconnect to restore instant enrichment.
</span>
</p>
<div class="ebay-action-row">
<button
v-if="ebay.access_token_expired"
class="ebay-btn ebay-btn--primary"
:disabled="ebay.connecting"
@click="startConnect"
>
Reconnect eBay account
</button>
<button
class="ebay-btn ebay-btn--danger"
:disabled="ebay.disconnecting"
@click="disconnect"
>
{{ ebay.disconnecting ? 'Disconnecting…' : 'Disconnect' }}
</button>
</div>
</div>
<!-- Not connected paid tier -->
<div v-else-if="session.isPaid || session.isPremium" class="ebay-disconnected">
<p class="settings-toggle-desc">
Connect your eBay account to enable instant seller registration date lookup
via the Trading API. Without it, Snipe falls back to slower Playwright
scraping (or Shopping API rate-limited calls) to determine account age.
</p>
<button
class="ebay-btn ebay-btn--primary"
:disabled="ebay.connecting"
@click="startConnect"
>
{{ ebay.connecting ? 'Redirecting to eBay…' : 'Connect eBay account' }}
</button>
</div>
<!-- Not connected free tier upsell -->
<div v-else class="ebay-disconnected">
<p class="settings-toggle-desc">
Connect your eBay account for instant seller trust scoring without scraping.
Available on Paid tier and above.
</p>
<a class="ebay-btn ebay-btn--upsell" href="/pricing" rel="noopener">
Upgrade to Paid
</a>
</div>
<p v-if="ebay.error" class="settings-error" role="alert">{{ ebay.error }}</p>
<p v-if="ebay.success" class="settings-success" role="status">{{ ebay.success }}</p>
</section>
<!-- Affiliate Links only shown to signed-in cloud users -->
<section v-if="session.isLoggedIn" class="settings-section">
<h2 class="settings-section-title">Affiliate Links</h2>
@ -174,13 +242,16 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useTheme } from '../composables/useTheme'
import { useSessionStore } from '../stores/session'
import { usePreferencesStore } from '../stores/preferences'
import { useLLMQueryBuilder } from '../composables/useLLMQueryBuilder'
const route = useRoute()
const router = useRouter()
const { enabled: trustSignalEnabled, setEnabled } = useTrustSignalPref()
const theme = useTheme()
const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
@ -212,6 +283,90 @@ watch(() => prefs.affiliateByokId, (val) => { byokInput.value = val })
function saveByokId() {
prefs.setAffiliateByokId(byokInput.value)
}
// eBay Account Connection
const ebay = reactive({
oauth_available: false,
connected: false,
access_token_expired: false,
scopes: [] as string[],
connecting: false,
disconnecting: false,
error: '',
success: '',
})
async function fetchEbayStatus() {
try {
const res = await fetch('/api/ebay/status')
if (!res.ok) return
const data = await res.json()
ebay.oauth_available = data.oauth_available ?? false
ebay.connected = data.connected ?? false
ebay.access_token_expired = data.access_token_expired ?? false
ebay.scopes = data.scopes ?? []
} catch {
// silently ignore section stays hidden if fetch fails
}
}
async function startConnect() {
ebay.connecting = true
ebay.error = ''
try {
const res = await fetch('/api/ebay/connect')
if (!res.ok) {
const body = await res.json().catch(() => ({}))
ebay.error = body.detail ?? 'eBay connection unavailable.'
return
}
const { auth_url } = await res.json()
window.location.href = auth_url
} catch {
ebay.error = 'Could not reach the server. Try again.'
ebay.connecting = false
}
}
async function disconnect() {
ebay.disconnecting = true
ebay.error = ''
ebay.success = ''
try {
const res = await fetch('/api/ebay/disconnect', { method: 'DELETE' })
if (res.ok || res.status === 204) {
ebay.connected = false
ebay.access_token_expired = false
ebay.scopes = []
ebay.success = 'eBay account disconnected.'
} else {
ebay.error = 'Disconnect failed. Try again.'
}
} catch {
ebay.error = 'Could not reach the server. Try again.'
} finally {
ebay.disconnecting = false
}
}
onMounted(async () => {
await fetchEbayStatus()
// Handle OAuth callback redirect params: ?ebay_connected=1 or ?ebay_error=access_denied
const connected = route.query.ebay_connected
const oauthError = route.query.ebay_error
if (connected) {
ebay.success = 'eBay account connected! Trust scores will now use the Trading API.'
await fetchEbayStatus()
router.replace({ query: { ...route.query, ebay_connected: undefined } })
} else if (oauthError) {
ebay.error = oauthError === 'access_denied'
? 'eBay authorization was cancelled.'
: `eBay OAuth error: ${oauthError}`
router.replace({ query: { ...route.query, ebay_error: undefined } })
}
})
</script>
<style scoped>
@ -373,7 +528,7 @@ function saveByokId() {
outline-offset: 2px;
}
/* ---- Error feedback ---- */
/* ---- Error / success feedback ---- */
.settings-error {
font-size: 0.8125rem;
color: var(--color-danger, #f85149);
@ -398,6 +553,100 @@ function saveByokId() {
border-color: var(--app-primary);
}
.settings-success {
font-size: 0.8125rem;
color: var(--color-success, #3fb950);
margin: 0;
}
/* ---- eBay Account section ---- */
.ebay-status-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.ebay-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
background: var(--color-border);
}
.ebay-status-dot--on {
background: var(--color-success, #3fb950);
}
.ebay-connected,
.ebay-disconnected {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ebay-warn {
display: block;
margin-top: var(--space-1);
color: var(--color-warning, #d29922);
}
.ebay-action-row {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.ebay-btn {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: opacity 0.15s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.ebay-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ebay-btn--primary {
background: var(--app-primary);
color: var(--color-text-inverse, #fff);
}
.ebay-btn--primary:hover:not(:disabled) { opacity: 0.85; }
.ebay-btn--danger {
background: transparent;
color: var(--color-danger, #f85149);
border: 1px solid var(--color-danger, #f85149);
}
.ebay-btn--danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger, #f85149) 12%, transparent);
}
.ebay-btn--upsell {
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.ebay-btn--upsell:hover { opacity: 0.85; }
.ebay-btn:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.theme-btn-group {
display: flex;
gap: 0;