diff --git a/api/ebay_webhook.py b/api/ebay_webhook.py index 0719455..7f59254 100644 --- a/api/ebay_webhook.py +++ b/api/ebay_webhook.py @@ -26,11 +26,11 @@ from pathlib import Path from typing import Optional import requests -from fastapi import APIRouter, Header, HTTPException, Request from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ec import ECDSA from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.serialization import load_pem_public_key +from fastapi import APIRouter, Header, HTTPException, Request from app.db.store import Store diff --git a/app/db/models.py b/app/db/models.py index 3f0acde..08a3eaa 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,5 +1,6 @@ """Dataclasses for all Snipe domain objects.""" from __future__ import annotations + from dataclasses import dataclass, field from typing import Optional diff --git a/app/db/store.py b/app/db/store.py index b246fa8..513fb63 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1,5 +1,6 @@ """Thin SQLite read/write layer for all Snipe models.""" from __future__ import annotations + import json from datetime import datetime, timezone from pathlib import Path @@ -7,7 +8,7 @@ from typing import Optional from circuitforge_core.db import get_connection, run_migrations -from .models import Listing, Seller, TrustScore, MarketComp, SavedSearch, ScammerEntry +from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore MIGRATIONS_DIR = Path(__file__).parent / "migrations" @@ -381,6 +382,59 @@ class Store: for r in rows ] + def save_community_signal(self, seller_id: str, confirmed: bool) -> None: + """Record a user's trust-score feedback signal into the shared DB.""" + self._conn.execute( + "INSERT INTO community_signals (seller_id, confirmed) VALUES (?, ?)", + (seller_id, 1 if confirmed else 0), + ) + self._conn.commit() + + # --- User Preferences --- + + def get_user_preference(self, path: str, default=None): + """Read a preference value at dot-separated path (e.g. 'affiliate.opt_out'). + + Reads from the singleton user_preferences row; returns *default* if the + table is empty or the path is not set. + """ + from circuitforge_core.preferences.paths import get_path + row = self._conn.execute( + "SELECT prefs_json FROM user_preferences WHERE id=1" + ).fetchone() + if not row: + return default + return get_path(json.loads(row[0]), path, default=default) + + def set_user_preference(self, path: str, value) -> None: + """Write *value* at dot-separated path (immutable JSON update). + + Creates the singleton row on first write; merges subsequent updates + so sibling paths are preserved. + """ + from circuitforge_core.preferences.paths import set_path + row = self._conn.execute( + "SELECT prefs_json FROM user_preferences WHERE id=1" + ).fetchone() + prefs = json.loads(row[0]) if row else {} + updated = set_path(prefs, path, value) + self._conn.execute( + "INSERT INTO user_preferences (id, prefs_json, updated_at) " + "VALUES (1, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) " + "ON CONFLICT(id) DO UPDATE SET " + " prefs_json = excluded.prefs_json, " + " updated_at = excluded.updated_at", + (json.dumps(updated),), + ) + self._conn.commit() + + def get_all_preferences(self) -> dict: + """Return all preferences as a plain dict (empty dict if not yet set).""" + row = self._conn.execute( + "SELECT prefs_json FROM user_preferences WHERE id=1" + ).fetchone() + return json.loads(row[0]) if row else {} + def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]: row = self._conn.execute( "SELECT platform, query_hash, median_price, sample_count, expires_at, id, fetched_at " diff --git a/app/platforms/__init__.py b/app/platforms/__init__.py index 93bd054..1ed2dd0 100644 --- a/app/platforms/__init__.py +++ b/app/platforms/__init__.py @@ -1,8 +1,10 @@ """PlatformAdapter abstract base and shared types.""" from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional + from app.db.models import Listing, Seller diff --git a/app/platforms/ebay/adapter.py b/app/platforms/ebay/adapter.py index 75ccafa..4b729b9 100644 --- a/app/platforms/ebay/adapter.py +++ b/app/platforms/ebay/adapter.py @@ -1,10 +1,12 @@ """eBay Browse API adapter.""" from __future__ import annotations + import hashlib import logging from dataclasses import replace from datetime import datetime, timedelta, timezone from typing import Optional + import requests log = logging.getLogger(__name__) @@ -18,7 +20,7 @@ _SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call _SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls _SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window -from app.db.models import Listing, Seller, MarketComp +from app.db.models import Listing, MarketComp, Seller from app.db.store import Store from app.platforms import PlatformAdapter, SearchFilters from app.platforms.ebay.auth import EbayTokenManager diff --git a/app/platforms/ebay/auth.py b/app/platforms/ebay/auth.py index f04c4cd..c20a06d 100644 --- a/app/platforms/ebay/auth.py +++ b/app/platforms/ebay/auth.py @@ -1,8 +1,10 @@ """eBay OAuth2 client credentials token manager.""" from __future__ import annotations + import base64 import time from typing import Optional + import requests EBAY_OAUTH_URLS = { diff --git a/app/platforms/ebay/normaliser.py b/app/platforms/ebay/normaliser.py index 99f4921..063a104 100644 --- a/app/platforms/ebay/normaliser.py +++ b/app/platforms/ebay/normaliser.py @@ -1,8 +1,10 @@ """Convert raw eBay API responses into Snipe domain objects.""" from __future__ import annotations + import json from datetime import datetime, timezone from typing import Optional + from app.db.models import Listing, Seller diff --git a/app/tasks/scheduler.py b/app/tasks/scheduler.py index 74fabd4..962666d 100644 --- a/app/tasks/scheduler.py +++ b/app/tasks/scheduler.py @@ -9,6 +9,7 @@ from circuitforge_core.tasks.scheduler import ( ) from circuitforge_core.tasks.scheduler import ( get_scheduler as _base_get_scheduler, + reset_scheduler, # re-export for lifespan teardown ) from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task diff --git a/app/trust/metadata.py b/app/trust/metadata.py index 9ce88a6..f672c6d 100644 --- a/app/trust/metadata.py +++ b/app/trust/metadata.py @@ -1,7 +1,9 @@ """Five metadata trust signals, each scored 0–20.""" from __future__ import annotations + import json from typing import Optional + from app.db.models import Seller ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"} diff --git a/app/trust/photo.py b/app/trust/photo.py index 78301b9..9fb541f 100644 --- a/app/trust/photo.py +++ b/app/trust/photo.py @@ -1,7 +1,9 @@ """Perceptual hash deduplication within a result set (free tier, v0.1).""" from __future__ import annotations -from typing import Optional + import io +from typing import Optional + import requests try: diff --git a/app/ui/Search.py b/app/ui/Search.py index 0b65650..5130d81 100644 --- a/app/ui/Search.py +++ b/app/ui/Search.py @@ -1,19 +1,24 @@ """Main search + results page.""" from __future__ import annotations + import logging import os from pathlib import Path + import streamlit as st from circuitforge_core.config import load_env + from app.db.store import Store from app.platforms import PlatformAdapter, SearchFilters from app.trust import TrustScorer -from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState -from app.ui.components.listing_row import render_listing_row from app.ui.components.easter_eggs import ( - inject_steal_css, check_snipe_mode, render_snipe_mode_banner, auction_hours_remaining, + check_snipe_mode, + inject_steal_css, + render_snipe_mode_banner, ) +from app.ui.components.filters import FilterState, build_filter_options, render_filter_sidebar +from app.ui.components.listing_row import render_listing_row log = logging.getLogger(__name__) diff --git a/app/ui/components/easter_eggs.py b/app/ui/components/easter_eggs.py index fddf033..4df09a7 100644 --- a/app/ui/components/easter_eggs.py +++ b/app/ui/components/easter_eggs.py @@ -22,7 +22,6 @@ import streamlit as st from app.db.models import Listing, TrustScore - # --------------------------------------------------------------------------- # 1. Konami → Snipe Mode # --------------------------------------------------------------------------- diff --git a/app/ui/components/filters.py b/app/ui/components/filters.py index 6756939..09fffbb 100644 --- a/app/ui/components/filters.py +++ b/app/ui/components/filters.py @@ -1,9 +1,12 @@ """Build dynamic filter options from a result set and render the Streamlit sidebar.""" from __future__ import annotations + import json from dataclasses import dataclass, field from typing import Optional + import streamlit as st + from app.db.models import Listing, TrustScore diff --git a/app/ui/components/listing_row.py b/app/ui/components/listing_row.py index 17056be..256fb98 100644 --- a/app/ui/components/listing_row.py +++ b/app/ui/components/listing_row.py @@ -1,13 +1,17 @@ """Render a single listing row with trust score, badges, and error states.""" from __future__ import annotations + import json from typing import Optional import streamlit as st -from app.db.models import Listing, TrustScore, Seller +from app.db.models import Listing, Seller, TrustScore from app.ui.components.easter_eggs import ( - is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining, + auction_hours_remaining, + is_steal, + render_auction_notice, + render_steal_banner, ) diff --git a/app/wizard/setup.py b/app/wizard/setup.py index 2ac8187..0bd3552 100644 --- a/app/wizard/setup.py +++ b/app/wizard/setup.py @@ -1,6 +1,8 @@ """First-run wizard: collect eBay credentials and write .env.""" from __future__ import annotations + from pathlib import Path + import streamlit as st from circuitforge_core.wizard import BaseWizard diff --git a/streamlit_app.py b/streamlit_app.py index c616633..1c21ffd 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -1,6 +1,8 @@ """Streamlit entrypoint.""" from pathlib import Path + import streamlit as st + from app.wizard import SnipeSetupWizard st.set_page_config( @@ -16,6 +18,7 @@ if not wizard.is_configured(): st.stop() from app.ui.components.easter_eggs import inject_konami_detector + inject_konami_detector() with st.sidebar: @@ -27,4 +30,5 @@ with st.sidebar: ) from app.ui.Search import render + render(audio_enabled=audio_enabled) diff --git a/tests/platforms/test_ebay_auth.py b/tests/platforms/test_ebay_auth.py index e16b517..3944c7c 100644 --- a/tests/platforms/test_ebay_auth.py +++ b/tests/platforms/test_ebay_auth.py @@ -1,7 +1,9 @@ import time -import requests -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import pytest +import requests + from app.platforms.ebay.auth import EbayTokenManager diff --git a/tests/platforms/test_ebay_normaliser.py b/tests/platforms/test_ebay_normaliser.py index ebb75fa..e8b0eaa 100644 --- a/tests/platforms/test_ebay_normaliser.py +++ b/tests/platforms/test_ebay_normaliser.py @@ -1,4 +1,5 @@ import pytest + from app.platforms.ebay.normaliser import normalise_listing, normalise_seller diff --git a/tests/platforms/test_ebay_scraper.py b/tests/platforms/test_ebay_scraper.py index a4c8519..a9cae13 100644 --- a/tests/platforms/test_ebay_scraper.py +++ b/tests/platforms/test_ebay_scraper.py @@ -3,16 +3,18 @@ Uses a minimal HTML fixture mirroring eBay's current s-card markup. No HTTP requests are made — all tests operate on the pure parsing functions. """ -import pytest from datetime import timedelta + +import pytest +from bs4 import BeautifulSoup + from app.platforms.ebay.scraper import ( - scrape_listings, - scrape_sellers, + _extract_seller_from_card, _parse_price, _parse_time_left, - _extract_seller_from_card, + scrape_listings, + scrape_sellers, ) -from bs4 import BeautifulSoup # --------------------------------------------------------------------------- # Minimal eBay search results HTML fixture (li.s-card schema) diff --git a/tests/test_feedback.py b/tests/test_feedback.py index e5e7e89..f75ff8c 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -4,12 +4,10 @@ from __future__ import annotations from collections.abc import Callable from unittest.mock import MagicMock, patch +from circuitforge_core.api.feedback import make_feedback_router from fastapi import FastAPI from fastapi.testclient import TestClient -from circuitforge_core.api.feedback import make_feedback_router - - # ── Test app factory ────────────────────────────────────────────────────────── def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient: diff --git a/web/src/App.vue b/web/src/App.vue index 39bf49d..592f820 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -2,10 +2,10 @@
+ +
- -
@@ -22,6 +22,7 @@ import { useSnipeMode } from './composables/useSnipeMode' import { useKonamiCode } from './composables/useKonamiCode' import { useSessionStore } from './stores/session' import { useBlocklistStore } from './stores/blocklist' +import { usePreferencesStore } from './stores/preferences' import AppNav from './components/AppNav.vue' import FeedbackButton from './components/FeedbackButton.vue' @@ -29,14 +30,16 @@ const motion = useMotion() const { activate, restore } = useSnipeMode() const session = useSessionStore() const blocklistStore = useBlocklistStore() +const preferencesStore = usePreferencesStore() const route = useRoute() useKonamiCode(activate) -onMounted(() => { - restore() // re-apply snipe mode from localStorage on hard reload - session.bootstrap() // fetch tier + feature flags from API - blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately +onMounted(async () => { + restore() // re-apply snipe mode from localStorage on hard reload + await session.bootstrap() // fetch tier + feature flags from API + blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately + preferencesStore.load() // load user preferences after session resolves }) diff --git a/web/src/composables/useTrustFeedback.ts b/web/src/composables/useTrustFeedback.ts index 66a0e77..a2bca32 100644 --- a/web/src/composables/useTrustFeedback.ts +++ b/web/src/composables/useTrustFeedback.ts @@ -10,8 +10,9 @@ export function useTrustFeedback(sellerId: string) { async function submitFeedback(confirmed: boolean): Promise { if (state.value !== 'idle') return state.value = 'sending' + const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' try { - await fetch('/api/community/signal', { + await fetch(`${apiBase}/api/community/signal`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seller_id: sellerId, confirmed }), diff --git a/web/src/stores/search.ts b/web/src/stores/search.ts index 042e4c0..c85a4eb 100644 --- a/web/src/stores/search.ts +++ b/web/src/stores/search.ts @@ -163,8 +163,11 @@ export const useSearchStore = defineStore('search', () => { // 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 params = new URLSearchParams({ q }) - if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice)) - if (filters.minPrice != null) params.set('min_price', String(filters.minPrice)) + // v-model.number sends empty string when a number input is cleared — guard against that + const maxPrice = Number(filters.maxPrice) + const minPrice = Number(filters.minPrice) + if (Number.isFinite(maxPrice) && maxPrice > 0) params.set('max_price', String(maxPrice)) + if (Number.isFinite(minPrice) && minPrice > 0) params.set('min_price', String(minPrice)) if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages)) if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim()) if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode)