diff --git a/lib/actions.ts b/lib/actions.ts index 8d5a3829..1e1b0985 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -5,15 +5,28 @@ import { getAgentMetadata as _getAgentMetadata, type AgentMetadata, } from "./contracts/erc8004"; +import { createServiceRoleClient, type User } from "./supabase"; import type { Address } from "viem"; /** * Server action that resolves an Ethereum address to a Farcaster profile. - * Callable from client components without exposing the API key. + * Prefers cached DB data, falls back to live API. */ export async function getFarcasterProfile( address: string, ): Promise { + // Try DB first + const dbUser = await getUserFromDB(address); + if (dbUser) { + return { + fid: dbUser.fid, + username: dbUser.username ?? "", + displayName: dbUser.display_name ?? dbUser.username ?? "", + pfpUrl: dbUser.pfp_url ?? null, + bio: dbUser.bio ?? null, + }; + } + // Fallback to live API return lookupByAddress(address); } @@ -25,3 +38,23 @@ export async function fetchAgentMetadata( ): Promise { return _getAgentMetadata(address as Address); } + +/** + * Look up a user from the DB by wallet address. + */ +export async function getUserFromDB( + address: string, +): Promise { + const supabase = createServiceRoleClient(); + if (!supabase) return null; + + const normalized = address.toLowerCase(); + + const { data } = await supabase + .from("users") + .select("*") + .contains("verified_addresses", [normalized]) + .single(); + + return data ?? null; +} diff --git a/lib/farcaster-indexer.ts b/lib/farcaster-indexer.ts new file mode 100644 index 00000000..b72db80e --- /dev/null +++ b/lib/farcaster-indexer.ts @@ -0,0 +1,241 @@ +/** + * [#563] Enhanced Steemhunt Farcaster Indexer Client + * + * Primary data source for user profiles (FREE, no API key required). + * Based on ~/Projects/dropcast/lib/farcaster-indexer.ts with: + * - Circuit breaker (5 failures → open 1 min) + * - Retry with exponential backoff (3 attempts, 3s timeout each) + * - 1-hour in-memory cache with 5-min DB cooldown + * - In-flight deduplication + * + * Data source: https://fc.hunt.town + */ + +// ============================================================================ +// Types +// ============================================================================ + +export interface SteemhuntUser { + fid: number; + username: string; + displayName: string; + pfpUrl: string; + addresses: string[]; + primaryAddress: string; + proSubscribed: boolean; + bio: string | null; + url: string | null; + location: string | null; + twitter: string | null; + github: string | null; + followersCount: number; + followingCount: number; + spamLabel: number; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// Configuration +// ============================================================================ + +const STEEMHUNT_BASE_URL = "https://fc.hunt.town"; +const CACHE_TTL_MS = 3_600_000; // 1 hour +const RETRY_CONFIG = { + maxRetries: 2, + initialDelayMs: 500, + maxDelayMs: 2000, + perAttemptTimeoutMs: 3000, +}; +const CIRCUIT_BREAKER = { + failureThreshold: 5, + resetTimeoutMs: 60_000, +}; + +// Circuit breaker state +let circuitState: "closed" | "open" | "half_open" = "closed"; +let consecutiveFailures = 0; +let circuitOpenedAt = 0; +let halfOpenProbeInFlight = false; + +// Cache and deduplication +const cache = new Map< + string, + { user: SteemhuntUser | null; expiresAt: number } +>(); +const inFlightWallet = new Map>(); +const inFlightFid = new Map>(); + +// ============================================================================ +// Circuit Breaker +// ============================================================================ + +function isCircuitOpen(): boolean { + if (circuitState === "closed") return false; + if (circuitState === "half_open") return true; + + if ( + circuitState === "open" && + Date.now() - circuitOpenedAt >= CIRCUIT_BREAKER.resetTimeoutMs + ) { + if (!halfOpenProbeInFlight) { + halfOpenProbeInFlight = true; + circuitState = "half_open"; + return false; + } + return true; + } + return true; +} + +function recordSuccess(): void { + consecutiveFailures = 0; + circuitState = "closed"; + halfOpenProbeInFlight = false; +} + +function recordFailure(): void { + if (circuitState === "half_open") { + circuitState = "open"; + circuitOpenedAt = Date.now(); + halfOpenProbeInFlight = false; + return; + } + consecutiveFailures++; + if ( + consecutiveFailures >= CIRCUIT_BREAKER.failureThreshold && + circuitState === "closed" + ) { + circuitState = "open"; + circuitOpenedAt = Date.now(); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ============================================================================ +// Fetch with retry +// ============================================================================ + +async function fetchWithRetry( + url: string, +): Promise { + if (isCircuitOpen()) return null; + + let lastStatus: number | null = null; + + for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) { + try { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(RETRY_CONFIG.perAttemptTimeoutMs), + }); + + if (res.ok) { + const data: SteemhuntUser = await res.json(); + recordSuccess(); + return data; + } + + if (res.status === 404) { + recordSuccess(); + return null; + } + + // Retryable status codes + if (res.status === 429 || res.status >= 500) { + lastStatus = res.status; + if (attempt < RETRY_CONFIG.maxRetries) { + const delay = Math.min( + RETRY_CONFIG.initialDelayMs * Math.pow(2, attempt), + RETRY_CONFIG.maxDelayMs, + ); + await sleep(delay); + } + continue; + } + + // Non-retryable client error + recordSuccess(); + return null; + } catch { + if (attempt < RETRY_CONFIG.maxRetries) { + const delay = Math.min( + RETRY_CONFIG.initialDelayMs * Math.pow(2, attempt), + RETRY_CONFIG.maxDelayMs, + ); + await sleep(delay); + } + } + } + + recordFailure(); + if (lastStatus) { + console.error(`[Steemhunt] Fetch failed: ${lastStatus} (retries exhausted)`); + } + return null; +} + +// ============================================================================ +// Public API +// ============================================================================ + +export async function getUserByWallet( + address: string, +): Promise { + const key = address.toLowerCase(); + + const cached = cache.get(`wallet:${key}`); + if (cached && cached.expiresAt > Date.now()) return cached.user; + + const existing = inFlightWallet.get(key); + if (existing) return existing; + + const promise = (async () => { + try { + const user = await fetchWithRetry( + `${STEEMHUNT_BASE_URL}/users/byWallet/${key}`, + ); + cache.set(`wallet:${key}`, { + user, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + return user; + } finally { + inFlightWallet.delete(key); + } + })(); + + inFlightWallet.set(key, promise); + return promise; +} + +export async function getUserByFid( + fid: number, +): Promise { + const cached = cache.get(`fid:${fid}`); + if (cached && cached.expiresAt > Date.now()) return cached.user; + + const existing = inFlightFid.get(fid); + if (existing) return existing; + + const promise = (async () => { + try { + const user = await fetchWithRetry( + `${STEEMHUNT_BASE_URL}/users/byFid/${fid}`, + ); + cache.set(`fid:${fid}`, { + user, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + return user; + } finally { + inFlightFid.delete(fid); + } + })(); + + inFlightFid.set(fid, promise); + return promise; +} diff --git a/lib/quotient.ts b/lib/quotient.ts new file mode 100644 index 00000000..9166d251 --- /dev/null +++ b/lib/quotient.ts @@ -0,0 +1,52 @@ +/** + * [#563] Quotient API Client + * + * Fetches Quotient Score (engagement/reputation metric) for Farcaster users. + * Based on ~/Projects/dropcast/lib/quotient.ts + * + * Reference: https://docs.quotient.social/reputation/context + */ + +export interface QuotientUserData { + fid: number; + username: string; + quotientScore: number; + quotientRank: number; + contextLabels: string[]; +} + +const QUOTIENT_API_URL = "https://api.quotient.social"; + +export const QUOTIENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export async function fetchQuotientScore( + fid: number, +): Promise { + const apiKey = process.env.QUOTIENT_API_KEY; + if (!apiKey) return null; + + try { + const res = await fetch(`${QUOTIENT_API_URL}/v1/user-reputation`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ fids: [fid], api_key: apiKey }), + signal: AbortSignal.timeout(8000), + }); + + if (!res.ok) return null; + + const json = await res.json(); + const data = json.data; + return Array.isArray(data) && data.length > 0 ? data[0] : null; + } catch { + return null; + } +} + +export function isQuotientStale(updatedAt: string | null): boolean { + if (!updatedAt) return true; + return Date.now() - new Date(updatedAt).getTime() > QUOTIENT_TTL_MS; +} diff --git a/lib/supabase.ts b/lib/supabase.ts index 8a7e7d62..1f6635b6 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -391,6 +391,114 @@ export interface Database { }; Relationships: []; }; + users: { + Row: { + id: string; + fid: number; + username: string | null; + display_name: string | null; + pfp_url: string | null; + custody_address: string | null; + verified_addresses: string[] | null; + primary_address: string | null; + bio: string | null; + url: string | null; + location: string | null; + twitter: string | null; + github: string | null; + follower_count: number; + following_count: number; + power_badge: boolean | null; + is_pro_subscriber: boolean | null; + neynar_score: number | null; + spam_label: number | null; + fc_created_at: string | null; + x_followers_count: number | null; + x_following_count: number | null; + x_verified: boolean | null; + x_display_name: string | null; + x_stats_fetched_at: string | null; + quotient_score: number | null; + quotient_rank: number | null; + quotient_labels: Json | null; + quotient_updated_at: string | null; + stats_fetched_at: string | null; + steemhunt_fetched_at: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id?: never; + fid: number; + username?: string | null; + display_name?: string | null; + pfp_url?: string | null; + custody_address?: string | null; + verified_addresses?: string[] | null; + primary_address?: string | null; + bio?: string | null; + url?: string | null; + location?: string | null; + twitter?: string | null; + github?: string | null; + follower_count?: number; + following_count?: number; + power_badge?: boolean | null; + is_pro_subscriber?: boolean | null; + neynar_score?: number | null; + spam_label?: number | null; + fc_created_at?: string | null; + x_followers_count?: number | null; + x_following_count?: number | null; + x_verified?: boolean | null; + x_display_name?: string | null; + x_stats_fetched_at?: string | null; + quotient_score?: number | null; + quotient_rank?: number | null; + quotient_labels?: Json | null; + quotient_updated_at?: string | null; + stats_fetched_at?: string | null; + steemhunt_fetched_at?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: never; + fid?: number; + username?: string | null; + display_name?: string | null; + pfp_url?: string | null; + custody_address?: string | null; + verified_addresses?: string[] | null; + primary_address?: string | null; + bio?: string | null; + url?: string | null; + location?: string | null; + twitter?: string | null; + github?: string | null; + follower_count?: number; + following_count?: number; + power_badge?: boolean | null; + is_pro_subscriber?: boolean | null; + neynar_score?: number | null; + spam_label?: number | null; + fc_created_at?: string | null; + x_followers_count?: number | null; + x_following_count?: number | null; + x_verified?: boolean | null; + x_display_name?: string | null; + x_stats_fetched_at?: string | null; + quotient_score?: number | null; + quotient_rank?: number | null; + quotient_labels?: Json | null; + quotient_updated_at?: string | null; + stats_fetched_at?: string | null; + steemhunt_fetched_at?: string | null; + created_at?: string; + updated_at?: string; + }; + Relationships: []; + }; }; Views: { [_ in never]: never; @@ -417,3 +525,4 @@ export type Donation = Database["public"]["Tables"]["donations"]["Row"]; export type Rating = Database["public"]["Tables"]["ratings"]["Row"]; export type Comment = Database["public"]["Tables"]["comments"]["Row"]; export type TradeHistory = Database["public"]["Tables"]["trade_history"]["Row"]; +export type User = Database["public"]["Tables"]["users"]["Row"]; diff --git a/lib/user-data.ts b/lib/user-data.ts new file mode 100644 index 00000000..e019923c --- /dev/null +++ b/lib/user-data.ts @@ -0,0 +1,80 @@ +/** + * [#563] Shared helper for building user data objects from SteemHunt/Neynar. + * Also fetches X stats when a twitter handle is available. + */ + +import type { SteemhuntUser } from "./farcaster-indexer"; +import type { FarcasterProfile } from "./farcaster"; +import type { QuotientUserData } from "./quotient"; +import { fetchXStats } from "./x-stats"; +import type { Database } from "./supabase"; + +type UserInsert = Database["public"]["Tables"]["users"]["Insert"]; + +export async function buildUserData(opts: { + steemhuntUser: SteemhuntUser | null; + neynarProfile: FarcasterProfile | null; + verifiedAddresses: string[]; + quotientData: QuotientUserData | null; +}): Promise { + const { steemhuntUser, neynarProfile, verifiedAddresses, quotientData } = + opts; + const now = new Date().toISOString(); + + const base: Partial = { + verified_addresses: verifiedAddresses, + steemhunt_fetched_at: now, + quotient_score: quotientData?.quotientScore ?? null, + quotient_rank: quotientData?.quotientRank ?? null, + quotient_labels: quotientData?.contextLabels ?? null, + quotient_updated_at: quotientData ? now : null, + }; + + // Fetch X stats if twitter handle available (non-blocking on failure) + const twitterHandle = steemhuntUser?.twitter ?? null; + if (twitterHandle) { + try { + const xStats = await fetchXStats(twitterHandle); + if (xStats) { + base.x_followers_count = xStats.followers; + base.x_following_count = xStats.following; + base.x_verified = xStats.isVerified; + base.x_display_name = xStats.displayName; + base.x_stats_fetched_at = now; + } + } catch { + // Non-fatal — X stats are optional + } + } + + if (steemhuntUser) { + return { + ...base, + fid: steemhuntUser.fid, + username: steemhuntUser.username, + display_name: steemhuntUser.displayName, + pfp_url: steemhuntUser.pfpUrl, + primary_address: + steemhuntUser.primaryAddress?.toLowerCase() || null, + bio: steemhuntUser.bio, + url: steemhuntUser.url, + location: steemhuntUser.location, + twitter: steemhuntUser.twitter, + github: steemhuntUser.github, + follower_count: steemhuntUser.followersCount || 0, + following_count: steemhuntUser.followingCount || 0, + is_pro_subscriber: steemhuntUser.proSubscribed ?? false, + spam_label: steemhuntUser.spamLabel, + fc_created_at: steemhuntUser.createdAt || null, + } as UserInsert; + } + + return { + ...base, + fid: neynarProfile!.fid, + username: neynarProfile!.username, + display_name: neynarProfile!.displayName, + pfp_url: neynarProfile!.pfpUrl, + bio: neynarProfile!.bio, + } as UserInsert; +} diff --git a/lib/x-stats.ts b/lib/x-stats.ts new file mode 100644 index 00000000..e99b3d62 --- /dev/null +++ b/lib/x-stats.ts @@ -0,0 +1,70 @@ +/** + * [#563] X (Twitter) profile stats fetcher via twitterapi.io + * Based on ~/Projects/dropcast/lib/twitterapi.ts + */ + +export interface XStats { + followers: number; + following: number; + isVerified: boolean; + displayName: string; +} + +function toNum(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = Number(v.trim()); + return Number.isFinite(n) ? n : null; + } + return null; +} + +export async function fetchXStats( + username: string, +): Promise { + const apiKey = process.env.TWITTERAPI_IO_KEY; + if (!apiKey) return null; + + const normalized = username.trim().replace(/^@/, "").toLowerCase(); + if (!normalized) return null; + + try { + const res = await fetch( + `https://api.twitterapi.io/twitter/user/info?userName=${encodeURIComponent(normalized)}`, + { + headers: { "X-API-Key": apiKey, Accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }, + ); + + if (!res.ok) return null; + + const json = await res.json(); + const data = json.data; + if (!data) return null; + + const followers = + toNum(data.followers) ?? + toNum(data.followersCount) ?? + toNum(data.followers_count); + const following = + toNum(data.following) ?? + toNum(data.followingCount) ?? + toNum(data.friends_count); + + if (followers === null || following === null) return null; + + return { + followers, + following, + isVerified: + !!data.isBlueVerified || + !!data.is_blue_verified || + !!data.verified, + displayName: + data.name || data.displayName || data.display_name || "", + }; + } catch { + return null; + } +} diff --git a/src/app/api/user/onboard/route.ts b/src/app/api/user/onboard/route.ts new file mode 100644 index 00000000..c1609c0a --- /dev/null +++ b/src/app/api/user/onboard/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; +import { getUserByWallet } from "../../../../../lib/farcaster-indexer"; +import { lookupByAddress } from "../../../../../lib/farcaster"; +import { fetchQuotientScore, isQuotientStale } from "../../../../../lib/quotient"; +import { buildUserData } from "../../../../../lib/user-data"; + +const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes + +/** + * POST /api/user/onboard + * Manual profile refresh. Enforces 5-min cooldown on ALL refreshes. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { walletAddress } = body; + + if (!walletAddress || typeof walletAddress !== "string") { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 }, + ); + } + + const normalizedAddress = walletAddress.toLowerCase(); + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json( + { error: "Database not configured" }, + { status: 500 }, + ); + } + + // Check existing user and cooldown + const { data: existingUser } = await supabase + .from("users") + .select("*") + .contains("verified_addresses", [normalizedAddress]) + .single(); + + // Enforce 5-min cooldown on ALL refreshes + if (existingUser?.steemhunt_fetched_at) { + const age = + Date.now() - + new Date(existingUser.steemhunt_fetched_at).getTime(); + if (age < COOLDOWN_MS) { + const remainingMs = COOLDOWN_MS - age; + return NextResponse.json( + { + error: "Profile refresh on cooldown", + cooldownRemainingMs: remainingMs, + cooldownRemainingSeconds: Math.ceil(remainingMs / 1000), + }, + { status: 429 }, + ); + } + } + + // Fetch fresh data from SteemHunt + const steemhuntUser = await getUserByWallet(normalizedAddress); + let neynarProfile = null; + if (!steemhuntUser) { + neynarProfile = await lookupByAddress(normalizedAddress); + } + + if (!steemhuntUser && !neynarProfile) { + return NextResponse.json( + { error: "No Farcaster account found for this wallet." }, + { status: 404 }, + ); + } + + const fid = steemhuntUser?.fid ?? neynarProfile?.fid; + + // Build verified addresses + let verifiedAddresses: string[]; + if (steemhuntUser) { + verifiedAddresses = (steemhuntUser.addresses || []).map( + (a: string) => a.toLowerCase(), + ); + } else { + verifiedAddresses = [normalizedAddress]; + } + if (!verifiedAddresses.includes(normalizedAddress)) { + verifiedAddresses.push(normalizedAddress); + } + + // Refresh Quotient Score if stale + let quotientData = null; + if ( + fid && + isQuotientStale(existingUser?.quotient_updated_at ?? null) + ) { + try { + quotientData = await fetchQuotientScore(fid); + } catch { + // Non-fatal + } + } + + const userData = await buildUserData({ + steemhuntUser, + neynarProfile, + verifiedAddresses, + quotientData, + }); + + // Upsert + if (existingUser) { + const { data, error } = await supabase + .from("users") + .update(userData) + .eq("fid", userData.fid) + .select() + .single(); + + if (error) { + console.error("[onboard] Update error:", error); + return NextResponse.json( + { error: "Failed to update user data" }, + { status: 500 }, + ); + } + return NextResponse.json({ success: true, user: data }); + } else { + const { data, error } = await supabase + .from("users") + .insert(userData) + .select() + .single(); + + if (error) { + console.error("[onboard] Insert error:", error); + return NextResponse.json( + { error: "Failed to save user data" }, + { status: 500 }, + ); + } + return NextResponse.json({ success: true, user: data }); + } + } catch (error) { + console.error("[onboard] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/user/register-by-wallet/route.ts b/src/app/api/user/register-by-wallet/route.ts new file mode 100644 index 00000000..a881576e --- /dev/null +++ b/src/app/api/user/register-by-wallet/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; +import { getUserByWallet } from "../../../../../lib/farcaster-indexer"; +import { lookupByAddress } from "../../../../../lib/farcaster"; +import { fetchQuotientScore } from "../../../../../lib/quotient"; +import { buildUserData } from "../../../../../lib/user-data"; + +/** + * POST /api/user/register-by-wallet + * Called on wallet connect — upserts all Farcaster profile fields. + * SteemHunt primary (free), Neynar fallback (paid). + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { walletAddress } = body; + + if (!walletAddress || typeof walletAddress !== "string") { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 }, + ); + } + + const normalizedAddress = walletAddress.toLowerCase(); + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json( + { error: "Database not configured" }, + { status: 500 }, + ); + } + + // Check if user exists and data is fresh (< 5 min) + const { data: existingUser } = await supabase + .from("users") + .select("*") + .contains("verified_addresses", [normalizedAddress]) + .single(); + + if (existingUser?.steemhunt_fetched_at) { + const age = + Date.now() - new Date(existingUser.steemhunt_fetched_at).getTime(); + if (age < 5 * 60 * 1000) { + return NextResponse.json({ success: true, user: existingUser }); + } + } + + // SteemHunt lookup (primary, free) + const steemhuntUser = await getUserByWallet(normalizedAddress); + + // Neynar fallback + let neynarProfile = null; + if (!steemhuntUser) { + neynarProfile = await lookupByAddress(normalizedAddress); + } + + if (!steemhuntUser && !neynarProfile) { + return NextResponse.json( + { + error: + "No Farcaster account found for this wallet. Please use a wallet linked to your Farcaster account.", + }, + { status: 404 }, + ); + } + + // Build verified addresses + let verifiedAddresses: string[]; + if (steemhuntUser) { + verifiedAddresses = (steemhuntUser.addresses || []).map( + (a: string) => a.toLowerCase(), + ); + } else { + verifiedAddresses = [normalizedAddress]; + } + if (!verifiedAddresses.includes(normalizedAddress)) { + verifiedAddresses.push(normalizedAddress); + } + + const fid = steemhuntUser?.fid ?? neynarProfile?.fid; + + // Fetch Quotient Score (non-blocking, don't fail if unavailable) + let quotientData = null; + if (fid) { + try { + quotientData = await fetchQuotientScore(fid); + } catch { + // Non-fatal + } + } + + const userData = await buildUserData({ + steemhuntUser, + neynarProfile, + verifiedAddresses, + quotientData, + }); + + // Upsert: INSERT then UPDATE on conflict + const { data: insertData, error: insertError } = await supabase + .from("users") + .insert(userData) + .select() + .single(); + + let finalData = insertData; + + if (insertError) { + if (insertError.code === "23505") { + // Unique violation — update existing + const { data: updateData, error: updateError } = await supabase + .from("users") + .update(userData) + .eq("fid", userData.fid) + .select() + .single(); + + if (updateError) { + console.error("[register-by-wallet] Update error:", updateError); + return NextResponse.json( + { error: "Failed to save user data" }, + { status: 500 }, + ); + } + finalData = updateData; + } else { + console.error("[register-by-wallet] Insert error:", insertError); + return NextResponse.json( + { error: "Failed to save user data" }, + { status: 500 }, + ); + } + } + + return NextResponse.json({ success: true, user: finalData }); + } catch (error) { + console.error("[register-by-wallet] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/user/x-stats/route.ts b/src/app/api/user/x-stats/route.ts new file mode 100644 index 00000000..80c0fb08 --- /dev/null +++ b/src/app/api/user/x-stats/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; +import { fetchXStats } from "../../../../../lib/x-stats"; + +const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes + +/** + * POST /api/user/x-stats + * Fetch X/Twitter stats for a user. Requires twitter handle in DB. + * Body: { walletAddress: string } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { walletAddress } = body; + + if (!walletAddress || typeof walletAddress !== "string") { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 }, + ); + } + + const normalizedAddress = walletAddress.toLowerCase(); + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json( + { error: "Database not configured" }, + { status: 500 }, + ); + } + + // Look up user + const { data: user } = await supabase + .from("users") + .select("*") + .contains("verified_addresses", [normalizedAddress]) + .single(); + + if (!user) { + return NextResponse.json( + { error: "User not found. Connect wallet first." }, + { status: 404 }, + ); + } + + if (!user.twitter) { + return NextResponse.json( + { error: "No X/Twitter handle linked to this account." }, + { status: 404 }, + ); + } + + // Check cooldown + if (user.x_stats_fetched_at) { + const age = + Date.now() - new Date(user.x_stats_fetched_at).getTime(); + if (age < COOLDOWN_MS) { + return NextResponse.json({ + success: true, + cached: true, + xStats: { + twitter: user.twitter, + x_followers_count: user.x_followers_count, + x_following_count: user.x_following_count, + x_verified: user.x_verified, + x_display_name: user.x_display_name, + x_stats_fetched_at: user.x_stats_fetched_at, + }, + }); + } + } + + // Fetch fresh stats + const stats = await fetchXStats(user.twitter); + if (!stats) { + return NextResponse.json( + { error: "Failed to fetch X stats. Try again later." }, + { status: 502 }, + ); + } + + // Update DB + const { error: updateError } = await supabase + .from("users") + .update({ + x_followers_count: stats.followers, + x_following_count: stats.following, + x_verified: stats.isVerified, + x_display_name: stats.displayName, + x_stats_fetched_at: new Date().toISOString(), + }) + .eq("id", user.id); + + if (updateError) { + console.error("[x-stats] Update error:", updateError); + } + + return NextResponse.json({ + success: true, + cached: false, + xStats: { + twitter: user.twitter, + x_followers_count: stats.followers, + x_following_count: stats.following, + x_verified: stats.isVerified, + x_display_name: stats.displayName, + x_stats_fetched_at: new Date().toISOString(), + }, + }); + } catch (error) { + console.error("[x-stats] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 15b5e5ba..ac9edd5b 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1,14 +1,14 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { useParams } from "next/navigation"; import { useAccount } from "wagmi"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { formatUnits, type Address } from "viem"; import Link from "next/link"; -import { supabase, type Storyline, type Donation, type TradeHistory } from "../../../../lib/supabase"; +import { supabase, type Storyline, type Donation, type TradeHistory, type User } from "../../../../lib/supabase"; import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } from "../../../../lib/contracts/constants"; -import { getFarcasterProfile, fetchAgentMetadata } from "../../../../lib/actions"; +import { getFarcasterProfile, fetchAgentMetadata, getUserFromDB } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; import { formatPrice } from "../../../../lib/format"; import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo } from "../../../../lib/price"; @@ -26,9 +26,16 @@ export default function ProfilePage() { const address = params.address.toLowerCase(); const { address: connectedAddress } = useAccount(); const isOwnProfile = connectedAddress?.toLowerCase() === address; + const queryClient = useQueryClient(); const [tab, setTab] = useState("stories"); + // DB user data (cached profiles) + const { data: dbUser } = useQuery({ + queryKey: ["db-user", address], + queryFn: () => getUserFromDB(address), + }); + const { data: fcProfile, isLoading: fcLoading } = useQuery({ queryKey: ["fc-profile", address], queryFn: () => getFarcasterProfile(address), @@ -55,6 +62,62 @@ export default function ProfilePage() { }, }); + // Refresh profile handler (5-min cooldown enforced server-side) + const [refreshing, setRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + setRefreshError(null); + try { + const res = await fetch("/api/user/onboard", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ walletAddress: address, forceRefresh: true }), + }); + const data = await res.json(); + if (!res.ok) { + if (res.status === 429 && data.cooldownRemainingSeconds) { + setRefreshError(`Cooldown: ${Math.ceil(data.cooldownRemainingSeconds / 60)}m remaining`); + } else { + setRefreshError(data.error || "Refresh failed"); + } + } else { + // Invalidate queries to show fresh data + queryClient.invalidateQueries({ queryKey: ["db-user", address] }); + queryClient.invalidateQueries({ queryKey: ["fc-profile", address] }); + } + } catch { + setRefreshError("Network error"); + } finally { + setRefreshing(false); + } + }, [address, queryClient]); + + // Proactive cooldown timer based on DB steemhunt_fetched_at + const COOLDOWN_MS = 5 * 60 * 1000; + const [cooldownRemaining, setCooldownRemaining] = useState(0); + + useEffect(() => { + if (!dbUser?.steemhunt_fetched_at) { + setCooldownRemaining(0); + return; + } + const computeRemaining = () => { + const age = Date.now() - new Date(dbUser.steemhunt_fetched_at!).getTime(); + return Math.max(0, COOLDOWN_MS - age); + }; + setCooldownRemaining(computeRemaining()); + const interval = setInterval(() => { + const r = computeRemaining(); + setCooldownRemaining(r); + if (r <= 0) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [dbUser?.steemhunt_fetched_at]); + + const onCooldown = cooldownRemaining > 0; + return (
{/* Tab navigation */} @@ -111,6 +181,13 @@ function ProfileHeader({ agentLoading, isAgent, claimedRoyalties, + dbUser, + isOwnProfile, + onRefresh, + refreshing, + refreshError, + onCooldown, + cooldownRemaining, }: { address: string; fcProfile: FarcasterProfile | null; @@ -119,6 +196,13 @@ function ProfileHeader({ agentLoading: boolean; isAgent: boolean; claimedRoyalties: bigint | null; + dbUser: User | null; + isOwnProfile: boolean; + onRefresh: () => void; + refreshing: boolean; + refreshError: string | null; + onCooldown: boolean; + cooldownRemaining: number; }) { const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null; @@ -174,6 +258,16 @@ function ProfileHeader({ @{fcProfile.username} )} + {dbUser?.twitter && ( + + @{dbUser.twitter} + + )} {fcProfile.bio}

)} + {/* Social stats from DB */} + {dbUser && ( +
+ {dbUser.follower_count > 0 && ( + {dbUser.follower_count.toLocaleString()} followers + )} + {dbUser.following_count > 0 && ( + {dbUser.following_count.toLocaleString()} following + )} + {dbUser.quotient_score !== null && ( + QS: {dbUser.quotient_score} + )} + {dbUser.x_followers_count !== null && ( + X: {dbUser.x_followers_count.toLocaleString()} followers + )} +
+ )} + {/* Cumulative claimed royalties */} {claimedRoyalties && claimedRoyalties > BigInt(0) && (
Royalties claimed: {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL}
)} + + {/* Refresh button (own profile only) */} + {isOwnProfile && ( +
+ + {refreshError && ( + {refreshError} + )} +
+ )}
diff --git a/src/hooks/useConnectedIdentity.ts b/src/hooks/useConnectedIdentity.ts index 8e63c4c3..163f10cc 100644 --- a/src/hooks/useConnectedIdentity.ts +++ b/src/hooks/useConnectedIdentity.ts @@ -8,6 +8,7 @@ import type { FarcasterProfile } from "../../lib/farcaster"; /** * Resolves the connected wallet's Farcaster identity. * Caches result for the session (re-fetches only on address change). + * Also triggers register-by-wallet to upsert user data in DB. */ export function useConnectedIdentity() { const { address } = useAccount(); @@ -16,12 +17,26 @@ export function useConnectedIdentity() { resolvedFor: string | undefined; }>({ profile: null, resolvedFor: undefined }); const fetchingRef = useRef(false); + const registeredRef = useRef(undefined); useEffect(() => { if (!address) return; let cancelled = false; fetchingRef.current = true; + + // Register user in DB (fire-and-forget) + if (registeredRef.current !== address) { + registeredRef.current = address; + fetch("/api/user/register-by-wallet", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ walletAddress: address }), + }).catch(() => { + // Non-fatal — profile will still work from live API + }); + } + getFarcasterProfile(address).then((p) => { if (!cancelled) { setResult({ profile: p, resolvedFor: address }); diff --git a/supabase/migrations/00027_create_users_table.sql b/supabase/migrations/00027_create_users_table.sql new file mode 100644 index 00000000..5b9f352a --- /dev/null +++ b/supabase/migrations/00027_create_users_table.sql @@ -0,0 +1,98 @@ +-- [#563] Create users table for cached Farcaster profile data +-- SteemHunt API as primary source (free), Neynar as fallback (paid) +-- Store ALL available data — Supabase storage is free, API calls are not. + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + fid INTEGER UNIQUE NOT NULL, + username TEXT, + display_name TEXT, + pfp_url TEXT, + custody_address TEXT, + verified_addresses TEXT[], + primary_address TEXT, + + -- Profile metadata + bio TEXT, + url TEXT, + location TEXT, + + -- Social handles + twitter TEXT, + github TEXT, + + -- Farcaster stats + follower_count INTEGER DEFAULT 0, + following_count INTEGER DEFAULT 0, + power_badge BOOLEAN, + is_pro_subscriber BOOLEAN, + neynar_score DECIMAL, + spam_label INTEGER, + fc_created_at TIMESTAMPTZ, + + -- X/Twitter stats + x_followers_count BIGINT, + x_following_count BIGINT, + x_verified BOOLEAN, + x_display_name TEXT, + x_stats_fetched_at TIMESTAMPTZ, + + -- Quotient score + quotient_score DECIMAL(10,4), + quotient_rank INTEGER, + quotient_labels JSONB, + quotient_updated_at TIMESTAMPTZ, + + -- Freshness tracking + stats_fetched_at TIMESTAMPTZ, + steemhunt_fetched_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Auto-update updated_at on row change +CREATE OR REPLACE FUNCTION update_users_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_users_updated_at(); + +-- Indexes for common lookups +CREATE INDEX IF NOT EXISTS idx_users_fid ON users (fid); +CREATE INDEX IF NOT EXISTS idx_users_primary_address ON users (primary_address); +CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); + +-- Index on verified_addresses for wallet-based lookups +CREATE INDEX IF NOT EXISTS idx_users_verified_addresses ON users USING GIN (verified_addresses); + +-- Non-negative constraints +DO $$ BEGIN + ALTER TABLE users ADD CONSTRAINT chk_users_x_followers_non_negative CHECK (x_followers_count >= 0); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +DO $$ BEGIN + ALTER TABLE users ADD CONSTRAINT chk_users_x_following_non_negative CHECK (x_following_count >= 0); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- RLS: public read, service-role write +ALTER TABLE users ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "users_public_read" ON users + FOR SELECT USING (true); + +CREATE POLICY "users_service_write" ON users + FOR ALL USING (auth.role() = 'service_role'); + +COMMENT ON TABLE users IS 'Cached Farcaster profile data — SteemHunt primary, Neynar fallback'; +COMMENT ON COLUMN users.steemhunt_fetched_at IS 'Last SteemHunt fetch — 5-min cooldown key'; +COMMENT ON COLUMN users.stats_fetched_at IS 'Last Neynar score fetch — 7-day refresh'; +COMMENT ON COLUMN users.quotient_updated_at IS 'Last Quotient Score fetch — 7-day refresh'; +COMMENT ON COLUMN users.x_stats_fetched_at IS 'Last X stats fetch — 5-min cooldown key';