diff --git a/lib/actions.ts b/lib/actions.ts index 1e1b0985..2ea2590a 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -15,9 +15,9 @@ import type { Address } from "viem"; export async function getFarcasterProfile( address: string, ): Promise { - // Try DB first + // Try DB first (only if user has a FID — wallet-only users have no Farcaster profile) const dbUser = await getUserFromDB(address); - if (dbUser) { + if (dbUser && dbUser.fid != null) { return { fid: dbUser.fid, username: dbUser.username ?? "", @@ -41,6 +41,7 @@ export async function fetchAgentMetadata( /** * Look up a user from the DB by wallet address. + * Searches verified_addresses first, then primary_address for wallet-only users. */ export async function getUserFromDB( address: string, @@ -50,11 +51,19 @@ export async function getUserFromDB( const normalized = address.toLowerCase(); - const { data } = await supabase + const { data: byVerified } = await supabase .from("users") .select("*") .contains("verified_addresses", [normalized]) .single(); - return data ?? null; + if (byVerified) return byVerified; + + const { data: byPrimary } = await supabase + .from("users") + .select("*") + .eq("primary_address", normalized) + .single(); + + return byPrimary ?? null; } diff --git a/lib/supabase.ts b/lib/supabase.ts index 1f6635b6..382015d4 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -394,7 +394,7 @@ export interface Database { users: { Row: { id: string; - fid: number; + fid: number | null; username: string | null; display_name: string | null; pfp_url: string | null; @@ -429,7 +429,7 @@ export interface Database { }; Insert: { id?: never; - fid: number; + fid?: number | null; username?: string | null; display_name?: string | null; pfp_url?: string | null; @@ -464,7 +464,7 @@ export interface Database { }; Update: { id?: never; - fid?: number; + fid?: number | null; username?: string | null; display_name?: string | null; pfp_url?: string | null; diff --git a/lib/user-data.ts b/lib/user-data.ts index e019923c..938fb6f5 100644 --- a/lib/user-data.ts +++ b/lib/user-data.ts @@ -69,12 +69,21 @@ export async function buildUserData(opts: { } as UserInsert; } + if (neynarProfile) { + return { + ...base, + fid: neynarProfile.fid, + username: neynarProfile.username, + display_name: neynarProfile.displayName, + pfp_url: neynarProfile.pfpUrl, + bio: neynarProfile.bio, + } as UserInsert; + } + + // Wallet-only user (no Farcaster account) return { ...base, - fid: neynarProfile!.fid, - username: neynarProfile!.username, - display_name: neynarProfile!.displayName, - pfp_url: neynarProfile!.pfpUrl, - bio: neynarProfile!.bio, + fid: null, + primary_address: verifiedAddresses[0] || null, } as UserInsert; } diff --git a/src/app/api/user/onboard/route.ts b/src/app/api/user/onboard/route.ts index c1609c0a..8ac0beee 100644 --- a/src/app/api/user/onboard/route.ts +++ b/src/app/api/user/onboard/route.ts @@ -32,12 +32,23 @@ export async function POST(request: NextRequest) { ); } - // Check existing user and cooldown - const { data: existingUser } = await supabase + // Check existing user (by verified_addresses or primary_address) + let existingUser = null; + const { data: byVerified } = await supabase .from("users") .select("*") .contains("verified_addresses", [normalizedAddress]) .single(); + if (byVerified) { + existingUser = byVerified; + } else { + const { data: byPrimary } = await supabase + .from("users") + .select("*") + .eq("primary_address", normalizedAddress) + .single(); + existingUser = byPrimary; + } // Enforce 5-min cooldown on ALL refreshes if (existingUser?.steemhunt_fetched_at) { @@ -64,14 +75,7 @@ export async function POST(request: NextRequest) { 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; + const fid = steemhuntUser?.fid ?? neynarProfile?.fid ?? null; // Build verified addresses let verifiedAddresses: string[]; @@ -106,12 +110,12 @@ export async function POST(request: NextRequest) { quotientData, }); - // Upsert + // Upsert — update by existing row identity if (existingUser) { const { data, error } = await supabase .from("users") .update(userData) - .eq("fid", userData.fid) + .eq("id", existingUser.id) .select() .single(); @@ -124,20 +128,41 @@ export async function POST(request: NextRequest) { } return NextResponse.json({ success: true, user: data }); } else { - const { data, error } = await supabase + const { data: insertData, error: insertError } = await supabase .from("users") .insert(userData) .select() .single(); - if (error) { - console.error("[onboard] Insert error:", error); + if (insertError) { + if (insertError.code === "23505") { + // Unique violation — update by conflicting identity + const updateQuery = supabase.from("users").update(userData); + const conditioned = + userData.fid != null + ? updateQuery.eq("fid", userData.fid) + : updateQuery.eq("primary_address", normalizedAddress); + + const { data: updateData, error: updateError } = await conditioned + .select() + .single(); + + if (updateError) { + console.error("[onboard] Update error:", updateError); + return NextResponse.json( + { error: "Failed to save user data" }, + { status: 500 }, + ); + } + return NextResponse.json({ success: true, user: updateData }); + } + console.error("[onboard] Insert error:", insertError); return NextResponse.json( { error: "Failed to save user data" }, { status: 500 }, ); } - return NextResponse.json({ success: true, user: data }); + return NextResponse.json({ success: true, user: insertData }); } } catch (error) { console.error("[onboard] Error:", error); diff --git a/src/app/api/user/register-by-wallet/route.ts b/src/app/api/user/register-by-wallet/route.ts index a881576e..6f0751de 100644 --- a/src/app/api/user/register-by-wallet/route.ts +++ b/src/app/api/user/register-by-wallet/route.ts @@ -7,8 +7,8 @@ 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). + * Called on wallet connect — upserts user profile fields. + * Works for both Farcaster and non-Farcaster wallet users. */ export async function POST(request: NextRequest) { try { @@ -31,13 +31,26 @@ export async function POST(request: NextRequest) { ); } - // Check if user exists and data is fresh (< 5 min) - const { data: existingUser } = await supabase + // Check if user exists (by verified_addresses or primary_address) + let existingUser = null; + const { data: byVerified } = await supabase .from("users") .select("*") .contains("verified_addresses", [normalizedAddress]) .single(); + if (byVerified) { + existingUser = byVerified; + } else { + const { data: byPrimary } = await supabase + .from("users") + .select("*") + .eq("primary_address", normalizedAddress) + .single(); + existingUser = byPrimary; + } + + // If user exists and data is fresh (< 5 min), return cached if (existingUser?.steemhunt_fetched_at) { const age = Date.now() - new Date(existingUser.steemhunt_fetched_at).getTime(); @@ -49,22 +62,12 @@ export async function POST(request: NextRequest) { // SteemHunt lookup (primary, free) const steemhuntUser = await getUserByWallet(normalizedAddress); - // Neynar fallback + // Neynar fallback (only if SteemHunt found nothing) 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) { @@ -78,9 +81,9 @@ export async function POST(request: NextRequest) { verifiedAddresses.push(normalizedAddress); } - const fid = steemhuntUser?.fid ?? neynarProfile?.fid; + const fid = steemhuntUser?.fid ?? neynarProfile?.fid ?? null; - // Fetch Quotient Score (non-blocking, don't fail if unavailable) + // Fetch Quotient Score (non-blocking, only when FID available) let quotientData = null; if (fid) { try { @@ -108,11 +111,15 @@ export async function POST(request: NextRequest) { 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) + // Unique violation — update by the conflicting identity + const updateQuery = supabase.from("users").update(userData); + const conditioned = existingUser + ? updateQuery.eq("id", existingUser.id) + : userData.fid != null + ? updateQuery.eq("fid", userData.fid) + : updateQuery.eq("primary_address", normalizedAddress); + + const { data: updateData, error: updateError } = await conditioned .select() .single(); diff --git a/supabase/migrations/00028_users_fid_nullable.sql b/supabase/migrations/00028_users_fid_nullable.sql new file mode 100644 index 00000000..cf8bc4b6 --- /dev/null +++ b/supabase/migrations/00028_users_fid_nullable.sql @@ -0,0 +1,8 @@ +-- [#567] Make fid nullable for non-Farcaster wallet users +-- PlotLink must work for ALL wallet users, not just Farcaster. + +ALTER TABLE users ALTER COLUMN fid DROP NOT NULL; + +-- Unique index on primary_address for wallet-only user upserts +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_primary_address_unique + ON users (primary_address) WHERE primary_address IS NOT NULL;