feat: reported sellers tracking + community blocklist opt-in
Some checks are pending
CI / Python tests (push) Waiting to run
CI / Frontend typecheck + tests (push) Waiting to run
Mirror / mirror (push) Waiting to run

Phase 2 (snipe#4): after bulk-reporting sellers to eBay T&S, Snipe now
persists which sellers were reported so cards show a muted "Reported to
eBay" badge and users aren't prompted to re-report the same seller.
- migration 012 adds reported_sellers table (user DB, UNIQUE on seller)
- Store.mark_reported / list_reported methods
- POST /api/reported + GET /api/reported endpoints
- reported store (frontend) with optimistic update + server persistence
- reportSelected wires into store after opening eBay tabs

Phase 3 prep (snipe#4): community blocklist share toggle
- Settings > Community section: "Share blocklist with community" toggle
  (visible only to signed-in cloud users, default OFF)
- Persisted as community.blocklist_share user preference
- Backend community signal publish now gated on opt-in preference;
  privacy-by-architecture: sharing is explicit, never implicit
This commit is contained in:
pyr0ball 2026-04-16 13:28:57 -07:00
parent 7005be02c2
commit 66ae9eb0b8
9 changed files with 199 additions and 3 deletions

View file

@ -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):

View file

@ -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);

View file

@ -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(

View file

@ -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
})
</script>

View file

@ -81,6 +81,9 @@
{{ flagLabel(flag) }}
</span>
</div>
<p v-if="sellerReported" class="card__reported-badge" aria-label="You reported this seller to eBay">
Reported to eBay
</p>
<p v-if="pendingSignalNames.length" class="card__score-pending">
Updating: {{ pendingSignalNames.join(', ') }}
</p>
@ -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);

View file

@ -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,
}
})

View file

@ -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<Set<string>>(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 }
})

View file

@ -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)"
/>
</div>
@ -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<string>()
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()
}

View file

@ -24,6 +24,29 @@
<span class="toggle-btn__thumb" />
</button>
</label>
<!-- Community blocklist share cloud signed-in users only -->
<label v-if="session.isLoggedIn" class="settings-toggle">
<div class="settings-toggle-text">
<span class="settings-toggle-label">Share blocklist with community</span>
<span class="settings-toggle-desc">
When enabled, sellers you block are anonymously contributed to the
community blocklist. Only the seller ID and flag reason are shared,
never your identity. A consensus threshold prevents false positives.
</span>
</div>
<button
class="toggle-btn"
:class="{ 'toggle-btn--on': communityBlocklistShare }"
:aria-pressed="String(communityBlocklistShare)"
:aria-busy="prefs.loading"
aria-label="Share blocked sellers with community blocklist"
@click="prefs.setCommunityBlocklistShare(!communityBlocklistShare)"
>
<span class="toggle-btn__track" />
<span class="toggle-btn__thumb" />
</button>
</label>
</section>
<!-- Appearance -->
@ -129,7 +152,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
import { useTheme } from '../composables/useTheme'
import { useSessionStore } from '../stores/session'
@ -146,6 +169,7 @@ const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
const session = useSessionStore()
const prefs = usePreferencesStore()
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
const communityBlocklistShare = computed(() => prefs.communityBlocklistShare)
// Local input buffer for BYOK ID synced from store, saved on blur/enter
const byokInput = ref(prefs.affiliateByokId)