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