269 lines
7.2 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|