213 lines
5.5 KiB
TypeScript
213 lines
5.5 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 {
|
|
filterTrustedEbayListings,
|
|
rankListingsForPart,
|
|
type CachedEbayListing,
|
|
type EbayCacheState,
|
|
type ManualPartInput,
|
|
} from "@/lib/ebay-parts-match"
|
|
|
|
type MatchPart = ManualPartInput & {
|
|
key?: string
|
|
ebayListings?: CachedEbayListing[]
|
|
}
|
|
|
|
type ManualPartsMatchResponse = {
|
|
manualFilename: string
|
|
parts: Array<
|
|
MatchPart & {
|
|
ebayListings: CachedEbayListing[]
|
|
}
|
|
>
|
|
cache: EbayCacheState
|
|
cacheSource: "convex" | "fallback"
|
|
error?: string
|
|
}
|
|
|
|
type ManualPartsRequest = {
|
|
manualFilename?: string
|
|
parts?: unknown[]
|
|
limit?: number
|
|
}
|
|
|
|
function getDisabledCacheState(message: string): EbayCacheState {
|
|
return {
|
|
key: "manual-parts",
|
|
status: "disabled",
|
|
lastSuccessfulAt: null,
|
|
lastAttemptAt: null,
|
|
nextEligibleAt: null,
|
|
lastError: message,
|
|
consecutiveFailures: 0,
|
|
queryCount: 0,
|
|
itemCount: 0,
|
|
sourceQueries: [],
|
|
freshnessMs: null,
|
|
isStale: true,
|
|
listingCount: 0,
|
|
activeListingCount: 0,
|
|
message,
|
|
}
|
|
}
|
|
|
|
function getErrorCacheState(message: string): EbayCacheState {
|
|
const now = Date.now()
|
|
return {
|
|
key: "manual-parts",
|
|
status: "error",
|
|
lastSuccessfulAt: null,
|
|
lastAttemptAt: now,
|
|
nextEligibleAt: null,
|
|
lastError: message,
|
|
consecutiveFailures: 1,
|
|
queryCount: 0,
|
|
itemCount: 0,
|
|
sourceQueries: [],
|
|
freshnessMs: null,
|
|
isStale: true,
|
|
listingCount: 0,
|
|
activeListingCount: 0,
|
|
message,
|
|
}
|
|
}
|
|
|
|
function createEmptyListingsParts(parts: MatchPart[]) {
|
|
return parts.map((part) => ({
|
|
...part,
|
|
ebayListings: [],
|
|
}))
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const message = "No manual parts were provided."
|
|
return NextResponse.json({
|
|
manualFilename,
|
|
parts: [],
|
|
cache: getDisabledCacheState(message),
|
|
cacheSource: "fallback",
|
|
error: message,
|
|
} satisfies ManualPartsMatchResponse)
|
|
}
|
|
|
|
if (!hasConvexUrl()) {
|
|
const message =
|
|
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
|
|
return NextResponse.json({
|
|
manualFilename,
|
|
parts: createEmptyListingsParts(parts),
|
|
cache: getDisabledCacheState(message),
|
|
cacheSource: "fallback",
|
|
error: message,
|
|
} satisfies ManualPartsMatchResponse)
|
|
}
|
|
|
|
try {
|
|
const [overview, listings] = await Promise.all([
|
|
fetchQuery(api.ebay.getCacheOverview, {}),
|
|
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
|
])
|
|
const trustedListings = filterTrustedEbayListings(
|
|
listings as CachedEbayListing[]
|
|
)
|
|
|
|
const rankedParts = parts
|
|
.map((part) => ({
|
|
...part,
|
|
ebayListings: rankListingsForPart(part, trustedListings, limit),
|
|
}))
|
|
.sort((a, b) => {
|
|
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,
|
|
cacheSource: "convex",
|
|
} satisfies ManualPartsMatchResponse)
|
|
} catch (error) {
|
|
console.error("Failed to load cached eBay matches:", error)
|
|
const message =
|
|
error instanceof Error
|
|
? `Cached eBay listings are unavailable: ${error.message}`
|
|
: "Cached eBay listings are unavailable."
|
|
return NextResponse.json(
|
|
{
|
|
manualFilename,
|
|
parts: createEmptyListingsParts(parts),
|
|
cache: getErrorCacheState(message),
|
|
cacheSource: "fallback",
|
|
error: message,
|
|
} satisfies ManualPartsMatchResponse,
|
|
{ status: 200 }
|
|
)
|
|
}
|
|
}
|