feat: reported sellers tracking + community blocklist opt-in
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:
parent
7005be02c2
commit
66ae9eb0b8
9 changed files with 199 additions and 3 deletions
41
api/main.py
41
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):
|
||||
|
|
|
|||
12
app/db/migrations/012_reported_sellers.sql
Normal file
12
app/db/migrations/012_reported_sellers.sql
Normal 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);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
58
web/src/stores/reported.ts
Normal file
58
web/src/stores/reported.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue