From ff5623434d2cd0420f29768528248b3f32a63106 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Sun, 29 Mar 2026 20:43:17 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20IP=20asset=20fetch=20pipeline=20?= =?UTF-8?q?=E2=80=94=20owner=20filtering,=20local=20caching,=20error=20sur?= =?UTF-8?q?facing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add owner/wallet address filter to GET_USER_IP_ASSETS GraphQL query so Launchpad shows only the connected user's assets - Add LocalIPAsset Prisma model for immediate DB persistence after Crossmint registration (before subgraph indexes) - Add /api/my-assets endpoint to query local DB assets by owner - Merge subgraph + local DB data in Launchpad with deduplication by storyProtocolId - Add metadataStatus field (loading/loaded/failed) to surface metadata fetch errors in the UI via warning badge on IPCard - Add retry with exponential backoff for Grove/IPFS metadata fetches Co-Authored-By: Paperclip --- app/api/my-assets/route.ts | 22 ++++++++++++ app/api/register-ip/route.ts | 23 +++++++++++++ components/ip-card.tsx | 9 ++++- components/launchpad.tsx | 65 ++++++++++++++++++++++++++++++------ lib/graphql.ts | 32 +++++++++++++++--- prisma/schema.prisma | 22 ++++++++++++ 6 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 app/api/my-assets/route.ts diff --git a/app/api/my-assets/route.ts b/app/api/my-assets/route.ts new file mode 100644 index 0000000..c7f2fb8 --- /dev/null +++ b/app/api/my-assets/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import db from '@/lib/database'; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const owner = searchParams.get('owner'); + + if (!owner) { + return NextResponse.json({ error: 'Missing owner query parameter' }, { status: 400 }); + } + + try { + const assets = await db.localIPAsset.findMany({ + where: { ownerAddress: owner.toLowerCase() }, + orderBy: { createdAt: 'desc' }, + }); + return NextResponse.json({ assets }); + } catch (error: any) { + console.error('Failed to fetch local assets:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/register-ip/route.ts b/app/api/register-ip/route.ts index ebd1c85..9db3958 100644 --- a/app/api/register-ip/route.ts +++ b/app/api/register-ip/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import db from '@/lib/database'; export async function POST(req: Request) { try { @@ -119,6 +120,28 @@ export async function POST(req: Request) { } const data = await res.json(); + + // Persist to local DB for immediate visibility before subgraph indexes + try { + await db.localIPAsset.create({ + data: { + ownerAddress: creatorAddress.toLowerCase(), + title, + description, + ipType, + mediaUrl: mediaUrl, + imageUrl: finalImageUrl, + metadataUri: '', + licenses, + royaltyRate: parseInt(royalty, 10) || 10, + crossmintId: data.id || data.actionId || null, + }, + }); + } catch (dbErr) { + // Log but don't fail the registration — Crossmint succeeded + console.error('Failed to persist IP asset locally:', dbErr); + } + return NextResponse.json(data); } catch (error: any) { console.error("Register IP Route Error:", error); diff --git a/components/ip-card.tsx b/components/ip-card.tsx index 9a25767..fa93ed7 100644 --- a/components/ip-card.tsx +++ b/components/ip-card.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import Image from 'next/image' -import { Music, BookOpen, ImageIcon, ShoppingCart, ExternalLink, Video } from 'lucide-react' +import { Music, BookOpen, ImageIcon, ShoppingCart, ExternalLink, Video, AlertTriangle } from 'lucide-react' import { cn } from '@/lib/utils' import { type IPAsset } from '@/lib/data' import { Button } from './ui/button' @@ -75,6 +75,13 @@ export function IPCard({ asset, className }: IPCardProps) { {asset.type} + {/* Metadata status warning */} + {(asset as any).metadataStatus === 'failed' && ( +
+ Metadata unavailable +
+ )} + {/* Story Protocol ID */}
{asset.storyProtocolId} diff --git a/components/launchpad.tsx b/components/launchpad.tsx index 7c13008..fea0117 100644 --- a/components/launchpad.tsx +++ b/components/launchpad.tsx @@ -18,7 +18,7 @@ import { Loader2, } from 'lucide-react' import { cn } from '@/lib/utils' -import { graphQLClient, GET_USER_IP_ASSETS, mapSubgraphAssetToIPAsset } from '@/lib/graphql' +import { graphQLClient, GET_USER_IP_ASSETS, mapSubgraphAssetToIPAsset, MetadataStatus } from '@/lib/graphql' const RECENT_ACTIVITY: { action: string, asset: string, by: string, time: string, amount: string | null }[] = [] // Empty activity stream for production MVP @@ -37,18 +37,61 @@ export function Launchpad() { } setLoading(true) try { - const data: any = await graphQLClient.request(GET_USER_IP_ASSETS, { - first: 10, - skip: 0 - }) - if (data.ipregistereds) { - const assets = await Promise.all( - data.ipregistereds.map((item: any) => mapSubgraphAssetToIPAsset(item)) - ) - setMyPortfolio(assets) + const ownerAddress = wallet.address.toLowerCase() + + // Fetch from both subgraph and local DB in parallel + const [subgraphResult, localResult] = await Promise.allSettled([ + graphQLClient.request(GET_USER_IP_ASSETS, { + first: 50, + skip: 0, + owner: ownerAddress, + }), + fetch(`/api/my-assets?owner=${encodeURIComponent(ownerAddress)}`).then(r => r.json()), + ]) + + // Map subgraph assets + let subgraphAssets: (IPAsset & { metadataStatus?: MetadataStatus })[] = [] + if (subgraphResult.status === 'fulfilled') { + const data = subgraphResult.value as any + if (data.ipregistereds) { + subgraphAssets = await Promise.all( + data.ipregistereds.map((item: any) => mapSubgraphAssetToIPAsset(item)) + ) + } + } else { + console.error('Failed to fetch from Goldsky:', subgraphResult.reason) } + + // Map local DB assets to IPAsset shape + let localAssets: (IPAsset & { metadataStatus?: MetadataStatus })[] = [] + if (localResult.status === 'fulfilled' && localResult.value.assets) { + localAssets = localResult.value.assets.map((a: any) => ({ + id: a.id, + storyProtocolId: a.storyProtocolId || '', + title: a.title, + creator: 'You', + creatorHandle: `${ownerAddress.substring(0, 6)}...${ownerAddress.substring(ownerAddress.length - 4)}`, + type: a.ipType || 'music', + coverImage: a.imageUrl || '/images/art-1.jpg', + description: a.description || '', + licenses: a.licenses ? a.licenses.split(',').map((s: string) => s.trim()) : ['Commercial'], + price: 0, + currency: 'USDC' as const, + royaltyRate: a.royaltyRate || 10, + registered: new Date(a.createdAt).toISOString().split('T')[0], + tags: ['On-Chain', 'Story Protocol'], + stats: { views: 0, licenses: 0, revenue: 0 }, + metadataStatus: 'loaded' as MetadataStatus, + metadataURI: a.metadataUri || '', + })) + } + + // Deduplicate: subgraph wins when storyProtocolId matches + const seenIds = new Set(subgraphAssets.map(a => a.storyProtocolId).filter(Boolean)) + const uniqueLocal = localAssets.filter(a => !a.storyProtocolId || !seenIds.has(a.storyProtocolId)) + setMyPortfolio([...subgraphAssets, ...uniqueLocal]) } catch (err) { - console.error('Failed to fetch from Goldsky:', err) + console.error('Failed to fetch assets:', err) } finally { setLoading(false) } diff --git a/lib/graphql.ts b/lib/graphql.ts index 9662033..474d648 100644 --- a/lib/graphql.ts +++ b/lib/graphql.ts @@ -28,8 +28,8 @@ export const GET_RECENT_IP_ASSETS = gql` ` export const GET_USER_IP_ASSETS = gql` - query GetUserIPAssets($first: Int!, $skip: Int!) { - ipregistereds(first: $first, skip: $skip, orderBy: timestamp_, orderDirection: desc) { + query GetUserIPAssets($first: Int!, $skip: Int!, $owner: String!) { + ipregistereds(first: $first, skip: $skip, orderBy: timestamp_, orderDirection: desc, where: { caller: $owner }) { id ipId chainId @@ -38,6 +38,7 @@ export const GET_USER_IP_ASSETS = gql` uri timestamp_ transactionHash_ + caller } } ` @@ -57,22 +58,44 @@ export const GET_IP_ASSET_BY_ID = gql` } ` +export type MetadataStatus = 'loading' | 'loaded' | 'failed' + +async function fetchWithRetry(url: string, retries = 3, baseDelayMs = 500): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + const res = await fetch(url) + if (res.ok) return res + if (attempt < retries - 1) { + await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt))) + } + } + // Final attempt — let caller handle the error + return fetch(url) +} + // Helper to map the raw Subgraph data to our application's IPAsset interface -export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise { +export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise { // Extract block timestamp safely (converting seconds to ms if needed) const dateObj = new Date(Number(subgraphData.timestamp_) * 1000) let metadata: any = null + let metadataStatus: MetadataStatus = 'loading' if (subgraphData.uri) { try { const url = resolveGroveURI(subgraphData.uri) if (url) { - const res = await fetch(url) + const res = await fetchWithRetry(url) + if (!res.ok) throw new Error(`HTTP ${res.status}`) metadata = await res.json() + metadataStatus = 'loaded' + } else { + metadataStatus = 'failed' } } catch (e) { console.warn('Failed to fetch metadata from Grove:', e) + metadataStatus = 'failed' } + } else { + metadataStatus = 'failed' } const getAttr = (key: string) => metadata?.attributes?.find((a: any) => a.key === key)?.value @@ -94,6 +117,7 @@ export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise