deploy: stabilize manuals eBay cache flow and smoke diagnostics

This commit is contained in:
DMleadgen 2026-04-08 11:27:59 -06:00
parent 087fda7ce6
commit 8fff380b24
Signed by: matt
GPG key ID: C2720CF8CD701894
14 changed files with 2477 additions and 442 deletions

View file

@ -0,0 +1,31 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { internal } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const result = await fetchAction(internal.ebay.refreshCache, {
reason: "admin",
force: true,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to refresh eBay cache:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to refresh eBay cache",
},
{ status: 500 }
)
}
}

View file

@ -0,0 +1,269 @@
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 }
)
}
}

View file

@ -1,167 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
/**
* eBay API Proxy Route
* Proxies requests to eBay Finding API to avoid CORS issues
*/
interface eBaySearchParams {
keywords: string
categoryId?: string
sortOrder?: string
maxResults?: number
}
interface eBaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
type MaybeArray<T> = T | T[]
const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const searchResponseCache = new Map<
string,
{ results: eBaySearchResult[]; timestamp: number }
>()
const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>()
// Affiliate campaign ID for generating links
const AFFILIATE_CAMPAIGN_ID =
process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
// Generate eBay affiliate link
function generateAffiliateLink(viewItemUrl: string): string {
if (!AFFILIATE_CAMPAIGN_ID) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", AFFILIATE_CAMPAIGN_ID)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
function first<T>(value: MaybeArray<T> | undefined): T | undefined {
if (!value) {
return undefined
}
return Array.isArray(value) ? value[0] : value
}
function normalizeItem(item: any): eBaySearchResult {
const currentPrice = first(item.sellingStatus?.currentPrice)
const shippingCost = first(item.shippingInfo?.shippingServiceCost)
const condition = first(item.condition)
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ""
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
rankListingsForQuery,
type CachedEbayListing,
type EbayCacheState,
} from "@/lib/ebay-parts-match"
import { searchStaticEbayListings } from "@/lib/server/manual-parts-data"
function getCacheStateFallback(message?: string): EbayCacheState {
return {
itemId: item.itemId || "",
title: item.title || "Unknown Item",
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: first(item.galleryURL) || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: generateAffiliateLink(viewItemUrl),
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.",
}
}
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning the raw text below.
}
return text.trim() || `eBay API error: ${response.status}`
}
function buildCacheKey(
keywords: string,
categoryId: string | undefined,
sortOrder: string,
maxResults: number
): string {
return [
keywords.trim().toLowerCase(),
categoryId || "",
sortOrder || "BestMatch",
String(maxResults),
].join("|")
}
function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null {
const cached = searchResponseCache.get(cacheKey)
if (!cached) {
return null
}
if (Date.now() - cached.timestamp > SEARCH_CACHE_TTL) {
searchResponseCache.delete(cacheKey)
return null
}
return cached.results
}
function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) {
searchResponseCache.set(cacheKey, {
results,
timestamp: Date.now(),
})
}
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")
const categoryId = searchParams.get("categoryId") || undefined
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
const keywords = searchParams.get("keywords")?.trim() || ""
const maxResults = Math.min(
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
20
)
if (!keywords) {
@ -171,114 +44,46 @@ export async function GET(request: NextRequest) {
)
}
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
console.error("EBAY_APP_ID not configured")
return NextResponse.json(
{
error:
"eBay API not configured. Please set EBAY_APP_ID environment variable.",
},
{ status: 503 }
)
}
const cachedResults = getCachedSearchResults(cacheKey)
if (cachedResults) {
return NextResponse.json(cachedResults)
}
const inFlight = inFlightSearchResponses.get(cacheKey)
if (inFlight) {
try {
const results = await inFlight
return NextResponse.json(results)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
},
{ status: 500 }
)
}
}
// Build eBay Finding API URL
const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1"
const url = new URL(baseUrl)
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", keywords)
url.searchParams.set("sortOrder", sortOrder)
url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
if (categoryId) {
url.searchParams.set("categoryId", categoryId)
if (!hasConvexUrl()) {
const staticResults = await searchStaticEbayListings(keywords, maxResults)
return NextResponse.json({
query: keywords,
results: staticResults,
cache: getCacheStateFallback(),
})
}
try {
const request = (async () => {
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
})
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
if (!response.ok) {
const errorMessage = await readEbayErrorMessage(response)
throw new Error(errorMessage)
}
const ranked = rankListingsForQuery(
keywords,
listings as CachedEbayListing[],
maxResults
)
const data = await response.json()
// Parse eBay API response
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
if (!findItemsAdvancedResponse) {
return []
}
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
if (
!searchResult ||
!searchResult.item ||
searchResult.item.length === 0
) {
return []
}
const items = Array.isArray(searchResult.item)
? searchResult.item
: [searchResult.item]
return items.map((item: any) => normalizeItem(item))
})()
inFlightSearchResponses.set(cacheKey, request)
const results = await request
setCachedSearchResults(cacheKey, results)
return NextResponse.json(results)
return NextResponse.json({
query: keywords,
results: ranked,
cache: overview,
})
} catch (error) {
console.error("Error fetching from eBay API:", error)
console.error("Failed to load cached eBay listings:", error)
const staticResults = await searchStaticEbayListings(keywords, maxResults)
return NextResponse.json(
{
error:
query: keywords,
results: staticResults,
cache: getCacheStateFallback(
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
? `Using bundled manual cache because cached listings failed: ${error.message}`
: "Using bundled manual cache because cached listings failed."
),
},
{ status: 500 }
{ status: 200 }
)
} finally {
inFlightSearchResponses.delete(cacheKey)
}
}

View file

