diff --git a/backend/main.py b/backend/main.py index 3383bc4..e7061ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,21 +2,17 @@ import io import uuid import random +import json from pathlib import Path from datetime import datetime, timezone from contextlib import asynccontextmanager from typing import Optional -from auth import get_current_user, get_google_oauth_url, exchange_code_for_session -from turnstile import TURNSTILE_SECRET_KEY, verify_turnstile_token from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from rate_limiter import limiter from fastapi_cache import FastAPICache from fastapi_cache.backends.inmemory import InMemoryBackend from fastapi_cache.decorator import cache -from chat_router import router as chat_router - - # Load .env file if present (python-dotenv) try: @@ -26,6 +22,16 @@ except ImportError: pass +from auth import get_current_user, get_google_oauth_url, exchange_code_for_session +from turnstile import TURNSTILE_SECRET_KEY, verify_turnstile_token +from chat_router import router as chat_router +from vendors import ( + get_vendor_achievements, + recalculate_vendor_metrics_and_achievements, + register_routes, + router as vendors_router, +) + from fastapi import Body, FastAPI, File, UploadFile, Form, HTTPException, Depends, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, JSONResponse @@ -287,6 +293,16 @@ def _row_to_payload(row: dict) -> dict: alerts = row.get("alert_flags") or [] photos = row.get("photo_urls") or [] + if isinstance(bm, str): + try: + bm = json.loads(bm) + except json.JSONDecodeError: + bm = {} + if isinstance(alerts, str): + alerts = [alerts] + if isinstance(photos, str): + photos = [photos] + # Use _build_biomarkers as a fallback when biomarker_json was not stored if not bm: bm = _build_biomarkers(freshness, freshness, freshness) @@ -320,6 +336,16 @@ def _row_to_payload(row: dict) -> dict: } +async def _refresh_vendor_after_scan(vendor_id: Optional[str]) -> None: + if not vendor_id: + return + try: + recalculate_vendor_metrics_and_achievements(_db(), vendor_id) + await FastAPICache.clear(namespace="markets") + except Exception as exc: + print(f"Vendor achievement refresh skipped for {vendor_id}: {exc}") + + async def _upload_image(image_bytes: bytes, user_id: str, scan_id: str) -> Optional[str]: try: client = supabase_service or supabase @@ -481,6 +507,7 @@ async def process_scan( "is_target_domain": is_target_domain, } ).execute() + await _refresh_vendor_after_scan(vendor_id) except Exception as exc: print(f"DB write failed (demo): {exc}") @@ -517,6 +544,7 @@ async def process_scan( "is_target_domain": is_target_domain, } ).execute() + await _refresh_vendor_after_scan(vendor_id) except Exception as exc: print(f"DB write failed: {exc}") @@ -751,7 +779,11 @@ async def get_vendors(): "trust_score, total_scans, avg_freshness_score, vendor_count" ) resp = _db().table("vendors").select(fields).execute() - return {"success": True, "vendors": resp.data} + rows = resp.data or [] + achievements = get_vendor_achievements(_db(), [str(v["id"]) for v in rows]) + for vendor in rows: + vendor["achievements"] = achievements.get(str(vendor["id"]), []) + return {"success": True, "vendors": rows} except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) @@ -762,12 +794,16 @@ async def get_vendor_leaderboard(): resp = ( _db() .table("vendors") - .select("id, name, trust_score, total_scans, avg_freshness_score, lat, lng") + .select("id, name, address, trust_score, total_scans, avg_freshness_score, trust_badge, trend, lat, lng") .order("trust_score", desc=True) .limit(10) .execute() ) - return {"success": True, "leaderboard": resp.data} + rows = resp.data or [] + achievements = get_vendor_achievements(_db(), [str(v["id"]) for v in rows]) + for vendor in rows: + vendor["achievements"] = achievements.get(str(vendor["id"]), []) + return {"success": True, "leaderboard": rows} except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) @@ -783,6 +819,8 @@ async def _get_markets_cached() -> dict: .select("id, name, avg_freshness_score, trust_score, lat, lng, vendor_count") .execute() ) + rows = resp.data or [] + achievements = get_vendor_achievements(_db(), [str(v["id"]) for v in rows]) markets = [ { "id": i + 1, @@ -791,8 +829,9 @@ async def _get_markets_cached() -> dict: "lat": float(v.get("lat") or 0), "lng": float(v.get("lng") or 0), "vendors": int(v.get("vendor_count") or 1), + "achievements": achievements.get(str(v["id"]), []), } - for i, v in enumerate(resp.data or []) + for i, v in enumerate(rows) if v.get("lat") and v.get("lng") ] return {"success": True, "markets": markets} @@ -996,9 +1035,6 @@ def _bwd_hook(_module, _grad_in, grad_out): } -# -- VENDOR TRUST SCORE (Issue #45) ----------------------------------------- -from vendors import router as vendors_router, register_routes - register_routes(vendors_router, _db) from markets import router as markets_router app.include_router(markets_router) diff --git a/backend/supabase/migrations/20240702000000_add_vendor_achievements.sql b/backend/supabase/migrations/20240702000000_add_vendor_achievements.sql new file mode 100644 index 0000000..1cb2a9f --- /dev/null +++ b/backend/supabase/migrations/20240702000000_add_vendor_achievements.sql @@ -0,0 +1,26 @@ +-- Vendor achievements awarded from scan history. + +CREATE TABLE IF NOT EXISTS public.vendor_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vendor_id UUID NOT NULL REFERENCES public.vendors(id) ON DELETE CASCADE, + code TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + icon TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'neon', + awarded_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc', now()) NOT NULL, + metadata JSONB DEFAULT '{}', + UNIQUE (vendor_id, code) +); + +ALTER TABLE public.vendor_achievements ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Anyone can view vendor achievements" ON public.vendor_achievements; +CREATE POLICY "Anyone can view vendor achievements" + ON public.vendor_achievements FOR SELECT USING (true); + +CREATE INDEX IF NOT EXISTS vendor_achievements_vendor_id_idx + ON public.vendor_achievements (vendor_id); + +CREATE INDEX IF NOT EXISTS vendor_achievements_code_idx + ON public.vendor_achievements (code); diff --git a/backend/supabase/migrations/20240702001000_grant_app_table_permissions.sql b/backend/supabase/migrations/20240702001000_grant_app_table_permissions.sql new file mode 100644 index 0000000..0685bdb --- /dev/null +++ b/backend/supabase/migrations/20240702001000_grant_app_table_permissions.sql @@ -0,0 +1,13 @@ +-- Grant table privileges required by local Supabase API roles. +-- RLS policies still enforce row-level access; these grants only allow the +-- roles to access the tables through PostgREST/Supabase clients. + +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; + +GRANT SELECT ON TABLE public.vendors TO anon, authenticated, service_role; +GRANT UPDATE ON TABLE public.vendors TO service_role; + +GRANT SELECT, INSERT ON TABLE public.scans TO authenticated, service_role; + +GRANT SELECT ON TABLE public.vendor_achievements TO anon, authenticated, service_role; +GRANT INSERT, UPDATE ON TABLE public.vendor_achievements TO service_role; diff --git a/backend/supabase/snippets/Untitled query 230.sql b/backend/supabase/snippets/Untitled query 230.sql new file mode 100644 index 0000000..f021164 --- /dev/null +++ b/backend/supabase/snippets/Untitled query 230.sql @@ -0,0 +1,45 @@ +insert into public.vendor_achievements + (vendor_id, code, title, description, icon, tier) +select + id, + badge.code, + badge.title, + badge.description, + badge.icon, + badge.tier +from public.vendors +cross join ( + values + ( + 'fresh_streak_50', + '50x Grade A Streak', + 'Awarded for 50 consecutive Grade A scans.', + 'bolt', + 'legendary' + ), + ( + 'consistent_30d', + '30 Days Consistently Fresh', + 'Awarded after Grade A scans across 30 clean scan days.', + 'calendar', + 'neon' + ), + ( + 'trusted_volume_100', + '100 Verified Scans', + 'Awarded after 100 recorded freshness scans.', + 'shield', + 'trusted' + ), + ( + 'top_1_percent', + 'Top 1% Freshness', + 'Awarded to the highest freshness performers on the vendor leaderboard.', + 'crown', + 'elite' + ) +) as badge(code, title, description, icon, tier) +where public.vendors.id = ( + select id from public.vendors order by avg_freshness_score desc limit 1 +) +on conflict (vendor_id, code) do nothing; \ No newline at end of file diff --git a/backend/vendors.py b/backend/vendors.py index 4e506ec..934ccb3 100644 --- a/backend/vendors.py +++ b/backend/vendors.py @@ -5,6 +5,33 @@ router = APIRouter(prefix="/api/v1/vendors", tags=["vendors"]) +ACHIEVEMENTS = { + "fresh_streak_50": { + "title": "50x Grade A Streak", + "description": "Awarded for 50 consecutive Grade A scans.", + "icon": "bolt", + "tier": "legendary", + }, + "consistent_30d": { + "title": "30 Days Consistently Fresh", + "description": "Awarded after Grade A scans across 30 clean scan days.", + "icon": "calendar", + "tier": "neon", + }, + "top_1_percent": { + "title": "Top 1% Freshness", + "description": "Awarded to the highest freshness performers on the vendor leaderboard.", + "icon": "crown", + "tier": "elite", + }, + "trusted_volume_100": { + "title": "100 Verified Scans", + "description": "Awarded after 100 recorded freshness scans.", + "icon": "shield", + "tier": "trusted", + }, +} + def _compute_badge(avg_score: float, total_scans: int) -> str: if total_scans < 5: @@ -57,6 +84,142 @@ def avg(rows): return "stable" +def _grade_a(row: dict) -> bool: + return row.get("final_grade") == "A" or (row.get("freshness_index") or 0) >= 75 + + +def _longest_recent_grade_a_streak(rows: list[dict]) -> int: + streak = 0 + for row in rows: + if _grade_a(row): + streak += 1 + else: + break + return streak + + +def _clean_grade_a_days(rows: list[dict]) -> int: + days: dict[str, bool] = {} + for row in rows: + ts = row.get("timestamp") or "" + day = ts[:10] + if not day: + continue + days[day] = days.get(day, True) and _grade_a(row) + return sum(1 for clean in days.values() if clean) + + +def _top_percent_vendor_ids(db) -> set[str]: + vendors = ( + db.table("vendors") + .select("id, avg_freshness_score, total_scans") + .order("avg_freshness_score", desc=True) + .execute() + ) + rows = [v for v in (vendors.data or []) if (v.get("total_scans") or 0) >= 5] + if not rows: + return set() + cutoff = max(1, int(len(rows) * 0.01)) + return {str(v["id"]) for v in rows[:cutoff]} + + +def _upsert_vendor_achievement(db, vendor_id: str, code: str, metadata: dict | None = None): + achievement = ACHIEVEMENTS[code] + db.table("vendor_achievements").upsert( + { + "vendor_id": vendor_id, + "code": code, + "title": achievement["title"], + "description": achievement["description"], + "icon": achievement["icon"], + "tier": achievement["tier"], + "metadata": metadata or {}, + }, + on_conflict="vendor_id,code", + ).execute() + + +def recalculate_vendor_metrics_and_achievements(db, vendor_id: str) -> dict: + """Update vendor trust metrics and award achievements from scan history.""" + scans = ( + db.table("scans") + .select("freshness_index, final_grade, timestamp") + .eq("vendor_id", vendor_id) + .order("timestamp", desc=True) + .execute() + ) + rows = [r for r in (scans.data or []) if r.get("freshness_index") is not None] + if not rows: + return {"total_scans": 0, "achievements": []} + + 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, vendor_id) + + db.table("vendors").update( + { + "avg_freshness_score": avg, + "trust_score": avg, + "total_scans": total, + "trust_badge": badge, + "trend": trend, + } + ).eq("id", vendor_id).execute() + + awarded = [] + recent_streak = _longest_recent_grade_a_streak(rows) + clean_days = _clean_grade_a_days(rows) + + candidates = [] + if recent_streak >= 50: + candidates.append(("fresh_streak_50", {"streak": recent_streak})) + if clean_days >= 30: + candidates.append(("consistent_30d", {"clean_days": clean_days})) + if total >= 100: + candidates.append(("trusted_volume_100", {"total_scans": total})) + if vendor_id in _top_percent_vendor_ids(db): + candidates.append(("top_1_percent", {"avg_freshness_score": avg})) + + for code, metadata in candidates: + try: + _upsert_vendor_achievement(db, vendor_id, code, metadata) + awarded.append(code) + except Exception as exc: + print(f"Achievement award failed for {vendor_id}/{code}: {exc}") + + return { + "avg_score": avg, + "total_scans": total, + "trust_badge": badge, + "trend": trend, + "achievements": awarded, + } + + +def get_vendor_achievements(db, vendor_ids: list[str]) -> dict[str, list[dict]]: + if not vendor_ids: + return {} + try: + resp = ( + db.table("vendor_achievements") + .select("vendor_id, code, title, description, icon, tier, awarded_at") + .in_("vendor_id", vendor_ids) + .order("awarded_at", desc=True) + .execute() + ) + except Exception as exc: + print(f"Achievement lookup skipped: {exc}") + return {} + + grouped: dict[str, list[dict]] = {vendor_id: [] for vendor_id in vendor_ids} + for row in resp.data or []: + vendor_id = str(row.pop("vendor_id")) + grouped.setdefault(vendor_id, []).append(row) + return grouped + + def register_routes(router: APIRouter, db_getter): @router.get("/leaderboard") async def get_leaderboard(limit: int = Query(default=20, ge=1, le=100)): @@ -70,7 +233,11 @@ async def get_leaderboard(limit: int = Query(default=20, ge=1, le=100)): .limit(limit) .execute() ) - return {"success": True, "leaderboard": resp.data or []} + rows = resp.data or [] + achievements = get_vendor_achievements(db_getter(), [str(v["id"]) for v in rows]) + for vendor in rows: + vendor["achievements"] = achievements.get(str(vendor["id"]), []) + return {"success": True, "leaderboard": rows} except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) @@ -90,6 +257,7 @@ async def get_vendor_trust_score(vendor_id: str): raise HTTPException(status_code=404, detail="Vendor not found.") vendor = resp.data[0] vendor["trend"] = _compute_trend(db_getter(), vendor_id) + vendor["achievements"] = get_vendor_achievements(db_getter(), [vendor_id]).get(vendor_id, []) return {"success": True, "vendor": vendor} except HTTPException: raise @@ -103,41 +271,16 @@ async def recalculate_trust_score( ): """Recompute trust score from scans. Requires authentication.""" 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: + result = recalculate_vendor_metrics_and_achievements(db_getter(), vendor_id) + if not result.get("total_scans"): 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() - await FastAPICache.clear(namespace="markets") return { "success": True, "vendor_id": vendor_id, - "avg_score": avg, - "total_scans": total, - "trust_badge": badge, - "trend": trend, + **result, } except HTTPException: raise diff --git a/src/lib/types.ts b/src/lib/types.ts index 3ce9cd6..2701602 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -63,6 +63,16 @@ export interface Market { lat: number; lng: number; vendors: number; + achievements?: VendorAchievement[]; +} + +export interface VendorAchievement { + code: string; + title: string; + description: string; + icon: string; + tier: string; + awarded_at?: string; } export interface UserProfile { diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx index 0f88a3e..8db035f 100644 --- a/src/pages/Leaderboard.tsx +++ b/src/pages/Leaderboard.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { useTranslation } from 'react-i18next'; +import { CalendarCheck, Crown, ShieldCheck, Zap } from "lucide-react"; import Skeleton from "../components/Skeleton"; +import type { VendorAchievement } from "../lib/types"; const API_BASE = import.meta.env.VITE_API_URL ?? ""; @@ -15,6 +17,7 @@ interface Vendor { total_scans: number; trust_badge: Badge; trend: Trend; + achievements?: VendorAchievement[]; } const BADGE: Record = { @@ -41,6 +44,36 @@ const TREND: Record = { }, }; +const ACHIEVEMENT_ICON = { + bolt: Zap, + calendar: CalendarCheck, + crown: Crown, + shield: ShieldCheck, +}; + +function AchievementBadges({ achievements = [] }: { achievements?: VendorAchievement[] }) { + if (!achievements.length) return null; + + return ( +
+ {achievements.slice(0, 4).map((achievement) => { + const Icon = + ACHIEVEMENT_ICON[achievement.icon as keyof typeof ACHIEVEMENT_ICON] ?? ShieldCheck; + return ( + + + + ); + })} +
+ ); +} + export default function Leaderboard() { const { t } = useTranslation(); const [vendors, setVendors] = useState([]); @@ -146,6 +179,7 @@ export default function Leaderboard() {

{vendor.address}

+
diff --git a/src/pages/MarketMapPage.tsx b/src/pages/MarketMapPage.tsx index d68363d..3a61e8f 100644 --- a/src/pages/MarketMapPage.tsx +++ b/src/pages/MarketMapPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; +import { CalendarCheck, Crown, ShieldCheck, Zap } from "lucide-react"; import L from "leaflet"; import "leaflet/dist/leaflet.css"; import GlassCard from "../components/GlassCard"; @@ -8,7 +9,7 @@ import StatusTerminal from "../components/StatusTerminal"; import Skeleton from "../components/Skeleton"; import { useTranslation } from 'react-i18next'; import { api } from "../lib/api"; -import type { Market } from "../lib/types"; +import type { Market, VendorAchievement } from "../lib/types"; const TILE_DARK = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; @@ -33,6 +34,36 @@ function getScoreBg(score: number) { return score >= 85 ? "bg-secondary" : score >= 70 ? "bg-neon" : "bg-error"; } +const ACHIEVEMENT_ICON = { + bolt: Zap, + calendar: CalendarCheck, + crown: Crown, + shield: ShieldCheck, +}; + +function AchievementBadges({ achievements = [] }: { achievements?: VendorAchievement[] }) { + if (!achievements.length) return null; + + return ( +
+ {achievements.slice(0, 4).map((achievement) => { + const Icon = + ACHIEVEMENT_ICON[achievement.icon as keyof typeof ACHIEVEMENT_ICON] ?? ShieldCheck; + return ( + + + + ); + })} +
+ ); +} + const createCustomIcon = (score: number) => { const hex = score >= 85 ? "#b5d25e" : score >= 70 ? "#c3f400" : "#ffb4ab"; return L.divIcon({ @@ -179,6 +210,7 @@ export default function MarketMapPage() { > {t('marketMap.scorePrefix')}{m.score} | {t('marketMap.vendorsPrefix')}{m.vendors}
+ @@ -222,6 +254,7 @@ export default function MarketMapPage() { {selected.vendors} {t('marketMap.vendorsLabel')} +