From f4ed6ecb5c88c7a17b971d696263f3fe70b1105c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:11:27 +0000 Subject: [PATCH 1/5] [#563] Add users table with SteemHunt API, cached profiles, refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create users table (Supabase migration) with all Farcaster, X/Twitter, and Quotient score columns - Add farcaster-indexer.ts with circuit breaker, retry, in-memory cache - Add quotient.ts client for reputation scores - Add x-stats.ts for X/Twitter profile stats via twitterapi.io - POST /api/user/register-by-wallet — upserts on wallet connect - POST /api/user/onboard — manual refresh with 5-min cooldown - POST /api/user/x-stats — fetch X stats with 5-min cooldown - Profile page reads from DB first, falls back to live API - Display X handle, Quotient score, follower counts on profile - Refresh Profile button on own profile with cooldown enforcement Fixes #563 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/actions.ts | 35 ++- lib/farcaster-indexer.ts | 241 ++++++++++++++++++ lib/quotient.ts | 52 ++++ lib/supabase.ts | 109 ++++++++ lib/x-stats.ts | 70 +++++ src/app/api/user/onboard/route.ts | 182 +++++++++++++ src/app/api/user/register-by-wallet/route.ts | 183 +++++++++++++ src/app/api/user/x-stats/route.ts | 118 +++++++++ src/app/profile/[address]/page.tsx | 106 +++++++- src/hooks/useConnectedIdentity.ts | 15 ++ .../migrations/00027_create_users_table.sql | 98 +++++++ 11 files changed, 1204 insertions(+), 5 deletions(-) create mode 100644 lib/farcaster-indexer.ts create mode 100644 lib/quotient.ts create mode 100644 lib/x-stats.ts create mode 100644 src/app/api/user/onboard/route.ts create mode 100644 src/app/api/user/register-by-wallet/route.ts create mode 100644 src/app/api/user/x-stats/route.ts create mode 100644 supabase/migrations/00027_create_users_table.sql 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/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..3154d380 --- /dev/null +++ b/src/app/api/user/onboard/route.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient, type Database } from "../../../../../lib/supabase"; +import { getUserByWallet } from "../../../../../lib/farcaster-indexer"; +import { lookupByAddress } from "../../../../../lib/farcaster"; +import { fetchQuotientScore, isQuotientStale } from "../../../../../lib/quotient"; + +const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes + +/** + * POST /api/user/onboard + * Manual profile refresh. Enforces 5-min cooldown unless forceRefresh=true + * is within cooldown (returns remaining time). + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { walletAddress, forceRefresh } = 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(); + + if (existingUser?.steemhunt_fetched_at && forceRefresh) { + 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 + } + } + + // Build update data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userData: Record = steemhuntUser + ? { + fid: steemhuntUser.fid, + username: steemhuntUser.username, + display_name: steemhuntUser.displayName, + pfp_url: steemhuntUser.pfpUrl, + verified_addresses: verifiedAddresses, + 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, + steemhunt_fetched_at: new Date().toISOString(), + } + : { + fid: neynarProfile!.fid, + username: neynarProfile!.username, + display_name: neynarProfile!.displayName, + pfp_url: neynarProfile!.pfpUrl, + verified_addresses: verifiedAddresses, + bio: neynarProfile!.bio, + steemhunt_fetched_at: new Date().toISOString(), + }; + + // Add Quotient data if refreshed + if (quotientData) { + userData.quotient_score = quotientData.quotientScore; + userData.quotient_rank = quotientData.quotientRank; + userData.quotient_labels = quotientData.contextLabels; + userData.quotient_updated_at = new Date().toISOString(); + } + + // 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 as Database["public"]["Tables"]["users"]["Insert"]) + .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..62e7cabf --- /dev/null +++ b/src/app/api/user/register-by-wallet/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient, type Database } from "../../../../../lib/supabase"; +import { getUserByWallet } from "../../../../../lib/farcaster-indexer"; +import { lookupByAddress } from "../../../../../lib/farcaster"; +import { fetchQuotientScore } from "../../../../../lib/quotient"; + +type UserInsert = Database["public"]["Tables"]["users"]["Insert"]; + +/** + * 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 + } + } + + // Build user data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userData: Record = steemhuntUser + ? { + fid: steemhuntUser.fid, + username: steemhuntUser.username, + display_name: steemhuntUser.displayName, + pfp_url: steemhuntUser.pfpUrl, + verified_addresses: verifiedAddresses, + 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, + steemhunt_fetched_at: new Date().toISOString(), + quotient_score: quotientData?.quotientScore ?? null, + quotient_rank: quotientData?.quotientRank ?? null, + quotient_labels: quotientData?.contextLabels ?? null, + quotient_updated_at: quotientData + ? new Date().toISOString() + : null, + } + : { + fid: neynarProfile!.fid, + username: neynarProfile!.username, + display_name: neynarProfile!.displayName, + pfp_url: neynarProfile!.pfpUrl, + verified_addresses: verifiedAddresses, + bio: neynarProfile!.bio, + steemhunt_fetched_at: new Date().toISOString(), + quotient_score: quotientData?.quotientScore ?? null, + quotient_rank: quotientData?.quotientRank ?? null, + quotient_labels: quotientData?.contextLabels ?? null, + quotient_updated_at: quotientData + ? new Date().toISOString() + : null, + }; + + // Upsert: INSERT then UPDATE on conflict + const { data: insertData, error: insertError } = await supabase + .from("users") + .insert(userData as UserInsert) + .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..917700f0 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 } 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,38 @@ 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]); + return (
{/* Tab navigation */} @@ -111,6 +155,11 @@ function ProfileHeader({ agentLoading, isAgent, claimedRoyalties, + dbUser, + isOwnProfile, + onRefresh, + refreshing, + refreshError, }: { address: string; fcProfile: FarcasterProfile | null; @@ -119,6 +168,11 @@ function ProfileHeader({ agentLoading: boolean; isAgent: boolean; claimedRoyalties: bigint | null; + dbUser: User | null; + isOwnProfile: boolean; + onRefresh: () => void; + refreshing: boolean; + refreshError: string | null; }) { const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null; @@ -174,6 +228,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..45cf8cff --- /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(5,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'; From bf613b5b390ccf1c25f8ac48d298a61e295ae541 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:16:06 +0000 Subject: [PATCH 2/5] [#563] Address T2b review: fix cooldown, widen quotient precision, extract helper - Cooldown now gates ALL refreshes in /api/user/onboard regardless of forceRefresh flag - Widen quotient_score from DECIMAL(5,4) to DECIMAL(10,4) to handle any score scale - Extract shared buildUserData() helper to lib/user-data.ts to reduce duplication between register-by-wallet and onboard routes Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/user-data.ts | 61 +++++++++++++++++++ src/app/api/user/onboard/route.ts | 59 ++++-------------- src/app/api/user/register-by-wallet/route.ts | 57 +++-------------- .../migrations/00027_create_users_table.sql | 2 +- 4 files changed, 84 insertions(+), 95 deletions(-) create mode 100644 lib/user-data.ts diff --git a/lib/user-data.ts b/lib/user-data.ts new file mode 100644 index 00000000..ff80abee --- /dev/null +++ b/lib/user-data.ts @@ -0,0 +1,61 @@ +/** + * [#563] Shared helper for building user data objects from SteemHunt/Neynar. + */ + +import type { SteemhuntUser } from "./farcaster-indexer"; +import type { FarcasterProfile } from "./farcaster"; +import type { QuotientUserData } from "./quotient"; +import type { Database } from "./supabase"; + +type UserInsert = Database["public"]["Tables"]["users"]["Insert"]; + +export function buildUserData(opts: { + steemhuntUser: SteemhuntUser | null; + neynarProfile: FarcasterProfile | null; + verifiedAddresses: string[]; + quotientData: QuotientUserData | null; +}): UserInsert { + const { steemhuntUser, neynarProfile, verifiedAddresses, quotientData } = + opts; + const now = new Date().toISOString(); + + const base = { + 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, + }; + + 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, + }; + } + + return { + ...base, + fid: neynarProfile!.fid, + username: neynarProfile!.username, + display_name: neynarProfile!.displayName, + pfp_url: neynarProfile!.pfpUrl, + bio: neynarProfile!.bio, + }; +} diff --git a/src/app/api/user/onboard/route.ts b/src/app/api/user/onboard/route.ts index 3154d380..0f86f16a 100644 --- a/src/app/api/user/onboard/route.ts +++ b/src/app/api/user/onboard/route.ts @@ -1,20 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { createServiceRoleClient, type Database } from "../../../../../lib/supabase"; +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 unless forceRefresh=true - * is within cooldown (returns remaining time). + * Manual profile refresh. Enforces 5-min cooldown on ALL refreshes. */ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { walletAddress, forceRefresh } = body; + const { walletAddress } = body; if (!walletAddress || typeof walletAddress !== "string") { return NextResponse.json( @@ -39,7 +39,8 @@ export async function POST(request: NextRequest) { .contains("verified_addresses", [normalizedAddress]) .single(); - if (existingUser?.steemhunt_fetched_at && forceRefresh) { + // Enforce 5-min cooldown on ALL refreshes + if (existingUser?.steemhunt_fetched_at) { const age = Date.now() - new Date(existingUser.steemhunt_fetched_at).getTime(); @@ -98,46 +99,12 @@ export async function POST(request: NextRequest) { } } - // Build update data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const userData: Record = steemhuntUser - ? { - fid: steemhuntUser.fid, - username: steemhuntUser.username, - display_name: steemhuntUser.displayName, - pfp_url: steemhuntUser.pfpUrl, - verified_addresses: verifiedAddresses, - 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, - steemhunt_fetched_at: new Date().toISOString(), - } - : { - fid: neynarProfile!.fid, - username: neynarProfile!.username, - display_name: neynarProfile!.displayName, - pfp_url: neynarProfile!.pfpUrl, - verified_addresses: verifiedAddresses, - bio: neynarProfile!.bio, - steemhunt_fetched_at: new Date().toISOString(), - }; - - // Add Quotient data if refreshed - if (quotientData) { - userData.quotient_score = quotientData.quotientScore; - userData.quotient_rank = quotientData.quotientRank; - userData.quotient_labels = quotientData.contextLabels; - userData.quotient_updated_at = new Date().toISOString(); - } + const userData = buildUserData({ + steemhuntUser, + neynarProfile, + verifiedAddresses, + quotientData, + }); // Upsert if (existingUser) { @@ -159,7 +126,7 @@ export async function POST(request: NextRequest) { } else { const { data, error } = await supabase .from("users") - .insert(userData as Database["public"]["Tables"]["users"]["Insert"]) + .insert(userData) .select() .single(); diff --git a/src/app/api/user/register-by-wallet/route.ts b/src/app/api/user/register-by-wallet/route.ts index 62e7cabf..a2c6b8e3 100644 --- a/src/app/api/user/register-by-wallet/route.ts +++ b/src/app/api/user/register-by-wallet/route.ts @@ -1,10 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import { createServiceRoleClient, type Database } from "../../../../../lib/supabase"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; import { getUserByWallet } from "../../../../../lib/farcaster-indexer"; import { lookupByAddress } from "../../../../../lib/farcaster"; import { fetchQuotientScore } from "../../../../../lib/quotient"; - -type UserInsert = Database["public"]["Tables"]["users"]["Insert"]; +import { buildUserData } from "../../../../../lib/user-data"; /** * POST /api/user/register-by-wallet @@ -91,55 +90,17 @@ export async function POST(request: NextRequest) { } } - // Build user data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const userData: Record = steemhuntUser - ? { - fid: steemhuntUser.fid, - username: steemhuntUser.username, - display_name: steemhuntUser.displayName, - pfp_url: steemhuntUser.pfpUrl, - verified_addresses: verifiedAddresses, - 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, - steemhunt_fetched_at: new Date().toISOString(), - quotient_score: quotientData?.quotientScore ?? null, - quotient_rank: quotientData?.quotientRank ?? null, - quotient_labels: quotientData?.contextLabels ?? null, - quotient_updated_at: quotientData - ? new Date().toISOString() - : null, - } - : { - fid: neynarProfile!.fid, - username: neynarProfile!.username, - display_name: neynarProfile!.displayName, - pfp_url: neynarProfile!.pfpUrl, - verified_addresses: verifiedAddresses, - bio: neynarProfile!.bio, - steemhunt_fetched_at: new Date().toISOString(), - quotient_score: quotientData?.quotientScore ?? null, - quotient_rank: quotientData?.quotientRank ?? null, - quotient_labels: quotientData?.contextLabels ?? null, - quotient_updated_at: quotientData - ? new Date().toISOString() - : null, - }; + const userData = buildUserData({ + steemhuntUser, + neynarProfile, + verifiedAddresses, + quotientData, + }); // Upsert: INSERT then UPDATE on conflict const { data: insertData, error: insertError } = await supabase .from("users") - .insert(userData as UserInsert) + .insert(userData) .select() .single(); diff --git a/supabase/migrations/00027_create_users_table.sql b/supabase/migrations/00027_create_users_table.sql index 45cf8cff..5b9f352a 100644 --- a/supabase/migrations/00027_create_users_table.sql +++ b/supabase/migrations/00027_create_users_table.sql @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS users ( x_stats_fetched_at TIMESTAMPTZ, -- Quotient score - quotient_score DECIMAL(5,4), + quotient_score DECIMAL(10,4), quotient_rank INTEGER, quotient_labels JSONB, quotient_updated_at TIMESTAMPTZ, From ed20acbc8726b1481558b78cb5e9d1475fbac5cb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:20:10 +0000 Subject: [PATCH 3/5] [#563] Address T2a review: populate X stats during registration, show cooldown timer - buildUserData() now fetches X stats via twitterapi.io when twitter handle is available (non-blocking on failure) - Profile page shows proactive cooldown timer (mm:ss) on Refresh button computed from steemhunt_fetched_at, updates every second - Button disabled during cooldown period Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/user-data.ts | 29 +++++++++++--- src/app/api/user/onboard/route.ts | 2 +- src/app/api/user/register-by-wallet/route.ts | 2 +- src/app/profile/[address]/page.tsx | 40 ++++++++++++++++++-- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/lib/user-data.ts b/lib/user-data.ts index ff80abee..e019923c 100644 --- a/lib/user-data.ts +++ b/lib/user-data.ts @@ -1,25 +1,27 @@ /** * [#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 function buildUserData(opts: { +export async function buildUserData(opts: { steemhuntUser: SteemhuntUser | null; neynarProfile: FarcasterProfile | null; verifiedAddresses: string[]; quotientData: QuotientUserData | null; -}): UserInsert { +}): Promise { const { steemhuntUser, neynarProfile, verifiedAddresses, quotientData } = opts; const now = new Date().toISOString(); - const base = { + const base: Partial = { verified_addresses: verifiedAddresses, steemhunt_fetched_at: now, quotient_score: quotientData?.quotientScore ?? null, @@ -28,6 +30,23 @@ export function buildUserData(opts: { 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, @@ -47,7 +66,7 @@ export function buildUserData(opts: { is_pro_subscriber: steemhuntUser.proSubscribed ?? false, spam_label: steemhuntUser.spamLabel, fc_created_at: steemhuntUser.createdAt || null, - }; + } as UserInsert; } return { @@ -57,5 +76,5 @@ export function buildUserData(opts: { display_name: neynarProfile!.displayName, pfp_url: neynarProfile!.pfpUrl, bio: neynarProfile!.bio, - }; + } as UserInsert; } diff --git a/src/app/api/user/onboard/route.ts b/src/app/api/user/onboard/route.ts index 0f86f16a..c1609c0a 100644 --- a/src/app/api/user/onboard/route.ts +++ b/src/app/api/user/onboard/route.ts @@ -99,7 +99,7 @@ export async function POST(request: NextRequest) { } } - const userData = buildUserData({ + const userData = await buildUserData({ steemhuntUser, neynarProfile, verifiedAddresses, diff --git a/src/app/api/user/register-by-wallet/route.ts b/src/app/api/user/register-by-wallet/route.ts index a2c6b8e3..a881576e 100644 --- a/src/app/api/user/register-by-wallet/route.ts +++ b/src/app/api/user/register-by-wallet/route.ts @@ -90,7 +90,7 @@ export async function POST(request: NextRequest) { } } - const userData = buildUserData({ + const userData = await buildUserData({ steemhuntUser, neynarProfile, verifiedAddresses, diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 917700f0..015c166f 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { useParams } from "next/navigation"; import { useAccount } from "wagmi"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -94,6 +94,30 @@ export default function ProfilePage() { } }, [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 */} @@ -160,6 +186,8 @@ function ProfileHeader({ onRefresh, refreshing, refreshError, + onCooldown, + cooldownRemaining, }: { address: string; fcProfile: FarcasterProfile | null; @@ -173,6 +201,8 @@ function ProfileHeader({ onRefresh: () => void; refreshing: boolean; refreshError: string | null; + onCooldown: boolean; + cooldownRemaining: number; }) { const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null; @@ -309,10 +339,14 @@ function ProfileHeader({
{refreshError && ( {refreshError} From e4a09849c7bf21e9b5fd0af95618cb9f96abf50c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:23:22 +0000 Subject: [PATCH 4/5] [#563] Fix cooldown timer: use floor for minutes, ceil for seconds Math.ceil on minutes overstated remaining time (e.g. 4m32s showed as 5m32s). Now uses Math.floor for minutes and Math.ceil for seconds so the display matches the actual lockout. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 015c166f..5f7779fe 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -345,7 +345,7 @@ function ProfileHeader({ {refreshing ? "Refreshing..." : onCooldown - ? `Refresh (${Math.ceil(cooldownRemaining / 60000)}m ${Math.ceil((cooldownRemaining % 60000) / 1000)}s)` + ? `Refresh (${Math.floor(cooldownRemaining / 60000)}m ${Math.ceil((cooldownRemaining % 60000) / 1000)}s)` : "Refresh Profile"} {refreshError && ( From 07fcad192851978789ad8f22d7343782fc2f3624 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:25:24 +0000 Subject: [PATCH 5/5] [#563] Fix cooldown timer: derive total seconds first, then split m/s Ceil total milliseconds to seconds once, then floor-divide for minutes and modulo for seconds. Prevents invalid "4m 60s" at minute boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index 5f7779fe..ac9edd5b 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -342,11 +342,14 @@ function ProfileHeader({ disabled={refreshing || onCooldown} className="border-border text-muted hover:text-accent hover:border-accent rounded border px-2.5 py-1 text-[11px] transition-colors disabled:opacity-50" > - {refreshing - ? "Refreshing..." - : onCooldown - ? `Refresh (${Math.floor(cooldownRemaining / 60000)}m ${Math.ceil((cooldownRemaining % 60000) / 1000)}s)` - : "Refresh Profile"} + {(() => { + if (refreshing) return "Refreshing..."; + if (!onCooldown) return "Refresh Profile"; + const totalSec = Math.ceil(cooldownRemaining / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `Refresh (${m}m ${s}s)`; + })()} {refreshError && ( {refreshError}