@ -40,18 +40,13 @@ import {
} from "@/lib/manuals-config"
import { ManualViewer } from "@/components/manual-viewer"
import { getManualsWithParts } from "@/lib/parts-lookup"
import { ebayClient } from "@/lib/ebay-api"
import type { CachedEbayListing, EbayCacheState } from "@/lib/ebay-parts-match"
// Product Suggestion Component
interface ProductSuggestion {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
affiliateLink: string
condition?: string
interface ProductSuggestionsResponse {
query: string
results: CachedEbayListing[]
cache: EbayCacheState
error?: string
}
interface ProductSuggestionsProps {
@ -63,33 +58,55 @@ function ProductSuggestions({
manual,
className = "",
}: ProductSuggestionsProps) {
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
const [suggestions, setSuggestions] = useState<CachedEbayListing[]>([])
const [cache, setCache] = useState<EbayCacheState | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!ebayClient.isConfigured()) {
setIsLoading(false)
return
}
async function loadSuggestions() {
setIsLoading(true)
setError(null)
try {
// Generate search query from manual content
const query = `${manual.manufacturer} ${manual.category} vending machine`
const results = await ebayClient.searchItems({
const query = [
manual.manufacturer,
manual.category,
manual.commonNames?.[0],
manual.searchTerms?.[0],
"vending machine",
]
.filter(Boolean)
.join(" ")
const params = new URLSearchParams({
keywords: query,
maxResults: 6,
maxResults: "6",
sortOrder: "BestMatch",
})
setSuggestions(results)
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = (await response.json().catch(() => null)) as
| ProductSuggestionsResponse
| null
if (!response.ok || !body) {
throw new Error(
body && typeof body.error === "string"
? body.error
: `Failed to load cached listings (${response.status})`
)
}
setSuggestions(Array.isArray(body.results) ? body.results : [])
setCache(body.cache || null)
} catch (err) {
console.error("Error loading product suggestions:", err)
setError("Could not load product suggestions")
setSuggestions([])
setCache(null)
setError(
err instanceof Error ? err.message : "Could not load product suggestions"
)
} finally {
setIsLoading(false)
}
@ -100,10 +117,6 @@ function ProductSuggestions({
}
}, [manual])
if (!ebayClient.isConfigured()) {
return null
}
if (isLoading) {
return (
<div
@ -121,9 +134,16 @@ function ProductSuggestions({
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<AlertCircle className="h-6 w-6 text-red-500" />
<span className="ml-2 text-sm text-red-500">{error}</span>
<div className="flex flex-col items-center justify-center gap-2 h-32 text-center">
<AlertCircle className="h-6 w-6 text-yellow-600" />
<span className="text-sm text-yellow-700 dark:text-yellow-200">
{error}
</span>
{cache?.lastSuccessfulAt ? (
<span className="text-[11px] text-yellow-600/80 dark:text-yellow-200/70">
Last refreshed {new Date(cache.lastSuccessfulAt).toLocaleString()}
</span>
) : null}
</div>
</div>
)
@ -134,10 +154,15 @@ function ProductSuggestions({
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center justify-center gap-2 h-32 text-center">
<AlertCircle className="h-6 w-6 text-yellow-500" />
<span className="ml-2 text-sm text-yellow-600">
No products found in sandbox environment
<span className="text-sm text-yellow-700 dark:text-yellow-200">
No cached eBay matches yet
</span>
<span className="text-[11px] text-yellow-600/80 dark:text-yellow-200/70">
{cache?.isStale
? "The background poll is behind, so this manual is showing the last known cache."
: "Try again after the next periodic cache refresh."}
</span>
</div>
</div>
@ -154,6 +179,14 @@ function ProductSuggestions({
Related Products
</h3>
</div>
{cache && (
<div className="mb-3 text-[11px] text-yellow-700/80 dark:text-yellow-200/70">
{cache.lastSuccessfulAt
? `Cache refreshed ${new Date(cache.lastSuccessfulAt).toLocaleString()}`
: "Cache is warming up"}
{cache.isStale ? " • stale cache" : ""}
</div>
)}
<div className="grid grid-cols-2 gap-3">
{suggestions.map((product) => (

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
interface PartsPanelProps {
@ -17,6 +18,26 @@ export function PartsPanel({
const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cache, setCache] = useState<EbayCacheState | null>(null)
const formatFreshness = (value: number | null) => {
if (!value) {
return "not refreshed yet"
}
const minutes = Math.max(0, Math.floor(value / 60000))
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const loadParts = useCallback(async () => {
setIsLoading(true)
@ -26,9 +47,11 @@ export function PartsPanel({
const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts)
setError(result.error ?? null)
setCache(result.cache ?? null)
} catch (err) {
console.error("Error loading parts:", err)
setParts([])
setCache(null)
setError("Could not load parts")
} finally {
setIsLoading(false)
@ -42,6 +65,7 @@ export function PartsPanel({
}, [loadParts, manualFilename])
const hasListings = parts.some((part) => part.ebayListings.length > 0)
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
const renderStatusCard = (title: string, message: string) => (
<div className={`flex flex-col h-full ${className}`}>
@ -83,6 +107,14 @@ export function PartsPanel({
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@ -107,12 +139,20 @@ export function PartsPanel({
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
@ -132,10 +172,18 @@ export function PartsPanel({
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No live eBay matches found for these parts yet
No cached eBay matches found for these parts yet
</div>
</div>
)
@ -150,6 +198,14 @@ export function PartsPanel({
Parts ({parts.length})
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
@ -159,15 +215,10 @@ export function PartsPanel({
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium">
Live eBay listings are unavailable right now.
Cached eBay listings are unavailable right now.
</p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error.includes("eBay API not configured")
? "Set EBAY_APP_ID in the app environment, then reload the panel."
: error.toLowerCase().includes("rate limit") ||
error.toLowerCase().includes("exceeded")
? "eBay is temporarily rate-limited. Reload after a short wait."
: error}
{error}
</p>
</div>
</div>

13
convex/crons.ts Normal file
View file

@ -0,0 +1,13 @@
import { cronJobs } from "convex/server"
import { internal } from "./_generated/api"
const crons = cronJobs()
crons.interval(
"ebay-manual-parts-refresh",
{ hours: 24 },
internal.ebay.refreshCache,
{ reason: "cron" }
)
export default crons

536
convex/ebay.ts Normal file
View file

@ -0,0 +1,536 @@
// @ts-nocheck
import { action, internalMutation, query } from "./_generated/server"
import { api, internal } from "./_generated/api"
import { v } from "convex/values"
const POLL_KEY = "manual-parts"
const LISTING_EXPIRES_MS = 14 * 24 * 60 * 60 * 1000
const STALE_AFTER_MS = 36 * 60 * 60 * 1000
const BASE_REFRESH_MS = 24 * 60 * 60 * 1000
const MAX_BACKOFF_MS = 7 * 24 * 60 * 60 * 1000
const MAX_RESULTS_PER_QUERY = 8
const MAX_UNIQUE_RESULTS = 48
const POLL_QUERIES = [
{
label: "vending machine parts",
keywords: "vending machine part",
categoryId: "11700",
},
{
label: "coin mech",
keywords: "coin mech vending",
categoryId: "11700",
},
{
label: "control board",
keywords: "vending machine control board",
categoryId: "11700",
},
{
label: "snack machine parts",
keywords: "snack machine part",
categoryId: "11700",
},
{
label: "beverage machine parts",
keywords: "beverage machine part",
categoryId: "11700",
},
] as const
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function buildAffiliateLink(viewItemUrl: string): string {
const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
if (!campaignId) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", campaignId)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
function firstValue<T>(value: T | T[] | undefined): T | undefined {
if (value === undefined) {
return undefined
}
return Array.isArray(value) ? value[0] : value
}
function isRateLimitError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("10001") ||
normalized.includes("rate limit") ||
normalized.includes("too many requests") ||
normalized.includes("exceeded the number of times") ||
normalized.includes("quota")
)
}
function getBackoffMs(consecutiveFailures: number, rateLimited: boolean) {
const base = rateLimited ? 2 * BASE_REFRESH_MS : BASE_REFRESH_MS / 2
const multiplier = Math.max(1, consecutiveFailures)
return Math.min(base * multiplier, MAX_BACKOFF_MS)
}
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning raw text.
}
return text.trim() || `eBay API error: ${response.status}`
}
function readEbayJsonError(data: any): string | null {
const errorMessage = data?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(errorMessage?.message)
? errorMessage.message[0]
: errorMessage?.message
if (typeof message !== "string" || !message.trim()) {
return null
}
const errorId = Array.isArray(errorMessage?.errorId)
? errorMessage.errorId[0]
: errorMessage?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
function normalizeEbayItem(item: any, fetchedAt: number) {
const currentPrice = firstValue(item?.sellingStatus?.currentPrice)
const shippingCost = firstValue(item?.shippingInfo?.shippingServiceCost)
const condition = firstValue(item?.condition)
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
const title = item?.title || "Unknown Item"
return {
itemId: String(item?.itemId || ""),
title,
normalizedTitle: normalizeText(title),
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: item?.galleryURL || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: buildAffiliateLink(viewItemUrl),
sourceQueries: [] as string[],
fetchedAt,
firstSeenAt: fetchedAt,
lastSeenAt: fetchedAt,
expiresAt: fetchedAt + LISTING_EXPIRES_MS,
active: true,
}
}
async function searchEbayListings(query: (typeof POLL_QUERIES)[number]) {
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
throw new Error("eBay App ID is not configured")
}
const url = new URL("https://svcs.ebay.com/services/search/FindingService/v1")
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", query.keywords)
url.searchParams.set("sortOrder", "BestMatch")
url.searchParams.set("paginationInput.entriesPerPage", String(MAX_RESULTS_PER_QUERY))
if (query.categoryId) {
url.searchParams.set("categoryId", query.categoryId)
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
throw new Error(await readEbayErrorMessage(response))
}
const data = await response.json()
const jsonError = readEbayJsonError(data)
if (jsonError) {
throw new Error(jsonError)
}
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
const searchResult = findItemsAdvancedResponse?.searchResult?.[0]
const items = Array.isArray(searchResult?.item)
? searchResult.item
: searchResult?.item
? [searchResult.item]
: []
return items
}
export const getCacheOverview = query({
args: {},
handler: async (ctx) => {
const now = Date.now()
const state =
(await ctx.db
.query("ebayPollState")
.withIndex("by_key", (q) => q.eq("key", POLL_KEY))
.unique()) || null
const listings = await ctx.db.query("ebayListings").collect()
const activeListings = listings.filter(
(listing) => listing.active && listing.expiresAt >= now
)
const freshnessMs = state?.lastSuccessfulAt
? Math.max(0, now - state.lastSuccessfulAt)
: null
return {
key: POLL_KEY,
status: state?.status || "idle",
lastSuccessfulAt: state?.lastSuccessfulAt || null,
lastAttemptAt: state?.lastAttemptAt || null,
nextEligibleAt: state?.nextEligibleAt || null,
lastError: state?.lastError || null,
consecutiveFailures: state?.consecutiveFailures || 0,
queryCount: state?.queryCount || 0,
itemCount: state?.itemCount || 0,
sourceQueries: state?.sourceQueries || [],
freshnessMs,
isStale: freshnessMs !== null ? freshnessMs >= STALE_AFTER_MS : true,
listingCount: listings.length,
activeListingCount: activeListings.length,
}
},
})
export const listCachedListings = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now()
const listings = await ctx.db.query("ebayListings").collect()
const normalized = listings.map((listing) => ({
...listing,
active: listing.active && listing.expiresAt >= now,
}))
normalized.sort((a, b) => {
if (a.active !== b.active) {
return Number(b.active) - Number(a.active)
}
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
return bFreshness - aFreshness
})
return typeof args.limit === "number" && args.limit > 0
? normalized.slice(0, args.limit)
: normalized
},
})
export const refreshCache = action({
args: {
reason: v.optional(v.string()),
force: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const now = Date.now()
const state =
(await ctx.runQuery(api.ebay.getCacheOverview, {})) || ({
status: "idle",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
} as const)
if (
!args.force &&
typeof state.nextEligibleAt === "number" &&
state.nextEligibleAt > now
) {
return {
status: "skipped",
message: "Refresh is deferred until the next eligible window.",
nextEligibleAt: state.nextEligibleAt,
}
}
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
const nextEligibleAt = now + BASE_REFRESH_MS
await ctx.runMutation(internal.ebay.upsertPollResult, {
key: POLL_KEY,
status: "missing_config",
lastAttemptAt: now,
lastSuccessfulAt: state.lastSuccessfulAt || null,
nextEligibleAt,
lastError: "EBAY_APP_ID is not configured.",
consecutiveFailures: state.consecutiveFailures + 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
listings: [],
reason: args.reason || "missing_config",
})
return {
status: "missing_config",
message: "EBAY_APP_ID is not configured.",
nextEligibleAt,
}
}
const sourceQueries: string[] = []
const collectedListings = new Map<string, ReturnType<typeof normalizeEbayItem>>()
let queryCount = 0
let rateLimited = false
let lastError: string | null = null
for (const query of POLL_QUERIES) {
if (collectedListings.size >= MAX_UNIQUE_RESULTS) {
break
}
queryCount += 1
sourceQueries.push(query.label)
try {
const items = await searchEbayListings(query)
for (const item of items) {
const listing = normalizeEbayItem(item, now)
if (!listing.itemId) {
continue
}
const existing = collectedListings.get(listing.itemId)
if (existing) {
existing.sourceQueries = Array.from(
new Set([...existing.sourceQueries, query.label])
)
existing.title = listing.title || existing.title
existing.normalizedTitle = normalizeText(existing.title)
existing.price = listing.price || existing.price
existing.currency = listing.currency || existing.currency
existing.imageUrl = listing.imageUrl || existing.imageUrl
existing.viewItemUrl = listing.viewItemUrl || existing.viewItemUrl
existing.condition = listing.condition || existing.condition
existing.shippingCost = listing.shippingCost || existing.shippingCost
existing.affiliateLink = listing.affiliateLink || existing.affiliateLink
existing.lastSeenAt = now
existing.fetchedAt = now
existing.expiresAt = now + LISTING_EXPIRES_MS
existing.active = true
continue
}
listing.sourceQueries = [query.label]
collectedListings.set(listing.itemId, listing)
}
} catch (error) {
lastError = error instanceof Error ? error.message : "Failed to refresh eBay listings"
if (isRateLimitError(lastError)) {
rateLimited = true
break
}
}
}
const listings = Array.from(collectedListings.values())
const nextEligibleAt = now + getBackoffMs(
state.consecutiveFailures + 1,
rateLimited
)
await ctx.runMutation(internal.ebay.upsertPollResult, {
key: POLL_KEY,
status: rateLimited
? "rate_limited"
: lastError
? "error"
: "success",
lastAttemptAt: now,
lastSuccessfulAt: rateLimited || lastError ? state.lastSuccessfulAt || null : now,
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
lastError: lastError || null,
consecutiveFailures:
rateLimited || lastError ? state.consecutiveFailures + 1 : 0,
queryCount,
itemCount: listings.length,
sourceQueries,
listings,
reason: args.reason || "cron",
})
return {
status: rateLimited ? "rate_limited" : lastError ? "error" : "success",
message: lastError || undefined,
queryCount,
itemCount: listings.length,
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
}
},
})
export const upsertPollResult = internalMutation({
args: {
key: v.string(),
status: v.union(
v.literal("idle"),
v.literal("success"),
v.literal("rate_limited"),
v.literal("error"),
v.literal("missing_config"),
v.literal("skipped")
),
lastAttemptAt: v.number(),
lastSuccessfulAt: v.union(v.number(), v.null()),
nextEligibleAt: v.union(v.number(), v.null()),
lastError: v.union(v.string(), v.null()),
consecutiveFailures: v.number(),
queryCount: v.number(),
itemCount: v.number(),
sourceQueries: v.array(v.string()),
listings: v.array(
v.object({
itemId: v.string(),
title: v.string(),
normalizedTitle: v.string(),
price: v.string(),
currency: v.string(),
imageUrl: v.optional(v.string()),
viewItemUrl: v.string(),
condition: v.optional(v.string()),
shippingCost: v.optional(v.string()),
affiliateLink: v.string(),
sourceQueries: v.array(v.string()),
fetchedAt: v.number(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiresAt: v.number(),
active: v.boolean(),
})
),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
for (const listing of args.listings) {
const existing = await ctx.db
.query("ebayListings")
.withIndex("by_itemId", (q) => q.eq("itemId", listing.itemId))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
title: listing.title,
normalizedTitle: listing.normalizedTitle,
price: listing.price,
currency: listing.currency,
imageUrl: listing.imageUrl,
viewItemUrl: listing.viewItemUrl,
condition: listing.condition,
shippingCost: listing.shippingCost,
affiliateLink: listing.affiliateLink,
sourceQueries: Array.from(
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
),
fetchedAt: listing.fetchedAt,
firstSeenAt: existing.firstSeenAt || listing.firstSeenAt,
lastSeenAt: listing.lastSeenAt,
expiresAt: listing.expiresAt,
active: listing.active,
})
continue
}
await ctx.db.insert("ebayListings", listing)
}
const existingState = await ctx.db
.query("ebayPollState")
.withIndex("by_key", (q) => q.eq("key", args.key))
.unique()
const stateRecord: Record<string, any> = {
key: args.key,
status: args.status,
lastAttemptAt: args.lastAttemptAt,
consecutiveFailures: args.consecutiveFailures,
queryCount: args.queryCount,
itemCount: args.itemCount,
sourceQueries: args.sourceQueries,
updatedAt: Date.now(),
}
if (args.lastSuccessfulAt !== null) {
stateRecord.lastSuccessfulAt = args.lastSuccessfulAt
}
if (args.nextEligibleAt !== null) {
stateRecord.nextEligibleAt = args.nextEligibleAt
}
if (args.lastError !== null) {
stateRecord.lastError = args.lastError
}
if (existingState) {
await ctx.db.patch(existingState._id, stateRecord)
return await ctx.db.get(existingState._id)
}
const id = await ctx.db.insert("ebayPollState", stateRecord)
return await ctx.db.get(id)
},
})

View file

@ -90,6 +90,50 @@ export default defineSchema({
.index("by_category", ["category"])
.index("by_path", ["path"]),
ebayListings: defineTable({
itemId: v.string(),
title: v.string(),
normalizedTitle: v.string(),
price: v.string(),
currency: v.string(),
imageUrl: v.optional(v.string()),
viewItemUrl: v.string(),
condition: v.optional(v.string()),
shippingCost: v.optional(v.string()),
affiliateLink: v.string(),
sourceQueries: v.array(v.string()),
fetchedAt: v.number(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiresAt: v.number(),
active: v.boolean(),
})
.index("by_itemId", ["itemId"])
.index("by_active", ["active"])
.index("by_expiresAt", ["expiresAt"])
.index("by_lastSeenAt", ["lastSeenAt"]),
ebayPollState: defineTable({
key: v.string(),
status: v.union(
v.literal("idle"),
v.literal("success"),
v.literal("rate_limited"),
v.literal("error"),
v.literal("missing_config"),
v.literal("skipped")
),
lastSuccessfulAt: v.optional(v.number()),
lastAttemptAt: v.optional(v.number()),
nextEligibleAt: v.optional(v.number()),
lastError: v.optional(v.string()),
consecutiveFailures: v.number(),
queryCount: v.number(),
itemCount: v.number(),
sourceQueries: v.array(v.string()),
updatedAt: v.number(),
}).index("by_key", ["key"]),
manualCategories: defineTable({
name: v.string(),
slug: v.string(),

View file

@ -0,0 +1,52 @@
# eBay Cache Diagnosis
Use this when the manuals/parts experience looks empty or stale and you want to know whether the problem is env, Convex, cache data, or the browser UI.
## What It Checks
- Public pages: `/`, `/contact-us`, `/products`, `/manuals`
- eBay cache routes:
- `GET /api/ebay/search?keywords=vending machine part`
- `POST /api/ebay/manual-parts`
- Notification validation:
- `GET /api/ebay/notifications?challenge_code=...`
- Admin refresh:
- `POST /api/admin/ebay/refresh` when an admin token is provided
- Browser smoke:
- Loads `/manuals`
- Opens the AP parts manual viewer
- Confirms the viewer or fallback state is visible
## How To Run
Local:
```bash
pnpm diagnose:ebay
```
Staging:
```bash
pnpm diagnose:ebay --base-url https://rmv.abundancepartners.app --admin-token "$ADMIN_API_TOKEN"
```
You can skip browser checks if Playwright browsers are unavailable:
```bash
SMOKE_SKIP_BROWSER=1 pnpm diagnose:ebay
```
## How To Read The Output
- `NEXT_PUBLIC_CONVEX_URL missing`
- The cache routes will intentionally fall back to `status: disabled`.
- `cache.status=disabled` with `Server Error`
- The app reached Convex, but the backend/query layer failed.
- `cache.status=success` or `idle`
- The cache backend is reachable. If listings are `0`, the cache is simply empty.
- Notification challenge returns `200`
- The eBay validation endpoint is wired correctly.
- Admin refresh returns `2xx`
- The cache seeding path is available and the admin token is accepted.

424
lib/ebay-parts-match.ts Normal file
View file

@ -0,0 +1,424 @@
export type CachedEbayListing = {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
normalizedTitle?: string
sourceQueries?: string[]
fetchedAt?: number
firstSeenAt?: number
lastSeenAt?: number
expiresAt?: number
active?: boolean
}
export type ManualPartInput = {
partNumber: string
description: string
manufacturer?: string
category?: string
manualFilename?: string
}
export type EbayCacheState = {
key: string
status:
| "idle"
| "success"
| "rate_limited"
| "error"
| "missing_config"
| "skipped"
| "disabled"
lastSuccessfulAt: number | null
lastAttemptAt: number | null
nextEligibleAt: number | null
lastError: string | null
consecutiveFailures: number
queryCount: number
itemCount: number
sourceQueries: string[]
freshnessMs: number | null
isStale: boolean
listingCount?: number
activeListingCount?: number
message?: string
}
const GENERIC_PART_TERMS = new Set([
"and",
"the",
"for",
"with",
"from",
"page",
"part",
"parts",
"number",
"numbers",
"read",
"across",
"refer",
"reference",
"shown",
"figure",
"fig",
"rev",
"revision",
"item",
"items",
"assembly",
"assy",
"machine",
"vending",
])
const COMMON_QUERY_STOPWORDS = new Set([
"a",
"an",
"and",
"for",
"in",
"of",
"the",
"to",
"with",
"vending",
"machine",
"machines",
"part",
"parts",
])
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function tokenize(value: string): string[] {
return Array.from(
new Set(
normalizeText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length > 1 && !COMMON_QUERY_STOPWORDS.has(token))
)
)
}
function listingSearchText(listing: Pick<CachedEbayListing, "title" | "normalizedTitle">): string {
return normalizeText(listing.normalizedTitle || listing.title)
}
function isListingFresh(listing: CachedEbayListing): boolean {
if (listing.active === false) {
return false
}
if (typeof listing.expiresAt === "number") {
return listing.expiresAt >= Date.now()
}
return true
}
function sourceQueryBonus(listing: CachedEbayListing, queryTerms: string[]): number {
if (!listing.sourceQueries || listing.sourceQueries.length === 0) {
return 0
}
const sourceText = listing.sourceQueries.map((query) => normalizeText(query)).join(" ")
let bonus = 0
for (const term of queryTerms) {
if (sourceText.includes(term)) {
bonus += 3
}
}
return bonus
}
function computeTokenOverlapScore(queryTerms: string[], haystackText: string): number {
let score = 0
for (const term of queryTerms) {
if (haystackText.includes(term)) {
score += 8
}
}
return score
}
function scoreListingForPart(part: ManualPartInput, listing: CachedEbayListing): number {
const partNumber = normalizeText(part.partNumber)
const description = normalizeText(part.description)
const manufacturer = normalizeText(part.manufacturer || "")
const category = normalizeText(part.category || "")
const titleText = listingSearchText(listing)
const listingTokens = tokenize(listing.title)
const descriptionTokens = tokenize(part.description)
const manufacturerTokens = tokenize(part.manufacturer || "")
const categoryTokens = tokenize(part.category || "")
let score = isListingFresh(listing) ? 10 : -6
if (!partNumber) {
return -100
}
if (titleText.includes(partNumber)) {
score += 110
}
const compactPartNumber = partNumber.replace(/\s+/g, "")
const compactTitle = titleText.replace(/\s+/g, "")
if (compactPartNumber && compactTitle.includes(compactPartNumber)) {
score += 90
}
const exactTokenMatch = listingTokens.includes(partNumber)
if (exactTokenMatch) {
score += 80
}
const digitsOnlyPart = partNumber.replace(/[^0-9]/g, "")
if (digitsOnlyPart.length >= 4 && compactTitle.includes(digitsOnlyPart)) {
score += 40
}
if (description) {
const overlap = descriptionTokens.filter((token) => titleText.includes(token)).length
score += Math.min(overlap * 7, 28)
}
if (manufacturer) {
score += Math.min(
manufacturerTokens.filter((token) => titleText.includes(token)).length * 8,
24
)
}
if (category) {
score += Math.min(
categoryTokens.filter((token) => titleText.includes(token)).length * 5,
10
)
}
score += computeTokenOverlapScore(tokenize(part.partNumber), titleText)
score += sourceQueryBonus(listing, [
partNumber,
...descriptionTokens,
...manufacturerTokens,
...categoryTokens,
])
if (GENERIC_PART_TERMS.has(partNumber)) {
score -= 50
}
if (titleText.includes("vending") || titleText.includes("machine")) {
score += 6
}
if (listing.condition && /new|used|refurbished/i.test(listing.condition)) {
score += 2
}
return score
}
function scoreListingForQuery(query: string, listing: CachedEbayListing): number {
const queryText = normalizeText(query)
const titleText = listingSearchText(listing)
const queryTerms = tokenize(query)
let score = isListingFresh(listing) ? 10 : -6
if (!queryText) {
return -100
}
if (titleText.includes(queryText)) {
score += 70
}
score += computeTokenOverlapScore(queryTerms, titleText)
score += sourceQueryBonus(listing, queryTerms)
if (queryTerms.some((term) => titleText.includes(term))) {
score += 20
}
if (titleText.includes("vending")) {
score += 8
}
if (GENERIC_PART_TERMS.has(queryText)) {
score -= 30
}
return score
}
export function rankListingsForPart(
part: ManualPartInput,
listings: CachedEbayListing[],
limit: number
): CachedEbayListing[] {
return listings
.map((listing) => ({
listing,
score: scoreListingForPart(part, listing),
}))
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score
}
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
.filter((entry) => entry.score > 0)
.map((entry) => entry.listing)
}
export function rankListingsForQuery(
query: string,
listings: CachedEbayListing[],
limit: number
): CachedEbayListing[] {
return listings
.map((listing) => ({
listing,
score: scoreListingForQuery(query, listing),
}))
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score
}
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
.filter((entry) => entry.score > 0)
.map((entry) => entry.listing)
}
export function isEbayRateLimitError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("10001") ||
normalized.includes("rate limit") ||
normalized.includes("exceeded the number of times") ||
normalized.includes("too many requests") ||
normalized.includes("quota")
)
}
export function buildAffiliateLink(
viewItemUrl: string,
campaignId?: string | null
): string {
const trimmedCampaignId = campaignId?.trim() || ""
if (!trimmedCampaignId) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", trimmedCampaignId)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
export function normalizeEbayItem(
item: any,
options?: {
campaignId?: string
sourceQuery?: string
fetchedAt?: number
existing?: CachedEbayListing
expiresAt?: number
}
): CachedEbayListing {
const currentPrice = item?.sellingStatus?.currentPrice
const shippingCost = item?.shippingInfo?.shippingServiceCost
const condition = item?.condition
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
const title = item?.title || "Unknown Item"
const fetchedAt = options?.fetchedAt ?? Date.now()
const existing = options?.existing
const sourceQueries = Array.from(
new Set([
...(existing?.sourceQueries || []),
...(options?.sourceQuery ? [options.sourceQuery] : []),
])
)
return {
itemId: String(item?.itemId || existing?.itemId || ""),
title,
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: item?.galleryURL || existing?.imageUrl || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || existing?.condition || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: existing?.shippingCost,
affiliateLink: buildAffiliateLink(viewItemUrl, options?.campaignId),
normalizedTitle: normalizeText(title),
sourceQueries,
firstSeenAt: existing?.firstSeenAt ?? fetchedAt,
lastSeenAt: fetchedAt,
fetchedAt,
expiresAt: options?.expiresAt ?? existing?.expiresAt ?? fetchedAt,
active: true,
}
}
export function sortListingsByFreshness(listings: CachedEbayListing[]): CachedEbayListing[] {
return [...listings].sort((a, b) => {
const aActive = a.active === false ? 0 : 1
const bActive = b.active === false ? 0 : 1
if (aActive !== bActive) {
return bActive - aActive
}
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
return bFreshness - aFreshness
})
}
export function estimateListingFreshness(now: number, lastSuccessfulAt?: number) {
if (!lastSuccessfulAt) {
return {
isFresh: false,
isStale: true,
freshnessMs: null as number | null,
}
}
const freshnessMs = Math.max(0, now - lastSuccessfulAt)
return {
isFresh: freshnessMs < 24 * 60 * 60 * 1000,
isStale: freshnessMs >= 24 * 60 * 60 * 1000,
freshnessMs,
}
}

View file

@ -2,24 +2,20 @@
* Parts lookup utility for frontend
*
* Provides functions to fetch parts data by manual filename.
* Static JSON remains the primary data source, while live eBay fallback
* goes through the server route so credentials never reach the browser.
* Static JSON remains the primary data source, while cached eBay matches
* are fetched from the server so normal browsing never reaches eBay.
*/
import type {
CachedEbayListing,
EbayCacheState,
ManualPartInput,
} from "@/lib/ebay-parts-match"
export interface PartForPage {
partNumber: string
description: string
ebayListings: Array<{
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}>
ebayListings: CachedEbayListing[]
}
interface ManualPartsLookup {
@ -32,30 +28,38 @@ interface ManualPagesParts {
}
}
interface EbaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
interface EbaySearchResponse {
results: EbaySearchResult[]
interface CachedPartsResponse {
manualFilename: string
parts: Array<
ManualPartInput & {
ebayListings: CachedEbayListing[]
}
>
cache: EbayCacheState
error?: string
}
// Cache for eBay search results
const ebaySearchCache = new Map<
interface CachedEbaySearchResponse {
results: CachedEbayListing[]
cache: EbayCacheState
error?: string
}
const cachedManualMatchResponses = new Map<
string,
{ results: EbaySearchResult[]; timestamp: number }
{ response: CachedPartsResponse; timestamp: number }
>()
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000
const cachedEbaySearchResponses = new Map<
string,
{ response: CachedEbaySearchResponse; timestamp: number }
>()
const inFlightEbaySearches = new Map<
string,
Promise<CachedEbaySearchResponse>
>()
const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000
const GENERIC_PART_TERMS = new Set([
"and",
@ -129,121 +133,196 @@ async function loadPartsData(): Promise<{
}
}
/**
* Search eBay for parts with caching.
* This calls the server route so the app never needs direct eBay credentials
* in client code.
*/
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: errorMessage || "eBay cache unavailable.",
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message: errorMessage || "eBay cache unavailable.",
}
}
async function fetchManualPartsMatches(
manualFilename: string,
parts: ManualPartInput[],
limit: number
): Promise<CachedPartsResponse> {
const cacheKey = [
manualFilename.trim().toLowerCase(),
String(limit),
parts
.map((part) =>
[
part.partNumber.trim().toLowerCase(),
part.description.trim().toLowerCase(),
part.manufacturer?.trim().toLowerCase() || "",
part.category?.trim().toLowerCase() || "",
].join(":")
)
.join("|"),
].join("::")
const cached = cachedManualMatchResponses.get(cacheKey)
if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) {
return cached.response
}
const inFlight = inFlightManualMatchRequests.get(cacheKey)
if (inFlight) {
return inFlight
}
const request = (async () => {
try {
const response = await fetch("/api/ebay/manual-parts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
manualFilename,
parts,
limit,
}),
})
const body = await response.json().catch(() => null)
if (!response.ok || !body || typeof body !== "object") {
const message =
body && typeof body.error === "string"
? body.error
: `Failed to load cached parts (${response.status})`
return {
manualFilename,
parts: parts.map((part) => ({
...part,
ebayListings: [],
})),
cache: makeFallbackCacheState(message),
error: message,
}
}
const partsResponse = body as CachedPartsResponse
return {
manualFilename: partsResponse.manualFilename || manualFilename,
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
cache: partsResponse.cache || makeFallbackCacheState(),
error:
typeof (partsResponse as CachedPartsResponse).error === "string"
? (partsResponse as CachedPartsResponse).error
: undefined,
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load cached parts"
return {
manualFilename,
parts: parts.map((part) => ({
...part,
ebayListings: [],
})),
cache: makeFallbackCacheState(message),
error: message,
}
}
})()
inFlightManualMatchRequests.set(cacheKey, request)
try {
const response = await request
cachedManualMatchResponses.set(cacheKey, {
response,
timestamp: Date.now(),
})
return response
} finally {
inFlightManualMatchRequests.delete(cacheKey)
}
}
async function searchEBayForParts(
partNumber: string,
description?: string,
manufacturer?: string
): Promise<EbaySearchResponse> {
): Promise<CachedEbaySearchResponse> {
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
// Check cache
const cached = ebaySearchCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
return { results: cached.results as EbaySearchResult[] }
const cached = cachedEbaySearchResponses.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
return cached.response
}
const buildQuery = () => {
let query = partNumber
if (description && description.length > 0 && description.length < 50) {
const descWords = description
.split(/\s+/)
.filter((word) => word.length > 3)
.slice(0, 3)
.join(" ")
if (descWords) {
query += ` ${descWords}`
}
}
if (manufacturer) {
query += ` ${manufacturer}`
}
return `${query} vending machine`
const inFlight = inFlightEbaySearches.get(cacheKey)
if (inFlight) {
return inFlight
}
const searchViaApi = async (
categoryId?: string
): Promise<EbaySearchResponse> => {
const requestKey = `${cacheKey}:${categoryId || "general"}`
const request = (async () => {
try {
const params = new URLSearchParams({
keywords: [partNumber, description, manufacturer, "vending machine"]
.filter(Boolean)
.join(" "),
maxResults: "3",
sortOrder: "BestMatch",
})
const inFlight = inFlightEbaySearches.get(requestKey)
if (inFlight) {
return inFlight
}
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = await response.json().catch(() => null)
const params = new URLSearchParams({
keywords: buildQuery(),
maxResults: "3",
sortOrder: "BestMatch",
})
if (categoryId) {
params.set("categoryId", categoryId)
}
const request = (async () => {
try {
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = await response.json().catch(() => null)
if (!response.ok) {
const message =
body && typeof body.error === "string"
? body.error
: `eBay API error: ${response.status}`
return { results: [], error: message }
}
const results = Array.isArray(body) ? body : []
return { results }
} catch (error) {
if (!response.ok || !body || typeof body !== "object") {
const message =
body && typeof body.error === "string"
? body.error
: `Failed to load cached eBay listings (${response.status})`
return {
results: [],
error:
error instanceof Error ? error.message : "Failed to search eBay",
cache: makeFallbackCacheState(message),
error: message,
}
}
})()
inFlightEbaySearches.set(requestKey, request)
try {
return await request
} finally {
inFlightEbaySearches.delete(requestKey)
return {
results: Array.isArray((body as any).results)
? ((body as any).results as CachedEbayListing[])
: [],
cache: (body as any).cache || makeFallbackCacheState(),
error:
typeof (body as any).error === "string" ? (body as any).error : undefined,
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load cached eBay listings"
return {
results: [],
cache: makeFallbackCacheState(message),
error: message,
}
}
}
})()
const categorySearch = await searchViaApi("11700")
if (categorySearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: categorySearch.results,
inFlightEbaySearches.set(cacheKey, request)
try {
const response = await request
cachedEbaySearchResponses.set(cacheKey, {
response,
timestamp: Date.now(),
})
return categorySearch
}
const generalSearch = await searchViaApi()
if (generalSearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: generalSearch.results,
timestamp: Date.now(),
})
return generalSearch
}
return {
results: [],
error: categorySearch.error || generalSearch.error,
return response
} finally {
inFlightEbaySearches.delete(cacheKey)
}
}
@ -490,8 +569,17 @@ export async function getPartsForPage(
return []
}
const enhanced = await enhancePartsData(parts)
return enhanced.parts
const matched = await fetchManualPartsMatches(
manualFilename,
parts.map((part) => ({
partNumber: part.partNumber,
description: part.description,
manualFilename,
})),
Math.max(parts.length, 1)
)
return matched.parts as PartForPage[]
}
/**
@ -503,6 +591,7 @@ export async function getTopPartsForManual(
): Promise<{
parts: PartForPage[]
error?: string
cache?: EbayCacheState
}> {
const { parts } = await getPartsForManualWithStatus(manualFilename)
@ -514,23 +603,20 @@ export async function getTopPartsForManual(
parts,
Math.max(limit * 2, limit)
)
const { parts: enrichedParts, error } =
await enhancePartsData(liveSearchCandidates)
const sorted = enrichedParts.sort((a, b) => {
const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0
const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0
if (aHasLiveListings !== bHasLiveListings) {
return bHasLiveListings - aHasLiveListings
}
return b.ebayListings.length - a.ebayListings.length
})
const matched = await fetchManualPartsMatches(
manualFilename,
liveSearchCandidates.map((part) => ({
partNumber: part.partNumber,
description: part.description,
manualFilename,
})),
limit
)
return {
parts: sorted.slice(0, limit),
error,
parts: matched.parts as PartForPage[],
error: matched.error,
cache: matched.cache,
}
}

View file

@ -0,0 +1,208 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import {
rankListingsForQuery,
sortListingsByFreshness,
type CachedEbayListing,
} from "@/lib/ebay-parts-match"
export type ManualPartRow = {
partNumber: string
description: string
ebayListings?: CachedEbayListing[]
}
type ManualPartsLookup = Record<string, ManualPartRow[]>
type ManualPagesPartsLookup = Record<string, Record<string, ManualPartRow[]>>
let manualPartsCache: ManualPartsLookup | null = null
let manualPagesPartsCache: ManualPagesPartsLookup | null = null
let staticEbayListingsCache: CachedEbayListing[] | null = null
async function readJsonFile<T>(filename: string): Promise<T> {
const filePath = path.join(process.cwd(), "public", filename)
const contents = await readFile(filePath, "utf8")
return JSON.parse(contents) as T
}
export async function loadManualPartsLookup(): Promise<ManualPartsLookup> {
if (!manualPartsCache) {
manualPartsCache = await readJsonFile<ManualPartsLookup>(
"manual_parts_lookup.json"
)
}
return manualPartsCache
}
export async function loadManualPagesPartsLookup(): Promise<ManualPagesPartsLookup> {
if (!manualPagesPartsCache) {
manualPagesPartsCache = await readJsonFile<ManualPagesPartsLookup>(
"manual_pages_parts.json"
)
}
return manualPagesPartsCache
}
export async function findManualParts(
manualFilename: string
): Promise<ManualPartRow[]> {
const manualParts = await loadManualPartsLookup()
if (manualFilename in manualParts) {
return manualParts[manualFilename]
}
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
if (filename.toLowerCase() === lowerFilename) {
return parts
}
}
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
if (
otherWithoutExt === lowerWithoutExt ||
otherWithoutExt.includes(lowerWithoutExt) ||
lowerWithoutExt.includes(otherWithoutExt)
) {
return parts
}
}
return []
}
export async function findManualPageParts(
manualFilename: string,
pageNumber: number
): Promise<ManualPartRow[]> {
const manualPagesParts = await loadManualPagesPartsLookup()
if (
manualPagesParts[manualFilename] &&
manualPagesParts[manualFilename][pageNumber.toString()]
) {
return manualPagesParts[manualFilename][pageNumber.toString()]
}
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
if (
filename.toLowerCase() === lowerFilename &&
pages[pageNumber.toString()]
) {
return pages[pageNumber.toString()]
}
}
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
if (
otherWithoutExt === lowerWithoutExt ||
otherWithoutExt.includes(lowerWithoutExt) ||
lowerWithoutExt.includes(otherWithoutExt)
) {
if (pages[pageNumber.toString()]) {
return pages[pageNumber.toString()]
}
}
}
return []
}
export async function listManualsWithParts(): Promise<Set<string>> {
const manualParts = await loadManualPartsLookup()
const manualsWithParts = new Set<string>()
for (const [filename, parts] of Object.entries(manualParts)) {
if (parts.length > 0) {
manualsWithParts.add(filename)
manualsWithParts.add(filename.toLowerCase())
manualsWithParts.add(filename.replace(/\.pdf$/i, ""))
manualsWithParts.add(filename.replace(/\.pdf$/i, "").toLowerCase())
}
}
return manualsWithParts
}
function dedupeListings(listings: CachedEbayListing[]): CachedEbayListing[] {
const byItemId = new Map<string, CachedEbayListing>()
for (const listing of listings) {
const itemId = listing.itemId?.trim()
if (!itemId) {
continue
}
const existing = byItemId.get(itemId)
if (!existing) {
byItemId.set(itemId, listing)
continue
}
const existingFreshness = existing.lastSeenAt ?? existing.fetchedAt ?? 0
const nextFreshness = listing.lastSeenAt ?? listing.fetchedAt ?? 0
if (nextFreshness >= existingFreshness) {
byItemId.set(itemId, {
...existing,
...listing,
sourceQueries: Array.from(
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
),
})
}
}
return sortListingsByFreshness(Array.from(byItemId.values()))
}
export async function loadStaticEbayListings(): Promise<CachedEbayListing[]> {
if (staticEbayListingsCache) {
return staticEbayListingsCache
}
const [manualParts, manualPagesParts] = await Promise.all([
loadManualPartsLookup(),
loadManualPagesPartsLookup(),
])
const listings: CachedEbayListing[] = []
for (const parts of Object.values(manualParts)) {
for (const part of parts) {
if (Array.isArray(part.ebayListings)) {
listings.push(...part.ebayListings)
}
}
}
for (const pages of Object.values(manualPagesParts)) {
for (const parts of Object.values(pages)) {
for (const part of parts) {
if (Array.isArray(part.ebayListings)) {
listings.push(...part.ebayListings)
}
}
}
}
staticEbayListingsCache = dedupeListings(listings)
return staticEbayListingsCache
}
export async function searchStaticEbayListings(
query: string,
limit = 6
): Promise<CachedEbayListing[]> {
const listings = await loadStaticEbayListings()
return rankListingsForQuery(query, listings, limit)
}

View file

@ -7,10 +7,13 @@
"scripts": {
"build": "next build",
"copy:check": "node scripts/check-public-copy.mjs",
"diagnose:ebay": "node scripts/staging-smoke.mjs",
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
"manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts",
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",

480
scripts/staging-smoke.mjs Normal file
View file

@ -0,0 +1,480 @@
import { existsSync } from "node:fs"
import path from "node:path"
import process from "node:process"
import dotenv from "dotenv"
const DEFAULT_BASE_URL = "http://127.0.0.1:3000"
const DEFAULT_MANUAL_CARD_TEXT = "653-655-657-hot-drink-center-parts-manual"
const DEFAULT_MANUAL_FILENAME = "653-655-657-hot-drink-center-parts-manual.pdf"
const DEFAULT_PART_NUMBER = "CABINET"
const DEFAULT_PART_DESCRIPTION = "- CABINET ASSEMBLY (SEE FIGURES 27, 28, 29) -"
function loadEnvFile() {
const envPath = path.resolve(process.cwd(), ".env.local")
if (existsSync(envPath)) {
dotenv.config({ path: envPath, override: false })
}
}
function parseArgs(argv) {
const args = {
baseUrl: process.env.BASE_URL || DEFAULT_BASE_URL,
manualCardText: DEFAULT_MANUAL_CARD_TEXT,
manualFilename: DEFAULT_MANUAL_FILENAME,
partNumber: DEFAULT_PART_NUMBER,
partDescription: DEFAULT_PART_DESCRIPTION,
adminToken: process.env.ADMIN_API_TOKEN || "",
skipBrowser: process.env.SMOKE_SKIP_BROWSER === "1",
}
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (token === "--base-url") {
args.baseUrl = argv[index + 1] || args.baseUrl
index += 1
continue
}
if (token === "--manual-card-text") {
args.manualCardText = argv[index + 1] || args.manualCardText
index += 1
continue
}
if (token === "--manual-filename") {
args.manualFilename = argv[index + 1] || args.manualFilename
index += 1
continue
}
if (token === "--part-number") {
args.partNumber = argv[index + 1] || args.partNumber
index += 1
continue
}
if (token === "--part-description") {
args.partDescription = argv[index + 1] || args.partDescription
index += 1
continue
}
if (token === "--admin-token") {
args.adminToken = argv[index + 1] || args.adminToken
index += 1
continue
}
if (token === "--skip-browser") {
args.skipBrowser = true
}
}
return args
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "")
}
function isLocalBaseUrl(baseUrl) {
try {
const url = new URL(baseUrl)
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname)
} catch {
return false
}
}
function envPresence(name) {
return Boolean(String(process.env[name] ?? "").trim())
}
function heading(title) {
console.log(`\n== ${title} ==`)
}
function report(name, state, detail = "") {
const suffix = detail ? `${detail}` : ""
console.log(`${name}: ${state}${suffix}`)
}
function summarizeCache(cache) {
if (!cache || typeof cache !== "object") {
return "no cache payload"
}
const status = String(cache.status ?? "unknown")
const listingCount = Number(cache.listingCount ?? cache.itemCount ?? 0)
const activeCount = Number(cache.activeListingCount ?? 0)
const lastError = typeof cache.lastError === "string" ? cache.lastError : ""
const freshnessMs =
typeof cache.freshnessMs === "number" ? `${cache.freshnessMs}ms` : "n/a"
return [
`status=${status}`,
`listings=${listingCount}`,
`active=${activeCount}`,
`freshness=${freshnessMs}`,
lastError ? `lastError=${lastError}` : null,
]
.filter(Boolean)
.join(", ")
}
async function requestJson(url, init) {
const response = await fetch(url, {
redirect: "follow",
...init,
})
const text = await response.text()
let body = null
if (text.trim()) {
try {
body = JSON.parse(text)
} catch {
body = null
}
}
return { response, body, text }
}
async function checkPages(baseUrl, failures) {
heading("Public Pages")
const pages = ["/", "/contact-us", "/products", "/manuals"]
for (const pagePath of pages) {
const { response } = await requestJson(`${baseUrl}${pagePath}`)
const ok = response.status === 200
report(pagePath, ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET ${pagePath} returned ${response.status}`)
}
}
}
async function checkEbaySearch(baseUrl, failures, isLocalBase) {
heading("eBay Cache Search")
const url = new URL(`${baseUrl}/api/ebay/search`)
url.searchParams.set("keywords", "vending machine part")
url.searchParams.set("maxResults", "3")
url.searchParams.set("sortOrder", "BestMatch")
const { response, body, text } = await requestJson(url)
const ok = response.status === 200
const cache = body?.cache
const cacheStatus = cache?.status ?? "unknown"
report("GET /api/ebay/search", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /api/ebay/search returned ${response.status}`)
console.log(text)
return
}
console.log(` cache: ${summarizeCache(cache)}`)
console.log(` results: ${Array.isArray(body?.results) ? body.results.length : 0}`)
const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL")
const disabledCache = cacheStatus === "disabled"
if (hasConvexUrl && disabledCache) {
failures.push(
"eBay search returned disabled cache while NEXT_PUBLIC_CONVEX_URL is configured."
)
}
const results = Array.isArray(body?.results) ? body.results : []
if (!hasConvexUrl && isLocalBase && disabledCache) {
failures.push(
"eBay search still returned a disabled cache when local static results should be available."
)
}
if (!hasConvexUrl && isLocalBase && results.length === 0) {
failures.push("eBay search did not return any bundled listings for the smoke query.")
}
}
async function checkManualParts(baseUrl, failures, isLocalBase, manualFilename, partNumber, partDescription) {
heading("Manual Parts Match")
const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
manualFilename,
limit: 3,
parts: [
{
partNumber,
description: partDescription,
},
],
}),
})
const ok = response.status === 200
const cache = body?.cache
const cacheStatus = cache?.status ?? "unknown"
report("POST /api/ebay/manual-parts", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`POST /api/ebay/manual-parts returned ${response.status}`)
console.log(text)
return
}
const parts = Array.isArray(body?.parts) ? body.parts : []
const firstCount = Array.isArray(parts[0]?.ebayListings) ? parts[0].ebayListings.length : 0
console.log(` cache: ${summarizeCache(cache)}`)
console.log(` matched parts: ${parts.length}`)
console.log(` first part listings: ${firstCount}`)
const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL")
const disabledCache = cacheStatus === "disabled"
if (hasConvexUrl && disabledCache) {
failures.push(
"Manual parts route returned disabled cache while NEXT_PUBLIC_CONVEX_URL is configured."
)
}
if (!hasConvexUrl && isLocalBase && disabledCache) {
failures.push(
"Manual parts route still returned a disabled cache when local static results should be available."
)
}
if (!hasConvexUrl && isLocalBase && firstCount === 0) {
failures.push("Manual parts route did not return bundled listings for the smoke manual.")
}
if (!body?.manualFilename || body.manualFilename !== manualFilename) {
failures.push("Manual parts response did not echo the requested manualFilename.")
}
}
async function checkNotifications(baseUrl, failures) {
heading("eBay Notification Challenge")
const url = new URL(`${baseUrl}/api/ebay/notifications`)
url.searchParams.set("challenge_code", "diagnostic-test")
const { response, body, text } = await requestJson(url)
const ok = response.status === 200
report("GET /api/ebay/notifications", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /api/ebay/notifications returned ${response.status}`)
console.log(text)
return
}
const challengeResponse = typeof body?.challengeResponse === "string"
? body.challengeResponse
: ""
console.log(` challengeResponse: ${challengeResponse ? "present" : "missing"}`)
if (!challengeResponse || challengeResponse.length < 32) {
failures.push("Notification challenge response is missing or malformed.")
}
}
async function checkAdminRefresh(baseUrl, failures, adminToken) {
heading("Admin Refresh")
if (!adminToken) {
report("POST /api/admin/ebay/refresh", "skipped", "no admin token provided")
return
}
const { response, body, text } = await requestJson(
`${baseUrl}/api/admin/ebay/refresh`,
{
method: "POST",
headers: {
"x-admin-token": adminToken,
},
}
)
const ok = response.status >= 200 && response.status < 300
report("POST /api/admin/ebay/refresh", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`POST /api/admin/ebay/refresh returned ${response.status}`)
console.log(text)
return
}
console.log(
` result: ${body && typeof body === "object" ? JSON.stringify(body) : text || "empty"}`
)
}
async function checkBrowser(baseUrl, manualCardText, failures) {
heading("Browser UI")
if (process.env.SMOKE_SKIP_BROWSER === "1") {
report("Browser smoke", "skipped", "SMOKE_SKIP_BROWSER=1")
return
}
let chromium
try {
chromium = (await import("playwright")).chromium
} catch (error) {
report(
"Browser smoke",
"skipped",
error instanceof Error ? error.message : "playwright unavailable"
)
return
}
let browser
try {
browser = await chromium.launch({ headless: true })
} catch (error) {
report(
"Browser smoke",
"skipped",
error instanceof Error ? error.message : "browser launch failed"
)
return
}
try {
const page = await browser.newPage()
const consoleErrors = []
page.on("console", (message) => {
if (message.type() === "error") {
const text = message.text()
if (!text.includes("Failed to load resource") && !text.includes("404")) {
consoleErrors.push(text)
}
}
})
await page.goto(`${baseUrl}/manuals`, { waitUntil: "domcontentloaded" })
await page.waitForTimeout(1200)
const titleVisible = await page
.getByRole("heading", { name: "Vending Machine Manuals" })
.isVisible()
if (!titleVisible) {
failures.push("Manuals page title was not visible in the browser smoke test.")
report("Manuals page", "fail", "title not visible")
return
}
const openButton = page.getByRole("button", { name: "View PDF" }).first()
await openButton.click({ force: true })
await page.waitForTimeout(1500)
const viewerOpen = await page.getByText("Parts").first().isVisible().catch(() => false)
const viewerFallback =
(await page
.getByText("No parts data extracted for this manual yet")
.first()
.isVisible()
.catch(() => false)) ||
(await page
.getByText("No cached eBay matches yet")
.first()
.isVisible()
.catch(() => false))
if (!viewerOpen && !viewerFallback) {
failures.push("Manual viewer did not open or did not show a parts/cache state.")
report("Manual viewer", "fail", "no parts state visible")
} else {
report("Manual viewer", "ok", viewerFallback ? "fallback state visible" : "viewer open")
}
if (consoleErrors.length > 0) {
failures.push(
`Browser smoke saw console errors: ${consoleErrors.slice(0, 3).join(" | ")}`
)
}
} catch (error) {
failures.push(
`Browser smoke failed: ${error instanceof Error ? error.message : String(error)}`
)
report("Browser smoke", "fail", error instanceof Error ? error.message : String(error))
} finally {
await browser?.close().catch(() => {})
}
}
async function main() {
loadEnvFile()
const args = parseArgs(process.argv.slice(2))
const baseUrl = normalizeBaseUrl(args.baseUrl)
const isLocalBase = isLocalBaseUrl(baseUrl)
const failures = []
heading("Environment")
report(
"NEXT_PUBLIC_CONVEX_URL",
envPresence("NEXT_PUBLIC_CONVEX_URL") ? "present" : "missing"
)
report("CONVEX_URL", envPresence("CONVEX_URL") ? "present" : "missing")
report("EBAY_APP_ID", envPresence("EBAY_APP_ID") ? "present" : "missing")
report(
"EBAY_AFFILIATE_CAMPAIGN_ID",
envPresence("EBAY_AFFILIATE_CAMPAIGN_ID") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_ENDPOINT",
envPresence("EBAY_NOTIFICATION_ENDPOINT") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_VERIFICATION_TOKEN",
envPresence("EBAY_NOTIFICATION_VERIFICATION_TOKEN") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_APP_ID",
envPresence("EBAY_NOTIFICATION_APP_ID") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_CERT_ID",
envPresence("EBAY_NOTIFICATION_CERT_ID") ? "present" : "missing"
)
report("Base URL", baseUrl)
await checkPages(baseUrl, failures)
await checkEbaySearch(baseUrl, failures, isLocalBase)
await checkManualParts(
baseUrl,
failures,
isLocalBase,
args.manualFilename,
args.partNumber,
args.partDescription
)
await checkNotifications(baseUrl, failures)
await checkAdminRefresh(baseUrl, failures, args.adminToken)
if (args.skipBrowser) {
heading("Browser UI")
report("Browser smoke", "skipped", "--skip-browser was provided")
} else {
await checkBrowser(baseUrl, args.manualCardText, failures)
}
heading("Summary")
if (failures.length > 0) {
console.log("Failures:")
for (const failure of failures) {
console.log(`- ${failure}`)
}
process.exitCode = 1
return
}
console.log("All smoke checks passed.")
}
main().catch((error) => {
console.error(error)
process.exit(1)
})