From 87a4c005450aaac7460b997bd2e325ad6ddd2231 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:50:18 +0000 Subject: [PATCH 1/4] =?UTF-8?q?[#567]=20Support=20non-Farcaster=20wallet?= =?UTF-8?q?=20users=20=E2=80=94=20make=20FID=20nullable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 00028: make fid nullable, add unique index on primary_address - buildUserData: add third path for wallet-only users (fid=null) - register-by-wallet: no longer rejects non-Farcaster wallets with 404, creates user record with fid=null and wallet address stored - Upsert logic: use primary_address as key when fid is null - User lookup: search by primary_address as fallback for wallet-only users - getFarcasterProfile: skip DB shortcut for wallet-only users (fid=null) - Supabase types: fid is now number | null across Row/Insert/Update Fixes #567 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/actions.ts | 17 +++++-- lib/supabase.ts | 6 +-- lib/user-data.ts | 19 +++++-- src/app/api/user/onboard/route.ts | 38 +++++++------- src/app/api/user/register-by-wallet/route.ts | 50 +++++++++++-------- .../migrations/00028_users_fid_nullable.sql | 8 +++ 6 files changed, 87 insertions(+), 51 deletions(-) create mode 100644 supabase/migrations/00028_users_fid_nullable.sql 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..fb5275e6 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,14 +110,14 @@ export async function POST(request: NextRequest) { quotientData, }); - // Upsert + // Upsert — use fid or primary_address as key if (existingUser) { - const { data, error } = await supabase - .from("users") - .update(userData) - .eq("fid", userData.fid) - .select() - .single(); + const updateQuery = supabase.from("users").update(userData); + const conditioned = + userData.fid != null + ? updateQuery.eq("fid", userData.fid) + : updateQuery.eq("primary_address", normalizedAddress); + const { data, error } = await conditioned.select().single(); if (error) { console.error("[onboard] Update 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..47552f13 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,14 @@ 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 existing by fid or primary_address + 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(); 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; From 6de599fc05c035c145a3402a3ff2db7c5549ffa0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:54:10 +0000 Subject: [PATCH 2/4] [#567] Fix upsert: update by existing row identity, not new payload fid When a wallet-only user (fid=null) later resolves to a Farcaster identity, the update must target the existing row by id/primary_address, not by the new fid which doesn't match the existing null fid row. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/user/onboard/route.ts | 14 +++++++------- src/app/api/user/register-by-wallet/route.ts | 9 ++++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/app/api/user/onboard/route.ts b/src/app/api/user/onboard/route.ts index fb5275e6..b93c1edc 100644 --- a/src/app/api/user/onboard/route.ts +++ b/src/app/api/user/onboard/route.ts @@ -110,14 +110,14 @@ export async function POST(request: NextRequest) { quotientData, }); - // Upsert — use fid or primary_address as key + // Upsert — update by existing row identity if (existingUser) { - const updateQuery = supabase.from("users").update(userData); - const conditioned = - userData.fid != null - ? updateQuery.eq("fid", userData.fid) - : updateQuery.eq("primary_address", normalizedAddress); - const { data, error } = await conditioned.select().single(); + const { data, error } = await supabase + .from("users") + .update(userData) + .eq("id", existingUser.id) + .select() + .single(); if (error) { console.error("[onboard] Update 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 47552f13..e688e669 100644 --- a/src/app/api/user/register-by-wallet/route.ts +++ b/src/app/api/user/register-by-wallet/route.ts @@ -111,12 +111,11 @@ export async function POST(request: NextRequest) { if (insertError) { if (insertError.code === "23505") { - // Unique violation — update existing by fid or primary_address + // Unique violation — update by existing row identity const updateQuery = supabase.from("users").update(userData); - const conditioned = - userData.fid != null - ? updateQuery.eq("fid", userData.fid) - : updateQuery.eq("primary_address", normalizedAddress); + const conditioned = existingUser + ? updateQuery.eq("id", existingUser.id) + : updateQuery.eq("primary_address", normalizedAddress); const { data: updateData, error: updateError } = await conditioned .select() From 8ae160c35e40b012b9b49ef88ea941acbe39c8a2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:57:10 +0000 Subject: [PATCH 3/4] [#567] Fix upsert fallback: use fid for FID conflicts, primary_address for wallet-only When existingUser is null but insert conflicts on fid (FID user with new wallet), update by fid. Only fall back to primary_address for wallet-only (fid=null) conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/user/register-by-wallet/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/api/user/register-by-wallet/route.ts b/src/app/api/user/register-by-wallet/route.ts index e688e669..6f0751de 100644 --- a/src/app/api/user/register-by-wallet/route.ts +++ b/src/app/api/user/register-by-wallet/route.ts @@ -111,11 +111,13 @@ export async function POST(request: NextRequest) { if (insertError) { if (insertError.code === "23505") { - // Unique violation — update by existing row identity + // Unique violation — update by the conflicting identity const updateQuery = supabase.from("users").update(userData); const conditioned = existingUser ? updateQuery.eq("id", existingUser.id) - : updateQuery.eq("primary_address", normalizedAddress); + : userData.fid != null + ? updateQuery.eq("fid", userData.fid) + : updateQuery.eq("primary_address", normalizedAddress); const { data: updateData, error: updateError } = await conditioned .select() From 09379b38b330289e67e594984ae20151ca064187 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 15:59:59 +0000 Subject: [PATCH 4/4] [#567] Add unique-violation recovery to onboard route insert path Mirror register-by-wallet's conflict handling: when insert conflicts on fid or primary_address, fall back to update by the conflicting identity instead of 500ing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/user/onboard/route.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/app/api/user/onboard/route.ts b/src/app/api/user/onboard/route.ts index b93c1edc..8ac0beee 100644 --- a/src/app/api/user/onboard/route.ts +++ b/src/app/api/user/onboard/route.ts @@ -128,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);