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"
|
import { NextResponse } from "next/server"
|
||||||
|
import { fetchQuery } from "convex/nextjs"
|
||||||
/**
|
import { api } from "@/convex/_generated/api"
|
||||||
* eBay API Proxy Route
|
import { hasConvexUrl } from "@/lib/convex-config"
|
||||||
* Proxies requests to eBay Finding API to avoid CORS issues
|
import {
|
||||||
*/
|
rankListingsForQuery,
|
||||||
|
type CachedEbayListing,
|
||||||
interface eBaySearchParams {
|
type EbayCacheState,
|
||||||
keywords: string
|
} from "@/lib/ebay-parts-match"
|
||||||
categoryId?: string
|
import { searchStaticEbayListings } from "@/lib/server/manual-parts-data"
|
||||||
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 || ""
|
|
||||||
|
|
||||||
|
function getCacheStateFallback(message?: string): EbayCacheState {
|
||||||
return {
|
return {
|
||||||
itemId: item.itemId || "",
|
key: "manual-parts",
|
||||||
title: item.title || "Unknown Item",
|
status: "success",
|
||||||
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
|
lastSuccessfulAt: Date.now(),
|
||||||
currency: currentPrice?.currencyId || "USD",
|
lastAttemptAt: null,
|
||||||
imageUrl: first(item.galleryURL) || undefined,
|
nextEligibleAt: null,
|
||||||
viewItemUrl,
|
lastError: null,
|
||||||
condition: condition?.conditionDisplayName || undefined,
|
consecutiveFailures: 0,
|
||||||
shippingCost: shippingCost?.value
|
queryCount: 0,
|
||||||
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
|
itemCount: 0,
|
||||||
: undefined,
|
sourceQueries: [],
|
||||||
affiliateLink: generateAffiliateLink(viewItemUrl),
|
freshnessMs: 0,
|
||||||
|
isStale: true,
|
||||||
|
listingCount: 0,
|
||||||
|
activeListingCount: 0,
|
||||||
|
message: message || "Using bundled manual cache.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readEbayErrorMessage(response: Response) {
|
export async function GET(request: Request) {
|
||||||
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) {
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
const keywords = searchParams.get("keywords")?.trim() || ""
|
||||||
const keywords = searchParams.get("keywords")
|
const maxResults = Math.min(
|
||||||
const categoryId = searchParams.get("categoryId") || undefined
|
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
|
||||||
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
|
20
|
||||||
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
|
|
||||||
const cacheKey = buildCacheKey(
|
|
||||||
keywords || "",
|
|
||||||
categoryId,
|
|
||||||
sortOrder,
|
|
||||||
maxResults
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!keywords) {
|
if (!keywords) {
|
||||||
|
|
@ -171,114 +44,46 @@ export async function GET(request: NextRequest) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const appId = process.env.EBAY_APP_ID?.trim()
|
if (!hasConvexUrl()) {
|
||||||
|
const staticResults = await searchStaticEbayListings(keywords, maxResults)
|
||||||
if (!appId) {
|
return NextResponse.json({
|
||||||
console.error("EBAY_APP_ID not configured")
|
query: keywords,
|
||||||
return NextResponse.json(
|
results: staticResults,
|
||||||
{
|
cache: getCacheStateFallback(),
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request = (async () => {
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = await readEbayErrorMessage(response)
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
try {
|
||||||
|
const [overview, listings] = await Promise.all([
|
||||||
|
fetchQuery(api.ebay.getCacheOverview, {}),
|
||||||
|
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
||||||
|
])
|
||||||
|
|
||||||
// Parse eBay API response
|
const ranked = rankListingsForQuery(
|
||||||
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
|
keywords,
|
||||||
if (!findItemsAdvancedResponse) {
|
listings as CachedEbayListing[],
|
||||||
return []
|
maxResults
|
||||||
}
|
)
|
||||||
|
|
||||||
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
|
return NextResponse.json({
|
||||||
if (
|
query: keywords,
|
||||||
!searchResult ||
|
results: ranked,
|
||||||
!searchResult.item ||
|
cache: overview,
|
||||||
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)
|
|
||||||
} catch (error) {
|
} 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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
query: keywords,
|
||||||
|
results: staticResults,
|
||||||
|
cache: getCacheStateFallback(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? `Using bundled manual cache because cached listings failed: ${error.message}`
|
||||||
: "Failed to fetch products from eBay",
|
: "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"
|
} from "@/lib/manuals-config"
|
||||||
import { ManualViewer } from "@/components/manual-viewer"
|
import { ManualViewer } from "@/components/manual-viewer"
|
||||||
import { getManualsWithParts } from "@/lib/parts-lookup"
|
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 ProductSuggestionsResponse {
|
||||||
interface ProductSuggestion {
|
query: string
|
||||||
itemId: string
|
results: CachedEbayListing[]
|
||||||
title: string
|
cache: EbayCacheState
|
||||||
price: string
|
error?: string
|
||||||
currency: string
|
|
||||||
imageUrl?: string
|
|
||||||
viewItemUrl: string
|
|
||||||
affiliateLink: string
|
|
||||||
condition?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductSuggestionsProps {
|
interface ProductSuggestionsProps {
|
||||||
|
|
@ -63,33 +58,55 @@ function ProductSuggestions({
|
||||||
manual,
|
manual,
|
||||||
className = "",
|
className = "",
|
||||||
}: ProductSuggestionsProps) {
|
}: ProductSuggestionsProps) {
|
||||||
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<CachedEbayListing[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
|
const [cache, setCache] = useState<EbayCacheState | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ebayClient.isConfigured()) {
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSuggestions() {
|
async function loadSuggestions() {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate search query from manual content
|
const query = [
|
||||||
const query = `${manual.manufacturer} ${manual.category} vending machine`
|
manual.manufacturer,
|
||||||
const results = await ebayClient.searchItems({
|
manual.category,
|
||||||
|
manual.commonNames?.[0],
|
||||||
|
manual.searchTerms?.[0],
|
||||||
|
"vending machine",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
keywords: query,
|
keywords: query,
|
||||||
maxResults: 6,
|
maxResults: "6",
|
||||||
sortOrder: "BestMatch",
|
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) {
|
} catch (err) {
|
||||||
console.error("Error loading product suggestions:", 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 {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -100,10 +117,6 @@ function ProductSuggestions({
|
||||||
}
|
}
|
||||||
}, [manual])
|
}, [manual])
|
||||||
|
|
||||||
if (!ebayClient.isConfigured()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -121,9 +134,16 @@ function ProductSuggestions({
|
||||||
<div
|
<div
|
||||||
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
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-red-500" />
|
<AlertCircle className="h-6 w-6 text-yellow-600" />
|
||||||
<span className="ml-2 text-sm text-red-500">{error}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -134,10 +154,15 @@ function ProductSuggestions({
|
||||||
<div
|
<div
|
||||||
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
|
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" />
|
<AlertCircle className="h-6 w-6 text-yellow-500" />
|
||||||
<span className="ml-2 text-sm text-yellow-600">
|
<span className="text-sm text-yellow-700 dark:text-yellow-200">
|
||||||
No products found in sandbox environment
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,6 +179,14 @@ function ProductSuggestions({
|
||||||
Related Products
|
Related Products
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{suggestions.map((product) => (
|
{suggestions.map((product) => (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
|
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
||||||
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
||||||
|
|
||||||
interface PartsPanelProps {
|
interface PartsPanelProps {
|
||||||
|
|
@ -17,6 +18,26 @@ export function PartsPanel({
|
||||||
const [parts, setParts] = useState<PartForPage[]>([])
|
const [parts, setParts] = useState<PartForPage[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 () => {
|
const loadParts = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
@ -26,9 +47,11 @@ export function PartsPanel({
|
||||||
const result = await getTopPartsForManual(manualFilename, 5)
|
const result = await getTopPartsForManual(manualFilename, 5)
|
||||||
setParts(result.parts)
|
setParts(result.parts)
|
||||||
setError(result.error ?? null)
|
setError(result.error ?? null)
|
||||||
|
setCache(result.cache ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading parts:", err)
|
console.error("Error loading parts:", err)
|
||||||
setParts([])
|
setParts([])
|
||||||
|
setCache(null)
|
||||||
setError("Could not load parts")
|
setError("Could not load parts")
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
@ -42,6 +65,7 @@ export function PartsPanel({
|
||||||
}, [loadParts, manualFilename])
|
}, [loadParts, manualFilename])
|
||||||
|
|
||||||
const hasListings = parts.some((part) => part.ebayListings.length > 0)
|
const hasListings = parts.some((part) => part.ebayListings.length > 0)
|
||||||
|
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
|
||||||
|
|
||||||
const renderStatusCard = (title: string, message: string) => (
|
const renderStatusCard = (title: string, message: string) => (
|
||||||
<div className={`flex flex-col h-full ${className}`}>
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
|
|
@ -83,6 +107,14 @@ export function PartsPanel({
|
||||||
Parts
|
Parts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
<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" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
|
@ -113,6 +145,14 @@ export function PartsPanel({
|
||||||
Parts
|
Parts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
<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" />
|
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
|
||||||
|
|
@ -132,10 +172,18 @@ export function PartsPanel({
|
||||||
Parts
|
Parts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -150,6 +198,14 @@ export function PartsPanel({
|
||||||
Parts ({parts.length})
|
Parts ({parts.length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
|
<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" />
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
Live eBay listings are unavailable right now.
|
Cached eBay listings are unavailable right now.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
|
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
|
||||||
{error.includes("eBay API not configured")
|
{error}
|
||||||
? "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}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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_category", ["category"])
|
||||||
.index("by_path", ["path"]),
|
.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({
|
manualCategories: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
slug: 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
|
* Parts lookup utility for frontend
|
||||||
*
|
*
|
||||||
* Provides functions to fetch parts data by manual filename.
|
* Provides functions to fetch parts data by manual filename.
|
||||||
* Static JSON remains the primary data source, while live eBay fallback
|
* Static JSON remains the primary data source, while cached eBay matches
|
||||||
* goes through the server route so credentials never reach the browser.
|
* are fetched from the server so normal browsing never reaches eBay.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CachedEbayListing,
|
||||||
|
EbayCacheState,
|
||||||
|
ManualPartInput,
|
||||||
|
} from "@/lib/ebay-parts-match"
|
||||||
|
|
||||||
export interface PartForPage {
|
export interface PartForPage {
|
||||||
partNumber: string
|
partNumber: string
|
||||||
description: string
|
description: string
|
||||||
ebayListings: Array<{
|
ebayListings: CachedEbayListing[]
|
||||||
itemId: string
|
|
||||||
title: string
|
|
||||||
price: string
|
|
||||||
currency: string
|
|
||||||
imageUrl?: string
|
|
||||||
viewItemUrl: string
|
|
||||||
condition?: string
|
|
||||||
shippingCost?: string
|
|
||||||
affiliateLink: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ManualPartsLookup {
|
interface ManualPartsLookup {
|
||||||
|
|
@ -32,30 +28,38 @@ interface ManualPagesParts {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EbaySearchResult {
|
interface CachedPartsResponse {
|
||||||
itemId: string
|
manualFilename: string
|
||||||
title: string
|
parts: Array<
|
||||||
price: string
|
ManualPartInput & {
|
||||||
currency: string
|
ebayListings: CachedEbayListing[]
|
||||||
imageUrl?: string
|
|
||||||
viewItemUrl: string
|
|
||||||
condition?: string
|
|
||||||
shippingCost?: string
|
|
||||||
affiliateLink: string
|
|
||||||
}
|
}
|
||||||
|
>
|
||||||
interface EbaySearchResponse {
|
cache: EbayCacheState
|
||||||
results: EbaySearchResult[]
|
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for eBay search results
|
interface CachedEbaySearchResponse {
|
||||||
const ebaySearchCache = new Map<
|
results: CachedEbayListing[]
|
||||||
|
cache: EbayCacheState
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedManualMatchResponses = new Map<
|
||||||
string,
|
string,
|
||||||
{ results: EbaySearchResult[]; timestamp: number }
|
{ response: CachedPartsResponse; timestamp: number }
|
||||||
>()
|
>()
|
||||||
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
|
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
|
||||||
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
|
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([
|
const GENERIC_PART_TERMS = new Set([
|
||||||
"and",
|
"and",
|
||||||
|
|
@ -129,121 +133,196 @@ async function loadPartsData(): Promise<{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
|
||||||
* Search eBay for parts with caching.
|
return {
|
||||||
* This calls the server route so the app never needs direct eBay credentials
|
key: "manual-parts",
|
||||||
* in client code.
|
status: "disabled",
|
||||||
*/
|
lastSuccessfulAt: null,
|
||||||
async function searchEBayForParts(
|
lastAttemptAt: null,
|
||||||
partNumber: string,
|
nextEligibleAt: null,
|
||||||
description?: string,
|
lastError: errorMessage || "eBay cache unavailable.",
|
||||||
manufacturer?: string
|
consecutiveFailures: 0,
|
||||||
): Promise<EbaySearchResponse> {
|
queryCount: 0,
|
||||||
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
|
itemCount: 0,
|
||||||
|
sourceQueries: [],
|
||||||
// Check cache
|
freshnessMs: null,
|
||||||
const cached = ebaySearchCache.get(cacheKey)
|
isStale: true,
|
||||||
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
|
listingCount: 0,
|
||||||
return { results: cached.results as EbaySearchResult[] }
|
activeListingCount: 0,
|
||||||
}
|
message: errorMessage || "eBay cache unavailable.",
|
||||||
|
|
||||||
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) {
|
async function fetchManualPartsMatches(
|
||||||
query += ` ${manufacturer}`
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${query} vending machine`
|
const inFlight = inFlightManualMatchRequests.get(cacheKey)
|
||||||
}
|
|
||||||
|
|
||||||
const searchViaApi = async (
|
|
||||||
categoryId?: string
|
|
||||||
): Promise<EbaySearchResponse> => {
|
|
||||||
const requestKey = `${cacheKey}:${categoryId || "general"}`
|
|
||||||
|
|
||||||
const inFlight = inFlightEbaySearches.get(requestKey)
|
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
return inFlight
|
return inFlight
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
keywords: buildQuery(),
|
|
||||||
maxResults: "3",
|
|
||||||
sortOrder: "BestMatch",
|
|
||||||
})
|
|
||||||
|
|
||||||
if (categoryId) {
|
|
||||||
params.set("categoryId", categoryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
const response = await fetch("/api/ebay/manual-parts", {
|
||||||
const body = await response.json().catch(() => null)
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
manualFilename,
|
||||||
|
parts,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
const body = await response.json().catch(() => null)
|
||||||
|
if (!response.ok || !body || typeof body !== "object") {
|
||||||
const message =
|
const message =
|
||||||
body && typeof body.error === "string"
|
body && typeof body.error === "string"
|
||||||
? body.error
|
? body.error
|
||||||
: `eBay API error: ${response.status}`
|
: `Failed to load cached parts (${response.status})`
|
||||||
|
return {
|
||||||
return { results: [], error: message }
|
manualFilename,
|
||||||
|
parts: parts.map((part) => ({
|
||||||
|
...part,
|
||||||
|
ebayListings: [],
|
||||||
|
})),
|
||||||
|
cache: makeFallbackCacheState(message),
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = Array.isArray(body) ? body : []
|
const partsResponse = body as CachedPartsResponse
|
||||||
return { results }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
return {
|
||||||
results: [],
|
manualFilename: partsResponse.manualFilename || manualFilename,
|
||||||
|
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
|
||||||
|
cache: partsResponse.cache || makeFallbackCacheState(),
|
||||||
error:
|
error:
|
||||||
error instanceof Error ? error.message : "Failed to search eBay",
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
inFlightEbaySearches.set(requestKey, request)
|
inFlightManualMatchRequests.set(cacheKey, request)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await request
|
const response = await request
|
||||||
|
cachedManualMatchResponses.set(cacheKey, {
|
||||||
|
response,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
return response
|
||||||
} finally {
|
} finally {
|
||||||
inFlightEbaySearches.delete(requestKey)
|
inFlightManualMatchRequests.delete(cacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const categorySearch = await searchViaApi("11700")
|
async function searchEBayForParts(
|
||||||
if (categorySearch.results.length > 0) {
|
partNumber: string,
|
||||||
ebaySearchCache.set(cacheKey, {
|
description?: string,
|
||||||
results: categorySearch.results,
|
manufacturer?: string
|
||||||
timestamp: Date.now(),
|
): Promise<CachedEbaySearchResponse> {
|
||||||
})
|
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
|
||||||
return categorySearch
|
|
||||||
|
const cached = cachedEbaySearchResponses.get(cacheKey)
|
||||||
|
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
|
||||||
|
return cached.response
|
||||||
}
|
}
|
||||||
|
|
||||||
const generalSearch = await searchViaApi()
|
const inFlight = inFlightEbaySearches.get(cacheKey)
|
||||||
if (generalSearch.results.length > 0) {
|
if (inFlight) {
|
||||||
ebaySearchCache.set(cacheKey, {
|
return inFlight
|
||||||
results: generalSearch.results,
|
}
|
||||||
timestamp: Date.now(),
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
keywords: [partNumber, description, manufacturer, "vending machine"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
maxResults: "3",
|
||||||
|
sortOrder: "BestMatch",
|
||||||
})
|
})
|
||||||
return generalSearch
|
|
||||||
|
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
||||||
|
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 eBay listings (${response.status})`
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
cache: makeFallbackCacheState(message),
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
results: [],
|
results: [],
|
||||||
error: categorySearch.error || generalSearch.error,
|
cache: makeFallbackCacheState(message),
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inFlightEbaySearches.set(cacheKey, request)
|
||||||
|
try {
|
||||||
|
const response = await request
|
||||||
|
cachedEbaySearchResponses.set(cacheKey, {
|
||||||
|
response,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
} finally {
|
||||||
|
inFlightEbaySearches.delete(cacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,8 +569,17 @@ export async function getPartsForPage(
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const enhanced = await enhancePartsData(parts)
|
const matched = await fetchManualPartsMatches(
|
||||||
return enhanced.parts
|
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<{
|
): Promise<{
|
||||||
parts: PartForPage[]
|
parts: PartForPage[]
|
||||||
error?: string
|
error?: string
|
||||||
|
cache?: EbayCacheState
|
||||||
}> {
|
}> {
|
||||||
const { parts } = await getPartsForManualWithStatus(manualFilename)
|
const { parts } = await getPartsForManualWithStatus(manualFilename)
|
||||||
|
|
||||||
|
|
@ -514,23 +603,20 @@ export async function getTopPartsForManual(
|
||||||
parts,
|
parts,
|
||||||
Math.max(limit * 2, limit)
|
Math.max(limit * 2, limit)
|
||||||
)
|
)
|
||||||
const { parts: enrichedParts, error } =
|
const matched = await fetchManualPartsMatches(
|
||||||
await enhancePartsData(liveSearchCandidates)
|
manualFilename,
|
||||||
|
liveSearchCandidates.map((part) => ({
|
||||||
const sorted = enrichedParts.sort((a, b) => {
|
partNumber: part.partNumber,
|
||||||
const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0
|
description: part.description,
|
||||||
const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0
|
manualFilename,
|
||||||
|
})),
|
||||||
if (aHasLiveListings !== bHasLiveListings) {
|
limit
|
||||||
return bHasLiveListings - aHasLiveListings
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return b.ebayListings.length - a.ebayListings.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parts: sorted.slice(0, limit),
|
parts: matched.parts as PartForPage[],
|
||||||
error,
|
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": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"copy:check": "node scripts/check-public-copy.mjs",
|
"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:env": "node scripts/deploy-readiness.mjs",
|
||||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
|
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
|
||||||
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
"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": "tsx scripts/sync-manuals-to-convex.ts",
|
||||||
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
"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.')\"",
|
"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