deploy: stabilize manuals eBay cache flow and smoke diagnostics
This commit is contained in:
parent
087fda7ce6
commit
8fff380b24
14 changed files with 2477 additions and 442 deletions
31
app/api/admin/ebay/refresh/route.ts
Normal file
31
app/api/admin/ebay/refresh/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
269
app/api/ebay/manual-parts/route.ts
Normal file
269
app/api/ebay/manual-parts/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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
13
convex/crons.ts
Normal 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
536
convex/ebay.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
52
docs/operations/EBAY_CACHE_DIAGNOSIS.md
Normal file
52
docs/operations/EBAY_CACHE_DIAGNOSIS.md
Normal 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
424
lib/ebay-parts-match.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
208
lib/server/manual-parts-data.ts
Normal file
208
lib/server/manual-parts-data.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
480
scripts/staging-smoke.mjs
Normal 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)
|
||||
})
|
||||
Loading…
Reference in a new issue