import { NextResponse } from "next/server" import { fetchQuery } from "convex/nextjs" import { api } from "@/convex/_generated/api" import { hasConvexUrl } from "@/lib/convex-config" import { rankListingsForPart, type CachedEbayListing, type EbayCacheState, type ManualPartInput, } from "@/lib/ebay-parts-match" import { findManualParts } from "@/lib/server/manual-parts-data" type MatchPart = ManualPartInput & { key?: string ebayListings?: CachedEbayListing[] } type ManualPartsMatchResponse = { manualFilename: string parts: Array< MatchPart & { ebayListings: CachedEbayListing[] } > cache: EbayCacheState } type ManualPartsRequest = { manualFilename?: string parts?: unknown[] limit?: number } function getCacheFallback(message?: string): EbayCacheState { return { key: "manual-parts", status: "success", lastSuccessfulAt: Date.now(), lastAttemptAt: null, nextEligibleAt: null, lastError: null, consecutiveFailures: 0, queryCount: 0, itemCount: 0, sourceQueries: [], freshnessMs: 0, isStale: true, listingCount: 0, activeListingCount: 0, message: message || "Using bundled manual cache.", } } function normalizePartInput(value: unknown): MatchPart | null { if (!value || typeof value !== "object") { return null } const part = value as Record const partNumber = typeof part.partNumber === "string" ? part.partNumber.trim() : "" const description = typeof part.description === "string" ? part.description.trim() : "" if (!partNumber && !description) { return null } return { key: typeof part.key === "string" ? part.key : undefined, partNumber, description, manufacturer: typeof part.manufacturer === "string" ? part.manufacturer.trim() : undefined, category: typeof part.category === "string" ? part.category.trim() : undefined, manualFilename: typeof part.manualFilename === "string" ? part.manualFilename.trim() : undefined, ebayListings: Array.isArray(part.ebayListings) ? (part.ebayListings as CachedEbayListing[]) : undefined, } } function mergeListings( primary: CachedEbayListing[], fallback: CachedEbayListing[] ): CachedEbayListing[] { const seen = new Set() const merged: CachedEbayListing[] = [] for (const listing of [...primary, ...fallback]) { const itemId = listing.itemId?.trim() if (!itemId || seen.has(itemId)) { continue } seen.add(itemId) merged.push(listing) } return merged } function getStaticListingsForPart( part: MatchPart, staticParts: Awaited> ): CachedEbayListing[] { const requestPartNumber = part.partNumber.trim().toLowerCase() const requestDescription = part.description.trim().toLowerCase() const match = staticParts.find((candidate) => { const candidatePartNumber = candidate.partNumber.trim().toLowerCase() const candidateDescription = candidate.description.trim().toLowerCase() if (candidatePartNumber && candidatePartNumber === requestPartNumber) { return true } if (candidatePartNumber && requestPartNumber && candidatePartNumber.includes(requestPartNumber)) { return true } if ( candidateDescription && requestDescription && candidateDescription.includes(requestDescription) ) { return true } return false }) return Array.isArray(match?.ebayListings) ? match.ebayListings : [] } export async function POST(request: Request) { let payload: ManualPartsRequest | null = null try { payload = (await request.json()) as ManualPartsRequest } catch { payload = null } const manualFilename = payload?.manualFilename?.trim() || "" const limit = Math.min( Math.max(Number.parseInt(String(payload?.limit ?? 5), 10) || 5, 1), 10 ) const parts: MatchPart[] = (payload?.parts || []) .map(normalizePartInput) .filter((part): part is MatchPart => Boolean(part)) if (!manualFilename) { return NextResponse.json( { error: "manualFilename is required" }, { status: 400 } ) } if (!parts.length) { return NextResponse.json({ manualFilename, parts: [], cache: getCacheFallback("No manual parts were provided."), }) } const staticManualPartsPromise = findManualParts(manualFilename) if (!hasConvexUrl()) { const staticManualParts = await staticManualPartsPromise return NextResponse.json({ manualFilename, parts: parts.map((part) => { const staticListings = getStaticListingsForPart(part, staticManualParts) const ebayListings = rankListingsForPart( part, mergeListings(part.ebayListings || [], staticListings), limit ) return { ...part, ebayListings, } }), cache: getCacheFallback(), }) } try { const [overview, listings, staticManualParts] = await Promise.all([ fetchQuery(api.ebay.getCacheOverview, {}), fetchQuery(api.ebay.listCachedListings, { limit: 200 }), staticManualPartsPromise, ]) const rankedParts = parts .map((part) => { const backendListings = rankListingsForPart( part, listings as CachedEbayListing[], limit ) const staticListings = getStaticListingsForPart(part, staticManualParts) const bundledListings = rankListingsForPart( part, mergeListings(part.ebayListings || [], staticListings), limit ) const ebayListings = mergeListings(backendListings, bundledListings).slice( 0, limit ) return { ...part, ebayListings, } }) .sort((a: MatchPart & { ebayListings: CachedEbayListing[] }, b: MatchPart & { ebayListings: CachedEbayListing[] }) => { const aCount = a.ebayListings.length const bCount = b.ebayListings.length if (aCount !== bCount) { return bCount - aCount } const aFreshness = a.ebayListings[0]?.lastSeenAt ?? a.ebayListings[0]?.fetchedAt ?? 0 const bFreshness = b.ebayListings[0]?.lastSeenAt ?? b.ebayListings[0]?.fetchedAt ?? 0 return bFreshness - aFreshness }) .slice(0, limit) return NextResponse.json({ manualFilename, parts: rankedParts, cache: overview, } satisfies ManualPartsMatchResponse) } catch (error) { console.error("Failed to load cached eBay matches:", error) const staticManualParts = await staticManualPartsPromise return NextResponse.json( { manualFilename, parts: parts.map((part: MatchPart) => { const staticListings = getStaticListingsForPart(part, staticManualParts) const ebayListings = rankListingsForPart( part, mergeListings(part.ebayListings || [], staticListings), limit ) return { ...part, ebayListings, } }), cache: getCacheFallback( error instanceof Error ? `Using bundled manual cache because cached listings failed: ${error.message}` : "Using bundled manual cache because cached listings failed." ), }, { status: 200 } ) } }