diff --git a/app/api/admin/ebay/refresh/route.ts b/app/api/admin/ebay/refresh/route.ts new file mode 100644 index 00000000..12d1e76a --- /dev/null +++ b/app/api/admin/ebay/refresh/route.ts @@ -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 } + ) + } +} diff --git a/app/api/ebay/manual-parts/route.ts b/app/api/ebay/manual-parts/route.ts new file mode 100644 index 00000000..4e5616f5 --- /dev/null +++ b/app/api/ebay/manual-parts/route.ts @@ -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 + 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() + 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> +): 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 } + ) + } +} diff --git a/app/api/ebay/search/route.ts b/app/api/ebay/search/route.ts index 62f41860..3bd470c9 100644 --- a/app/api/ebay/search/route.ts +++ b/app/api/ebay/search/route.ts @@ -1,167 +1,40 @@ -import { NextRequest, NextResponse } from "next/server" - -/** - * eBay API Proxy Route - * Proxies requests to eBay Finding API to avoid CORS issues - */ - -interface eBaySearchParams { - keywords: string - categoryId?: string - sortOrder?: string - maxResults?: number -} - -interface eBaySearchResult { - itemId: string - title: string - price: string - currency: string - imageUrl?: string - viewItemUrl: string - condition?: string - shippingCost?: string - affiliateLink: string -} - -type MaybeArray = T | T[] - -const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes -const searchResponseCache = new Map< - string, - { results: eBaySearchResult[]; timestamp: number } ->() -const inFlightSearchResponses = new Map>() - -// 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(value: MaybeArray | undefined): T | undefined { - if (!value) { - return undefined - } - - return Array.isArray(value) ? value[0] : value -} - -function normalizeItem(item: any): eBaySearchResult { - const currentPrice = first(item.sellingStatus?.currentPrice) - const shippingCost = first(item.shippingInfo?.shippingServiceCost) - const condition = first(item.condition) - const viewItemUrl = item.viewItemURL || item.viewItemUrl || "" +import { NextResponse } from "next/server" +import { fetchQuery } from "convex/nextjs" +import { api } from "@/convex/_generated/api" +import { hasConvexUrl } from "@/lib/convex-config" +import { + rankListingsForQuery, + type CachedEbayListing, + type EbayCacheState, +} from "@/lib/ebay-parts-match" +import { searchStaticEbayListings } from "@/lib/server/manual-parts-data" +function getCacheStateFallback(message?: string): EbayCacheState { return { - itemId: item.itemId || "", - title: item.title || "Unknown Item", - price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`, - currency: currentPrice?.currencyId || "USD", - imageUrl: first(item.galleryURL) || undefined, - viewItemUrl, - condition: condition?.conditionDisplayName || undefined, - shippingCost: shippingCost?.value - ? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}` - : undefined, - affiliateLink: generateAffiliateLink(viewItemUrl), + key: "manual-parts", + status: "success", + lastSuccessfulAt: Date.now(), + lastAttemptAt: null, + nextEligibleAt: null, + lastError: null, + consecutiveFailures: 0, + queryCount: 0, + itemCount: 0, + sourceQueries: [], + freshnessMs: 0, + isStale: true, + listingCount: 0, + activeListingCount: 0, + message: message || "Using bundled manual cache.", } } -async function readEbayErrorMessage(response: Response) { - const text = await response.text().catch(() => "") - if (!text.trim()) { - return `eBay API error: ${response.status}` - } - - try { - const parsed = JSON.parse(text) as any - const messages = parsed?.errorMessage?.[0]?.error?.[0] - const message = Array.isArray(messages?.message) - ? messages.message[0] - : messages?.message - - if (typeof message === "string" && message.trim()) { - const errorId = Array.isArray(messages?.errorId) - ? messages.errorId[0] - : messages?.errorId - return errorId - ? `eBay API error ${errorId}: ${message}` - : `eBay API error: ${message}` - } - } catch { - // Fall through to returning the raw text below. - } - - return text.trim() || `eBay API error: ${response.status}` -} - -function buildCacheKey( - keywords: string, - categoryId: string | undefined, - sortOrder: string, - maxResults: number -): string { - return [ - keywords.trim().toLowerCase(), - categoryId || "", - sortOrder || "BestMatch", - String(maxResults), - ].join("|") -} - -function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null { - const cached = searchResponseCache.get(cacheKey) - - if (!cached) { - return null - } - - if (Date.now() - cached.timestamp > SEARCH_CACHE_TTL) { - searchResponseCache.delete(cacheKey) - return null - } - - return cached.results -} - -function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) { - searchResponseCache.set(cacheKey, { - results, - timestamp: Date.now(), - }) -} - -export async function GET(request: NextRequest) { +export async function GET(request: Request) { const { searchParams } = new URL(request.url) - - const keywords = searchParams.get("keywords") - const categoryId = searchParams.get("categoryId") || undefined - const sortOrder = searchParams.get("sortOrder") || "BestMatch" - const maxResults = parseInt(searchParams.get("maxResults") || "6", 10) - const cacheKey = buildCacheKey( - keywords || "", - categoryId, - sortOrder, - maxResults + const keywords = searchParams.get("keywords")?.trim() || "" + const maxResults = Math.min( + Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1), + 20 ) if (!keywords) { @@ -171,114 +44,46 @@ export async function GET(request: NextRequest) { ) } - const appId = process.env.EBAY_APP_ID?.trim() - - if (!appId) { - console.error("EBAY_APP_ID not configured") - return NextResponse.json( - { - error: - "eBay API not configured. Please set EBAY_APP_ID environment variable.", - }, - { status: 503 } - ) - } - - const cachedResults = getCachedSearchResults(cacheKey) - if (cachedResults) { - return NextResponse.json(cachedResults) - } - - const inFlight = inFlightSearchResponses.get(cacheKey) - if (inFlight) { - try { - const results = await inFlight - return NextResponse.json(results) - } catch (error) { - return NextResponse.json( - { - error: - error instanceof Error - ? error.message - : "Failed to fetch products from eBay", - }, - { status: 500 } - ) - } - } - - // Build eBay Finding API URL - const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1" - const url = new URL(baseUrl) - - url.searchParams.set("OPERATION-NAME", "findItemsAdvanced") - url.searchParams.set("SERVICE-VERSION", "1.0.0") - url.searchParams.set("SECURITY-APPNAME", appId) - url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON") - url.searchParams.set("REST-PAYLOAD", "true") - url.searchParams.set("keywords", keywords) - url.searchParams.set("sortOrder", sortOrder) - url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString()) - - if (categoryId) { - url.searchParams.set("categoryId", categoryId) + if (!hasConvexUrl()) { + const staticResults = await searchStaticEbayListings(keywords, maxResults) + return NextResponse.json({ + query: keywords, + results: staticResults, + cache: getCacheStateFallback(), + }) } try { - const request = (async () => { - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Accept: "application/json", - }, - }) + const [overview, listings] = await Promise.all([ + fetchQuery(api.ebay.getCacheOverview, {}), + fetchQuery(api.ebay.listCachedListings, { limit: 200 }), + ]) - if (!response.ok) { - const errorMessage = await readEbayErrorMessage(response) - throw new Error(errorMessage) - } + const ranked = rankListingsForQuery( + keywords, + listings as CachedEbayListing[], + maxResults + ) - const data = await response.json() - - // Parse eBay API response - const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0] - if (!findItemsAdvancedResponse) { - return [] - } - - const searchResult = findItemsAdvancedResponse.searchResult?.[0] - if ( - !searchResult || - !searchResult.item || - searchResult.item.length === 0 - ) { - return [] - } - - const items = Array.isArray(searchResult.item) - ? searchResult.item - : [searchResult.item] - - return items.map((item: any) => normalizeItem(item)) - })() - - inFlightSearchResponses.set(cacheKey, request) - - const results = await request - setCachedSearchResults(cacheKey, results) - return NextResponse.json(results) + return NextResponse.json({ + query: keywords, + results: ranked, + cache: overview, + }) } catch (error) { - console.error("Error fetching from eBay API:", error) + console.error("Failed to load cached eBay listings:", error) + const staticResults = await searchStaticEbayListings(keywords, maxResults) return NextResponse.json( { - error: + query: keywords, + results: staticResults, + cache: getCacheStateFallback( error instanceof Error - ? error.message - : "Failed to fetch products from eBay", + ? `Using bundled manual cache because cached listings failed: ${error.message}` + : "Using bundled manual cache because cached listings failed." + ), }, - { status: 500 } + { status: 200 } ) - } finally { - inFlightSearchResponses.delete(cacheKey) } } diff --git a/components/manuals-page-client.tsx b/components/manuals-page-client.tsx index 40930c66..597c7a4e 100644 --- a/components/manuals-page-client.tsx +++ b/components/manuals-page-client.tsx @@ -40,18 +40,13 @@ import { } from "@/lib/manuals-config" import { ManualViewer } from "@/components/manual-viewer" import { getManualsWithParts } from "@/lib/parts-lookup" -import { ebayClient } from "@/lib/ebay-api" +import type { CachedEbayListing, EbayCacheState } from "@/lib/ebay-parts-match" -// Product Suggestion Component -interface ProductSuggestion { - itemId: string - title: string - price: string - currency: string - imageUrl?: string - viewItemUrl: string - affiliateLink: string - condition?: string +interface ProductSuggestionsResponse { + query: string + results: CachedEbayListing[] + cache: EbayCacheState + error?: string } interface ProductSuggestionsProps { @@ -63,33 +58,55 @@ function ProductSuggestions({ manual, className = "", }: ProductSuggestionsProps) { - const [suggestions, setSuggestions] = useState([]) - const [isLoading, setIsLoading] = useState(ebayClient.isConfigured()) + const [suggestions, setSuggestions] = useState([]) + const [cache, setCache] = useState(null) + const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { - if (!ebayClient.isConfigured()) { - setIsLoading(false) - return - } - async function loadSuggestions() { setIsLoading(true) setError(null) try { - // Generate search query from manual content - const query = `${manual.manufacturer} ${manual.category} vending machine` - const results = await ebayClient.searchItems({ + const query = [ + manual.manufacturer, + manual.category, + manual.commonNames?.[0], + manual.searchTerms?.[0], + "vending machine", + ] + .filter(Boolean) + .join(" ") + + const params = new URLSearchParams({ keywords: query, - maxResults: 6, + maxResults: "6", sortOrder: "BestMatch", }) - setSuggestions(results) + const response = await fetch(`/api/ebay/search?${params.toString()}`) + const body = (await response.json().catch(() => null)) as + | ProductSuggestionsResponse + | null + + if (!response.ok || !body) { + throw new Error( + body && typeof body.error === "string" + ? body.error + : `Failed to load cached listings (${response.status})` + ) + } + + setSuggestions(Array.isArray(body.results) ? body.results : []) + setCache(body.cache || null) } catch (err) { console.error("Error loading product suggestions:", err) - setError("Could not load product suggestions") + setSuggestions([]) + setCache(null) + setError( + err instanceof Error ? err.message : "Could not load product suggestions" + ) } finally { setIsLoading(false) } @@ -100,10 +117,6 @@ function ProductSuggestions({ } }, [manual]) - if (!ebayClient.isConfigured()) { - return null - } - if (isLoading) { return (
-
- - {error} +
+ + + {error} + + {cache?.lastSuccessfulAt ? ( + + Last refreshed {new Date(cache.lastSuccessfulAt).toLocaleString()} + + ) : null}
) @@ -134,10 +154,15 @@ function ProductSuggestions({
-
+
- - No products found in sandbox environment + + No cached eBay matches yet + + + {cache?.isStale + ? "The background poll is behind, so this manual is showing the last known cache." + : "Try again after the next periodic cache refresh."}
@@ -154,6 +179,14 @@ function ProductSuggestions({ Related Products
+ {cache && ( +
+ {cache.lastSuccessfulAt + ? `Cache refreshed ${new Date(cache.lastSuccessfulAt).toLocaleString()}` + : "Cache is warming up"} + {cache.isStale ? " • stale cache" : ""} +
+ )}
{suggestions.map((product) => ( diff --git a/components/parts-panel.tsx b/components/parts-panel.tsx index fc227103..67005a18 100644 --- a/components/parts-panel.tsx +++ b/components/parts-panel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react" import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react" import { Button } from "@/components/ui/button" +import type { EbayCacheState } from "@/lib/ebay-parts-match" import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup" interface PartsPanelProps { @@ -17,6 +18,26 @@ export function PartsPanel({ const [parts, setParts] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const [cache, setCache] = useState(null) + + const formatFreshness = (value: number | null) => { + if (!value) { + return "not refreshed yet" + } + + const minutes = Math.max(0, Math.floor(value / 60000)) + if (minutes < 60) { + return `${minutes}m ago` + } + + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours}h ago` + } + + const days = Math.floor(hours / 24) + return `${days}d ago` + } const loadParts = useCallback(async () => { setIsLoading(true) @@ -26,9 +47,11 @@ export function PartsPanel({ const result = await getTopPartsForManual(manualFilename, 5) setParts(result.parts) setError(result.error ?? null) + setCache(result.cache ?? null) } catch (err) { console.error("Error loading parts:", err) setParts([]) + setCache(null) setError("Could not load parts") } finally { setIsLoading(false) @@ -42,6 +65,7 @@ export function PartsPanel({ }, [loadParts, manualFilename]) const hasListings = parts.some((part) => part.ebayListings.length > 0) + const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null) const renderStatusCard = (title: string, message: string) => (
@@ -83,6 +107,14 @@ export function PartsPanel({ Parts
+ {cache && ( +
+ {cache.lastSuccessfulAt + ? `Cache updated ${cacheFreshnessText}` + : "Cache warming up"} + {cache.isStale ? " • stale" : ""} +
+ )}
@@ -107,12 +139,20 @@ export function PartsPanel({ return (
-
+
Parts
+ {cache && ( +
+ {cache.lastSuccessfulAt + ? `Cache updated ${cacheFreshnessText}` + : "Cache warming up"} + {cache.isStale ? " • stale" : ""} +
+ )}
@@ -132,10 +172,18 @@ export function PartsPanel({ Parts
+ {cache && ( +
+ {cache.lastSuccessfulAt + ? `Cache updated ${cacheFreshnessText}` + : "Cache warming up"} + {cache.isStale ? " • stale" : ""} +
+ )}
- No live eBay matches found for these parts yet + No cached eBay matches found for these parts yet
) @@ -150,6 +198,14 @@ export function PartsPanel({ Parts ({parts.length})
+ {cache && ( +
+ {cache.lastSuccessfulAt + ? `Cache updated ${cacheFreshnessText}` + : "Cache warming up"} + {cache.isStale ? " • stale" : ""} +
+ )}
@@ -159,15 +215,10 @@ export function PartsPanel({

- Live eBay listings are unavailable right now. + Cached eBay listings are unavailable right now.

- {error.includes("eBay API not configured") - ? "Set EBAY_APP_ID in the app environment, then reload the panel." - : error.toLowerCase().includes("rate limit") || - error.toLowerCase().includes("exceeded") - ? "eBay is temporarily rate-limited. Reload after a short wait." - : error} + {error}

diff --git a/convex/crons.ts b/convex/crons.ts new file mode 100644 index 00000000..0b5d2814 --- /dev/null +++ b/convex/crons.ts @@ -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 diff --git a/convex/ebay.ts b/convex/ebay.ts new file mode 100644 index 00000000..979d0170 --- /dev/null +++ b/convex/ebay.ts @@ -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(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>() + 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 = { + 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) + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index 03826abb..bb8ada16 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -90,6 +90,50 @@ export default defineSchema({ .index("by_category", ["category"]) .index("by_path", ["path"]), + ebayListings: defineTable({ + itemId: v.string(), + title: v.string(), + normalizedTitle: v.string(), + price: v.string(), + currency: v.string(), + imageUrl: v.optional(v.string()), + viewItemUrl: v.string(), + condition: v.optional(v.string()), + shippingCost: v.optional(v.string()), + affiliateLink: v.string(), + sourceQueries: v.array(v.string()), + fetchedAt: v.number(), + firstSeenAt: v.number(), + lastSeenAt: v.number(), + expiresAt: v.number(), + active: v.boolean(), + }) + .index("by_itemId", ["itemId"]) + .index("by_active", ["active"]) + .index("by_expiresAt", ["expiresAt"]) + .index("by_lastSeenAt", ["lastSeenAt"]), + + ebayPollState: defineTable({ + key: v.string(), + status: v.union( + v.literal("idle"), + v.literal("success"), + v.literal("rate_limited"), + v.literal("error"), + v.literal("missing_config"), + v.literal("skipped") + ), + lastSuccessfulAt: v.optional(v.number()), + lastAttemptAt: v.optional(v.number()), + nextEligibleAt: v.optional(v.number()), + lastError: v.optional(v.string()), + consecutiveFailures: v.number(), + queryCount: v.number(), + itemCount: v.number(), + sourceQueries: v.array(v.string()), + updatedAt: v.number(), + }).index("by_key", ["key"]), + manualCategories: defineTable({ name: v.string(), slug: v.string(), diff --git a/docs/operations/EBAY_CACHE_DIAGNOSIS.md b/docs/operations/EBAY_CACHE_DIAGNOSIS.md new file mode 100644 index 00000000..a6164641 --- /dev/null +++ b/docs/operations/EBAY_CACHE_DIAGNOSIS.md @@ -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. + diff --git a/lib/ebay-parts-match.ts b/lib/ebay-parts-match.ts new file mode 100644 index 00000000..153bd44f --- /dev/null +++ b/lib/ebay-parts-match.ts @@ -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): 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, + } +} diff --git a/lib/parts-lookup.ts b/lib/parts-lookup.ts index 0b9f0512..48be135c 100644 --- a/lib/parts-lookup.ts +++ b/lib/parts-lookup.ts @@ -2,24 +2,20 @@ * Parts lookup utility for frontend * * Provides functions to fetch parts data by manual filename. - * Static JSON remains the primary data source, while live eBay fallback - * goes through the server route so credentials never reach the browser. + * Static JSON remains the primary data source, while cached eBay matches + * are fetched from the server so normal browsing never reaches eBay. */ +import type { + CachedEbayListing, + EbayCacheState, + ManualPartInput, +} from "@/lib/ebay-parts-match" + export interface PartForPage { partNumber: string description: string - ebayListings: Array<{ - itemId: string - title: string - price: string - currency: string - imageUrl?: string - viewItemUrl: string - condition?: string - shippingCost?: string - affiliateLink: string - }> + ebayListings: CachedEbayListing[] } interface ManualPartsLookup { @@ -32,30 +28,38 @@ interface ManualPagesParts { } } -interface EbaySearchResult { - itemId: string - title: string - price: string - currency: string - imageUrl?: string - viewItemUrl: string - condition?: string - shippingCost?: string - affiliateLink: string -} - -interface EbaySearchResponse { - results: EbaySearchResult[] +interface CachedPartsResponse { + manualFilename: string + parts: Array< + ManualPartInput & { + ebayListings: CachedEbayListing[] + } + > + cache: EbayCacheState error?: string } -// Cache for eBay search results -const ebaySearchCache = new Map< +interface CachedEbaySearchResponse { + results: CachedEbayListing[] + cache: EbayCacheState + error?: string +} + +const cachedManualMatchResponses = new Map< string, - { results: EbaySearchResult[]; timestamp: number } + { response: CachedPartsResponse; timestamp: number } >() -const inFlightEbaySearches = new Map>() -const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes +const inFlightManualMatchRequests = new Map>() +const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000 +const cachedEbaySearchResponses = new Map< + string, + { response: CachedEbaySearchResponse; timestamp: number } +>() +const inFlightEbaySearches = new Map< + string, + Promise +>() +const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000 const GENERIC_PART_TERMS = new Set([ "and", @@ -129,121 +133,196 @@ async function loadPartsData(): Promise<{ } } -/** - * Search eBay for parts with caching. - * This calls the server route so the app never needs direct eBay credentials - * in client code. - */ +function makeFallbackCacheState(errorMessage?: string): EbayCacheState { + return { + key: "manual-parts", + status: "disabled", + lastSuccessfulAt: null, + lastAttemptAt: null, + nextEligibleAt: null, + lastError: errorMessage || "eBay cache unavailable.", + consecutiveFailures: 0, + queryCount: 0, + itemCount: 0, + sourceQueries: [], + freshnessMs: null, + isStale: true, + listingCount: 0, + activeListingCount: 0, + message: errorMessage || "eBay cache unavailable.", + } +} + +async function fetchManualPartsMatches( + manualFilename: string, + parts: ManualPartInput[], + limit: number +): Promise { + const cacheKey = [ + manualFilename.trim().toLowerCase(), + String(limit), + parts + .map((part) => + [ + part.partNumber.trim().toLowerCase(), + part.description.trim().toLowerCase(), + part.manufacturer?.trim().toLowerCase() || "", + part.category?.trim().toLowerCase() || "", + ].join(":") + ) + .join("|"), + ].join("::") + + const cached = cachedManualMatchResponses.get(cacheKey) + if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) { + return cached.response + } + + const inFlight = inFlightManualMatchRequests.get(cacheKey) + if (inFlight) { + return inFlight + } + + const request = (async () => { + try { + const response = await fetch("/api/ebay/manual-parts", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + manualFilename, + parts, + limit, + }), + }) + + const body = await response.json().catch(() => null) + if (!response.ok || !body || typeof body !== "object") { + const message = + body && typeof body.error === "string" + ? body.error + : `Failed to load cached parts (${response.status})` + return { + manualFilename, + parts: parts.map((part) => ({ + ...part, + ebayListings: [], + })), + cache: makeFallbackCacheState(message), + error: message, + } + } + + const partsResponse = body as CachedPartsResponse + return { + manualFilename: partsResponse.manualFilename || manualFilename, + parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [], + cache: partsResponse.cache || makeFallbackCacheState(), + error: + typeof (partsResponse as CachedPartsResponse).error === "string" + ? (partsResponse as CachedPartsResponse).error + : undefined, + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load cached parts" + return { + manualFilename, + parts: parts.map((part) => ({ + ...part, + ebayListings: [], + })), + cache: makeFallbackCacheState(message), + error: message, + } + } + })() + + inFlightManualMatchRequests.set(cacheKey, request) + try { + const response = await request + cachedManualMatchResponses.set(cacheKey, { + response, + timestamp: Date.now(), + }) + return response + } finally { + inFlightManualMatchRequests.delete(cacheKey) + } +} + async function searchEBayForParts( partNumber: string, description?: string, manufacturer?: string -): Promise { +): Promise { const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}` - // Check cache - const cached = ebaySearchCache.get(cacheKey) - if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) { - return { results: cached.results as EbaySearchResult[] } + const cached = cachedEbaySearchResponses.get(cacheKey) + if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) { + return cached.response } - const buildQuery = () => { - let query = partNumber - - if (description && description.length > 0 && description.length < 50) { - const descWords = description - .split(/\s+/) - .filter((word) => word.length > 3) - .slice(0, 3) - .join(" ") - - if (descWords) { - query += ` ${descWords}` - } - } - - if (manufacturer) { - query += ` ${manufacturer}` - } - - return `${query} vending machine` + const inFlight = inFlightEbaySearches.get(cacheKey) + if (inFlight) { + return inFlight } - const searchViaApi = async ( - categoryId?: string - ): Promise => { - const requestKey = `${cacheKey}:${categoryId || "general"}` + const request = (async () => { + try { + const params = new URLSearchParams({ + keywords: [partNumber, description, manufacturer, "vending machine"] + .filter(Boolean) + .join(" "), + maxResults: "3", + sortOrder: "BestMatch", + }) - const inFlight = inFlightEbaySearches.get(requestKey) - if (inFlight) { - return inFlight - } + const response = await fetch(`/api/ebay/search?${params.toString()}`) + const body = await response.json().catch(() => null) - const params = new URLSearchParams({ - keywords: buildQuery(), - maxResults: "3", - sortOrder: "BestMatch", - }) - - if (categoryId) { - params.set("categoryId", categoryId) - } - - const request = (async () => { - try { - const response = await fetch(`/api/ebay/search?${params.toString()}`) - const body = await response.json().catch(() => null) - - if (!response.ok) { - const message = - body && typeof body.error === "string" - ? body.error - : `eBay API error: ${response.status}` - - return { results: [], error: message } - } - - const results = Array.isArray(body) ? body : [] - return { results } - } catch (error) { + if (!response.ok || !body || typeof body !== "object") { + const message = + body && typeof body.error === "string" + ? body.error + : `Failed to load cached eBay listings (${response.status})` return { results: [], - error: - error instanceof Error ? error.message : "Failed to search eBay", + cache: makeFallbackCacheState(message), + error: message, } } - })() - inFlightEbaySearches.set(requestKey, request) - - try { - return await request - } finally { - inFlightEbaySearches.delete(requestKey) + return { + results: Array.isArray((body as any).results) + ? ((body as any).results as CachedEbayListing[]) + : [], + cache: (body as any).cache || makeFallbackCacheState(), + error: + typeof (body as any).error === "string" ? (body as any).error : undefined, + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load cached eBay listings" + return { + results: [], + cache: makeFallbackCacheState(message), + error: message, + } } - } + })() - const categorySearch = await searchViaApi("11700") - if (categorySearch.results.length > 0) { - ebaySearchCache.set(cacheKey, { - results: categorySearch.results, + inFlightEbaySearches.set(cacheKey, request) + try { + const response = await request + cachedEbaySearchResponses.set(cacheKey, { + response, timestamp: Date.now(), }) - return categorySearch - } - - const generalSearch = await searchViaApi() - if (generalSearch.results.length > 0) { - ebaySearchCache.set(cacheKey, { - results: generalSearch.results, - timestamp: Date.now(), - }) - return generalSearch - } - - return { - results: [], - error: categorySearch.error || generalSearch.error, + return response + } finally { + inFlightEbaySearches.delete(cacheKey) } } @@ -490,8 +569,17 @@ export async function getPartsForPage( return [] } - const enhanced = await enhancePartsData(parts) - return enhanced.parts + const matched = await fetchManualPartsMatches( + manualFilename, + parts.map((part) => ({ + partNumber: part.partNumber, + description: part.description, + manualFilename, + })), + Math.max(parts.length, 1) + ) + + return matched.parts as PartForPage[] } /** @@ -503,6 +591,7 @@ export async function getTopPartsForManual( ): Promise<{ parts: PartForPage[] error?: string + cache?: EbayCacheState }> { const { parts } = await getPartsForManualWithStatus(manualFilename) @@ -514,23 +603,20 @@ export async function getTopPartsForManual( parts, Math.max(limit * 2, limit) ) - const { parts: enrichedParts, error } = - await enhancePartsData(liveSearchCandidates) - - const sorted = enrichedParts.sort((a, b) => { - const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0 - const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0 - - if (aHasLiveListings !== bHasLiveListings) { - return bHasLiveListings - aHasLiveListings - } - - return b.ebayListings.length - a.ebayListings.length - }) + const matched = await fetchManualPartsMatches( + manualFilename, + liveSearchCandidates.map((part) => ({ + partNumber: part.partNumber, + description: part.description, + manualFilename, + })), + limit + ) return { - parts: sorted.slice(0, limit), - error, + parts: matched.parts as PartForPage[], + error: matched.error, + cache: matched.cache, } } diff --git a/lib/server/manual-parts-data.ts b/lib/server/manual-parts-data.ts new file mode 100644 index 00000000..d7bd88b0 --- /dev/null +++ b/lib/server/manual-parts-data.ts @@ -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 +type ManualPagesPartsLookup = Record> + +let manualPartsCache: ManualPartsLookup | null = null +let manualPagesPartsCache: ManualPagesPartsLookup | null = null +let staticEbayListingsCache: CachedEbayListing[] | null = null + +async function readJsonFile(filename: string): Promise { + 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 { + if (!manualPartsCache) { + manualPartsCache = await readJsonFile( + "manual_parts_lookup.json" + ) + } + + return manualPartsCache +} + +export async function loadManualPagesPartsLookup(): Promise { + if (!manualPagesPartsCache) { + manualPagesPartsCache = await readJsonFile( + "manual_pages_parts.json" + ) + } + + return manualPagesPartsCache +} + +export async function findManualParts( + manualFilename: string +): Promise { + 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 { + 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> { + const manualParts = await loadManualPartsLookup() + const manualsWithParts = new Set() + + 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() + + 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 { + 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 { + const listings = await loadStaticEbayListings() + return rankListingsForQuery(query, listings, limit) +} diff --git a/package.json b/package.json index 255b91c6..5f927374 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,13 @@ "scripts": { "build": "next build", "copy:check": "node scripts/check-public-copy.mjs", + "diagnose:ebay": "node scripts/staging-smoke.mjs", "deploy:staging:env": "node scripts/deploy-readiness.mjs", "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build", "deploy:staging:smoke": "node scripts/staging-smoke.mjs", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", + "manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts", + "manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts", "manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts", "manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run", "convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"", diff --git a/scripts/staging-smoke.mjs b/scripts/staging-smoke.mjs new file mode 100644 index 00000000..08923263 --- /dev/null +++ b/scripts/staging-smoke.mjs @@ -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) +})