diff --git a/api/main.py b/api/main.py index 6a6d534..79741ca 100644 --- a/api/main.py +++ b/api/main.py @@ -905,9 +905,12 @@ def add_to_blocklist(body: BlocklistAdd, session: CloudUser = Depends(get_sessio source="manual", )) - # Publish seller trust signal to community DB (fire-and-forget; never fails the request). + # Publish to community DB only if the user has opted in via community.blocklist_share. + # Privacy-by-architecture: default is OFF; the user must explicitly enable sharing. + user_store = Store(session.user_db) + share_enabled = user_store.get_user_preference("community.blocklist_share", default=False) cs = _get_community_store() - if cs is not None: + if cs is not None and share_enabled: try: cs.publish_seller_signal( platform_seller_id=body.platform_seller_id, @@ -992,6 +995,40 @@ async def import_blocklist( return {"imported": imported, "errors": errors} +# ── Reported Sellers ───────────────────────────────────────────────────────── + +class ReportedSellerEntry(BaseModel): + platform_seller_id: str + username: Optional[str] = None + + +class ReportBatch(BaseModel): + sellers: list[ReportedSellerEntry] + + +@app.post("/api/reported", status_code=204) +def record_reported(body: ReportBatch, session: CloudUser = Depends(get_session)): + """Record that the user has filed eBay T&S reports for the given sellers. + + Stored in the user DB so they don't get prompted to re-report the same seller. + """ + user_store = Store(session.user_db) + for entry in body.sellers: + user_store.mark_reported( + platform="ebay", + platform_seller_id=entry.platform_seller_id, + username=entry.username, + reported_by="bulk_action", + ) + + +@app.get("/api/reported") +def list_reported(session: CloudUser = Depends(get_session)) -> dict: + """Return the set of platform_seller_ids already reported by this user.""" + ids = Store(session.user_db).list_reported("ebay") + return {"reported": ids} + + # ── User Preferences ────────────────────────────────────────────────────────── class PreferenceUpdate(BaseModel): diff --git a/app/db/migrations/012_reported_sellers.sql b/app/db/migrations/012_reported_sellers.sql new file mode 100644 index 0000000..718dd98 --- /dev/null +++ b/app/db/migrations/012_reported_sellers.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS reported_sellers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_seller_id TEXT NOT NULL, + username TEXT, + reported_at TEXT DEFAULT CURRENT_TIMESTAMP, + reported_by TEXT NOT NULL DEFAULT 'user', -- user | bulk_action + UNIQUE(platform, platform_seller_id) +); + +CREATE INDEX IF NOT EXISTS idx_reported_sellers_lookup + ON reported_sellers(platform, platform_seller_id); diff --git a/app/db/store.py b/app/db/store.py index 513fb63..d2c0684 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -382,6 +382,35 @@ class Store: for r in rows ] + # --- Reported Sellers --- + + def mark_reported( + self, + platform: str, + platform_seller_id: str, + username: Optional[str] = None, + reported_by: str = "user", + ) -> None: + """Record that the user has filed an eBay T&S report for this seller. + + Uses IGNORE on conflict so the first-report timestamp is preserved. + """ + self._conn.execute( + "INSERT OR IGNORE INTO reported_sellers " + "(platform, platform_seller_id, username, reported_by) " + "VALUES (?,?,?,?)", + (platform, platform_seller_id, username, reported_by), + ) + self._conn.commit() + + def list_reported(self, platform: str = "ebay") -> list[str]: + """Return all platform_seller_ids that have been reported.""" + rows = self._conn.execute( + "SELECT platform_seller_id FROM reported_sellers WHERE platform=?", + (platform,), + ).fetchall() + return [r[0] 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( diff --git a/web/src/App.vue b/web/src/App.vue index d85caf7..a2560dd 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -24,6 +24,7 @@ import { useKonamiCode } from './composables/useKonamiCode' import { useSessionStore } from './stores/session' import { useBlocklistStore } from './stores/blocklist' import { usePreferencesStore } from './stores/preferences' +import { useReportedStore } from './stores/reported' import AppNav from './components/AppNav.vue' import FeedbackButton from './components/FeedbackButton.vue' @@ -33,6 +34,7 @@ const { restore: restoreTheme } = useTheme() const session = useSessionStore() const blocklistStore = useBlocklistStore() const preferencesStore = usePreferencesStore() +const reportedStore = useReportedStore() const route = useRoute() useKonamiCode(activate) @@ -43,6 +45,7 @@ onMounted(async () => { 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 + reportedStore.load() // pre-load reported sellers so cards show badge immediately }) diff --git a/web/src/components/ListingCard.vue b/web/src/components/ListingCard.vue index b84b009..579e241 100644 --- a/web/src/components/ListingCard.vue +++ b/web/src/components/ListingCard.vue @@ -81,6 +81,9 @@ {{ flagLabel(flag) }} +

+ ⚐ Reported to eBay +

↻ Updating: {{ pendingSignalNames.join(', ') }}

