Rocky_Mountain_Vending/app/api/ebay/manual-parts/route.ts

269 lines
7.2 KiB
TypeScript

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<string, unknown>
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<string>()
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<ReturnType<typeof findManualParts>>
): 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 }
)
}
}