Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,3 +812,7 @@ def _bwd_hook(_module, _grad_in, grad_out):
"class_index": pred_class,
"mode": "real",
}
# ── VENDOR TRUST SCORE (Issue #45) ──────────────────────────────────────────
from vendors import router as vendors_router, register_routes
register_routes(vendors_router, _db)
app.include_router(vendors_router)
16 changes: 16 additions & 0 deletions backend/migrations/add_vendor_trust_score.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Migration: Vendor Trust Score (Issue #45)
-- Run this in your Supabase SQL Editor

ALTER TABLE vendors
ADD COLUMN IF NOT EXISTS trust_badge TEXT
CHECK (trust_badge IN ('gold', 'silver', 'bronze', 'unranked'))
DEFAULT 'unranked',
ADD COLUMN IF NOT EXISTS trend TEXT
CHECK (trend IN ('up', 'down', 'stable'))
DEFAULT 'stable',
ADD COLUMN IF NOT EXISTS total_scans INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS avg_freshness_score NUMERIC(4,2) DEFAULT 0.0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: avg_freshness_score NUMERIC(4,2) cannot store the value 100.00, which is possible when a vendor's average freshness reaches the maximum of the 0–100 percentage scale. This will cause numeric overflow errors at runtime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/migrations/add_vendor_trust_score.sql, line 12:

<comment>`avg_freshness_score NUMERIC(4,2)` cannot store the value `100.00`, which is possible when a vendor's average freshness reaches the maximum of the 0–100 percentage scale. This will cause numeric overflow errors at runtime.</comment>

<file context>
@@ -0,0 +1,16 @@
+    CHECK (trend IN ('up', 'down', 'stable'))
+    DEFAULT 'stable',
+  ADD COLUMN IF NOT EXISTS total_scans INTEGER DEFAULT 0,
+  ADD COLUMN IF NOT EXISTS avg_freshness_score NUMERIC(4,2) DEFAULT 0.0;
+
+-- Index for fast leaderboard sorting
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ADD COLUMN IF NOT EXISTS silently skips the entire column definition when a column already exists, masking missing or incorrect type, defaults, and CHECK constraints. The avg_freshness_score column already exists in master_schema.sql as INTEGER DEFAULT 0, so this migration silently fails to change it to NUMERIC(4,2) DEFAULT 0.0. Similarly, trust_badge and trend CHECK constraints and defaults would be skipped if those columns were ever added manually. This creates schema drift across environments.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/migrations/add_vendor_trust_score.sql, line 12:

<comment>`ADD COLUMN IF NOT EXISTS` silently skips the entire column definition when a column already exists, masking missing or incorrect type, defaults, and CHECK constraints. The `avg_freshness_score` column already exists in `master_schema.sql` as `INTEGER DEFAULT 0`, so this migration silently fails to change it to `NUMERIC(4,2) DEFAULT 0.0`. Similarly, `trust_badge` and `trend` CHECK constraints and defaults would be skipped if those columns were ever added manually. This creates schema drift across environments.</comment>

<file context>
@@ -0,0 +1,16 @@
+    CHECK (trend IN ('up', 'down', 'stable'))
+    DEFAULT 'stable',
+  ADD COLUMN IF NOT EXISTS total_scans INTEGER DEFAULT 0,
+  ADD COLUMN IF NOT EXISTS avg_freshness_score NUMERIC(4,2) DEFAULT 0.0;
+
+-- Index for fast leaderboard sorting
</file context>


-- Index for fast leaderboard sorting
CREATE INDEX IF NOT EXISTS idx_vendors_avg_freshness
ON vendors (avg_freshness_score DESC);
143 changes: 143 additions & 0 deletions backend/vendors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
from fastapi import APIRouter, HTTPException
from datetime import datetime, timedelta, timezone
from typing import Optional

router = APIRouter(prefix="/api/v1/vendors", tags=["vendors"])


def _compute_badge(avg_score: float, total_scans: int) -> str:
if total_scans < 5:
return "unranked"
if avg_score >= 80:
return "gold"
if avg_score >= 60:
return "silver"
if avg_score >= 40:
return "bronze"
return "unranked"


def _compute_trend(db, vendor_id: str) -> str:
now = datetime.now(timezone.utc)
week_ago = (now - timedelta(days=7)).isoformat()
two_weeks_ago = (now - timedelta(days=14)).isoformat()

recent = db.table("scans").select("freshness_index") \
.eq("vendor_id", vendor_id) \
.gte("timestamp", week_ago).execute()

prior = db.table("scans").select("freshness_index") \
.eq("vendor_id", vendor_id) \
.gte("timestamp", two_weeks_ago) \
.lt("timestamp", week_ago).execute()

def avg(rows):
vals = [r["freshness_index"] for r in rows if r.get("freshness_index")]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Trend calculation incorrectly ignores valid freshness_index = 0 values, which can skew vendor trend results.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/vendors.py, line 35:

<comment>Trend calculation incorrectly ignores valid `freshness_index = 0` values, which can skew vendor trend results.</comment>

<file context>
@@ -0,0 +1,143 @@
+        .lt("timestamp", week_ago).execute()
+
+    def avg(rows):
+        vals = [r["freshness_index"] for r in rows if r.get("freshness_index")]
+        return sum(vals) / len(vals) if vals else None
+
</file context>

return sum(vals) / len(vals) if vals else None

r_avg = avg(recent.data or [])
p_avg = avg(prior.data or [])

if r_avg is None or p_avg is None:
return "stable"
if r_avg > p_avg + 3:
return "up"
if r_avg < p_avg - 3:
return "down"
return "stable"


def register_routes(router: APIRouter, db_getter):
"""
Called from main.py to register all vendor trust routes.
db_getter is the _db function already defined in main.py.
"""

@router.get("/leaderboard")
async def get_leaderboard(limit: int = 20):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing range validation on limit query parameter in public /leaderboard endpoint

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/vendors.py, line 57:

<comment>Missing range validation on `limit` query parameter in public `/leaderboard` endpoint</comment>

<file context>
@@ -0,0 +1,143 @@
+    """
+
+    @router.get("/leaderboard")
+    async def get_leaderboard(limit: int = 20):
+        """Public leaderboard — no auth required."""
+        try:
</file context>

"""Public leaderboard — no auth required."""
try:
resp = (
db_getter().table("vendors")
.select(
"id, name, address, avg_freshness_score, "
"total_scans, trust_badge, trend"
)
.order("avg_freshness_score", desc=True)
.limit(limit)
.execute()
)
return {"success": True, "leaderboard": resp.data or []}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))

@router.get("/{vendor_id}/trust-score")
async def get_vendor_trust_score(vendor_id: str):
"""Trust score for a single vendor — no auth required."""
try:
resp = (
db_getter().table("vendors")
.select(
"id, name, address, avg_freshness_score, "
"total_scans, trust_badge, trend"
)
.eq("id", vendor_id)
.limit(1)
.execute()
)
if not resp.data:
raise HTTPException(status_code=404, detail="Vendor not found.")
vendor = resp.data[0]
trend = _compute_trend(db_getter(), vendor_id)
vendor["trend"] = trend
return {"success": True, "vendor": vendor}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))

@router.post("/{vendor_id}/recalculate")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: State-changing POST /recalculate endpoint is unauthenticated, allowing any caller to mutate vendor trust scores, badges, and trends. Other write endpoints in the app require Depends(get_current_user) but this endpoint lacks auth entirely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/vendors.py, line 99:

<comment>State-changing POST `/recalculate` endpoint is unauthenticated, allowing any caller to mutate vendor trust scores, badges, and trends. Other write endpoints in the app require `Depends(get_current_user)` but this endpoint lacks auth entirely.</comment>

<file context>
@@ -0,0 +1,143 @@
+        except Exception as exc:
+            raise HTTPException(status_code=500, detail=str(exc))
+
+    @router.post("/{vendor_id}/recalculate")
+    async def recalculate_trust_score(vendor_id: str):
+        """
</file context>

async def recalculate_trust_score(vendor_id: str):
"""
Recompute avg_freshness_score, total_scans, trust_badge, trend
from the scans table. Call this after a new scan is submitted.
"""
try:
scans = (
db_getter().table("scans")
.select("freshness_index")
.eq("vendor_id", vendor_id)
.execute()
)
rows = [r for r in (scans.data or []) if r.get("freshness_index") is not None]
if not rows:
raise HTTPException(
status_code=404,
detail="No scans found for this vendor."
)

scores = [r["freshness_index"] for r in rows]
total = len(scores)
avg = round(sum(scores) / total, 2)
badge = _compute_badge(avg, total)
trend = _compute_trend(db_getter(), vendor_id)

db_getter().table("vendors").update({
"avg_freshness_score": avg,
"total_scans": total,
"trust_badge": badge,
"trend": trend,
}).eq("id", vendor_id).execute()

return {
"success": True,
"vendor_id": vendor_id,
"avg_score": avg,
"total_scans": total,
"trust_badge": badge,
"trend": trend,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
10 changes: 6 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ import ScannerPage from './pages/ScannerPage';
import AnalysisDashboard from './pages/AnalysisDashboard';
import MarketMapPage from './pages/MarketMapPage';
import ResultsPage from './pages/ResultsPage';
import Leaderboard from './pages/Leaderboard';
import PostHogPageView from './components/PostHogPageView';
import NotFound from './pages/NotFound'; // Importing the new 404 component
import NotFound from './pages/NotFound';

export default function App() {
return (
<BrowserRouter>
{/* Toast provider for global error notifications */}
<Toaster position="bottom-right" />

{/* Fires a $pageview event to PostHog on every SPA route change */}
<PostHogPageView />

<Routes>
<Route element={<Layout />}>
<Route path="/" element={<LandingPage />} />
Expand All @@ -29,7 +30,8 @@ export default function App() {
<Route path="/analysis" element={<AnalysisDashboard />} />
<Route path="/map" element={<MarketMapPage />} />
<Route path="/results" element={<ResultsPage />} />

<Route path="/leaderboard" element={<Leaderboard />} />

{/* Catch-all route for broken links/404s */}
<Route path="*" element={<NotFound />} />
</Route>
Expand Down
120 changes: 120 additions & 0 deletions src/pages/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";

type Badge = "gold" | "silver" | "bronze" | "unranked";
type Trend = "up" | "down" | "stable";

interface Vendor {
id: string;
name: string;
address: string;
avg_freshness_score: number;
total_scans: number;
trust_badge: Badge;
trend: Trend;
}

const BADGE: Record<Badge, { emoji: string; label: string; color: string }> = {
gold: { emoji: "🥇", label: "Gold", color: "#f59e0b" },
silver: { emoji: "🥈", label: "Silver", color: "#9ca3af" },
bronze: { emoji: "🥉", label: "Bronze", color: "#f97316" },
unranked: { emoji: "⚪", label: "Unranked", color: "#d1d5db" },
};

Comment on lines +16 to +22
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NO EMOJIS: This project strictly avoids emojis to maintain a professional, academic/cyberpunk aesthetic. Please remove the 🥇🥈🥉⚪ emojis. Instead, use mono-spaced text labels like [ GOLD ] or use custom SVG icons.

const TREND: Record<Trend, { icon: string; color: string; label: string }> = {
up: { icon: "↑", color: "#22c55e", label: "Improving" },
Comment on lines +22 to +24
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded Colors: You used classes like bg-white, text-gray-800, and text-blue-600. These completely break our Dark/Light theme toggle. You must use our CSS variables. For example, replace bg-white with bg-surface-low or use the existing component. Replace text colors with text-on-surface or text-neon.

down: { icon: "↓", color: "#ef4444", label: "Declining" },
stable: { icon: "→", color: "#9ca3af", label: "Stable" },
};

export default function Leaderboard() {
const [vendors, setVendors] = useState<Vendor[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
fetch("/api/v1/vendors/leaderboard")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This fetch bypasses the configured API base URL, so leaderboard requests can fail in deployed environments where backend and frontend are on different origins.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/pages/Leaderboard.tsx, line 35:

<comment>This fetch bypasses the configured API base URL, so leaderboard requests can fail in deployed environments where backend and frontend are on different origins.</comment>

<file context>
@@ -0,0 +1,120 @@
+  const [error,   setError]     = useState<string | null>(null);
+
+  useEffect(() => {
+    fetch("/api/v1/vendors/leaderboard")
+      .then((r) => {
+        if (!r.ok) throw new Error("Failed to fetch leaderboard.");
</file context>

.then((r) => {
if (!r.ok) throw new Error("Failed to fetch leaderboard.");
return r.json();
})
.then((data) => setVendors(data.leaderboard || []))
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);

if (loading) return (
<div className="flex items-center justify-center min-h-screen text-gray-500">
Loading leaderboard...
</div>
);

if (error) return (
<div className="flex items-center justify-center min-h-screen text-red-500">
{error}
</div>
);

return (
<div className="max-w-2xl mx-auto px-4 py-10">
<h1 className="text-3xl font-bold mb-1">🐟 Vendor Trust Leaderboard</h1>
<p className="text-gray-500 mb-8 text-sm">
Rankings based on anonymous freshness scans across markets.
</p>
Comment on lines +58 to +62
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please utilize our custom fonts. Headers should use font-[family-name:var(--font-display)] and metrics/small text should use font-[family-name:var(--font-mono)] tracking-widest to match the terminal-like appearance.


{vendors.length === 0 ? (
<p className="text-gray-400 text-center py-20">No vendor data yet.</p>
) : (
<div className="space-y-3">
{vendors.map((vendor, index) => {
const badge = BADGE[vendor.trust_badge ?? "unranked"];
const trend = TREND[vendor.trend ?? "stable"];
return (
<div
key={vendor.id}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brutalist Aesthetic: Our app avoids soft, rounded UI elements. Please remove rounded-xl and shadow-sm and stick to the sharp borders (border-outline-variant/30) used in the rest of the application.

className="flex items-center gap-4 p-4 rounded-xl border border-gray-200 bg-white shadow-sm"
>
{/* Rank */}
<span className="w-7 text-center text-lg font-bold text-gray-300">
{index + 1}
</span>

{/* Badge */}
<span
className="text-2xl"
title={badge.label}
style={{ color: badge.color }}
>
{badge.emoji}
</span>

{/* Info */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-800 truncate">{vendor.name}</p>
<p className="text-xs text-gray-400 truncate">{vendor.address}</p>
</div>

{/* Score */}
<div className="text-right shrink-0">
<p className="text-lg font-bold text-blue-600">
{(vendor.avg_freshness_score ?? 0).toFixed(1)}
<span className="text-xs font-normal text-gray-400">/100</span>
</p>
<p className="text-xs text-gray-400">{vendor.total_scans ?? 0} scans</p>
</div>

{/* Trend */}
<span
className="text-xl font-bold shrink-0"
title={trend.label}
style={{ color: trend.color }}
>
{trend.icon}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
Loading