@@ -203,6 +206,7 @@ const props = defineProps<{ marketPrice: number | null selected?: boolean selectMode?: boolean + sellerReported?: boolean }>() const emit = defineEmits<{ toggle: [] }>() @@ -529,6 +533,17 @@ const formattedMarket = computed(() => { font-weight: 600; } +.card__reported-badge { + font-size: 0.6875rem; + color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-text-muted) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent); + border-radius: var(--radius-sm); + padding: 1px var(--space-2); + margin: 0; + display: inline-block; +} + .card__partial-warning { font-size: 0.75rem; color: var(--color-warning); diff --git a/web/src/stores/preferences.ts b/web/src/stores/preferences.ts index 3402738..817e517 100644 --- a/web/src/stores/preferences.ts +++ b/web/src/stores/preferences.ts @@ -9,6 +9,9 @@ export interface UserPreferences { ebay?: string } } + community?: { + blocklist_share?: boolean + } } const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' @@ -21,6 +24,7 @@ export const usePreferencesStore = defineStore('preferences', () => { const affiliateOptOut = computed(() => prefs.value.affiliate?.opt_out ?? false) const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '') + const communityBlocklistShare = computed(() => prefs.value.community?.blocklist_share ?? false) async function load() { if (!session.isLoggedIn) return @@ -67,14 +71,20 @@ export const usePreferencesStore = defineStore('preferences', () => { await setPref('affiliate.byok_ids.ebay', id.trim() || null) } + async function setCommunityBlocklistShare(value: boolean) { + await setPref('community.blocklist_share', value) + } + return { prefs, loading, error, affiliateOptOut, affiliateByokId, + communityBlocklistShare, load, setAffiliateOptOut, setAffiliateByokId, + setCommunityBlocklistShare, } }) diff --git a/web/src/stores/reported.ts b/web/src/stores/reported.ts new file mode 100644 index 0000000..8cbccce --- /dev/null +++ b/web/src/stores/reported.ts @@ -0,0 +1,58 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' + +/** + * Tracks sellers the user has already reported to eBay T&S. + * Persisted server-side for logged-in users; falls back to a session-local + * Set for guests so the UI still suppresses duplicate prompts within a session. + */ +export const useReportedStore = defineStore('reported', () => { + const reportedIds = ref>(new Set()) + const loading = ref(false) + + async function load() { + loading.value = true + try { + const res = await fetch(`${apiBase}/api/reported`) + if (res.ok) { + const data = await res.json() as { reported: string[] } + reportedIds.value = new Set(data.reported) + } + } catch { + // Non-cloud deploy or network error — start with empty set + } finally { + loading.value = false + } + } + + async function markReported(sellers: Array<{ platform_seller_id: string; username?: string | null }>) { + // Optimistic update — add to local set immediately + const next = new Set(reportedIds.value) + for (const s of sellers) next.add(s.platform_seller_id) + reportedIds.value = next + + // Persist server-side (best-effort — no rollback on failure) + try { + await fetch(`${apiBase}/api/reported`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sellers: sellers.map(s => ({ + platform_seller_id: s.platform_seller_id, + username: s.username ?? null, + })), + }), + }) + } catch { + // Persist failed — local set already updated, good enough for session + } + } + + function isReported(platformSellerId: string): boolean { + return reportedIds.value.has(platformSellerId) + } + + return { reportedIds, loading, load, markReported, isReported } +}) diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index 0600faf..24ed50d 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -434,6 +434,7 @@ :market-price="store.marketPrice" :selected="selectedIds.has(listing.platform_listing_id)" :select-mode="selectMode" + :seller-reported="reported.isReported(listing.seller_platform_id)" @toggle="toggleSelect(listing.platform_listing_id)" /> @@ -452,6 +453,7 @@ import type { Listing, TrustScore, SearchFilters, MustIncludeMode } from '../sto import { useSavedSearchesStore } from '../stores/savedSearches' import { useSessionStore } from '../stores/session' import { useBlocklistStore } from '../stores/blocklist' +import { useReportedStore } from '../stores/reported' import ListingCard from '../components/ListingCard.vue' import LLMQueryPanel from '../components/LLMQueryPanel.vue' @@ -460,6 +462,7 @@ const store = useSearchStore() const savedStore = useSavedSearchesStore() const session = useSessionStore() const blocklist = useBlocklistStore() +const reported = useReportedStore() const queryInput = ref('') // ── Multi-select + bulk actions ─────────────────────────────────────────────── @@ -519,6 +522,7 @@ async function blockSelected() { function reportSelected() { const toReport = visibleListings.value.filter(l => selectedIds.value.has(l.platform_listing_id)) // De-duplicate by seller — one report per seller covers all their listings + const reportedEntries: Array<{ platform_seller_id: string; username: string | null }> = [] const seenSellers = new Set() for (const l of toReport) { if (l.seller_platform_id && !seenSellers.has(l.seller_platform_id)) { @@ -530,8 +534,12 @@ function reportSelected() { '_blank', 'noopener,noreferrer', ) + reportedEntries.push({ platform_seller_id: l.seller_platform_id, username: seller?.username ?? null }) } } + if (reportedEntries.length) { + reported.markReported(reportedEntries) + } clearSelection() } diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 4213b4a..66e4dff 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -24,6 +24,29 @@ + + + @@ -129,7 +152,7 @@